Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306<script lang="ts"> import { profileIdentifier } from "#lib/api/identity.ts"; import type { Snippet } from "svelte"; import Camera from "$icon/camera"; import Check from "$icon/check"; import Pencil from "$icon/pencil"; import X from "$icon/x"; import { createAction } from "#lib/action.svelte.ts"; import type { Moderation } from "#lib/api/moderationLabels.ts"; import type { ProfileRecord } from "#lib/api/records.ts"; import { getAuth } from "#lib/auth.svelte.ts"; import ModerationNotice from "#lib/components/moderation/ModerationNotice.svelte"; import { createAuthorGate } from "#lib/components/moderation/gate.svelte.ts"; import FollowButton from "#lib/components/profile/FollowButton.svelte"; import NetworkVouches from "#lib/components/profile/NetworkVouches.svelte"; import ProfileEditForm from "#lib/components/profile/ProfileEditForm.svelte"; import ProfileBio from "#lib/components/profile/ProfileBio.svelte"; import ProfileMoreMenu from "#lib/components/profile/ProfileMoreMenu.svelte"; import { PinnedEdit, setPinnedEdit } from "#lib/components/profile/pinnedEdit.svelte.ts"; import ProfileSocialLinks from "#lib/components/profile/ProfileSocialLinks.svelte"; import ProfileTabs from "#lib/components/profile/ProfileTabs.svelte"; import VouchButton from "#lib/components/profile/VouchButton.svelte"; import OnboardingNudge from "#lib/components/welcome/OnboardingNudge.svelte"; import type { DirectVouch, NetworkVouch, ProfileCounts } from "#lib/components/profile/types.ts"; import Avatar from "#lib/components/ui/Avatar.svelte"; import { avatarUrl } from "#lib/avatar.ts"; import { avatarProblem } from "#lib/api/profile.ts"; import Button from "#lib/components/ui/Button.svelte"; import Card from "#lib/components/ui/Card.svelte"; import LayerCard from "#lib/components/ui/LayerCard.svelte"; import LayerCardPrimary from "#lib/components/ui/LayerCardPrimary.svelte"; import { lightbox } from "#lib/lightbox.svelte.ts";
interface Props { identity: { did: string; handle: string }; profile: ProfileRecord | null; counts: ProfileCounts; viewerFollowRkey?: string | null; viewerVouch: DirectVouch | null; networkVouches: NetworkVouch[]; activeTab: string; moderation?: Moderation; content: Snippet; }
let { identity, profile, counts, viewerFollowRkey, viewerVouch, networkVouches, activeTab, moderation, content }: Props = $props();
const auth = getAuth(); const isSelf = $derived(auth.currentDid === identity.did); const pins = setPinnedEdit(new PinnedEdit(() => identity)); const editing = $derived(pins.editing); let saved = $state<ProfileRecord | null>(null); const shown = $derived(saved ?? profile); const formId = $props.id(); let form = $state<ReturnType<typeof ProfileEditForm>>();
let avatarInput = $state<HTMLInputElement>(); let avatarFile = $state<File | null>(null); let avatarError = $state<string>(); const avatarPreview = $derived(avatarFile ? URL.createObjectURL(avatarFile) : undefined); $effect(() => { const url = avatarPreview; return () => { if (url) URL.revokeObjectURL(url); }; });
const pickAvatar = (event: Event & { currentTarget: HTMLInputElement }) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (!file) return; avatarError = avatarProblem(file); if (!avatarError) avatarFile = file; };
const saveAction = () => createAction(async () => { const record = await form?.save(); if (!record) return; saved = record; pins.saved = record.pinnedRepositories ?? []; stopEditing(); }); // a fresh one per edit, so a failed save's error doesn't greet the next edit let save = $state.raw(saveAction());
const startEditing = () => { save = saveAction(); pins.keys = [...new Set(shown?.pinnedRepositories)]; pins.editing = true; };
const stopEditing = () => { pins.editing = false; avatarFile = null; avatarError = undefined; };
const actor = $derived(profileIdentifier(identity.handle, identity.did));
const gate = createAuthorGate( () => moderation, () => identity.did );</script>
{#snippet gatedContent()} {#if gate.masked} <ModerationNotice {gate} /> {:else} {@render content()} {/if}{/snippet}
<ProfileTabs {actor} active={activeTab} {counts} layered={activeTab === "overview"} />{#if activeTab === "overview"} <LayerCard as="section" class="relative text-foreground-default"> <div class="h-[calc(5.5625rem-var(--layer-gap)-1px)]"></div> <LayerCardPrimary padding="none" class="static overflow-visible"> <div class="absolute top-5.25 left-1/2 size-32 -translate-x-1/2 rounded-full bg-background-default sm:left-6 sm:translate-x-0" > {#if editing} <Avatar did={identity.did} handle={identity.handle} src={avatarPreview} size="full" pixels={128} loading="eager" moderation={gate.moderation} /> <Button icon={Camera} size="large" aria-label="Change avatar" class="absolute right-0 bottom-0 [--btn-radius:9999px]" onclick={() => avatarInput?.click()} /> {:else} <button type="button" class={["size-full rounded-full", !gate.masked && "cursor-zoom-in"]} aria-label="View {identity.handle}'s avatar" disabled={gate.masked} onclick={(event) => lightbox.show( [ { src: avatarUrl(identity.did), alt: `${identity.handle}'s avatar`, origin: event.currentTarget, class: "aspect-square object-cover rounded-full!" } ], 0 )} > <Avatar did={identity.did} handle={identity.handle} size="full" pixels={128} loading="eager" moderation={gate.moderation} /> </button> {/if} </div> <div class="sm:absolute sm:top-8.5 sm:right-4 sm:left-43 sm:flex sm:items-center sm:gap-md" > <h1 title={identity.handle} class="max-w-full truncate px-xl pt-30.75 typography-heading-4 sm:min-w-0 sm:flex-1 sm:px-none sm:pt-none" > {identity.handle} {#if shown?.pronouns && !gate.masked} <span data-testid="profilePronouns" data-pronouns={shown.pronouns} class="ml-xs typography-paragraph-regular font-normal text-foreground-muted" > {shown.pronouns} </span> {/if} </h1> <div class="absolute inset-x-4 top-39.25 flex items-center justify-between gap-2xs sm:static sm:shrink-0 sm:justify-end" > <div class={["flex items-center gap-2xs", isSelf && "ml-auto"]}> {#if isSelf && editing} <Button icon={X} disabled={save.loading} onclick={stopEditing} >Cancel</Button > <Button type="submit" form={formId} variant="primary" icon={Check} loading={save.loading}>Save</Button > {:else if isSelf} <Button variant="default" icon={Pencil} onclick={startEditing} >Edit profile</Button > {:else} <div class="min-w-23"> <FollowButton profileDid={identity.did} initialRkey={viewerFollowRkey} /> </div> <div class="sm:min-w-22.5"> <VouchButton profileDid={identity.did} profileLabel={identity.handle} initialVouch={viewerVouch} {networkVouches} hideLabelOnMobile /> </div> {/if} </div> <ProfileMoreMenu {identity} /> </div> </div>
<div class="flex min-h-16 flex-col gap-md px-xl pt-xs sm:ml-39 sm:flex-row sm:justify-between sm:gap-xl sm:px-md sm:py-sm" > {#if editing} <div class="w-full"> <input data-testid="profileAvatarInput" bind:this={avatarInput} type="file" accept="image/png,image/jpeg" class="hidden" onchange={pickAvatar} /> <ProfileEditForm bind:this={form} id={formId} profile={shown} pinned={pins.keys} {avatarFile} {avatarError} error={save.error} onsubmit={() => save.run()} /> </div> {:else if gate.masked} <ModerationNotice {gate} class="w-full sm:py-2xs" /> {:else} {#if shown?.description} <ProfileBio text={shown.description} class="min-w-0 typography-paragraph-regular wrap-break-word sm:-mt-px sm:flex-1 sm:py-2xs" /> {/if} {#if shown} <ProfileSocialLinks {identity} profile={shown} followers={counts.followers} following={counts.following} class="max-sm:-ml-2.5 max-sm:flex-col max-sm:items-start max-sm:gap-xs sm:max-w-1/2 sm:shrink-0 sm:self-start" /> {/if} {/if} </div>
{#if networkVouches.length > 0 && !gate.masked} <div class="px-xl py-md sm:ml-39 sm:px-md sm:pt-none"> <NetworkVouches subject={actor} vouches={networkVouches} /> </div> {/if} {#if isSelf} <OnboardingNudge class="px-md pt-md" /> {/if} {#if !gate.masked} <div class="p-md">{@render content()}</div> {/if} </LayerCardPrimary> </LayerCard>{:else} <Card padding="none" class="overflow-hidden"> <div class="p-md">{@render gatedContent()}</div> </Card>{/if}