a tool for shared writing and social publishing
0

Configure Feed

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

render profile sub things as pages

Jared Pereira (Dec 12, 2025, 1:01 PM +0400) 9ede2f2e 97decb38

+439 -428
-151
app/p/[didOrHandle]/ProfilePageLayout.tsx
··· 1 - "use client"; 2 - import { Actions } from "app/(home-pages)/home/Actions/Actions"; 3 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 4 - import { useState } from "react"; 5 - import { ProfileTabs, TabContent } from "./ProfileTabs/Tabs"; 6 - import { Json } from "supabase/database.types"; 7 - import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 8 - import { Avatar } from "components/Avatar"; 9 - import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 10 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 - import { PubIcon } from "components/ActionBar/Publications"; 12 - import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 13 - import { colorToString } from "components/ThemeManager/useColorAttribute"; 14 - import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 15 - import type { Cursor } from "./getProfilePosts"; 16 - 17 - export const ProfilePageLayout = (props: { 18 - publications: { record: Json; uri: string }[]; 19 - posts: Post[]; 20 - nextCursor: Cursor | null; 21 - profile: { 22 - did: string; 23 - handle: string | null; 24 - indexed_at: string; 25 - record: Json; 26 - } | null; 27 - }) => { 28 - if (!props.profile) return null; 29 - 30 - return ( 31 - <DashboardLayout 32 - id={props.profile.did} 33 - cardBorderHidden={false} 34 - defaultTab="home" 35 - tabs={{ 36 - home: { 37 - content: ( 38 - <ProfilePageContent 39 - profile={props.profile} 40 - publications={props.publications} 41 - posts={props.posts} 42 - nextCursor={props.nextCursor} 43 - /> 44 - ), 45 - controls: null, 46 - }, 47 - }} 48 - actions={<Actions />} 49 - currentPage="home" 50 - /> 51 - ); 52 - }; 53 - 54 - export type profileTabsType = "posts" | "comments" | "subscriptions"; 55 - const ProfilePageContent = (props: { 56 - publications: { record: Json; uri: string }[]; 57 - posts: Post[]; 58 - nextCursor: Cursor | null; 59 - profile: { 60 - did: string; 61 - handle: string | null; 62 - indexed_at: string; 63 - record: Json; 64 - } | null; 65 - }) => { 66 - let [tab, setTab] = useState<profileTabsType>("posts"); 67 - 68 - let profileRecord = props.profile?.record as AppBskyActorProfile.Record; 69 - 70 - if (!props.profile) return; 71 - return ( 72 - <div className="h-full"> 73 - <div 74 - className={` 75 - max-w-prose mx-auto w-full h-full 76 - flex flex-col 77 - border border-border-light rounded-lg 78 - text-center mt-8`} 79 - > 80 - <Avatar 81 - src={ 82 - profileRecord.avatar?.ref && 83 - blobRefToSrc(profileRecord.avatar?.ref, props.profile.did) 84 - } 85 - displayName={profileRecord.displayName} 86 - className="mx-auto -mt-8" 87 - giant 88 - /> 89 - <div className=" px-3 sm:px-4 flex flex-col "> 90 - <h3 className="pt-2 leading-tight"> 91 - {profileRecord.displayName 92 - ? profileRecord.displayName 93 - : `@${props.profile?.handle}`} 94 - </h3> 95 - {profileRecord.displayName && ( 96 - <div className="text-tertiary text-sm pb-1 italic"> 97 - @{props.profile?.handle} 98 - </div> 99 - )} 100 - <div className="text-secondary">{profileRecord.description}</div> 101 - <div className="flex flex-row gap-2 mx-auto my-3"> 102 - <div>pub 1</div> 103 - <div>pub 2</div> 104 - </div> 105 - </div> 106 - <ProfileTabs tab={tab} setTab={setTab} /> 107 - 108 - <div className="h-full overflow-y-scroll pt-3 pb-4 px-3 sm:px-4 flex flex-col"> 109 - <TabContent 110 - tab={tab} 111 - did={props.profile.did} 112 - posts={props.posts} 113 - nextCursor={props.nextCursor} 114 - /> 115 - </div> 116 - </div> 117 - </div> 118 - ); 119 - }; 120 - 121 - const PubListingCompact = () => { 122 - return <div></div>; 123 - }; 124 - 125 - const PublicationCard = (props: { 126 - record: PubLeafletPublication.Record; 127 - uri: string; 128 - }) => { 129 - const { record, uri } = props; 130 - const { bgLeaflet, bgPage } = usePubTheme(record.theme); 131 - 132 - return ( 133 - <a 134 - href={`https://${record.base_path}`} 135 - className="border border-border p-2 rounded-lg hover:no-underline!" 136 - style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }} 137 - > 138 - <div 139 - className="rounded-md p-2 flex flex-row gap-2" 140 - style={{ 141 - backgroundColor: record.theme?.showPageBackground 142 - ? `rgb(${colorToString(bgPage, "rgb")})` 143 - : undefined, 144 - }} 145 - > 146 - <PubIcon record={record} uri={uri} /> 147 - <h4>{record.name}</h4> 148 - </div> 149 - </a> 150 - ); 151 - };
-55
app/p/[didOrHandle]/page.tsx
··· 1 - import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 - import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 3 - import { ProfilePageLayout } from "./ProfilePageLayout"; 4 - import { supabaseServerClient } from "supabase/serverClient"; 5 - import { getProfilePosts } from "./getProfilePosts"; 6 - 7 - export default async function ProfilePage(props: { 8 - params: Promise<{ didOrHandle: string }>; 9 - }) { 10 - let params = await props.params; 11 - let didOrHandle = decodeURIComponent(params.didOrHandle); 12 - 13 - // Resolve handle to DID if necessary 14 - let did = didOrHandle; 15 - 16 - if (!didOrHandle.startsWith("did:")) { 17 - let resolved = await idResolver.handle.resolve(didOrHandle); 18 - if (!resolved) { 19 - return ( 20 - <NotFoundLayout> 21 - <p className="font-bold">Sorry, can&apos;t resolve handle!</p> 22 - <p> 23 - This may be a glitch on our end. If the issue persists please{" "} 24 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 25 - </p> 26 - </NotFoundLayout> 27 - ); 28 - } 29 - did = resolved; 30 - } 31 - 32 - // Fetch profile, publications, and initial posts in parallel 33 - let [{ data: profile }, { data: pubs }, { posts, nextCursor }] = 34 - await Promise.all([ 35 - supabaseServerClient 36 - .from("bsky_profiles") 37 - .select(`*`) 38 - .eq("did", did) 39 - .single(), 40 - supabaseServerClient 41 - .from("publications") 42 - .select("*") 43 - .eq("identity_did", did), 44 - getProfilePosts(did), 45 - ]); 46 - 47 - return ( 48 - <ProfilePageLayout 49 - profile={profile} 50 - publications={pubs || []} 51 - posts={posts} 52 - nextCursor={nextCursor} 53 - /> 54 - ); 55 - }
+88
app/p/[didOrHandle]/(profile)/PostsContent.tsx
··· 1 + "use client"; 2 + 3 + import { PostListing } from "components/PostListing"; 4 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 + import type { Cursor } from "../getProfilePosts"; 6 + import { getProfilePosts } from "../getProfilePosts"; 7 + import useSWRInfinite from "swr/infinite"; 8 + import { useEffect, useRef } from "react"; 9 + 10 + export const ProfilePostsContent = (props: { 11 + did: string; 12 + posts: Post[]; 13 + nextCursor: Cursor | null; 14 + }) => { 15 + const getKey = ( 16 + pageIndex: number, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 21 + ) => { 22 + // Reached the end 23 + if (previousPageData && !previousPageData.nextCursor) return null; 24 + 25 + // First page, we don't have previousPageData 26 + if (pageIndex === 0) return ["profile-posts", props.did, null] as const; 27 + 28 + // Add the cursor to the key 29 + return ["profile-posts", props.did, previousPageData?.nextCursor] as const; 30 + }; 31 + 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 33 + getKey, 34 + ([_, did, cursor]) => getProfilePosts(did, cursor), 35 + { 36 + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 37 + revalidateFirstPage: false, 38 + }, 39 + ); 40 + 41 + const loadMoreRef = useRef<HTMLDivElement>(null); 42 + 43 + // Set up intersection observer to load more when trigger element is visible 44 + useEffect(() => { 45 + const observer = new IntersectionObserver( 46 + (entries) => { 47 + if (entries[0].isIntersecting && !isValidating) { 48 + const hasMore = data && data[data.length - 1]?.nextCursor; 49 + if (hasMore) { 50 + setSize(size + 1); 51 + } 52 + } 53 + }, 54 + { threshold: 0.1 }, 55 + ); 56 + 57 + if (loadMoreRef.current) { 58 + observer.observe(loadMoreRef.current); 59 + } 60 + 61 + return () => observer.disconnect(); 62 + }, [data, size, setSize, isValidating]); 63 + 64 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 65 + 66 + if (allPosts.length === 0 && !isValidating) { 67 + return <div className="text-tertiary text-center py-4">No posts yet</div>; 68 + } 69 + 70 + return ( 71 + <div className="flex flex-col gap-2 text-left relative"> 72 + {allPosts.map((post) => ( 73 + <PostListing key={post.documents.uri} {...post} /> 74 + ))} 75 + {/* Trigger element for loading more posts */} 76 + <div 77 + ref={loadMoreRef} 78 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 79 + aria-hidden="true" 80 + /> 81 + {isValidating && ( 82 + <div className="text-center text-tertiary py-4"> 83 + Loading more posts... 84 + </div> 85 + )} 86 + </div> 87 + ); 88 + };
+42
app/p/[didOrHandle]/(profile)/ProfileDashboardLayout.tsx
··· 1 + "use client"; 2 + 3 + import { Actions } from "app/(home-pages)/home/Actions/Actions"; 4 + import { Footer } from "components/ActionBar/Footer"; 5 + import { Sidebar } from "components/ActionBar/Sidebar"; 6 + import { 7 + DesktopNavigation, 8 + MobileNavigation, 9 + } from "components/ActionBar/Navigation"; 10 + import { MediaContents } from "components/Media"; 11 + import { Separator } from "components/Layout"; 12 + 13 + export function ProfileDashboardLayout(props: { 14 + did: string; 15 + children: React.ReactNode; 16 + }) { 17 + return ( 18 + <div 19 + className={`dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6`} 20 + > 21 + <MediaContents mobile={false}> 22 + <div className="flex flex-col gap-3 my-6"> 23 + <DesktopNavigation currentPage="home" /> 24 + <Sidebar alwaysOpen> 25 + <Actions /> 26 + </Sidebar> 27 + </div> 28 + </MediaContents> 29 + <div 30 + className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-12 px-3 sm:pt-8 sm:pb-12 sm:pl-6 sm:pr-4`} 31 + id="home-content" 32 + > 33 + {props.children} 34 + </div> 35 + <Footer> 36 + <MobileNavigation currentPage="home" /> 37 + <Separator /> 38 + <Actions /> 39 + </Footer> 40 + </div> 41 + ); 42 + }
+79
app/p/[didOrHandle]/(profile)/ProfileHeader.tsx
··· 1 + "use client"; 2 + import { Avatar } from "components/Avatar"; 3 + import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 4 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 + import type { ProfileData } from "./layout"; 6 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 7 + import { colorToString } from "components/ThemeManager/useColorAttribute"; 8 + import { PubIcon } from "components/ActionBar/Publications"; 9 + import { Json } from "supabase/database.types"; 10 + 11 + export const ProfileHeader = (props: { 12 + profile: ProfileData; 13 + publications: { record: Json; uri: string }[]; 14 + }) => { 15 + let profileRecord = props.profile.record as AppBskyActorProfile.Record; 16 + 17 + return ( 18 + <> 19 + <Avatar 20 + src={ 21 + profileRecord.avatar?.ref && 22 + blobRefToSrc(profileRecord.avatar?.ref, props.profile.did) 23 + } 24 + displayName={profileRecord.displayName} 25 + className="mx-auto -mt-8" 26 + giant 27 + /> 28 + <div className="px-3 sm:px-4 flex flex-col"> 29 + <h3 className="pt-2 leading-tight"> 30 + {profileRecord.displayName 31 + ? profileRecord.displayName 32 + : `@${props.profile.handle}`} 33 + </h3> 34 + {profileRecord.displayName && ( 35 + <div className="text-tertiary text-sm pb-1 italic"> 36 + @{props.profile.handle} 37 + </div> 38 + )} 39 + <div className="text-secondary">{profileRecord.description}</div> 40 + <div className="flex flex-row gap-2 mx-auto my-3"> 41 + {props.publications.map((p) => ( 42 + <PublicationCard 43 + record={p.record as PubLeafletPublication.Record} 44 + uri={p.uri} 45 + /> 46 + ))} 47 + </div> 48 + </div> 49 + </> 50 + ); 51 + }; 52 + 53 + const PublicationCard = (props: { 54 + record: PubLeafletPublication.Record; 55 + uri: string; 56 + }) => { 57 + const { record, uri } = props; 58 + const { bgLeaflet, bgPage } = usePubTheme(record.theme); 59 + 60 + return ( 61 + <a 62 + href={`https://${record.base_path}`} 63 + className="border border-border p-2 rounded-lg hover:no-underline!" 64 + style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }} 65 + > 66 + <div 67 + className="rounded-md p-2 flex flex-row gap-2" 68 + style={{ 69 + backgroundColor: record.theme?.showPageBackground 70 + ? `rgb(${colorToString(bgPage, "rgb")})` 71 + : undefined, 72 + }} 73 + > 74 + <PubIcon record={record} uri={uri} /> 75 + <h4>{record.name}</h4> 76 + </div> 77 + </a> 78 + ); 79 + };
+53
app/p/[didOrHandle]/(profile)/ProfileTabs.tsx
··· 1 + "use client"; 2 + 3 + import { SpeedyLink } from "components/SpeedyLink"; 4 + import { useSelectedLayoutSegment } from "next/navigation"; 5 + 6 + export type ProfileTabType = "posts" | "comments" | "subscriptions"; 7 + 8 + export const ProfileTabs = (props: { didOrHandle: string }) => { 9 + const segment = useSelectedLayoutSegment(); 10 + const currentTab = (segment || "posts") as ProfileTabType; 11 + 12 + const baseUrl = `/p/${props.didOrHandle}`; 13 + 14 + return ( 15 + <div className="flex flex-col w-full px-3 sm:px-4"> 16 + <div className="flex gap-2 justify-between"> 17 + <div className="flex gap-2"> 18 + <TabLink 19 + href={baseUrl} 20 + name="Posts" 21 + selected={currentTab === "posts"} 22 + /> 23 + <TabLink 24 + href={`${baseUrl}/comments`} 25 + name="Comments" 26 + selected={currentTab === "comments"} 27 + /> 28 + </div> 29 + <TabLink 30 + href={`${baseUrl}/subscriptions`} 31 + name="Subscriptions" 32 + selected={currentTab === "subscriptions"} 33 + /> 34 + </div> 35 + <hr className="border-border-light mt-1" /> 36 + </div> 37 + ); 38 + }; 39 + 40 + const TabLink = (props: { href: string; name: string; selected: boolean }) => { 41 + return ( 42 + <SpeedyLink 43 + href={props.href} 44 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${ 45 + props.selected 46 + ? "text-accent-2 bg-accent-1 font-bold -mb-px" 47 + : "text-tertiary" 48 + }`} 49 + > 50 + {props.name} 51 + </SpeedyLink> 52 + ); 53 + };
+73
app/p/[didOrHandle]/(profile)/layout.tsx
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Json } from "supabase/database.types"; 5 + import { ProfileHeader } from "./ProfileHeader"; 6 + import { ProfileTabs } from "./ProfileTabs"; 7 + import { ProfileDashboardLayout } from "./ProfileDashboardLayout"; 8 + 9 + export default async function ProfileLayout(props: { 10 + params: Promise<{ didOrHandle: string }>; 11 + children: React.ReactNode; 12 + }) { 13 + let params = await props.params; 14 + let didOrHandle = decodeURIComponent(params.didOrHandle); 15 + 16 + // Resolve handle to DID if necessary 17 + let did = didOrHandle; 18 + 19 + if (!didOrHandle.startsWith("did:")) { 20 + let resolved = await idResolver.handle.resolve(didOrHandle); 21 + if (!resolved) { 22 + return ( 23 + <NotFoundLayout> 24 + <p className="font-bold">Sorry, can&apos;t resolve handle!</p> 25 + <p> 26 + This may be a glitch on our end. If the issue persists please{" "} 27 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 28 + </p> 29 + </NotFoundLayout> 30 + ); 31 + } 32 + did = resolved; 33 + } 34 + 35 + let { data: profile } = await supabaseServerClient 36 + .from("bsky_profiles") 37 + .select(`*`) 38 + .eq("did", did) 39 + .single(); 40 + let { data: publications } = await supabaseServerClient 41 + .from("publications") 42 + .select("*") 43 + .eq("identity_did", did); 44 + 45 + if (!profile) return null; 46 + 47 + return ( 48 + <ProfileDashboardLayout did={did}> 49 + <div className="h-full"> 50 + <div 51 + className={` 52 + max-w-prose mx-auto w-full h-full 53 + flex flex-col 54 + border border-border-light rounded-lg 55 + text-center mt-8`} 56 + > 57 + <ProfileHeader profile={profile} publications={publications || []} /> 58 + <ProfileTabs didOrHandle={params.didOrHandle} /> 59 + <div className="h-full overflow-y-scroll pt-3 pb-4 px-3 sm:px-4 flex flex-col"> 60 + {props.children} 61 + </div> 62 + </div> 63 + </div> 64 + </ProfileDashboardLayout> 65 + ); 66 + } 67 + 68 + export type ProfileData = { 69 + did: string; 70 + handle: string | null; 71 + indexed_at: string; 72 + record: Json; 73 + };
+24
app/p/[didOrHandle]/(profile)/page.tsx
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getProfilePosts } from "../getProfilePosts"; 3 + import { ProfilePostsContent } from "./PostsContent"; 4 + 5 + export default async function ProfilePostsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + let params = await props.params; 9 + let didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + let resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { posts, nextCursor } = await getProfilePosts(did); 20 + 21 + return ( 22 + <ProfilePostsContent did={did} posts={posts} nextCursor={nextCursor} /> 23 + ); 24 + }
-147
app/p/[didOrHandle]/ProfileTabs/Tabs.tsx
··· 1 - import { Tab } from "components/Tab"; 2 - import { profileTabsType } from "../ProfilePageLayout"; 3 - import { PostListing } from "components/PostListing"; 4 - import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 - import type { Cursor } from "../getProfilePosts"; 6 - import { getProfilePosts } from "../getProfilePosts"; 7 - import useSWRInfinite from "swr/infinite"; 8 - import { useEffect, useRef } from "react"; 9 - import { CommentTabContent } from "./TabContent.tsx/Comment"; 10 - 11 - export const ProfileTabs = (props: { 12 - tab: profileTabsType; 13 - setTab: (t: profileTabsType) => void; 14 - }) => { 15 - return ( 16 - <div className="flex flex-col w-full px-3 sm:px-4 "> 17 - <div className="flex gap-2 justify-between"> 18 - <div className="flex gap-2"> 19 - <Tab 20 - name="Posts" 21 - selected={props.tab === "posts"} 22 - onSelect={() => { 23 - props.setTab("posts"); 24 - }} 25 - /> 26 - <Tab 27 - name="Comments" 28 - selected={props.tab === "comments"} 29 - onSelect={() => { 30 - props.setTab("comments"); 31 - }} 32 - /> 33 - </div> 34 - <Tab 35 - name="Subscriptions" 36 - selected={props.tab === "subscriptions"} 37 - onSelect={() => { 38 - props.setTab("subscriptions"); 39 - }} 40 - /> 41 - </div> 42 - <hr className="border-border-light mt-1" /> 43 - </div> 44 - ); 45 - }; 46 - 47 - export const TabContent = (props: { 48 - tab: profileTabsType; 49 - did: string; 50 - posts: Post[]; 51 - nextCursor: Cursor | null; 52 - }) => { 53 - switch (props.tab) { 54 - case "posts": 55 - return ( 56 - <ProfilePostsContent 57 - did={props.did} 58 - posts={props.posts} 59 - nextCursor={props.nextCursor} 60 - /> 61 - ); 62 - case "comments": 63 - return <CommentTabContent />; 64 - case "subscriptions": 65 - return <div>subscriptions here!</div>; 66 - } 67 - }; 68 - 69 - const ProfilePostsContent = (props: { 70 - did: string; 71 - posts: Post[]; 72 - nextCursor: Cursor | null; 73 - }) => { 74 - const getKey = ( 75 - pageIndex: number, 76 - previousPageData: { 77 - posts: Post[]; 78 - nextCursor: Cursor | null; 79 - } | null, 80 - ) => { 81 - // Reached the end 82 - if (previousPageData && !previousPageData.nextCursor) return null; 83 - 84 - // First page, we don't have previousPageData 85 - if (pageIndex === 0) return ["profile-posts", props.did, null] as const; 86 - 87 - // Add the cursor to the key 88 - return ["profile-posts", props.did, previousPageData?.nextCursor] as const; 89 - }; 90 - 91 - const { data, size, setSize, isValidating } = useSWRInfinite( 92 - getKey, 93 - ([_, did, cursor]) => getProfilePosts(did, cursor), 94 - { 95 - fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 96 - revalidateFirstPage: false, 97 - }, 98 - ); 99 - 100 - const loadMoreRef = useRef<HTMLDivElement>(null); 101 - 102 - // Set up intersection observer to load more when trigger element is visible 103 - useEffect(() => { 104 - const observer = new IntersectionObserver( 105 - (entries) => { 106 - if (entries[0].isIntersecting && !isValidating) { 107 - const hasMore = data && data[data.length - 1]?.nextCursor; 108 - if (hasMore) { 109 - setSize(size + 1); 110 - } 111 - } 112 - }, 113 - { threshold: 0.1 }, 114 - ); 115 - 116 - if (loadMoreRef.current) { 117 - observer.observe(loadMoreRef.current); 118 - } 119 - 120 - return () => observer.disconnect(); 121 - }, [data, size, setSize, isValidating]); 122 - 123 - const allPosts = data ? data.flatMap((page) => page.posts) : []; 124 - 125 - if (allPosts.length === 0 && !isValidating) { 126 - return <div className="text-tertiary text-center py-4">No posts yet</div>; 127 - } 128 - 129 - return ( 130 - <div className="flex flex-col gap-2 text-left relative"> 131 - {allPosts.map((post) => ( 132 - <PostListing key={post.documents.uri} {...post} /> 133 - ))} 134 - {/* Trigger element for loading more posts */} 135 - <div 136 - ref={loadMoreRef} 137 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 138 - aria-hidden="true" 139 - /> 140 - {isValidating && ( 141 - <div className="text-center text-tertiary py-4"> 142 - Loading more posts... 143 - </div> 144 - )} 145 - </div> 146 - ); 147 - };
+77
app/p/[didOrHandle]/(profile)/comments/page.tsx
··· 1 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 2 + 3 + export default function ProfileCommentsPage() { 4 + return <CommentsContent />; 5 + } 6 + 7 + const CommentsContent = () => { 8 + let isReply = true; 9 + return ( 10 + <> 11 + <Comment 12 + displayName="celine" 13 + postTitle="Tagging and Flaggin Babyyyy make this super long so it doesn't wrap around please" 14 + comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol" 15 + isReply 16 + /> 17 + <Comment 18 + displayName="celine" 19 + postTitle="Another day, another test post eh," 20 + comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol" 21 + /> 22 + <Comment 23 + displayName="celine" 24 + postTitle="Some other post title" 25 + comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol" 26 + /> 27 + <Comment 28 + displayName="celine" 29 + postTitle="Leaflet Lab Notes" 30 + comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol" 31 + isReply 32 + /> 33 + </> 34 + ); 35 + }; 36 + 37 + const Comment = (props: { 38 + displayName: React.ReactNode; 39 + postTitle: string; 40 + comment: string; 41 + isReply?: boolean; 42 + }) => { 43 + return ( 44 + <div className={`w-full flex flex-col text-left mb-8`}> 45 + <div className="flex gap-2 w-full"> 46 + <div className={`rounded-full bg-test shrink-0 w-5 h-5`} /> 47 + <div className={`flex flex-col w-full min-w-0 grow`}> 48 + <div className="flex flex-row gap-2"> 49 + <div className={`text-tertiary text-sm truncate`}> 50 + <span className="font-bold text-secondary"> 51 + {props.displayName} 52 + </span>{" "} 53 + {props.isReply ? "replied" : "commented"} on{" "} 54 + <span className=" italic text-accent-contrast"> 55 + {props.postTitle} 56 + </span> 57 + </div> 58 + </div> 59 + {props.isReply && ( 60 + <div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center"> 61 + <ReplyTiny className="shrink-0" /> 62 + <div className="font-bold shrink-0">jared</div> 63 + <div className="grow truncate"> 64 + this is the content of what i was saying and its very long so i 65 + can get a good look at what's happening 66 + </div> 67 + </div> 68 + )} 69 + 70 + <div className={`w-full text-left text-secondary `}> 71 + {props.comment} 72 + </div> 73 + </div> 74 + </div> 75 + </div> 76 + ); 77 + };
+3
app/p/[didOrHandle]/(profile)/subscriptions/page.tsx
··· 1 + export default function ProfileSubscriptionsPage() { 2 + return <div>subscriptions here!</div>; 3 + }
-75
app/p/[didOrHandle]/ProfileTabs/TabContent.tsx/Comment.tsx
··· 1 - import Post from "app/lish/[did]/[publication]/[rkey]/l-quote/[quote]/page"; 2 - import { CommentTiny } from "components/Icons/CommentTiny"; 3 - import { ReplyTiny } from "components/Icons/ReplyTiny"; 4 - 5 - export const CommentTabContent = () => { 6 - let isReply = true; 7 - return ( 8 - <> 9 - <Comment 10 - displayName="celine" 11 - postTitle="Tagging and Flaggin Babyyyy make this super long so it doesn't wrap around please" 12 - comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol" 13 - isReply 14 - /> 15 - <Comment 16 - displayName="celine" 17 - postTitle="Another day, another test post eh," 18 - comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol" 19 - /> 20 - <Comment 21 - displayName="celine" 22 - postTitle="Some other post title" 23 - comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol" 24 - /> 25 - <Comment 26 - displayName="celine" 27 - postTitle="Leaflet Lab Notes" 28 - comment="Here's my reply! I'm hoping i can makie ti long enought to space two lines. Do we want rich text here? Probably not since we dont support that anyway lol" 29 - isReply 30 - /> 31 - </> 32 - ); 33 - }; 34 - 35 - const Comment = (props: { 36 - displayName: React.ReactNode; 37 - postTitle: string; 38 - comment: string; 39 - isReply?: boolean; 40 - }) => { 41 - return ( 42 - <div className={`w-full flex flex-col text-left mb-8`}> 43 - <div className="flex gap-2 w-full"> 44 - <div className={`rounded-full bg-test shrink-0 w-5 h-5`} /> 45 - <div className={`flex flex-col w-full min-w-0 grow`}> 46 - <div className="flex flex-row gap-2"> 47 - <div className={`text-tertiary text-sm truncate`}> 48 - <span className="font-bold text-secondary"> 49 - {props.displayName} 50 - </span>{" "} 51 - {props.isReply ? "replied" : "commented"} on{" "} 52 - <span className=" italic text-accent-contrast"> 53 - {props.postTitle} 54 - </span> 55 - </div> 56 - </div> 57 - {props.isReply && ( 58 - <div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center"> 59 - <ReplyTiny className="shrink-0" /> 60 - <div className="font-bold shrink-0">jared</div> 61 - <div className="grow truncate"> 62 - this is the content of what i was saying and its very long so i 63 - can get a good look at what's happening 64 - </div> 65 - </div> 66 - )} 67 - 68 - <div className={`w-full text-left text-secondary `}> 69 - {props.comment} 70 - </div> 71 - </div> 72 - </div> 73 - </div> 74 - ); 75 - };
app/p/[didOrHandle]/ProfileTabs/TabContent.tsx/Post.tsx
app/p/[didOrHandle]/ProfileTabs/TabContent.tsx/Subscription.tsx