grain.social is a photo sharing platform built on atproto.

switch follows to grain graph only, add followers/following pages, update notifcations to include grain follows

+464 -436
+2 -2
__generated__/lexicons.ts
··· 2444 2444 reason: { 2445 2445 type: 'string', 2446 2446 description: 2447 - "Expected values are 'gallery-favorite', and 'unknown'.", 2448 - knownValues: ['gallery-favorite', 'unknown'], 2447 + 'The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower.', 2448 + knownValues: ['follow', 'gallery-favorite', 'unknown'], 2449 2449 }, 2450 2450 reasonSubject: { 2451 2451 type: 'string',
+2 -2
__generated__/types/social/grain/notification/defs.ts
··· 20 20 uri: string 21 21 cid: string 22 22 author: SocialGrainActorDefs.ProfileView 23 - /** Expected values are 'gallery-favorite', and 'unknown'. */ 24 - reason: 'gallery-favorite' | 'unknown' | (string & {}) 23 + /** The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower. */ 24 + reason: 'follow' | 'gallery-favorite' | 'unknown' | (string & {}) 25 25 reasonSubject?: string 26 26 record: { [_ in string]: unknown } 27 27 isRead: boolean
+1 -1
deno.json
··· 2 2 "imports": { 3 3 "$lexicon/": "./__generated__/", 4 4 "@atproto/syntax": "npm:@atproto/syntax@^0.4.0", 5 - "@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.31", 5 + "@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.32", 6 6 "@std/http": "jsr:@std/http@^1.0.17", 7 7 "@std/path": "jsr:@std/path@^1.0.9", 8 8 "@tailwindcss/cli": "npm:@tailwindcss/cli@^4.1.4",
+4 -4
deno.lock
··· 2 2 "version": "5", 3 3 "specifiers": { 4 4 "jsr:@bigmoves/atproto-oauth-client@0.2": "0.2.0", 5 - "jsr:@bigmoves/bff@0.3.0-beta.31": "0.3.0-beta.31", 5 + "jsr:@bigmoves/bff@0.3.0-beta.32": "0.3.0-beta.32", 6 6 "jsr:@std/assert@^1.0.13": "1.0.13", 7 7 "jsr:@std/cli@^1.0.18": "1.0.19", 8 8 "jsr:@std/encoding@^1.0.10": "1.0.10", ··· 65 65 "npm:tailwind-merge" 66 66 ] 67 67 }, 68 - "@bigmoves/bff@0.3.0-beta.31": { 69 - "integrity": "2f0ec89088ff8099abeaec25af5a5998894c1dbdbed66cc6d3b0a9b2500b18e0", 68 + "@bigmoves/bff@0.3.0-beta.32": { 69 + "integrity": "d33581157c6d52bd9ecccdbcb090559377de71d28137ce7fdf3882740390a389", 70 70 "dependencies": [ 71 71 "jsr:@bigmoves/atproto-oauth-client", 72 72 "jsr:@std/assert", ··· 1596 1596 }, 1597 1597 "workspace": { 1598 1598 "dependencies": [ 1599 - "jsr:@bigmoves/bff@0.3.0-beta.31", 1599 + "jsr:@bigmoves/bff@0.3.0-beta.32", 1600 1600 "jsr:@std/http@^1.0.17", 1601 1601 "jsr:@std/path@^1.0.9", 1602 1602 "npm:@atproto/syntax@0.4",
+2 -1
lexicons/social/grain/notification/defs.json
··· 22 22 }, 23 23 "reason": { 24 24 "type": "string", 25 - "description": "Expected values are 'gallery-favorite', and 'unknown'.", 25 + "description": "The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower.", 26 26 "knownValues": [ 27 + "follow", 27 28 "gallery-favorite", 28 29 "unknown" 29 30 ]
+27
src/components/Breadcrumb.tsx
··· 1 + type BreadcrumbItem = { 2 + label: string; 3 + href?: string; 4 + }; 5 + 6 + export function Breadcrumb({ items }: Readonly<{ items: BreadcrumbItem[] }>) { 7 + return ( 8 + <nav className="mb-4 text-sm text-zinc-500 dark:text-zinc-300"> 9 + {items.map((item, idx) => ( 10 + <> 11 + {item.href 12 + ? ( 13 + <a href={item.href} className="text-sky-500 hover:underline"> 14 + {item.label} 15 + </a> 16 + ) 17 + : ( 18 + <span className="text-zinc-700 dark:text-zinc-100"> 19 + {item.label} 20 + </span> 21 + )} 22 + {idx < items.length - 1 && <span className="mx-2">&gt;</span>} 23 + </> 24 + ))} 25 + </nav> 26 + ); 27 + }
+8 -47
src/components/FollowButton.tsx
··· 1 + import { AtUri } from "@atproto/syntax"; 1 2 import { Button, cn } from "@bigmoves/bff/components"; 2 - import type { SocialNetwork } from "../lib/timeline.ts"; 3 - import { formatGraphName } from "./Timeline.tsx"; 4 3 5 4 export function FollowButton({ 6 5 followeeDid, 7 6 followUri, 8 - collection, 9 - class: classProp, 10 - }: Readonly< 11 - { 12 - followeeDid: string; 13 - followUri?: string; 14 - collection?: string; 15 - class?: string; 16 - } 17 - >) { 7 + }: Readonly<{ followeeDid: string; load?: boolean; followUri?: string }>) { 18 8 const isFollowing = followUri; 19 - let followPostUrl = `/actions/follow/${followeeDid}`; 20 - const hideCollectionParam = !collection ? "&hideCollection=true" : ""; 21 - const followDeleteUrl = followUri 22 - ? `/actions/follow/${followeeDid}?uri=${ 23 - encodeURIComponent(followUri) 24 - }${hideCollectionParam}` 25 - : undefined; 26 - if (collection) { 27 - followPostUrl += `?collection=${encodeURIComponent(collection)}`; 28 - } else { 29 - followPostUrl += `?collection=${ 30 - encodeURIComponent("social.grain.graph.follow") 31 - }${hideCollectionParam}`; 32 - } 33 - const source = formatGraphName(sourceForCollection(collection || "")); 34 - 35 9 return ( 36 10 <Button 37 - id="follow-botton" 38 11 variant="primary" 39 12 class={cn( 40 13 "w-full sm:w-fit whitespace-nowrap", 41 14 isFollowing && 42 15 "bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 text-zinc-950 dark:text-zinc-50", 43 - classProp, 44 16 )} 45 17 {...(isFollowing 46 18 ? { 47 - children: source ? `Following on ${source}` : "Following", 48 - "hx-delete": followDeleteUrl, 19 + children: "Following", 20 + "hx-delete": `/actions/follow/${followeeDid}/${ 21 + new AtUri(followUri).rkey 22 + }`, 49 23 } 50 24 : { 51 25 children: ( 52 26 <> 53 27 <i class="fa-solid fa-plus mr-2" /> 54 - {source ? `Follow on ${source}` : "Follow"} 28 + Follow 55 29 </> 56 30 ), 57 - "hx-post": followPostUrl, 31 + "hx-post": `/actions/follow/${followeeDid}`, 58 32 })} 59 33 hx-trigger="click" 60 34 hx-target="this" ··· 62 36 /> 63 37 ); 64 38 } 65 - 66 - function sourceForCollection(collection: string): SocialNetwork | "" { 67 - switch (collection) { 68 - case "app.bsky.graph.follow": 69 - return "bluesky"; 70 - case "social.grain.graph.follow": 71 - return "grain"; 72 - case "sh.tangled.graph.follow": 73 - return "tangled"; 74 - default: 75 - return ""; 76 - } 77 - }
-59
src/components/FollowsButton.tsx
··· 1 - import { Button, cn } from "@bigmoves/bff/components"; 2 - import { FollowMap } from "../lib/follow.ts"; 3 - import type { SocialNetwork } from "../lib/timeline.ts"; 4 - import { collectionForSource } from "./FollowsDialog.tsx"; 5 - 6 - export function FollowsButton({ 7 - actorProfiles, 8 - followeeDid, 9 - followMap, 10 - }: Readonly< 11 - { 12 - actorProfiles: SocialNetwork[]; 13 - followeeDid: string; 14 - followMap: FollowMap; 15 - } 16 - >) { 17 - const followSources = followMap 18 - ? (Object.keys(followMap) as Array<keyof typeof followMap>).filter( 19 - (source) => followMap[source] !== undefined, 20 - ) 21 - : []; 22 - const isFollowing = followSources.length > 0; 23 - const totalSources = actorProfiles.length; 24 - const followingCount = 25 - actorProfiles.filter((source) => 26 - !!followMap[collectionForSource(source) as keyof typeof followMap] 27 - ).length; 28 - return ( 29 - <Button 30 - id="follows-button" 31 - variant="primary" 32 - class={cn( 33 - "w-full sm:w-fit whitespace-nowrap", 34 - isFollowing && 35 - "bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 text-zinc-950 dark:text-zinc-50", 36 - )} 37 - hx-get={`/dialogs/follows/${followeeDid}`} 38 - hx-trigger="click" 39 - hx-target="#layout" 40 - hx-swap="afterbegin" 41 - {...(isFollowing 42 - ? { 43 - children: ( 44 - <> 45 - Following ({followingCount}/{totalSources}) 46 - </> 47 - ), 48 - } 49 - : { 50 - children: ( 51 - <> 52 - <i class="fa-solid fa-plus mr-2" /> 53 - Follow ({followingCount}/{totalSources}) 54 - </> 55 - ), 56 - })} 57 - /> 58 - ); 59 - }
-50
src/components/FollowsDialog.tsx
··· 1 - import { Dialog } from "@bigmoves/bff/components"; 2 - import { type FollowMap } from "../lib/follow.ts"; 3 - import type { SocialNetwork } from "../lib/timeline.ts"; 4 - import { FollowButton } from "./FollowButton.tsx"; 5 - 6 - export function FollowsDialog( 7 - { sources, followeeDid, followMap }: Readonly< 8 - { sources: SocialNetwork[]; followeeDid: string; followMap: FollowMap } 9 - >, 10 - ) { 11 - return ( 12 - <Dialog class="z-100" _="on closeDialog call window.location.reload()"> 13 - <Dialog.Content class="dark:bg-zinc-950 relative"> 14 - <Dialog.X /> 15 - <Dialog.Title>Follow</Dialog.Title> 16 - <ul class="w-full my-4 space-y-2"> 17 - {sources.map((source) => { 18 - const collection = collectionForSource(source); 19 - return ( 20 - <li key={source} class="w-full"> 21 - <FollowButton 22 - class="sm:w-full" 23 - collection={collection} 24 - followeeDid={followeeDid} 25 - followUri={followMap[collection as keyof FollowMap]} 26 - /> 27 - </li> 28 - ); 29 - })} 30 - </ul> 31 - <Dialog.Close class="w-full mt-2"> 32 - Close 33 - </Dialog.Close> 34 - </Dialog.Content> 35 - </Dialog> 36 - ); 37 - } 38 - 39 - export function collectionForSource(source: SocialNetwork): string { 40 - switch (source) { 41 - case "bluesky": 42 - return "app.bsky.graph.follow"; 43 - case "grain": 44 - return "social.grain.graph.follow"; 45 - case "tangled": 46 - return "sh.tangled.graph.follow"; 47 - default: 48 - return ""; 49 - } 50 - }
+41
src/components/FollowsList.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { profileLink } from "../utils.ts"; 3 + import { ActorAvatar } from "./ActorAvatar.tsx"; 4 + 5 + export function FollowsList( 6 + { profiles }: Readonly<{ profiles: ProfileView[] }>, 7 + ) { 8 + return ( 9 + <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y"> 10 + {profiles.length === 0 11 + ? ( 12 + <li> 13 + Not following anyone yet. 14 + </li> 15 + ) 16 + : ( 17 + profiles.map((profile) => ( 18 + <li key={profile.did} class="pb-4"> 19 + <a 20 + href={profileLink(profile.handle)} 21 + class="flex items-center" 22 + > 23 + <div class="flex flex-col space-y-2"> 24 + <div class="flex items-center"> 25 + <ActorAvatar profile={profile} size={32} class="mr-2" /> 26 + <div class="flex flex-col"> 27 + <p>{profile.displayName}</p> 28 + <p class="text-zinc-600 dark:text-zinc-500"> 29 + @{profile.handle || profile.displayName} 30 + </p> 31 + </div> 32 + </div> 33 + <p>{profile.description}</p> 34 + </div> 35 + </a> 36 + </li> 37 + )) 38 + )} 39 + </ul> 40 + ); 41 + }
+15 -3
src/components/NotificationsPage.tsx
··· 1 1 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 2 2 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 3 + import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts"; 3 4 import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 4 5 import { Un$Typed } from "$lexicon/util.ts"; 5 6 import { formatRelativeTime, profileLink } from "../utils.ts"; ··· 44 45 </span> 45 46 </a> 46 47 <span class="break-words"> 47 - favorited your gallery · {formatRelativeTime( 48 - new Date((notification.record as Favorite).createdAt), 48 + {notification.reason === "gallery-favorite" && ( 49 + <> 50 + favorited your gallery · {formatRelativeTime( 51 + new Date((notification.record as Favorite).createdAt), 52 + )} 53 + </> 54 + )} 55 + {notification.reason === "follow" && ( 56 + <> 57 + followed you · {formatRelativeTime( 58 + new Date((notification.record as Follow).createdAt), 59 + )} 60 + </> 49 61 )} 50 62 </span> 51 63 </div> 52 - {galleriesMap.get( 64 + {notification.reason === "gallery-favorite" && galleriesMap.get( 53 65 (notification.record as Favorite).subject, 54 66 ) 55 67 ? (
+75 -68
src/components/ProfilePage.tsx
··· 5 5 import { Un$Typed } from "$lexicon/util.ts"; 6 6 import { AtUri } from "@atproto/syntax"; 7 7 import { Button, cn } from "@bigmoves/bff/components"; 8 - import { FollowMap } from "../lib/follow.ts"; 9 - import type { SocialNetwork, TimelineItem } from "../lib/timeline.ts"; 10 - import { bskyProfileLink, galleryLink, profileLink } from "../utils.ts"; 8 + import type { SocialNetwork } from "../lib/timeline.ts"; 9 + import { 10 + bskyProfileLink, 11 + followersLink, 12 + followingLink, 13 + galleryLink, 14 + profileLink, 15 + } from "../utils.ts"; 11 16 import { ActorAvatar } from "./ActorAvatar.tsx"; 12 17 import { AvatarButton } from "./AvatarButton.tsx"; 13 18 import { FollowButton } from "./FollowButton.tsx"; 14 - import { FollowsButton } from "./FollowsButton.tsx"; 15 - import { TimelineItem as Item } from "./TimelineItem.tsx"; 16 19 17 - export type ProfileTabs = "favs" | "galleries" | null; 20 + export type ProfileTabs = "favs" | "galleries"; 18 21 19 22 export function ProfilePage({ 23 + followUri, 24 + followersCount, 25 + followingCount, 20 26 userProfiles, 21 - actorProfiles, 22 - followMap, 23 27 loggedInUserDid, 24 - timelineItems, 25 28 profile, 26 29 selectedTab, 27 30 galleries, 28 31 galleryFavs, 29 32 }: Readonly<{ 33 + followUri?: string; 34 + followersCount?: number; 35 + followingCount?: number; 30 36 userProfiles: SocialNetwork[]; 31 37 actorProfiles: SocialNetwork[]; 32 - followMap: FollowMap; 33 38 loggedInUserDid?: string; 34 - timelineItems: TimelineItem[]; 35 39 profile: Un$Typed<ProfileView>; 36 40 selectedTab?: ProfileTabs; 37 41 galleries?: GalleryView[]; ··· 39 43 }>) { 40 44 const isCreator = loggedInUserDid === profile.did; 41 45 const displayName = profile.displayName || profile.handle; 42 - const grainOnly = actorProfiles.length === 1 && 43 - actorProfiles.includes("grain"); 44 - const profilesIntersection = userProfiles.filter((p) => 45 - actorProfiles.includes(p) 46 - ); 47 46 return ( 48 47 <div class="px-4 mb-4" id="profile-page"> 49 48 <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4"> ··· 51 50 <AvatarButton profile={profile} /> 52 51 <p class="text-2xl font-bold">{displayName}</p> 53 52 <p class="text-zinc-600 dark:text-zinc-500">@{profile.handle}</p> 53 + <p class="space-x-1"> 54 + <a href={followersLink(profile.handle)}> 55 + <span class="font-semibold" id="followers-count"> 56 + {followersCount ?? 0} 57 + </span>{" "} 58 + <span class="text-zinc-600 dark:text-zinc-500">followers</span> 59 + </a>{" "} 60 + <a href={followingLink(profile.handle)}> 61 + <span class="font-semibold" id="following-count"> 62 + {followingCount ?? 0} 63 + </span>{" "} 64 + <span class="text-zinc-600 dark:text-zinc-500">following</span> 65 + </a>{" "} 66 + <span class="font-semibold">{galleries?.length ?? 0}</span> 67 + <span class="text-zinc-600 dark:text-zinc-500">galleries</span> 68 + </p> 54 69 {profile.description 55 70 ? <p class="mt-2 sm:max-w-[500px]">{profile.description}</p> 56 71 : null} ··· 69 84 {!isCreator && loggedInUserDid 70 85 ? ( 71 86 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 72 - {grainOnly 73 - ? ( 74 - <FollowButton 75 - followeeDid={profile.did} 76 - followUri={followMap["social.grain.graph.follow"]} 77 - /> 78 - ) 79 - : ( 80 - <FollowsButton 81 - actorProfiles={profilesIntersection} 82 - followeeDid={profile.did} 83 - followMap={followMap} 84 - /> 85 - )} 87 + <FollowButton 88 + followeeDid={profile.did} 89 + followUri={followUri} 90 + /> 86 91 </div> 87 92 ) 88 93 : null} ··· 130 135 > 131 136 <button 132 137 type="button" 138 + name="tab" 139 + value="galleries" 133 140 hx-get={profileLink(profile.handle)} 134 - hx-target="body" 141 + hx-target="#profile-page" 135 142 hx-swap="outerHTML" 136 143 class={cn( 137 144 "flex-1 min-w-[120px] py-2 px-4 cursor-pointer font-semibold", 138 - !selectedTab && "bg-zinc-100 dark:bg-zinc-800 font-semibold", 145 + selectedTab === "galleries" && "bg-zinc-100 dark:bg-zinc-800", 139 146 )} 140 147 role="tab" 141 - aria-selected={!selectedTab} 148 + aria-selected={selectedTab === "galleries"} 142 149 aria-controls="tab-content" 143 - hx-push-url="true" 144 150 > 145 - Activity 151 + Galleries 146 152 </button> 147 153 {isCreator && ( 148 154 <button ··· 159 165 role="tab" 160 166 aria-selected={selectedTab === "favs"} 161 167 aria-controls="tab-content" 162 - hx-push-url="true" 163 168 > 164 169 Favs 165 170 </button> 166 171 )} 167 - <button 172 + { 173 + /* <button 168 174 type="button" 169 - name="tab" 170 - value="galleries" 171 175 hx-get={profileLink(profile.handle)} 172 - hx-target="#profile-page" 176 + hx-target="body" 173 177 hx-swap="outerHTML" 174 178 class={cn( 175 179 "flex-1 min-w-[120px] py-2 px-4 cursor-pointer font-semibold", 176 - selectedTab === "galleries" && "bg-zinc-100 dark:bg-zinc-800", 180 + !selectedTab && "bg-zinc-100 dark:bg-zinc-800 font-semibold", 177 181 )} 178 182 role="tab" 179 - aria-selected={selectedTab === "galleries"} 183 + aria-selected={!selectedTab} 180 184 aria-controls="tab-content" 181 185 hx-push-url="true" 182 186 > 183 - Galleries 184 - </button> 187 + Activity 188 + </button> */ 189 + } 185 190 </div> 186 191 <div id="tab-content" role="tabpanel"> 187 - {!selectedTab 188 - ? ( 189 - <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y w-fit"> 190 - {timelineItems.length 191 - ? ( 192 - timelineItems.map((item) => ( 193 - <Item item={item} key={item.itemUri} /> 194 - )) 195 - ) 196 - : <li>No activity yet.</li>} 197 - </ul> 198 - ) 199 - : null} 200 - {selectedTab === "favs" 192 + {selectedTab === "galleries" 201 193 ? ( 202 194 <div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4"> 203 - {galleryFavs?.length 195 + {galleries?.length 204 196 ? ( 205 - galleryFavs.map((gallery) => ( 197 + galleries.map((gallery) => ( 206 198 <a 207 199 href={galleryLink( 208 200 gallery.creator.handle, ··· 222 214 : ( 223 215 <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" /> 224 216 )} 225 - <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2 flex items-center gap-2"> 226 - <ActorAvatar profile={gallery.creator} size={20} />{" "} 217 + <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2"> 227 218 {(gallery.record as Gallery).title} 228 219 </div> 229 220 </a> 230 221 )) 231 222 ) 232 - : <p>No favs yet.</p>} 223 + : <p>No galleries yet.</p>} 233 224 </div> 234 225 ) 235 226 : null} 236 - {selectedTab === "galleries" 227 + {selectedTab === "favs" 237 228 ? ( 238 229 <div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4"> 239 - {galleries?.length 230 + {galleryFavs?.length 240 231 ? ( 241 - galleries.map((gallery) => ( 232 + galleryFavs.map((gallery) => ( 242 233 <a 243 234 href={galleryLink( 244 235 gallery.creator.handle, ··· 258 249 : ( 259 250 <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" /> 260 251 )} 261 - <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2"> 252 + <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2 flex items-center gap-2"> 253 + <ActorAvatar profile={gallery.creator} size={20} />{" "} 262 254 {(gallery.record as Gallery).title} 263 255 </div> 264 256 </a> 265 257 )) 266 258 ) 267 - : <p>No galleries yet.</p>} 259 + : <p>No favs yet.</p>} 268 260 </div> 269 261 ) 270 262 : null} 263 + { 264 + /* {!selectedTab 265 + ? ( 266 + <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y w-fit"> 267 + {timelineItems.length 268 + ? ( 269 + timelineItems.map((item) => ( 270 + <Item item={item} key={item.itemUri} /> 271 + )) 272 + ) 273 + : <li>No activity yet.</li>} 274 + </ul> 275 + ) 276 + : null} */ 277 + } 271 278 </div> 272 279 </div> 273 280 );
+18 -39
src/legal.tsx
··· 1 1 import { ComponentChildren } from "preact"; 2 + import { Breadcrumb } from "./components/Breadcrumb.tsx"; 2 3 3 4 type SectionProps = { 4 5 title: string; ··· 19 20 export function Terms() { 20 21 return ( 21 22 <div className="px-4 py-4"> 22 - <nav className="mb-4 text-sm text-zinc-500 dark:text-zinc-300"> 23 - <a href="/support" className="text-sky-500 hover:underline"> 24 - support 25 - </a>{" "} 26 - <span className="mx-1">&gt;</span>{" "} 27 - <span className="text-zinc-700 dark:text-zinc-100">terms</span> 28 - </nav> 23 + <Breadcrumb 24 + items={[{ label: "support", href: "/support" }, { label: "terms" }]} 25 + /> 29 26 <h1 className="text-3xl font-bold mb-6 text-zinc-900 dark:text-white"> 30 27 Terms and Conditions 31 28 </h1> 32 - 33 29 <div className="mb-6 text-sm text-zinc-900 dark:text-white"> 34 30 Last Updated: June 3, 2025 35 31 </div> 36 - 37 32 <Section title="Overview"> 38 33 <p> 39 34 Grain is a photo sharing app built on the{" "} ··· 42 37 className="text-sky-500 hover:underline" 43 38 target="_blank" 44 39 rel="noopener noreferrer" 45 - > 46 - AT Protocol 47 - </a> 48 - . All data, including photos, galleries, favorites, and metadata, is 49 - public and stored on the AT Protocol network. Users can upload photos, 50 - create and favorite galleries, and view non-location EXIF metadata. 40 + /> 41 + AT Protocol . All data, including photos, galleries, favorites, and 42 + metadata, is public and stored on the AT Protocol network. Users can 43 + upload photos, create and favorite galleries, and view non-location 44 + EXIF metadata. 51 45 </p> 52 46 <p> 53 47 Grain is an open source project. These Terms apply to your use of the ··· 140 134 className="text-sky-500 hover:underline" 141 135 > 142 136 support@grain.social 143 - </a> 144 - . 137 + </a>. 145 138 </p> 146 139 </Section> 147 140 </div> ··· 151 144 export function PrivacyPolicy() { 152 145 return ( 153 146 <div className="px-4 py-4"> 154 - <nav className="mb-4 text-sm text-zinc-500 dark:text-zinc-300"> 155 - <a href="/support" className="text-sky-500 hover:underline"> 156 - support 157 - </a>{" "} 158 - <span className="mx-1">&gt;</span>{" "} 159 - <span className="text-zinc-700 dark:text-zinc-100">privacy</span> 160 - </nav> 147 + <Breadcrumb 148 + items={[{ label: "support", href: "/support" }, { label: "privacy" }]} 149 + /> 161 150 <h1 className="text-3xl font-bold mb-6 text-zinc-900 dark:text-white"> 162 151 Privacy Policy 163 152 </h1> 164 - 165 153 <div className="mb-6 text-sm text-zinc-900 dark:text-white"> 166 154 Last Updated: June 3, 2025 167 155 </div> 168 - 169 156 <Section title="Data Storage and Access"> 170 157 <p> 171 158 Your data is stored on the AT Protocol. If you use a{" "} ··· 245 232 className="text-sky-500 hover:underline" 246 233 > 247 234 support@grain.social 248 - </a> 249 - . 235 + </a>. 250 236 </p> 251 237 </Section> 252 238 </div> ··· 256 242 export function CopyrightPolicy() { 257 243 return ( 258 244 <div className="px-4 py-4"> 259 - <nav className="mb-4 text-sm text-zinc-500 dark:text-zinc-300"> 260 - <a href="/support" className="text-sky-500 hover:underline"> 261 - support 262 - </a>{" "} 263 - <span className="mx-1">&gt;</span>{" "} 264 - <span className="text-zinc-700 dark:text-zinc-100">copyright</span> 265 - </nav> 245 + <Breadcrumb 246 + items={[{ label: "support", href: "/support" }, { label: "copyright" }]} 247 + /> 266 248 <h1 className="text-3xl font-bold mb-6 text-zinc-900 dark:text-white"> 267 249 Copyright Policy 268 250 </h1> 269 - 270 251 <div className="mb-6 text-sm text-zinc-900 dark:text-white"> 271 252 Last Updated: June 3, 2025 272 253 </div> 273 - 274 254 <Section title="Copyright Infringement"> 275 255 <p> 276 256 Grain respects the intellectual property rights of others and expects ··· 317 297 className="text-sky-500 hover:underline" 318 298 > 319 299 support@grain.social 320 - </a> 321 - . 300 + </a>. 322 301 </p> 323 302 </Section> 324 303 </div>
+80 -36
src/lib/follow.ts
··· 1 - import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts"; 2 - import { Record as TangledFollow } from "$lexicon/types/sh/tangled/graph/follow.ts"; 3 1 import { Record as GrainFollow } from "$lexicon/types/social/grain/graph/follow.ts"; 4 2 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 3 + import { getActorProfile } from "./actor.ts"; 5 4 6 - export type FollowSource = 7 - | "app.bsky.graph.follow" 8 - | "sh.tangled.graph.follow" 9 - | "social.grain.graph.follow"; 10 - 11 - export type FollowMap = Record<FollowSource, string>; 12 - 13 - export function getFollows( 5 + export function getFollow( 14 6 followeeDid: string, 15 7 followerDid: string, 16 8 ctx: BffContext, 17 - ): FollowMap { 18 - const sources: FollowSource[] = [ 19 - "app.bsky.graph.follow", 20 - "sh.tangled.graph.follow", 9 + ) { 10 + const { 11 + items: [follow], 12 + } = ctx.indexService.getRecords< 13 + WithBffMeta<GrainFollow> 14 + >( 21 15 "social.grain.graph.follow", 22 - ]; 23 - 24 - const result: FollowMap = {} as FollowMap; 25 - 26 - for (const source of sources) { 27 - const { 28 - items: [follow], 29 - } = ctx.indexService.getRecords< 30 - WithBffMeta<BskyFollow | GrainFollow | TangledFollow> 31 - >(source, { 32 - where: [ 33 - { 16 + { 17 + where: { 18 + AND: [{ 34 19 field: "did", 35 20 equals: followerDid, 36 - }, 37 - { 21 + }, { 38 22 field: "subject", 39 23 equals: followeeDid, 40 - }, 41 - ], 42 - }); 43 - if (follow && "uri" in follow) { 44 - result[source] = follow.uri; 45 - } 46 - } 47 - return result; 24 + }], 25 + }, 26 + }, 27 + ); 28 + 29 + return follow; 30 + } 31 + 32 + export function getFollowers( 33 + followeeDid: string, 34 + ctx: BffContext, 35 + ): WithBffMeta<GrainFollow>[] { 36 + const { items: followers } = ctx.indexService.getRecords< 37 + WithBffMeta<GrainFollow> 38 + >( 39 + "social.grain.graph.follow", 40 + { 41 + orderBy: [{ field: "createdAt", direction: "desc" }], 42 + where: [{ 43 + field: "subject", 44 + equals: followeeDid, 45 + }], 46 + }, 47 + ); 48 + return followers; 49 + } 50 + 51 + export function getFollowing( 52 + followerDid: string, 53 + ctx: BffContext, 54 + ): WithBffMeta<GrainFollow>[] { 55 + const { items: following } = ctx.indexService.getRecords< 56 + WithBffMeta<GrainFollow> 57 + >( 58 + "social.grain.graph.follow", 59 + { 60 + orderBy: [{ field: "createdAt", direction: "desc" }], 61 + where: [{ 62 + field: "did", 63 + equals: followerDid, 64 + }], 65 + }, 66 + ); 67 + return following; 68 + } 69 + 70 + export function getFollowersWithProfiles( 71 + followeeDid: string, 72 + ctx: BffContext, 73 + ) { 74 + const followers = getFollowers(followeeDid, ctx); 75 + return followers 76 + .map((follow) => getActorProfile(follow.did, ctx)) 77 + .filter((profile): profile is NonNullable<typeof profile> => 78 + profile != null 79 + ); 80 + } 81 + 82 + export function getFollowingWithProfiles( 83 + followerDid: string, 84 + ctx: BffContext, 85 + ) { 86 + const following = getFollowing(followerDid, ctx); 87 + return following 88 + .map((follow) => getActorProfile(follow.subject, ctx)) 89 + .filter((profile): profile is NonNullable<typeof profile> => 90 + profile != null 91 + ); 48 92 }
+27 -14
src/lib/notifications.ts
··· 1 1 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 2 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 3 + import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts"; 3 4 import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 4 5 import { Un$Typed } from "$lexicon/util.ts"; 5 6 import { ActorTable, BffContext, WithBffMeta } from "@bigmoves/bff"; 6 7 import { getActorProfile } from "./actor.ts"; 7 8 8 - export type NotificationRecords = WithBffMeta<Favorite>; 9 + export type NotificationRecords = WithBffMeta<Favorite | Follow>; 9 10 10 11 export function getNotifications( 11 12 currentUser: ActorTable, ··· 13 14 ) { 14 15 const { lastSeenNotifs } = currentUser; 15 16 const notifications = ctx.getNotifications<NotificationRecords>(); 16 - return notifications.map((notification) => { 17 - const actor = ctx.indexService.getActor(notification.did); 18 - const authorProfile = getActorProfile(notification.did, ctx); 19 - if (!actor || !authorProfile) return null; 20 - return notificationToView( 21 - notification, 22 - authorProfile, 23 - lastSeenNotifs, 24 - ); 25 - }).filter((view): view is Un$Typed<NotificationView> => Boolean(view)); 17 + return notifications 18 + .filter( 19 + (notification) => 20 + notification.$type === "social.grain.favorite" || 21 + notification.$type === "social.grain.graph.follow", 22 + ) 23 + .map((notification) => { 24 + const actor = ctx.indexService.getActor(notification.did); 25 + const authorProfile = getActorProfile(notification.did, ctx); 26 + if (!actor || !authorProfile) return null; 27 + return notificationToView( 28 + notification, 29 + authorProfile, 30 + lastSeenNotifs, 31 + ); 32 + }) 33 + .filter((view): view is Un$Typed<NotificationView> => Boolean(view)); 26 34 } 27 35 28 36 export function notificationToView( ··· 30 38 author: Un$Typed<ProfileView>, 31 39 lastSeenNotifs: string | undefined, 32 40 ): Un$Typed<NotificationView> { 33 - const reason = record.$type === "social.grain.favorite" 34 - ? "gallery-favorite" 35 - : "unknown"; 41 + let reason: string; 42 + if (record.$type === "social.grain.favorite") { 43 + reason = "gallery-favorite"; 44 + } else if (record.$type === "social.grain.graph.follow") { 45 + reason = "follow"; 46 + } else { 47 + reason = "unknown"; 48 + } 36 49 const reasonSubject = record.$type === "social.grain.favorite" 37 50 ? record.subject 38 51 : undefined;
+6 -3
src/main.tsx
··· 7 7 import * as actionHandlers from "./routes/actions.tsx"; 8 8 import * as dialogHandlers from "./routes/dialogs.tsx"; 9 9 import { handler as exploreHandler } from "./routes/explore.tsx"; 10 + import { handler as followersHandler } from "./routes/followers.tsx"; 11 + import { handler as followsHandler } from "./routes/follows.tsx"; 10 12 import { handler as galleryHandler } from "./routes/gallery.tsx"; 11 13 import * as legalHandlers from "./routes/legal.tsx"; 12 14 import { handler as notificationsHandler } from "./routes/notifications.tsx"; ··· 59 61 route("/explore", exploreHandler), 60 62 route("/notifications", notificationsHandler), 61 63 route("/profile/:handle", profileHandler), 64 + route("/profile/:handle/followers", followersHandler), 65 + route("/profile/:handle/follows", followsHandler), 62 66 route("/profile/:handle/gallery/:rkey", galleryHandler), 63 67 route("/upload", uploadHandler), 64 68 route("/onboard", onboardHandler), ··· 78 82 "/dialogs/photo-select/:galleryRkey", 79 83 dialogHandlers.galleryPhotoSelect, 80 84 ), 81 - route("/dialogs/follows/:followeeDid", dialogHandlers.follows), 82 85 route("/actions/update-seen", ["POST"], actionHandlers.updateSeen), 83 - route("/actions/follow/:did", ["POST"], actionHandlers.follow), 86 + route("/actions/follow/:followeeDid", ["POST"], actionHandlers.follow), 84 87 route( 85 - "/actions/follow/:followeeDid", 88 + "/actions/follow/:followeeDid/:rkey", 86 89 ["DELETE"], 87 90 actionHandlers.unfollow, 88 91 ),
+26 -42
src/routes/actions.tsx
··· 12 12 import { PhotoButton } from "../components/PhotoButton.tsx"; 13 13 import { PhotoPreview } from "../components/PhotoPreview.tsx"; 14 14 import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx"; 15 - import { BadRequestError } from "../lib/errors.ts"; 15 + import { getFollowers } from "../lib/follow.ts"; 16 16 import { deleteGallery, getGallery, getGalleryFavs } from "../lib/gallery.ts"; 17 17 import { photoThumb, photoToView } from "../lib/photo.ts"; 18 18 import type { State } from "../state.ts"; ··· 29 29 }; 30 30 31 31 export const follow: RouteHandler = async ( 32 - req, 32 + _req, 33 33 params, 34 34 ctx: BffContext<State>, 35 35 ) => { 36 36 ctx.requireAuth(); 37 - const did = params.did; 38 - const url = new URL(req.url); 39 - const collection = url.searchParams.get("collection") || undefined; 40 - const hideCollection = url.searchParams.get("hideCollection") === "true"; 41 - // TODO: check for supported collections 42 - if (!did || !collection) { 43 - throw new BadRequestError("Missing did or collection"); 44 - } 37 + const followeeDid = params.followeeDid; 38 + if (!followeeDid) return ctx.next(); 45 39 const followUri = await ctx.createRecord<BskyFollow>( 46 - collection, 40 + "social.grain.graph.follow", 47 41 { 48 - subject: did, 42 + subject: followeeDid, 49 43 createdAt: new Date().toISOString(), 50 44 }, 51 45 ); 52 - if (collection) { 53 - return ctx.html( 54 - <FollowButton 55 - {...!hideCollection && { 56 - class: "sm:w-full", 57 - collection, 58 - }} 59 - followeeDid={did} 60 - followUri={followUri} 61 - />, 62 - ); 63 - } 64 - return ctx.html(<FollowButton followeeDid={did} followUri={followUri} />); 46 + const followers = getFollowers(followeeDid, ctx); 47 + return ctx.html( 48 + <> 49 + <div hx-swap-oob="innerHTML:#followers-count"> 50 + {followers.length} 51 + </div> 52 + <FollowButton followeeDid={followeeDid} followUri={followUri} /> 53 + </>, 54 + ); 65 55 }; 66 56 67 57 export const unfollow: RouteHandler = async ( 68 - req, 58 + _req, 69 59 params, 70 60 ctx: BffContext<State>, 71 61 ) => { 72 - ctx.requireAuth(); 62 + const { did } = ctx.requireAuth(); 73 63 const followeeDid = params.followeeDid; 74 - const url = new URL(req.url); 75 - const uri = url.searchParams.get("uri") || undefined; 76 - const hideCollection = url.searchParams.get("hideCollection") === "true"; 77 - if (!followeeDid || !uri) { 78 - throw new BadRequestError("Missing followeeDid or uri"); 79 - } 64 + const rkey = params.rkey; 80 65 await ctx.deleteRecord( 81 - uri, 66 + `at://${did}/social.grain.graph.follow/${rkey}`, 82 67 ); 68 + const followers = getFollowers(followeeDid, ctx); 83 69 return ctx.html( 84 - <FollowButton 85 - {...!hideCollection && { 86 - class: "sm:w-full", 87 - collection: new AtUri(uri).collection, 88 - }} 89 - followeeDid={followeeDid} 90 - followUri={undefined} 91 - />, 70 + <> 71 + <div hx-swap-oob="innerHTML:#followers-count"> 72 + {followers.length} 73 + </div> 74 + <FollowButton followeeDid={followeeDid} followUri={undefined} /> 75 + </>, 92 76 ); 93 77 }; 94 78
+1 -29
src/routes/dialogs.tsx
··· 7 7 import { wrap } from "popmotion"; 8 8 import { AvatarDialog } from "../components/AvatarDialog.tsx"; 9 9 import { CreateAccountDialog } from "../components/CreateAccountDialog.tsx"; 10 - import { FollowsDialog } from "../components/FollowsDialog.tsx"; 11 10 import { GalleryCreateEditDialog } from "../components/GalleryCreateEditDialog.tsx"; 12 11 import { GallerySortDialog } from "../components/GallerySortDialog.tsx"; 13 12 import { PhotoAltDialog } from "../components/PhotoAltDialog.tsx"; 14 13 import { PhotoDialog } from "../components/PhotoDialog.tsx"; 15 14 import { PhotoSelectDialog } from "../components/PhotoSelectDialog.tsx"; 16 15 import { ProfileDialog } from "../components/ProfileDialog.tsx"; 17 - import { 18 - getActorPhotos, 19 - getActorProfile, 20 - getActorProfiles, 21 - } from "../lib/actor.ts"; 22 - import { BadRequestError } from "../lib/errors.ts"; 23 - import { getFollows } from "../lib/follow.ts"; 16 + import { getActorPhotos, getActorProfile } from "../lib/actor.ts"; 24 17 import { getGallery, getGalleryItemsAndPhotos } from "../lib/gallery.ts"; 25 18 import { photoToView } from "../lib/photo.ts"; 26 19 import type { State } from "../state.ts"; ··· 169 162 ) => { 170 163 return ctx.html(<CreateAccountDialog />); 171 164 }; 172 - 173 - export const follows: RouteHandler = ( 174 - _req, 175 - params, 176 - ctx: BffContext<State>, 177 - ) => { 178 - const { did } = ctx.requireAuth(); 179 - const followeeDid = params.followeeDid; 180 - if (!followeeDid) { 181 - throw new BadRequestError("Missing followeeDid parameter"); 182 - } 183 - const followMap = getFollows(followeeDid, did, ctx); 184 - const sources = getActorProfiles(followeeDid, ctx); 185 - return ctx.html( 186 - <FollowsDialog 187 - sources={sources} 188 - followeeDid={followeeDid} 189 - followMap={followMap} 190 - />, 191 - ); 192 - };
+44
src/routes/followers.tsx
··· 1 + import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 + import { Breadcrumb } from "../components/Breadcrumb.tsx"; 3 + import { FollowsList } from "../components/FollowsList.tsx"; 4 + import { Header } from "../components/Header.tsx"; 5 + import { getActorProfile } from "../lib/actor.ts"; 6 + import { getFollowersWithProfiles } from "../lib/follow.ts"; 7 + import { State } from "../state.ts"; 8 + import { profileLink } from "../utils.ts"; 9 + 10 + export const handler: RouteHandler = ( 11 + _req, 12 + params, 13 + ctx: BffContext<State>, 14 + ) => { 15 + const handle = params.handle; 16 + if (!handle) return ctx.next(); 17 + 18 + const actor = ctx.indexService.getActorByHandle(handle); 19 + 20 + if (!actor) return ctx.next(); 21 + 22 + const profile = getActorProfile(actor?.did, ctx); 23 + 24 + if (!actor) return ctx.next(); 25 + 26 + const followers = getFollowersWithProfiles(actor.did, ctx); 27 + 28 + ctx.state.meta = [{ title: `People following @${handle} — Grain` }]; 29 + 30 + return ctx.render( 31 + <div class="p-4"> 32 + <Breadcrumb 33 + items={[{ label: "profile", href: profileLink(handle) }, { 34 + label: "followers", 35 + }]} 36 + /> 37 + <Header>{profile?.displayName}</Header> 38 + <p class="mb-6 text-zinc-600 dark:text-zinc-500"> 39 + {followers.length ?? 0} followers 40 + </p> 41 + <FollowsList profiles={followers} /> 42 + </div>, 43 + ); 44 + };
+44
src/routes/follows.tsx
··· 1 + import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 + import { Breadcrumb } from "../components/Breadcrumb.tsx"; 3 + import { FollowsList } from "../components/FollowsList.tsx"; 4 + import { Header } from "../components/Header.tsx"; 5 + import { getActorProfile } from "../lib/actor.ts"; 6 + import { getFollowingWithProfiles } from "../lib/follow.ts"; 7 + import { State } from "../state.ts"; 8 + import { profileLink } from "../utils.ts"; 9 + 10 + export const handler: RouteHandler = ( 11 + _req, 12 + params, 13 + ctx: BffContext<State>, 14 + ) => { 15 + const handle = params.handle; 16 + if (!handle) return ctx.next(); 17 + 18 + const actor = ctx.indexService.getActorByHandle(handle); 19 + 20 + if (!actor) return ctx.next(); 21 + 22 + const profile = getActorProfile(actor?.did, ctx); 23 + 24 + if (!actor) return ctx.next(); 25 + 26 + const following = getFollowingWithProfiles(actor.did, ctx); 27 + 28 + ctx.state.meta = [{ title: `People followed by @${handle} — Grain` }]; 29 + 30 + return ctx.render( 31 + <div class="p-4"> 32 + <Breadcrumb 33 + items={[{ label: "profile", href: profileLink(handle) }, { 34 + label: "following", 35 + }]} 36 + /> 37 + <Header>{profile?.displayName}</Header> 38 + <p class="mb-6 text-zinc-600 dark:text-zinc-500"> 39 + {following.length ?? 0} following 40 + </p> 41 + <FollowsList profiles={following} /> 42 + </div>, 43 + ); 44 + };
+5
src/routes/gallery.tsx
··· 17 17 const handle = params.handle; 18 18 const rkey = params.rkey; 19 19 const gallery = getGallery(handle, rkey, ctx); 20 + 20 21 if (!gallery) return ctx.next(); 22 + 21 23 favs = getGalleryFavs(gallery.uri, ctx); 24 + 22 25 ctx.state.meta = [ 23 26 { title: `${(gallery.record as Gallery).title} — Grain` }, 24 27 ...getPageMeta(galleryLink(handle, rkey)), 25 28 ...getGalleryMeta(gallery), 26 29 ]; 30 + 27 31 ctx.state.scripts = ["photo_dialog.js", "masonry.js", "sortable.js"]; 32 + 28 33 return ctx.render( 29 34 <GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />, 30 35 );
+18 -31
src/routes/profile.tsx
··· 6 6 getActorProfile, 7 7 getActorProfiles, 8 8 } from "../lib/actor.ts"; 9 - import { type FollowMap, getFollows } from "../lib/follow.ts"; 10 - import { getActorTimeline, type SocialNetwork } from "../lib/timeline.ts"; 9 + import { getFollow, getFollowers, getFollowing } from "../lib/follow.ts"; 10 + import { type SocialNetwork } from "../lib/timeline.ts"; 11 11 import { getPageMeta } from "../meta.ts"; 12 12 import type { State } from "../state.ts"; 13 13 import { profileLink } from "../utils.ts"; ··· 20 20 const url = new URL(req.url); 21 21 const tab = url.searchParams.get("tab") as ProfileTabs; 22 22 const handle = params.handle; 23 - const timelineItems = getActorTimeline(handle, ctx); 24 23 const actor = ctx.indexService.getActorByHandle(handle); 25 24 const isHxRequest = req.headers.get("hx-request") !== null; 26 25 const render = isHxRequest ? ctx.html : ctx.render; ··· 28 27 if (!actor) return ctx.next(); 29 28 30 29 const profile = getActorProfile(actor.did, ctx); 30 + const galleries = getActorGalleries(handle, ctx); 31 + const followers = getFollowers(actor.did, ctx); 32 + const following = getFollowing(actor.did, ctx); 31 33 32 34 if (!profile) return ctx.next(); 33 35 34 - let followMap: FollowMap = { 35 - "social.grain.graph.follow": "", 36 - "app.bsky.graph.follow": "", 37 - "sh.tangled.graph.follow": "", 38 - }; 36 + let followUri: string | undefined; 39 37 let actorProfiles: SocialNetwork[] = []; 40 38 let userProfiles: SocialNetwork[] = []; 41 39 42 40 if (ctx.currentUser) { 43 - followMap = getFollows(profile.did, ctx.currentUser.did, ctx); 41 + followUri = getFollow(profile.did, ctx.currentUser.did, ctx)?.uri; 44 42 actorProfiles = getActorProfiles(ctx.currentUser.did, ctx); 45 43 } 46 44 ··· 61 59 const galleryFavs = getActorGalleryFavs(handle, ctx); 62 60 return render( 63 61 <ProfilePage 62 + followersCount={followers.length} 63 + followingCount={following.length} 64 64 userProfiles={userProfiles} 65 65 actorProfiles={actorProfiles} 66 - followMap={followMap} 66 + followUri={followUri} 67 67 loggedInUserDid={ctx.currentUser?.did} 68 - timelineItems={timelineItems} 69 68 profile={profile} 70 - selectedTab={tab} 71 - galleries={[]} 69 + selectedTab="favs" 70 + galleries={galleries} 72 71 galleryFavs={galleryFavs} 73 72 />, 74 73 ); 75 74 } 76 - if (tab === "galleries") { 77 - const galleries = getActorGalleries(handle, ctx); 78 - return render( 79 - <ProfilePage 80 - userProfiles={userProfiles} 81 - actorProfiles={actorProfiles} 82 - followMap={followMap} 83 - loggedInUserDid={ctx.currentUser?.did} 84 - timelineItems={timelineItems} 85 - profile={profile} 86 - selectedTab={tab} 87 - galleries={galleries} 88 - />, 89 - ); 90 - } 91 - return ctx.render( 75 + return render( 92 76 <ProfilePage 77 + followersCount={followers.length} 78 + followingCount={following.length} 93 79 userProfiles={userProfiles} 94 80 actorProfiles={actorProfiles} 95 - followMap={followMap} 81 + followUri={followUri} 96 82 loggedInUserDid={ctx.currentUser?.did} 97 - timelineItems={timelineItems} 98 83 profile={profile} 84 + selectedTab="galleries" 85 + galleries={galleries} 99 86 />, 100 87 ); 101 88 };
+8
src/utils.ts
··· 32 32 return `/profile/${handle}`; 33 33 } 34 34 35 + export function followersLink(handle: string) { 36 + return `/profile/${handle}/followers`; 37 + } 38 + 39 + export function followingLink(handle: string) { 40 + return `/profile/${handle}/follows`; 41 + } 42 + 35 43 export function galleryLink(handle: string, galleryRkey: string) { 36 44 return `/profile/${handle}/gallery/${galleryRkey}`; 37 45 }
+10 -5
static/styles.css
··· 287 287 .mx-1 { 288 288 margin-inline: calc(var(--spacing) * 1); 289 289 } 290 + .mx-2 { 291 + margin-inline: calc(var(--spacing) * 2); 292 + } 290 293 .mx-auto { 291 294 margin-inline: auto; 292 295 } ··· 525 528 --tw-space-y-reverse: 0; 526 529 margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); 527 530 margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); 531 + } 532 + } 533 + .space-x-1 { 534 + :where(& > :not(:last-child)) { 535 + --tw-space-x-reverse: 0; 536 + margin-inline-start: calc(calc(var(--spacing) * 1) * var(--tw-space-x-reverse)); 537 + margin-inline-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-x-reverse))); 528 538 } 529 539 } 530 540 .space-x-2 { ··· 873 883 .sm\:w-fit { 874 884 @media (width >= 40rem) { 875 885 width: fit-content; 876 - } 877 - } 878 - .sm\:w-full { 879 - @media (width >= 40rem) { 880 - width: 100%; 881 886 } 882 887 } 883 888 .sm\:max-w-\[400px\] {