grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
50
fork

Configure Feed

Select the types of activity you want to include in your feed.

at ad873ce4327522dbacf71688ffa52ded2dd960d3 383 lines 12 kB view raw
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}