grain.social is a photo sharing platform built on atproto.
grain.social
atproto
photography
appview
1import { LabelValueDefinition } from "$lexicon/types/com/atproto/label/defs.ts";
2import { ProfileViewDetailed } from "$lexicon/types/social/grain/actor/defs.ts";
3import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
4import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
5import { Un$Typed } from "$lexicon/util.ts";
6import { Facet } from "@atproto/api";
7import { AtUri } from "@atproto/syntax";
8import { LabelerPolicies } from "@bigmoves/bff";
9import {
10 atprotoLabelValueDefinitions,
11 ModerationDecsion,
12} from "../lib/moderation.ts";
13import type { SocialNetwork } from "../lib/timeline.ts";
14import {
15 bskyProfileLink,
16 followersLink,
17 followingLink,
18 galleryLink,
19 profileLink,
20} from "../utils.ts";
21import { ActorAvatar } from "./ActorAvatar.tsx";
22import { AvatarButton } from "./AvatarButton.tsx";
23import { Button } from "./Button.tsx";
24import { CameraBadges } from "./CameraBadges.tsx";
25import { FollowButton } from "./FollowButton.tsx";
26import { LabelDefinitionButton } from "./LabelDefinitionButton.tsx";
27import { LabelerAvatar } from "./LabelerAvatar.tsx";
28import { RenderFacetedText } from "./RenderFacetedText.tsx";
29
30export type ProfileTabs = "favs" | "galleries" | "labels";
31
32export function ProfilePage({
33 userProfiles,
34 loggedInUserDid,
35 profile,
36 descriptionFacets,
37 selectedTab,
38 galleries,
39 galleryFavs,
40 galleryModDecisionsMap = new Map(),
41 isLabeler,
42 labelerDefinitions,
43}: Readonly<{
44 userProfiles: SocialNetwork[];
45 actorProfiles: SocialNetwork[];
46 loggedInUserDid?: string;
47 profile: Un$Typed<ProfileViewDetailed>;
48 descriptionFacets?: Facet[];
49 selectedTab?: ProfileTabs;
50 galleries?: GalleryView[];
51 galleryFavs?: GalleryView[];
52 galleryModDecisionsMap?: Map<string, ModerationDecsion>;
53 isLabeler?: boolean;
54 labelerDefinitions?: LabelerPolicies;
55}>) {
56 const isCreator = loggedInUserDid === profile.did;
57 const displayName = profile.displayName || profile.handle;
58 return (
59 <div class="px-4 mb-4" id="profile-page">
60 <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4">
61 <div class="flex flex-col mb-4">
62 {isLabeler
63 ? <LabelerAvatar profile={profile} size={64} />
64 : <AvatarButton profile={profile} />}
65 <p class="text-2xl font-bold">{displayName}</p>
66 <p class="text-zinc-600 dark:text-zinc-500">@{profile.handle}</p>
67 {!isLabeler && (
68 <>
69 <p class="space-x-1">
70 <a href={followersLink(profile.handle)}>
71 <span class="font-semibold" id="followers-count">
72 {profile.followersCount ?? 0}
73 </span>{" "}
74 <span class="text-zinc-600 dark:text-zinc-500">
75 followers
76 </span>
77 </a>{" "}
78 <a href={followingLink(profile.handle)}>
79 <span class="font-semibold" id="following-count">
80 {profile.followsCount ?? 0}
81 </span>{" "}
82 <span class="text-zinc-600 dark:text-zinc-500">
83 following
84 </span>
85 </a>{" "}
86 <span class="font-semibold">{galleries?.length ?? 0}</span>
87 <span class="text-zinc-600 dark:text-zinc-500">galleries</span>
88 </p>
89 <CameraBadges cameras={profile.cameras ?? []} class="mt-2" />
90 </>
91 )}
92 {profile.description
93 ? (
94 descriptionFacets
95 ? (
96 <p class="mt-2 sm:max-w-[500px]">
97 <RenderFacetedText
98 text={profile.description}
99 facets={descriptionFacets}
100 />
101 </p>
102 )
103 : (
104 <p class="mt-2 sm:max-w-[500px]">
105 {profile.description}
106 </p>
107 )
108 )
109 : null}
110 <p>
111 {userProfiles.includes("bluesky") && (
112 <a
113 href={bskyProfileLink(profile.handle)}
114 class="text-xs hover:underline"
115 >
116 <i class="fa-brands fa-bluesky text-sky-500" />{" "}
117 @{profile.handle}
118 </a>
119 )}
120 </p>
121 </div>
122 {!isCreator && !isLabeler && loggedInUserDid
123 ? (
124 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row">
125 <FollowButton
126 followeeDid={profile.did}
127 followUri={profile.viewer?.following}
128 />
129 </div>
130 )
131 : null}
132 {isCreator
133 ? (
134 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row sm:flex-wrap sm:justify-end">
135 <Button
136 variant="secondary"
137 class="w-full sm:w-fit whitespace-nowrap"
138 asChild
139 >
140 <a href="/upload">
141 <i class="fa-solid fa-upload mr-2" />
142 Photo library
143 </a>
144 </Button>
145 <Button
146 variant="secondary"
147 type="button"
148 hx-get="/dialogs/profile"
149 hx-target="#layout"
150 hx-swap="afterbegin"
151 class="w-full sm:w-fit whitespace-nowrap"
152 >
153 Edit profile
154 </Button>
155 <Button
156 variant="primary"
157 type="button"
158 class="w-full sm:w-fit whitespace-nowrap"
159 hx-get="/dialogs/gallery/new"
160 hx-target="#layout"
161 hx-swap="afterbegin"
162 >
163 Create gallery
164 </Button>
165 </div>
166 )
167 : null}
168 </div>
169 <div
170 class="my-4 w-full flex sm:w-fit space-x-2 overflow-x-auto"
171 role="tablist"
172 style={{ WebkitOverflowScrolling: "touch" }}
173 >
174 {isLabeler
175 ? (
176 <Button
177 variant="tab"
178 name="tab"
179 class="flex-1"
180 value="favs"
181 hx-get={profileLink(profile.handle)}
182 hx-target="#profile-page"
183 hx-swap="outerHTML"
184 role="tab"
185 aria-selected={selectedTab === "labels"}
186 aria-controls="tab-content"
187 >
188 Labels
189 </Button>
190 )
191 : (
192 <Button
193 variant="tab"
194 name="tab"
195 class="flex-1"
196 value="galleries"
197 hx-get={profileLink(profile.handle)}
198 hx-target="#profile-page"
199 hx-swap="outerHTML"
200 role="tab"
201 aria-selected={selectedTab === "galleries"}
202 aria-controls="tab-content"
203 >
204 Galleries
205 </Button>
206 )}
207
208 {isCreator && (
209 <Button
210 variant="tab"
211 name="tab"
212 class="flex-1"
213 value="favs"
214 hx-get={profileLink(profile.handle)}
215 hx-target="#profile-page"
216 hx-swap="outerHTML"
217 role="tab"
218 aria-selected={selectedTab === "favs"}
219 aria-controls="tab-content"
220 >
221 Favs
222 </Button>
223 )}
224 </div>
225 {selectedTab === "labels" && labelerDefinitions
226 ? <LabelerPoliciesList defs={labelerDefinitions} />
227 : null}
228 {selectedTab === "galleries"
229 ? (
230 <div class="grid grid-cols-3 gap-1 mb-4">
231 {galleries?.length
232 ? (
233 galleries.map((gallery) => (
234 <GalleryItem
235 key={gallery.uri}
236 gallery={gallery}
237 galleryModDecisionsMap={galleryModDecisionsMap}
238 />
239 ))
240 )
241 : <p>No galleries yet.</p>}
242 </div>
243 )
244 : null}
245 {selectedTab === "favs"
246 ? (
247 <div class="grid grid-cols-3 gap-1 mb-4">
248 {galleryFavs?.length
249 ? (
250 galleryFavs.map((gallery) => (
251 <GalleryFavItem
252 key={gallery.uri}
253 gallery={gallery}
254 galleryModDecisionsMap={galleryModDecisionsMap}
255 />
256 ))
257 )
258 : <p>No favs yet.</p>}
259 </div>
260 )
261 : null}
262 </div>
263 );
264}
265
266function LabelerPoliciesList({ defs }: Readonly<{ defs: LabelerPolicies }>) {
267 if (!defs?.labelValues?.length) return <p>No labels yet.</p>;
268 return (
269 <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y">
270 {defs?.labelValues?.map((val) => {
271 let def = defs?.labelValueDefinitions?.find((def) =>
272 def.identifier === val
273 );
274 // Fallback to atproto definitions if not found
275 def ??= atprotoLabelValueDefinitions[val];
276 if (!def) return null;
277 return <LabelValueDefinitionListItem key={def.identifier} def={def} />;
278 })}
279 </ul>
280 );
281}
282
283function LabelValueDefinitionListItem({
284 def,
285}: Readonly<{ def: LabelValueDefinition }>) {
286 const enLocale = def.locales.find((v) => v.lang === "en");
287 return (
288 <li class="flex flex-col pb-4 gap-2">
289 <div class="font-semibold">{enLocale?.name}</div>
290 <div>{enLocale?.description}</div>
291 {def.adultOnly
292 ? (
293 <div class="flex items-center gap-2 text-sm">
294 <i class="fa fa-info-circle" />{" "}
295 <span>Adult content is disabled.</span>
296 </div>
297 )
298 : null}
299 <div class="text-sm">
300 Default setting:{" "}
301 <span class="font-semibold">
302 {def.defaultSetting || "No default value set"}
303 </span>
304 </div>
305 </li>
306 );
307}
308
309function GalleryItem({
310 gallery,
311 galleryModDecisionsMap,
312}: Readonly<{
313 gallery: GalleryView;
314 galleryModDecisionsMap: Map<string, ModerationDecsion>;
315}>) {
316 const modDecision = galleryModDecisionsMap.get(gallery.uri);
317 return (
318 <a
319 href={galleryLink(
320 gallery.creator.handle,
321 new AtUri(gallery.uri).rkey,
322 )}
323 class="cursor-pointer relative aspect-3/4"
324 >
325 {modDecision && !modDecision.isMe
326 ? (
327 <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900 p-2 text-sm">
328 <i class="fa fa-circle-info text-zinc-500"></i> {modDecision.name}
329 <div class="text-sm">
330 Labeled by @{modDecision?.labeledBy || "unknown"}.{" "}
331 <LabelDefinitionButton
332 src={modDecision.src}
333 val={modDecision.val}
334 />
335 </div>
336 </div>
337 )
338 : gallery.items?.length
339 ? (
340 <img
341 src={gallery.items?.filter(isPhotoView)?.[0]?.fullsize}
342 alt={gallery.items?.filter(isPhotoView)?.[0]?.alt}
343 class="w-full h-full object-cover"
344 />
345 )
346 : <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" />}
347 <div class="absolute sm:flex hidden bottom-0 left-0 bg-black/80 text-white p-2 items-center gap-2">
348 {gallery.title}
349 </div>
350 </a>
351 );
352}
353
354function GalleryFavItem({
355 gallery,
356 galleryModDecisionsMap,
357}: Readonly<{
358 gallery: GalleryView;
359 galleryModDecisionsMap: Map<string, ModerationDecsion>;
360}>) {
361 return (
362 <a
363 href={galleryLink(
364 gallery.creator.handle,
365 new AtUri(gallery.uri).rkey,
366 )}
367 class="cursor-pointer relative aspect-3/4"
368 >
369 {gallery.items?.length
370 ? (
371 <img
372 src={gallery.items?.filter(isPhotoView)?.[0]?.fullsize}
373 alt={gallery.items?.filter(isPhotoView)?.[0]?.alt}
374 class="w-full h-full object-cover"
375 />
376 )
377 : <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" />}
378 <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2 hidden sm:flex items-center gap-2">
379 <ActorAvatar profile={gallery.creator} size={20} /> {gallery.title}
380 </div>
381 </a>
382 );
383}