Monorepo for Tangled
tangled.org
1<script lang="ts">
2 import FollowButton from "./FollowButton.svelte";
3 import ProfileEditForm from "./ProfileEditForm.svelte";
4 import FollowerFollowing from "./FollowerFollowing.svelte";
5 import MapPin from "$icon/map-pin";
6 import Link from "$icon/link";
7 import Pencil from "$icon/pencil";
8 import Rss from "$icon/rss";
9 import UserRound from "$icon/user-round";
10 import Button from "$lib/components/ui/Button.svelte";
11 import { getAuth } from "$lib/auth.svelte";
12 import type { ProfileRecord } from "$lib/api/records";
13
14 interface Identity {
15 did: string;
16 handle: string;
17 avatar?: string;
18 }
19
20 interface Props {
21 identity: Identity;
22 profile: ProfileRecord | null;
23 followers: number;
24 following: number;
25 viewerFollowRkey?: string | null;
26 }
27
28 let { identity, profile, followers, following, viewerFollowRkey }: Props = $props();
29
30 const auth = getAuth();
31 const isSelf = $derived(auth.currentDid === identity.did);
32
33 let editing = $state(false);
34 let saved = $state<ProfileRecord | null>(null);
35 const shown = $derived(saved ?? profile);
36
37 const links = $derived((shown?.links ?? []).filter((l) => l.trim().length > 0));
38 const blueskyUrl = $derived(shown?.bluesky ? `https://bsky.app/profile/${identity.did}` : null);
39</script>
40
41<div class="grid grid-cols-3 items-center gap-1 md:grid-cols-1">
42 <div class="col-span-1 flex items-center justify-center">
43 <div class="relative aspect-square w-3/4">
44 {#if identity.avatar}
45 <img
46 class="absolute inset-0 h-full w-full rounded-full object-cover p-2"
47 src={identity.avatar}
48 alt={identity.handle}
49 />
50 {:else}
51 <span class="absolute inset-0 h-full w-full p-2" aria-hidden="true">
52 <span
53 class="flex h-full w-full items-center justify-center rounded-full bg-background-inset text-foreground-placeholder"
54 >
55 <UserRound class="size-1/2" />
56 </span>
57 </span>
58 {/if}
59 </div>
60 </div>
61
62 <div class="col-span-2">
63 <div class="flex flex-row flex-nowrap items-center gap-2">
64 <h1
65 title={identity.handle}
66 class="overflow-hidden text-lg font-bold text-ellipsis whitespace-nowrap"
67 >
68 {identity.handle}
69 </h1>
70 {#if shown?.pronouns}
71 <p class="text-foreground-subtle">{shown.pronouns}</p>
72 {/if}
73 </div>
74 <div class="md:hidden">
75 <FollowerFollowing handle={identity.handle} {followers} {following} />
76 </div>
77 </div>
78
79 <div class="col-span-3 md:col-span-full">
80 {#if editing}
81 <ProfileEditForm
82 profile={shown}
83 onSaved={(record) => {
84 saved = record;
85 editing = false;
86 }}
87 onCancel={() => (editing = false)}
88 />
89 {:else}
90 <div class="space-y-2 text-sm">
91 {#if shown?.description}
92 <p class="pb-4 text-base md:pb-2">{shown.description}</p>
93 {/if}
94
95 <div class="hidden md:block">
96 <FollowerFollowing handle={identity.handle} {followers} {following} />
97 </div>
98
99 {#if shown?.location || links.length > 0 || blueskyUrl}
100 <div
101 class="mb-2 flex max-w-full flex-col gap-2 overflow-hidden text-ellipsis whitespace-nowrap"
102 >
103 {#if shown?.location}
104 <div class="flex items-center gap-2">
105 <span class="flex-shrink-0"><MapPin class="size-4" aria-hidden="true" /></span>
106 <span>{shown.location}</span>
107 </div>
108 {/if}
109 {#if blueskyUrl}
110 <div class="flex items-center gap-2">
111 <span class="flex-shrink-0"><Link class="size-4" aria-hidden="true" /></span>
112 <a
113 href={blueskyUrl}
114 target="_blank"
115 rel="external noopener noreferrer"
116 class="truncate no-underline hover:underline">{identity.handle}</a
117 >
118 </div>
119 {/if}
120 {#each links as link, index (index)}
121 <div class="flex items-center gap-2">
122 <span class="flex-shrink-0"><Link class="size-4" aria-hidden="true" /></span>
123 <a
124 href={link}
125 target="_blank"
126 rel="external nofollow me noopener noreferrer"
127 class="truncate no-underline hover:underline">{link}</a
128 >
129 </div>
130 {/each}
131 </div>
132 {/if}
133
134 <div class="my-2 flex items-center gap-2">
135 {#if isSelf}
136 <Button variant="default" class="w-full gap-2" onclick={() => (editing = true)}>
137 <Pencil class="size-4" aria-hidden="true" />
138 Edit
139 </Button>
140 {:else}
141 <div class="w-full">
142 <FollowButton profileDid={identity.did} initialRkey={viewerFollowRkey} />
143 </div>
144 {/if}
145 <Button
146 href={`/${identity.handle}/feed.atom`}
147 variant="default"
148 class="px-3"
149 aria-label="RSS feed"
150 >
151 <Rss class="size-4" aria-hidden="true" />
152 </Button>
153 </div>
154 </div>
155 {/if}
156 </div>
157</div>