This repository has no description
1<script lang="ts">
2 import { untrack } from "svelte";
3 import Check from "$icon/check";
4 import Link from "$icon/link";
5 import MapPin from "$icon/map-pin";
6 import X from "$icon/x";
7 import Button from "$lib/components/ui/Button.svelte";
8 import { getAuth } from "$lib/auth.svelte";
9 import { putProfile } from "$lib/api/profile";
10 import type { ProfileRecord } from "$lib/api/records";
11
12 interface Props {
13 profile: ProfileRecord | null;
14 onSaved: (record: ProfileRecord) => void;
15 onCancel: () => void;
16 }
17
18 let { profile, onSaved, onCancel }: Props = $props();
19
20 const auth = getAuth();
21
22 // seed once; the profile prop can't change while the form is open.
23 const seed = untrack(() => profile);
24 let description = $state(seed?.description ?? "");
25 let pronouns = $state(seed?.pronouns ?? "");
26 let location = $state(seed?.location ?? "");
27 let bluesky = $state(seed?.bluesky ?? false);
28 let links = $state(
29 Array.from({ length: 5 }, (_, index) => ({
30 value: (seed?.links?.[index] as string | undefined) ?? ""
31 }))
32 );
33 let busy = $state(false);
34 let error = $state<string | null>(null);
35
36 const inputClass =
37 "w-full rounded border border-border-default bg-background-default px-2 py-1 outline-none focus:border-border-strong focus:ring-1 focus:ring-border-strong";
38
39 const save = async (event: SubmitEvent) => {
40 event.preventDefault();
41 const agent = auth.agent;
42 if (!agent || busy) return;
43 busy = true;
44 error = null;
45 const cleanLinks = links.map((link) => link.value.trim()).filter(Boolean);
46 // put replaces the whole record, so carry unedited fields (avatar, pins, stats).
47 const record: ProfileRecord = {
48 ...profile,
49 $type: "sh.tangled.actor.profile",
50 bluesky,
51 description: description.trim() || undefined,
52 pronouns: pronouns.trim() || undefined,
53 location: location.trim() || undefined,
54 links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord["links"]) : undefined
55 };
56 try {
57 await putProfile(agent, record);
58 onSaved(record);
59 } catch {
60 error = "Could not save profile. Try again.";
61 } finally {
62 busy = false;
63 }
64 };
65</script>
66
67<form class="my-2 flex max-w-full flex-col gap-4 text-sm" onsubmit={save}>
68 <div class="flex flex-col gap-1">
69 <label for="profile-bio">Bio</label>
70 <textarea
71 id="profile-bio"
72 class={inputClass}
73 rows="3"
74 placeholder="Write a bio"
75 bind:value={description}></textarea>
76 </div>
77
78 <div class="flex flex-col gap-1">
79 <label for="profile-pronouns">Pronouns</label>
80 <input
81 id="profile-pronouns"
82 type="text"
83 class={inputClass}
84 placeholder="they/them"
85 bind:value={pronouns}
86 />
87 </div>
88
89 <div class="flex flex-col gap-1">
90 <label for="profile-location">Location</label>
91 <div class="flex w-full items-center gap-2">
92 <MapPin class="size-4 shrink-0" aria-hidden="true" />
93 <input id="profile-location" type="text" class={inputClass} bind:value={location} />
94 </div>
95 </div>
96
97 <div class="flex flex-col gap-1">
98 <span>Social links</span>
99 <label class="flex items-center gap-2 py-1">
100 <input type="checkbox" bind:checked={bluesky} />
101 Link to Bluesky account
102 </label>
103 {#each links as link, index (index)}
104 <div class="flex w-full items-center gap-2">
105 <Link class="size-4 shrink-0" aria-hidden="true" />
106 <input
107 type="text"
108 class={inputClass}
109 placeholder={`Social link ${index + 1}`}
110 bind:value={link.value}
111 />
112 </div>
113 {/each}
114 </div>
115
116 {#if error}
117 <p class="typography-paragraph-small text-foreground-danger">{error}</p>
118 {/if}
119
120 <div class="flex items-center justify-between gap-2">
121 <Button type="submit" variant="default" class="w-full gap-2" loading={busy}>
122 <Check class="size-4" aria-hidden="true" />
123 Save
124 </Button>
125 <Button variant="default" class="w-full gap-2" disabled={busy} onclick={onCancel}>
126 <X class="size-4" aria-hidden="true" />
127 Cancel
128 </Button>
129 </div>
130</form>