a tool for shared writing and social publishing

support publishing standalone leaflets

+206 -74
+58 -24
actions/publishToPublication.ts
··· 53 53 description, 54 54 }: { 55 55 root_entity: string; 56 - publication_uri: string; 56 + publication_uri?: string; 57 57 leaflet_id: string; 58 58 title?: string; 59 59 description?: string; ··· 66 66 let agent = new AtpBaseClient( 67 67 credentialSession.fetchHandler.bind(credentialSession), 68 68 ); 69 - let { data: draft } = await supabaseServerClient 70 - .from("leaflets_in_publications") 71 - .select("*, publications(*), documents(*)") 72 - .eq("publication", publication_uri) 73 - .eq("leaflet", leaflet_id) 74 - .single(); 75 - if (!draft || identity.atp_did !== draft?.publications?.identity_did) 76 - throw new Error("No draft or not publisher"); 69 + 70 + // Check if we're publishing to a publication or standalone 71 + let draft: any = null; 72 + let existingDocUri: string | null = null; 73 + 74 + if (publication_uri) { 75 + // Publishing to a publication - use leaflets_in_publications 76 + let { data } = await supabaseServerClient 77 + .from("leaflets_in_publications") 78 + .select("*, publications(*), documents(*)") 79 + .eq("publication", publication_uri) 80 + .eq("leaflet", leaflet_id) 81 + .single(); 82 + if (!data || identity.atp_did !== data?.publications?.identity_did) 83 + throw new Error("No draft or not publisher"); 84 + draft = data; 85 + existingDocUri = draft?.doc; 86 + } else { 87 + // Publishing standalone - use leaflets_to_documents 88 + let { data } = await supabaseServerClient 89 + .from("leaflets_to_documents") 90 + .select("*, documents(*)") 91 + .eq("leaflet", leaflet_id) 92 + .single(); 93 + draft = data; 94 + existingDocUri = draft?.document; 95 + } 96 + 77 97 let { data } = await supabaseServerClient.rpc("get_facts", { 78 98 root: root_entity, 79 99 }); ··· 91 111 let record: PubLeafletDocument.Record = { 92 112 $type: "pub.leaflet.document", 93 113 author: credentialSession.did!, 94 - publication: publication_uri, 114 + ...(publication_uri && { publication: publication_uri }), 95 115 publishedAt: new Date().toISOString(), 96 116 ...existingRecord, 97 117 title: title || "Untitled", ··· 118 138 }), 119 139 ], 120 140 }; 121 - let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); 141 + 142 + // Keep the same rkey if updating an existing document 143 + let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 122 144 let { data: result } = await agent.com.atproto.repo.putRecord({ 123 145 rkey, 124 146 repo: credentialSession.did!, ··· 127 149 validate: false, //TODO publish the lexicon so we can validate! 128 150 }); 129 151 152 + // Optimistically create database entries 130 153 await supabaseServerClient.from("documents").upsert({ 131 154 uri: result.uri, 132 155 data: record as Json, 133 156 }); 134 - await Promise.all([ 135 - //Optimistically put these in! 136 - supabaseServerClient.from("documents_in_publications").upsert({ 137 - publication: record.publication, 157 + 158 + if (publication_uri) { 159 + // Publishing to a publication - update both tables 160 + await Promise.all([ 161 + supabaseServerClient.from("documents_in_publications").upsert({ 162 + publication: publication_uri, 163 + document: result.uri, 164 + }), 165 + supabaseServerClient 166 + .from("leaflets_in_publications") 167 + .update({ 168 + doc: result.uri, 169 + }) 170 + .eq("leaflet", leaflet_id) 171 + .eq("publication", publication_uri), 172 + ]); 173 + } else { 174 + // Publishing standalone - update leaflets_to_documents 175 + await supabaseServerClient.from("leaflets_to_documents").upsert({ 176 + leaflet: leaflet_id, 138 177 document: result.uri, 139 - }), 140 - supabaseServerClient 141 - .from("leaflets_in_publications") 142 - .update({ 143 - doc: result.uri, 144 - }) 145 - .eq("leaflet", leaflet_id) 146 - .eq("publication", publication_uri), 147 - ]); 178 + title: title || "Untitled", 179 + description: description || "", 180 + }); 181 + } 148 182 149 183 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 150 184 }
+5
app/(home-pages)/home/HomeLayout.tsx
··· 38 38 GetLeafletDataReturnType["result"]["data"], 39 39 null 40 40 >["leaflets_in_publications"]; 41 + leaflets_to_documents?: Exclude< 42 + GetLeafletDataReturnType["result"]["data"], 43 + null 44 + >["leaflets_to_documents"]; 41 45 }; 42 46 }; 43 47 ··· 218 222 value={{ 219 223 ...leaflet, 220 224 leaflets_in_publications: leaflet.leaflets_in_publications || [], 225 + leaflets_to_documents: leaflet.leaflets_to_documents || [], 221 226 blocked_by_admin: null, 222 227 custom_domain_routes: [], 223 228 }}
+30 -16
app/[leaflet_id]/actions/PublishButton.tsx
··· 60 60 let [isLoading, setIsLoading] = useState(false); 61 61 let { data: pub, mutate } = useLeafletPublicationData(); 62 62 let { permission_token, rootEntity } = useReplicache(); 63 + let { identity } = useIdentityData(); 63 64 let toaster = useToaster(); 64 65 65 66 return ( ··· 68 69 icon={<PublishSmall className="shrink-0" />} 69 70 label={isLoading ? <DotLoader /> : "Update!"} 70 71 onClick={async () => { 71 - if (!pub || !pub.publications) return; 72 + if (!pub) return; 72 73 setIsLoading(true); 73 74 let doc = await publishToPublication({ 74 75 root_entity: rootEntity, 75 - publication_uri: pub.publications.uri, 76 + publication_uri: pub.publications?.uri, 76 77 leaflet_id: permission_token.id, 77 78 title: pub.title, 78 79 description: pub.description, 79 80 }); 80 81 setIsLoading(false); 81 82 mutate(); 83 + 84 + // Generate URL based on whether it's in a publication or standalone 85 + let docUrl = pub.publications 86 + ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` 87 + : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; 88 + 82 89 toaster({ 83 90 content: ( 84 91 <div> 85 92 {pub.doc ? "Updated! " : "Published! "} 86 - <SpeedyLink 87 - href={`${getPublicationURL(pub.publications)}/${doc?.rkey}`} 88 - > 89 - link 90 - </SpeedyLink> 93 + <SpeedyLink href={docUrl}>link</SpeedyLink> 91 94 </div> 92 95 ), 93 96 type: "success", ··· 169 172 <hr className="border-border-light mt-3 mb-2" /> 170 173 171 174 <div className="flex gap-2 items-center place-self-end"> 172 - <SaveAsDraftButton 173 - selectedPub={selectedPub} 174 - leafletId={permission_token.id} 175 - metadata={{ title: title, description }} 176 - entitiesToDelete={entitiesToDelete} 177 - /> 175 + {selectedPub !== "looseleaf" && ( 176 + <SaveAsDraftButton 177 + selectedPub={selectedPub} 178 + leafletId={permission_token.id} 179 + metadata={{ title: title, description }} 180 + entitiesToDelete={entitiesToDelete} 181 + /> 182 + )} 178 183 <ButtonPrimary 179 184 disabled={selectedPub === undefined} 180 185 onClick={async (e) => { 181 186 if (!selectedPub) return; 182 187 e.preventDefault(); 183 188 if (selectedPub === "create") return; 184 - router.push( 185 - `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`, 186 - ); 189 + 190 + // For looseleaf, navigate without publication_uri 191 + if (selectedPub === "looseleaf") { 192 + router.push( 193 + `${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`, 194 + ); 195 + } else { 196 + router.push( 197 + `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`, 198 + ); 199 + } 187 200 }} 188 201 > 189 202 Next{selectedPub === "create" && ": Create Pub!"} ··· 305 318 let pubRecord = p.record as PubLeafletPublication.Record; 306 319 return ( 307 320 <PubOption 321 + key={p.uri} 308 322 selected={props.selectedPub === p.uri} 309 323 onSelect={() => props.setSelectedPub(p.uri)} 310 324 >
+57 -10
app/[leaflet_id]/publish/PublishPost.tsx
··· 18 18 editorStateToFacetedText, 19 19 } from "./BskyPostEditorProsemirror"; 20 20 import { EditorState } from "prosemirror-state"; 21 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 22 + import { PubIcon } from "components/ActionBar/Publications"; 21 23 22 24 type Props = { 23 25 title: string; ··· 25 27 root_entity: string; 26 28 profile: ProfileViewDetailed; 27 29 description: string; 28 - publication_uri: string; 30 + publication_uri?: string; 29 31 record?: PubLeafletPublication.Record; 30 32 posts_in_pub?: number; 31 33 }; ··· 75 77 }); 76 78 if (!doc) return; 77 79 78 - let post_url = `https://${props.record?.base_path}/${doc.rkey}`; 80 + // Generate post URL based on whether it's in a publication or standalone 81 + let post_url = props.record?.base_path 82 + ? `https://${props.record.base_path}/${doc.rkey}` 83 + : `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`; 84 + 79 85 let [text, facets] = editorStateRef.current 80 86 ? editorStateToFacetedText(editorStateRef.current) 81 87 : []; ··· 103 109 }} 104 110 > 105 111 <div className="container flex flex-col gap-2 sm:p-3 p-4"> 112 + <PublishingTo 113 + publication_uri={props.publication_uri} 114 + record={props.record} 115 + /> 116 + <hr className="border-border-light my-1" /> 106 117 <Radio 107 118 checked={shareOption === "quiet"} 108 119 onChange={(e) => { ··· 199 210 ); 200 211 }; 201 212 213 + const PublishingTo = (props: { 214 + publication_uri?: string; 215 + record?: PubLeafletPublication.Record; 216 + }) => { 217 + if (props.publication_uri && props.record) { 218 + return ( 219 + <div className="flex flex-col gap-1"> 220 + <div className="text-sm text-tertiary">Publishing to…</div> 221 + <div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]"> 222 + <PubIcon record={props.record} uri={props.publication_uri} /> 223 + <div className="font-bold text-secondary">{props.record.name}</div> 224 + </div> 225 + </div> 226 + ); 227 + } 228 + 229 + return ( 230 + <div className="flex flex-col gap-1"> 231 + <div className="text-sm text-tertiary">Publishing as…</div> 232 + <div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]"> 233 + <LooseLeafSmall className="shrink-0" /> 234 + <div className="font-bold text-secondary">Looseleaf</div> 235 + </div> 236 + </div> 237 + ); 238 + }; 239 + 202 240 const PublishPostSuccess = (props: { 203 241 post_url: string; 204 - publication_uri: string; 242 + publication_uri?: string; 205 243 record: Props["record"]; 206 244 posts_in_pub: number; 207 245 }) => { 208 - let uri = new AtUri(props.publication_uri); 246 + let uri = props.publication_uri ? new AtUri(props.publication_uri) : null; 209 247 return ( 210 248 <div className="container p-4 m-3 sm:m-4 flex flex-col gap-1 justify-center text-center w-fit h-fit mx-auto"> 211 249 <PublishIllustration posts_in_pub={props.posts_in_pub} /> 212 250 <h2 className="pt-2">Published!</h2> 213 - <Link 214 - className="hover:no-underline! font-bold place-self-center pt-2" 215 - href={`/lish/${uri.host}/${encodeURIComponent(props.record?.name || "")}/dashboard`} 216 - > 217 - <ButtonPrimary>Back to Dashboard</ButtonPrimary> 218 - </Link> 251 + {uri && props.record ? ( 252 + <Link 253 + className="hover:no-underline! font-bold place-self-center pt-2" 254 + href={`/lish/${uri.host}/${encodeURIComponent(props.record.name || "")}/dashboard`} 255 + > 256 + <ButtonPrimary>Back to Dashboard</ButtonPrimary> 257 + </Link> 258 + ) : ( 259 + <Link 260 + className="hover:no-underline! font-bold place-self-center pt-2" 261 + href="/" 262 + > 263 + <ButtonPrimary>Back to Home</ButtonPrimary> 264 + </Link> 265 + )} 219 266 <a href={props.post_url}>See published post</a> 220 267 </div> 221 268 );
+33 -17
app/[leaflet_id]/publish/page.tsx
··· 32 32 *, 33 33 documents_in_publications(count) 34 34 ), 35 - documents(*))`, 35 + documents(*)), 36 + leaflets_to_documents( 37 + *, 38 + documents(*) 39 + )`, 36 40 ) 37 41 .eq("id", leaflet_id) 38 42 .single(); 39 43 let rootEntity = data?.root_entity; 44 + 45 + // Try to find publication from leaflets_in_publications first 40 46 let publication = data?.leaflets_in_publications[0]?.publications; 47 + 48 + // If not found, check if publication_uri is in searchParams 41 49 if (!publication) { 42 50 let pub_uri = (await props.searchParams).publication_uri; 43 - if (!pub_uri) return; 44 - console.log(decodeURIComponent(pub_uri)); 45 - let { data, error } = await supabaseServerClient 46 - .from("publications") 47 - .select("*, documents_in_publications(count)") 48 - .eq("uri", decodeURIComponent(pub_uri)) 49 - .single(); 50 - console.log(error); 51 - publication = data; 51 + if (pub_uri) { 52 + console.log(decodeURIComponent(pub_uri)); 53 + let { data: pubData, error } = await supabaseServerClient 54 + .from("publications") 55 + .select("*, documents_in_publications(count)") 56 + .eq("uri", decodeURIComponent(pub_uri)) 57 + .single(); 58 + console.log(error); 59 + publication = pubData; 60 + } 52 61 } 53 - if (!data || !rootEntity || !publication) 62 + 63 + // Check basic data requirements 64 + if (!data || !rootEntity) 54 65 return ( 55 66 <div> 56 67 missin something ··· 60 71 61 72 let identity = await getIdentityData(); 62 73 if (!identity || !identity.atp_did) return null; 74 + 75 + // Get title and description from either source 63 76 let title = 64 77 data.leaflets_in_publications[0]?.title || 65 - decodeURIComponent((await props.searchParams).title); 78 + data.leaflets_to_documents[0]?.title || 79 + decodeURIComponent((await props.searchParams).title || ""); 66 80 let description = 67 81 data.leaflets_in_publications[0]?.description || 68 - decodeURIComponent((await props.searchParams).description); 69 - let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 82 + data.leaflets_to_documents[0]?.description || 83 + decodeURIComponent((await props.searchParams).description || ""); 70 84 85 + let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 71 86 let profile = await agent.getProfile({ actor: identity.atp_did }); 87 + 72 88 return ( 73 89 <ReplicacheProvider 74 90 rootEntity={rootEntity} ··· 82 98 profile={profile.data} 83 99 title={title} 84 100 description={description} 85 - publication_uri={publication.uri} 86 - record={publication.record as PubLeafletPublication.Record} 87 - posts_in_pub={publication.documents_in_publications[0].count} 101 + publication_uri={publication?.uri} 102 + record={publication?.record as PubLeafletPublication.Record | undefined} 103 + posts_in_pub={publication?.documents_in_publications[0]?.count} 88 104 /> 89 105 </ReplicacheProvider> 90 106 );
+3 -1
app/api/rpc/[command]/get_leaflet_data.ts
··· 7 7 >; 8 8 9 9 const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`; 10 + const leaflets_to_documents_query = `leaflets_to_documents(*, documents(*))`; 10 11 export const get_leaflet_data = makeRoute({ 11 12 route: "get_leaflet_data", 12 13 input: z.object({ ··· 20 21 `*, 21 22 permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}))), 22 23 custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), 23 - ${leaflets_in_publications_query}`, 24 + ${leaflets_in_publications_query}, 25 + ${leaflets_to_documents_query}`, 24 26 ) 25 27 .eq("id", token_id) 26 28 .single();
+20 -6
components/PageSWRDataProvider.tsx
··· 66 66 }; 67 67 export function useLeafletPublicationData() { 68 68 let { data, mutate } = useLeafletData(); 69 + 70 + // First check for leaflets in publications 71 + let pubData = 72 + data?.leaflets_in_publications?.[0] || 73 + data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 74 + (p) => p.leaflets_in_publications.length, 75 + )?.leaflets_in_publications?.[0]; 76 + 77 + // If not found, check for standalone documents 78 + if (!pubData && data?.leaflets_to_documents?.[0]) { 79 + // Transform standalone document data to match the expected format 80 + let standaloneDoc = data.leaflets_to_documents[0]; 81 + pubData = { 82 + ...standaloneDoc, 83 + publications: null, // No publication for standalone docs 84 + doc: standaloneDoc.document, 85 + } as any; 86 + } 87 + 69 88 return { 70 - data: 71 - data?.leaflets_in_publications?.[0] || 72 - data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 73 - (p) => p.leaflets_in_publications.length, 74 - )?.leaflets_in_publications?.[0] || 75 - null, 89 + data: pubData || null, 76 90 mutate, 77 91 }; 78 92 }