a tool for shared writing and social publishing

render profile sub things as pages

+282 -271
+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 + }
+3
app/p/[didOrHandle]/(profile)/subscriptions/page.tsx
··· 1 + export default function ProfileSubscriptionsPage() { 2 + return <div>subscriptions here!</div>; 3 + }
-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 - };
+5 -3
app/p/[didOrHandle]/ProfileTabs/TabContent.tsx/Comment.tsx app/p/[didOrHandle]/(profile)/comments/page.tsx
··· 1 - import Post from "app/lish/[did]/[publication]/[rkey]/l-quote/[quote]/page"; 2 - import { CommentTiny } from "components/Icons/CommentTiny"; 3 1 import { ReplyTiny } from "components/Icons/ReplyTiny"; 4 2 5 - export const CommentTabContent = () => { 3 + export default function ProfileCommentsPage() { 4 + return <CommentsContent />; 5 + } 6 + 7 + const CommentsContent = () => { 6 8 let isReply = true; 7 9 return ( 8 10 <>
app/p/[didOrHandle]/ProfileTabs/TabContent.tsx/Post.tsx

This is a binary file and will not be displayed.

app/p/[didOrHandle]/ProfileTabs/TabContent.tsx/Subscription.tsx

This is a binary file and will not be displayed.

+3 -62
app/p/[didOrHandle]/ProfileTabs/Tabs.tsx app/p/[didOrHandle]/(profile)/PostsContent.tsx
··· 1 - import { Tab } from "components/Tab"; 2 - import { profileTabsType } from "../ProfilePageLayout"; 1 + "use client"; 2 + 3 3 import { PostListing } from "components/PostListing"; 4 4 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 5 import type { Cursor } from "../getProfilePosts"; 6 6 import { getProfilePosts } from "../getProfilePosts"; 7 7 import useSWRInfinite from "swr/infinite"; 8 8 import { useEffect, useRef } from "react"; 9 - import { CommentTabContent } from "./TabContent.tsx/Comment"; 10 9 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: { 10 + export const ProfilePostsContent = (props: { 70 11 did: string; 71 12 posts: Post[]; 72 13 nextCursor: Cursor | null;
-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 - }