a tool for shared writing and social publishing
0
fork

Configure Feed

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

make publishing to publication work!

+173 -209
+46 -31
actions/publishToPublication.ts
··· 26 26 import { Json } from "supabase/database.types"; 27 27 28 28 const idResolver = new IdResolver(); 29 - export async function publishToPublication( 30 - root_entity: string, 31 - blocks: Block[], 32 - publication_uri: string, 33 - ) { 29 + export async function publishToPublication({ 30 + root_entity, 31 + blocks, 32 + publication_uri, 33 + leaflet_id, 34 + title, 35 + description, 36 + }: { 37 + root_entity: string; 38 + blocks: Block[]; 39 + publication_uri: string; 40 + leaflet_id: string; 41 + title?: string; 42 + description?: string; 43 + }) { 34 44 const oauthClient = await createOauthClient(); 35 45 let identity = await getIdentityData(); 36 46 if (!identity || !identity.atp_did) return null; ··· 39 49 let agent = new AtpBaseClient( 40 50 credentialSession.fetchHandler.bind(credentialSession), 41 51 ); 52 + let { data: draft } = await supabaseServerClient 53 + .from("leaflets_in_publications") 54 + .select("*, publications(*)") 55 + .eq("publication", publication_uri) 56 + .eq("leaflet", leaflet_id) 57 + .single(); 42 58 let { data } = await supabaseServerClient.rpc("get_facts", { 43 59 root: root_entity, 44 60 }); 45 - console.log(data); 46 61 47 62 let scan = scanIndexLocal( 48 63 (data as unknown as Fact<keyof typeof Attributes>[]) || [], 49 64 ); 50 - const getBlockContent = (b: string) => { 51 - let [content] = scan.eav(b, "block/text"); 52 - if (!content) return ""; 53 - let doc = new Y.Doc(); 54 - const update = base64.toByteArray(content.data.value); 55 - Y.applyUpdate(doc, update); 56 - let nodes = doc.getXmlElement("prosemirror").toArray(); 57 - let stringValue = YJSFragmentToString(nodes[0]); 58 - return stringValue; 59 - }; 60 65 let images = blocks 61 66 .filter((b) => b.type === "image") 62 67 .map((b) => scan.eav(b.value, "block/image")[0]); ··· 68 73 let blob = await agent.com.atproto.repo.uploadBlob(binary, { 69 74 headers: { "Content-Type": binary.type }, 70 75 }); 71 - console.log(blob); 72 76 imageMap.set(b.data.src, blob.data.blob); 73 77 }), 74 78 ); 75 79 76 - let title = "Untitled"; 77 - let titleBlock = blocks.find((f) => f.type === "heading"); 78 - if (titleBlock) title = getBlockContent(titleBlock.value); 79 80 let b: PubLeafletPagesLinearDocument.Block[] = blocksToRecord( 80 81 blocks, 81 82 imageMap, 82 83 scan, 83 84 ); 84 85 85 - let record: OmitKey<PubLeafletDocument.Record, "$type"> = { 86 + let record: PubLeafletDocument.Record = { 87 + $type: "pub.leaflet.document", 86 88 author: credentialSession.did!, 87 - title, 89 + title: title || "Untitled", 88 90 publication: publication_uri, 89 91 pages: [ 90 92 { ··· 93 95 }, 94 96 ], 95 97 }; 96 - let rkey = TID.nextStr(); 97 - let result = await agent.pub.leaflet.document.create( 98 - { repo: credentialSession.did!, rkey, validate: false }, 98 + let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); 99 + let { data: result } = await agent.com.atproto.repo.putRecord({ 100 + rkey, 101 + repo: credentialSession.did!, 102 + collection: record.$type, 99 103 record, 100 - ); 104 + validate: false, //TODO publish the lexicon so we can validate! 105 + }); 101 106 102 - await Promise.all([ 103 - //Optimistically put these in! 104 - supabaseServerClient.from("documents").upsert({ 107 + console.log(result); 108 + console.log( 109 + await supabaseServerClient.from("documents").upsert({ 105 110 uri: result.uri, 106 111 data: record as Json, 107 112 }), 108 - supabaseServerClient.from("documents_in_publications").insert({ 113 + ); 114 + await Promise.all([ 115 + //Optimistically put these in! 116 + supabaseServerClient.from("documents_in_publications").upsert({ 109 117 publication: record.publication, 110 118 document: result.uri, 111 119 }), 120 + supabaseServerClient 121 + .from("leaflets_in_publications") 122 + .update({ 123 + doc: result.uri, 124 + }) 125 + .eq("leaflet", leaflet_id) 126 + .eq("publication", publication_uri), 112 127 sendPostToEmailSubscribers(publication_uri, { 113 - title, 128 + title: title || "Untitiled", 114 129 content: blocksToHtml(blocks, imageMap, scan, publication_uri), 115 130 }), 116 131 ]);
+38 -1
app/[leaflet_id]/Actions.tsx
··· 1 + import { publishToPublication } from "actions/publishToPublication"; 1 2 import { ActionButton } from "components/ActionBar/ActionButton"; 2 3 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 3 4 import { GoBackSmall } from "components/Icons/GoBackSmall"; 4 5 import { PublishSmall } from "components/Icons/PublishSmall"; 5 6 import { useIdentityData } from "components/IdentityProvider"; 7 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 + import { useToaster } from "components/Toast"; 6 9 import { publications } from "drizzle/schema"; 7 10 import Link from "next/link"; 11 + import { useParams } from "next/navigation"; 12 + import { useBlocks } from "src/hooks/queries/useBlocks"; 13 + import { useEntity, useReplicache } from "src/replicache"; 8 14 export const BackToPubButton = (props: { 9 15 publication: { 10 16 identity_did: string; ··· 28 34 }; 29 35 30 36 export const PublishButton = () => { 37 + let { data } = useLeafletPublicationData(); 38 + let identity = useIdentityData(); 39 + let { permission_token, rootEntity } = useReplicache(); 40 + let rootPage = useEntity(rootEntity, "root/page")[0]; 41 + let blocks = useBlocks(rootPage?.data.value); 42 + let toaster = useToaster(); 43 + let pub = data[0]; 31 44 return ( 32 45 <ActionButton 33 46 primary 34 47 icon={<PublishSmall className="shrink-0" />} 35 - label="Publish!" 48 + label={pub.doc ? "Update!" : "Publish!"} 49 + onClick={async () => { 50 + if (!pub || !pub.publications) return; 51 + let doc = await publishToPublication({ 52 + root_entity: rootEntity, 53 + blocks, 54 + publication_uri: pub.publications.uri, 55 + leaflet_id: permission_token.id, 56 + title: pub.title, 57 + description: pub.description, 58 + }); 59 + toaster({ 60 + content: ( 61 + <div> 62 + {pub.doc ? "Updated! " : "Published! "} 63 + <Link 64 + href={`/lish/${pub.publications.identity_did}/${pub.publications.uri}/${doc?.rkey}`} 65 + > 66 + link 67 + </Link> 68 + </div> 69 + ), 70 + type: "success", 71 + }); 72 + }} 36 73 /> 37 74 ); 38 75 };
+17 -3
app/lish/[handle]/[publication]/Actions.tsx
··· 2 2 3 3 import { Media } from "components/Media"; 4 4 import { NewDraftActionButton } from "./NewDraftButton"; 5 - import { HomeButton } from "components/HomeButton"; 6 5 import { ActionButton } from "components/ActionBar/ActionButton"; 7 6 import { useRouter } from "next/navigation"; 8 7 import { Popover } from "components/Popover"; ··· 12 11 import { Menu } from "components/Layout"; 13 12 import { MenuItem } from "components/Layout"; 14 13 import Link from "next/link"; 14 + import { HomeSmall } from "components/Icons/HomeSmall"; 15 15 16 16 export const Actions = (props: { publication: string }) => { 17 17 return ( 18 18 <> 19 19 <Media mobile> 20 - <HomeButton isPublication /> 20 + <Link 21 + href="/home" 22 + prefetch 23 + className="hover:no-underline" 24 + style={{ textDecorationLine: "none !important" }} 25 + > 26 + <ActionButton icon={<HomeSmall />} label="Go Home" /> 27 + </Link> 21 28 </Media> 22 29 <NewDraftActionButton publication={props.publication} /> 23 30 <PublicationShareButton /> 24 31 <PublicationSettingsButton publication={props.publication} /> 25 32 <hr className="border-border-light" /> 26 - <HomeButton isPublication /> 33 + <Link 34 + href="/home" 35 + prefetch 36 + className="hover:no-underline" 37 + style={{ textDecorationLine: "none !important" }} 38 + > 39 + <ActionButton icon={<HomeSmall />} label="Go Home" /> 40 + </Link> 27 41 </> 28 42 ); 29 43 };
+32 -14
app/lish/[handle]/[publication]/[rkey]/page.tsx
··· 23 23 let { data: document } = await supabaseServerClient 24 24 .from("documents") 25 25 .select("*") 26 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey)) 26 + .eq( 27 + "uri", 28 + AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), 29 + ) 27 30 .single(); 28 31 29 32 if (!document) return { title: "404" }; 30 33 let record = document.data as PubLeafletDocument.Record; 31 34 return { 32 - title: record.title + " - " + decodeURIComponent((await props.params).publication), 35 + title: 36 + record.title + 37 + " - " + 38 + decodeURIComponent((await props.params).publication), 33 39 }; 34 40 } 35 41 export default async function Post(props: { ··· 40 46 let { data: document } = await supabaseServerClient 41 47 .from("documents") 42 48 .select("*") 43 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey)) 49 + .eq( 50 + "uri", 51 + AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), 52 + ) 44 53 .single(); 45 54 if (!document?.data) return <div>notfound</div>; 46 55 let record = document.data as PubLeafletDocument.Record; ··· 50 59 blocks = firstPage.blocks || []; 51 60 } 52 61 return ( 53 - (<div className="postPage w-full h-screen bg-bg-leaflet flex items-stretch"> 62 + <div className="postPage w-full h-screen bg-bg-leaflet flex items-stretch"> 54 63 <div className="pubWrapper flex flex-col w-full "> 55 64 <div className="pubContent flex flex-col px-4 py-6 mx-auto max-w-prose h-full w-full overflow-auto"> 56 65 <Link ··· 59 68 > 60 69 {decodeURIComponent((await props.params).publication)} 61 70 </Link> 62 - {/* <h1>{record.title}</h1> */} 63 - {blocks.map((b) => { 71 + <h1>{record.title}</h1> 72 + {blocks.map((b, index) => { 64 73 switch (true) { 65 74 case PubLeafletBlocksImage.isMain(b.block): { 66 75 return ( 67 76 <img 77 + key={index} 68 78 height={b.block.aspectRatio?.height} 69 79 width={b.block.aspectRatio?.width} 70 80 className="pb-2 sm:pb-3" ··· 73 83 ); 74 84 } 75 85 case PubLeafletBlocksText.isMain(b.block): 76 - return <p className="pt-0 pb-2 sm:pb-3">{b.block.plaintext}</p>; 86 + return ( 87 + <p key={index} className="pt-0 pb-2 sm:pb-3"> 88 + {b.block.plaintext} 89 + </p> 90 + ); 77 91 case PubLeafletBlocksHeader.isMain(b.block): { 78 92 if (b.block.level === 1) 79 93 return ( 80 - <h1 className="pb-0 pt-2 sm:pt-3">{b.block.plaintext}</h1> 94 + <h1 key={index} className="pb-0 pt-2 sm:pt-3"> 95 + {b.block.plaintext} 96 + </h1> 81 97 ); 82 98 if (b.block.level === 2) 83 99 return ( 84 - <h3 className="pb-0 pt-2 sm:pt-3">{b.block.plaintext}</h3> 100 + <h3 key={index} className="pb-0 pt-2 sm:pt-3"> 101 + {b.block.plaintext} 102 + </h3> 85 103 ); 86 104 if (b.block.level === 3) 87 105 return ( 88 - <h4 className="pb-0 pt-2 sm:pt-3">{b.block.plaintext}</h4> 106 + <h4 key={index} className="pb-0 pt-2 sm:pt-3"> 107 + {b.block.plaintext} 108 + </h4> 89 109 ); 90 110 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 91 111 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 92 - return <h6>{b.block.plaintext}</h6>; 112 + return <h6 key={index}>{b.block.plaintext}</h6>; 93 113 } 94 114 default: 95 115 return null; 96 116 } 97 117 })} 98 118 </div> 99 - 100 - <Footer pageType="post" /> 101 119 </div> 102 - </div>) 120 + </div> 103 121 ); 104 122 }
+37 -4
app/lish/[handle]/[publication]/page.tsx
··· 12 12 import { getIdentityData } from "actions/getIdentityData"; 13 13 import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 14 14 import { Actions } from "./Actions"; 15 + import Link from "next/link"; 16 + import { AtUri } from "@atproto/syntax"; 17 + import { PubLeafletDocument } from "lexicons/api"; 15 18 16 19 const idResolver = new IdResolver(); 17 20 ··· 37 40 export default async function Publication(props: { 38 41 params: Promise<{ publication: string; handle: string }>; 39 42 }) { 43 + let params = await props.params; 40 44 let identity = await getIdentityData(); 41 45 if (!identity || !identity.atp_did) return <PubNotFound />; 42 46 let did = await idResolver.handle.resolve((await props.params).handle); ··· 77 81 Drafts: ( 78 82 <DraftList 79 83 publication={publication.uri} 80 - drafts={publication.leaflets_in_publications} 84 + drafts={publication.leaflets_in_publications.filter( 85 + (p) => !p.doc, 86 + )} 81 87 /> 82 88 ), 83 89 Published: ( 84 90 <div className="w-full container text-center place-items-center flex flex-col gap-3 p-3"> 85 - <div className="italic text-tertiary"> 86 - Nothing's been published yet... 87 - </div> 91 + {publication.documents_in_publications.length === 0 ? ( 92 + <div className="italic text-tertiary"> 93 + Nothing's been published yet... 94 + </div> 95 + ) : ( 96 + publication.documents_in_publications.map((doc) => { 97 + if (!doc.documents) return null; 98 + let leaflet = publication.leaflets_in_publications.find( 99 + (l) => doc.documents && l.doc === doc.documents.uri, 100 + ); 101 + let uri = new AtUri(doc.documents.uri); 102 + let record = doc.documents 103 + .data as PubLeafletDocument.Record; 104 + return ( 105 + <div 106 + className="w-full flex flex-row justify-between" 107 + key={doc.documents?.uri} 108 + > 109 + <Link 110 + href={`/lish/${params.handle}/${params.publication}/${uri.rkey}`} 111 + > 112 + {record.title} 113 + </Link> 114 + {leaflet && ( 115 + <Link href={`/${leaflet.leaflet}`}>edit</Link> 116 + )} 117 + </div> 118 + ); 119 + }) 120 + )} 88 121 <NewDraftSecondaryButton publication={publication.uri} /> 89 122 </div> 90 123 ),
+3 -3
components/HomeButton.tsx
··· 11 11 import { HomeSmall } from "./Icons/HomeSmall"; 12 12 import { permission } from "process"; 13 13 14 - export function HomeButton(props: { isPublication?: boolean }) { 14 + export function HomeButton() { 15 15 let { permissions } = useEntitySetContext(); 16 16 let searchParams = useSearchParams(); 17 17 18 - if (permissions.write || props.isPublication) 18 + if (permissions.write) 19 19 return ( 20 20 <> 21 21 <Link ··· 26 26 > 27 27 <ActionButton icon={<HomeSmall />} label="Go Home" /> 28 28 </Link> 29 - {!props.isPublication && <AddToHomeButton />} 29 + {<AddToHomeButton />} 30 30 </> 31 31 ); 32 32 return null;
-153
components/ShareOptions/PublicationOptions.tsx
··· 1 - import { publishToPublication } from "actions/publishToPublication"; 2 - import { ButtonPrimary } from "components/Buttons"; 3 - import { useIdentityData } from "components/IdentityProvider"; 4 - import { InputWithLabel } from "components/Input"; 5 - import { useState } from "react"; 6 - import { Popover } from "components/Popover"; 7 - import { useBlocks } from "src/hooks/queries/useBlocks"; 8 - import { useEntity, useReplicache } from "src/replicache"; 9 - import { usePublicationContext } from "components/Providers/PublicationContext"; 10 - import { useRouter } from "next/navigation"; 11 - import Link from "next/link"; 12 - 13 - export const PublishToPublication = () => { 14 - type PublishState = 15 - | { state: "default" } 16 - | { state: "test" } 17 - | { state: "testSuccess" } 18 - | { state: "success"; link: string }; 19 - 20 - let [state, setState] = useState<PublishState>({ state: "default" }); 21 - let publication = usePublicationContext(); 22 - let rep = useReplicache(); 23 - let rootPage = useEntity(rep.rootEntity, "root/page")[0]; 24 - let blocks = useBlocks(rootPage?.data.value); 25 - let router = useRouter(); 26 - 27 - let [titleValue, setTitleValue] = useState(""); 28 - let [descriptionValue, setDescriptionValue] = useState(""); 29 - let [testValue, setTestValue] = useState(""); 30 - if (!publication.publication) return null; 31 - 32 - return ( 33 - <Popover 34 - onOpenChange={() => setState({ state: "default" })} 35 - asChild 36 - trigger={<ButtonPrimary className="">Publish</ButtonPrimary>} 37 - > 38 - <div className="publishMenu w-72 flex flex-col gap-3"> 39 - {state.state === "default" ? ( 40 - <> 41 - <div className="w-full flex flex-col"> 42 - <h3 className="place-self-start"> 43 - Publish to {publication.publication.name} 44 - </h3> 45 - {/* <small className="text-tertiary"> 46 - Publish this post to a PUBLICATION HERE and send it as an email 47 - to your XX subscribers. 48 - </small> */} 49 - </div> 50 - <form 51 - className="flex flex-col gap-3 w-full" 52 - onSubmit={async (e) => { 53 - e.preventDefault(); 54 - if (!publication.publication) return; 55 - let data = await publishToPublication( 56 - rep.rootEntity, 57 - blocks, 58 - publication.publication.uri, 59 - ); 60 - if (data) 61 - setState({ 62 - state: "success", 63 - link: `/lish/${data.handle?.alsoKnownAs?.[0].slice(5)}/${publication.publication.name}/${data.rkey}`, 64 - }); 65 - }} 66 - > 67 - <InputWithLabel 68 - textarea 69 - rows={3} 70 - label="Description" 71 - value={descriptionValue} 72 - onChange={(e) => { 73 - setDescriptionValue(e.currentTarget.value); 74 - }} 75 - /> 76 - <div className="flex gap-3 justify-end w-full"> 77 - {/* <button 78 - onClick={() => { 79 - setState({ state: "test" }); 80 - }} 81 - className="font-bold text-accent-contrast" 82 - > 83 - Send Test 84 - </button> */} 85 - <ButtonPrimary>Publish </ButtonPrimary> 86 - </div> 87 - </form> 88 - </> 89 - ) : state.state === "test" ? ( 90 - <> 91 - <h3>Send out a test</h3> 92 - <form 93 - className="flex flex-col gap-3" 94 - onSubmit={() => { 95 - setState({ state: "testSuccess" }); 96 - }} 97 - > 98 - <InputWithLabel 99 - label="Send to" 100 - placeholder="email here..." 101 - value={testValue} 102 - onChange={(e) => { 103 - setTestValue(e.currentTarget.value); 104 - }} 105 - /> 106 - 107 - <ButtonPrimary type="submit" className="place-self-end"> 108 - Send Test 109 - </ButtonPrimary> 110 - </form> 111 - </> 112 - ) : state.state === "testSuccess" ? ( 113 - <> 114 - <div 115 - className="w-full p-4 rounded-md flex flex-col text-center" 116 - style={{ 117 - backgroundColor: 118 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 119 - }} 120 - > 121 - <div>Sent! Check you email!</div> 122 - <div className="italic font-bold">{testValue}</div> 123 - <button 124 - onClick={() => { 125 - setState({ state: "default" }); 126 - }} 127 - className="w-fit mx-auto font-bold text-accent-contrast mt-3" 128 - > 129 - Back 130 - </button> 131 - </div> 132 - </> 133 - ) : state.state === "success" ? ( 134 - <div 135 - className="w-full p-4 rounded-md flex flex-col text-center" 136 - style={{ 137 - backgroundColor: 138 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 139 - }} 140 - > 141 - <div className="font-bold">Woot! It's Published!</div> 142 - <Link 143 - href={state.link} 144 - className="w-fit mx-auto font-bold text-accent-contrast mt-1" 145 - > 146 - View Post 147 - </Link> 148 - </div> 149 - ) : null} 150 - </div> 151 - </Popover> 152 - ); 153 - };