From 8fe7157b44da2b29c75c5873faaacdf643849bc0 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 18 Nov 2025 15:55:59 -0500 Subject: [PATCH] support publishing standalone leaflets --- actions/publishToPublication.ts | 82 +++++++++++++++------- app/(home-pages)/home/HomeLayout.tsx | 5 ++ app/[leaflet_id]/actions/PublishButton.tsx | 46 +++++++----- app/[leaflet_id]/publish/PublishPost.tsx | 67 +++++++++++++++--- app/[leaflet_id]/publish/page.tsx | 50 ++++++++----- app/api/rpc/[command]/get_leaflet_data.ts | 4 +- components/PageSWRDataProvider.tsx | 26 +++++-- 7 files changed, 206 insertions(+), 74 deletions(-) diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index eb62eb97..8d21c053 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -53,7 +53,7 @@ export async function publishToPublication({ description, }: { root_entity: string; - publication_uri: string; + publication_uri?: string; leaflet_id: string; title?: string; description?: string; @@ -66,14 +66,34 @@ export async function publishToPublication({ let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); - let { data: draft } = await supabaseServerClient - .from("leaflets_in_publications") - .select("*, publications(*), documents(*)") - .eq("publication", publication_uri) - .eq("leaflet", leaflet_id) - .single(); - if (!draft || identity.atp_did !== draft?.publications?.identity_did) - throw new Error("No draft or not publisher"); + + // Check if we're publishing to a publication or standalone + let draft: any = null; + let existingDocUri: string | null = null; + + if (publication_uri) { + // Publishing to a publication - use leaflets_in_publications + let { data } = await supabaseServerClient + .from("leaflets_in_publications") + .select("*, publications(*), documents(*)") + .eq("publication", publication_uri) + .eq("leaflet", leaflet_id) + .single(); + if (!data || identity.atp_did !== data?.publications?.identity_did) + throw new Error("No draft or not publisher"); + draft = data; + existingDocUri = draft?.doc; + } else { + // Publishing standalone - use leaflets_to_documents + let { data } = await supabaseServerClient + .from("leaflets_to_documents") + .select("*, documents(*)") + .eq("leaflet", leaflet_id) + .single(); + draft = data; + existingDocUri = draft?.document; + } + let { data } = await supabaseServerClient.rpc("get_facts", { root: root_entity, }); @@ -91,7 +111,7 @@ export async function publishToPublication({ let record: PubLeafletDocument.Record = { $type: "pub.leaflet.document", author: credentialSession.did!, - publication: publication_uri, + ...(publication_uri && { publication: publication_uri }), publishedAt: new Date().toISOString(), ...existingRecord, title: title || "Untitled", @@ -118,7 +138,9 @@ export async function publishToPublication({ }), ], }; - let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); + + // Keep the same rkey if updating an existing document + let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); let { data: result } = await agent.com.atproto.repo.putRecord({ rkey, repo: credentialSession.did!, @@ -127,24 +149,36 @@ export async function publishToPublication({ validate: false, //TODO publish the lexicon so we can validate! }); + // Optimistically create database entries await supabaseServerClient.from("documents").upsert({ uri: result.uri, data: record as Json, }); - await Promise.all([ - //Optimistically put these in! - supabaseServerClient.from("documents_in_publications").upsert({ - publication: record.publication, + + if (publication_uri) { + // Publishing to a publication - update both tables + await Promise.all([ + supabaseServerClient.from("documents_in_publications").upsert({ + publication: publication_uri, + document: result.uri, + }), + supabaseServerClient + .from("leaflets_in_publications") + .update({ + doc: result.uri, + }) + .eq("leaflet", leaflet_id) + .eq("publication", publication_uri), + ]); + } else { + // Publishing standalone - update leaflets_to_documents + await supabaseServerClient.from("leaflets_to_documents").upsert({ + leaflet: leaflet_id, document: result.uri, - }), - supabaseServerClient - .from("leaflets_in_publications") - .update({ - doc: result.uri, - }) - .eq("leaflet", leaflet_id) - .eq("publication", publication_uri), - ]); + title: title || "Untitled", + description: description || "", + }); + } return { rkey, record: JSON.parse(JSON.stringify(record)) }; } diff --git a/app/(home-pages)/home/HomeLayout.tsx b/app/(home-pages)/home/HomeLayout.tsx index e714df2e..d7d55166 100644 --- a/app/(home-pages)/home/HomeLayout.tsx +++ b/app/(home-pages)/home/HomeLayout.tsx @@ -38,6 +38,10 @@ type Leaflet = { GetLeafletDataReturnType["result"]["data"], null >["leaflets_in_publications"]; + leaflets_to_documents?: Exclude< + GetLeafletDataReturnType["result"]["data"], + null + >["leaflets_to_documents"]; }; }; @@ -218,6 +222,7 @@ export function LeafletList(props: { value={{ ...leaflet, leaflets_in_publications: leaflet.leaflets_in_publications || [], + leaflets_to_documents: leaflet.leaflets_to_documents || [], blocked_by_admin: null, custom_domain_routes: [], }} diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx index 295a2bc5..73044af0 100644 --- a/app/[leaflet_id]/actions/PublishButton.tsx +++ b/app/[leaflet_id]/actions/PublishButton.tsx @@ -60,6 +60,7 @@ const UpdateButton = () => { let [isLoading, setIsLoading] = useState(false); let { data: pub, mutate } = useLeafletPublicationData(); let { permission_token, rootEntity } = useReplicache(); + let { identity } = useIdentityData(); let toaster = useToaster(); return ( @@ -68,26 +69,28 @@ const UpdateButton = () => { icon={} label={isLoading ? : "Update!"} onClick={async () => { - if (!pub || !pub.publications) return; + if (!pub) return; setIsLoading(true); let doc = await publishToPublication({ root_entity: rootEntity, - publication_uri: pub.publications.uri, + publication_uri: pub.publications?.uri, leaflet_id: permission_token.id, title: pub.title, description: pub.description, }); setIsLoading(false); mutate(); + + // Generate URL based on whether it's in a publication or standalone + let docUrl = pub.publications + ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` + : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; + toaster({ content: (
{pub.doc ? "Updated! " : "Published! "} - - link - + link
), type: "success", @@ -169,21 +172,31 @@ const PublishToPublicationButton = (props: { entityID: string }) => {
- + {selectedPub !== "looseleaf" && ( + + )} { if (!selectedPub) return; e.preventDefault(); if (selectedPub === "create") return; - router.push( - `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`, - ); + + // For looseleaf, navigate without publication_uri + if (selectedPub === "looseleaf") { + router.push( + `${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`, + ); + } else { + router.push( + `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`, + ); + } }} > Next{selectedPub === "create" && ": Create Pub!"} @@ -305,6 +318,7 @@ const PubSelector = (props: { let pubRecord = p.record as PubLeafletPublication.Record; return ( props.setSelectedPub(p.uri)} > diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx index 570dfd4a..a7e46399 100644 --- a/app/[leaflet_id]/publish/PublishPost.tsx +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -18,6 +18,8 @@ import { editorStateToFacetedText, } from "./BskyPostEditorProsemirror"; import { EditorState } from "prosemirror-state"; +import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; +import { PubIcon } from "components/ActionBar/Publications"; type Props = { title: string; @@ -25,7 +27,7 @@ type Props = { root_entity: string; profile: ProfileViewDetailed; description: string; - publication_uri: string; + publication_uri?: string; record?: PubLeafletPublication.Record; posts_in_pub?: number; }; @@ -75,7 +77,11 @@ const PublishPostForm = ( }); if (!doc) return; - let post_url = `https://${props.record?.base_path}/${doc.rkey}`; + // Generate post URL based on whether it's in a publication or standalone + let post_url = props.record?.base_path + ? `https://${props.record.base_path}/${doc.rkey}` + : `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`; + let [text, facets] = editorStateRef.current ? editorStateToFacetedText(editorStateRef.current) : []; @@ -103,6 +109,11 @@ const PublishPostForm = ( }} >
+ +
{ @@ -199,23 +210,59 @@ const PublishPostForm = ( ); }; +const PublishingTo = (props: { + publication_uri?: string; + record?: PubLeafletPublication.Record; +}) => { + if (props.publication_uri && props.record) { + return ( +
+
Publishing to…
+
+ +
{props.record.name}
+
+
+ ); + } + + return ( +
+
Publishing as…
+
+ +
Looseleaf
+
+
+ ); +}; + const PublishPostSuccess = (props: { post_url: string; - publication_uri: string; + publication_uri?: string; record: Props["record"]; posts_in_pub: number; }) => { - let uri = new AtUri(props.publication_uri); + let uri = props.publication_uri ? new AtUri(props.publication_uri) : null; return (

Published!

- - Back to Dashboard - + {uri && props.record ? ( + + Back to Dashboard + + ) : ( + + Back to Home + + )} See published post
); diff --git a/app/[leaflet_id]/publish/page.tsx b/app/[leaflet_id]/publish/page.tsx index 592990ff..68629120 100644 --- a/app/[leaflet_id]/publish/page.tsx +++ b/app/[leaflet_id]/publish/page.tsx @@ -32,25 +32,36 @@ export default async function PublishLeafletPage(props: Props) { *, documents_in_publications(count) ), - documents(*))`, + documents(*)), + leaflets_to_documents( + *, + documents(*) + )`, ) .eq("id", leaflet_id) .single(); let rootEntity = data?.root_entity; + + // Try to find publication from leaflets_in_publications first let publication = data?.leaflets_in_publications[0]?.publications; + + // If not found, check if publication_uri is in searchParams if (!publication) { let pub_uri = (await props.searchParams).publication_uri; - if (!pub_uri) return; - console.log(decodeURIComponent(pub_uri)); - let { data, error } = await supabaseServerClient - .from("publications") - .select("*, documents_in_publications(count)") - .eq("uri", decodeURIComponent(pub_uri)) - .single(); - console.log(error); - publication = data; + if (pub_uri) { + console.log(decodeURIComponent(pub_uri)); + let { data: pubData, error } = await supabaseServerClient + .from("publications") + .select("*, documents_in_publications(count)") + .eq("uri", decodeURIComponent(pub_uri)) + .single(); + console.log(error); + publication = pubData; + } } - if (!data || !rootEntity || !publication) + + // Check basic data requirements + if (!data || !rootEntity) return (
missin something @@ -60,15 +71,20 @@ export default async function PublishLeafletPage(props: Props) { let identity = await getIdentityData(); if (!identity || !identity.atp_did) return null; + + // Get title and description from either source let title = data.leaflets_in_publications[0]?.title || - decodeURIComponent((await props.searchParams).title); + data.leaflets_to_documents[0]?.title || + decodeURIComponent((await props.searchParams).title || ""); let description = data.leaflets_in_publications[0]?.description || - decodeURIComponent((await props.searchParams).description); - let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); + data.leaflets_to_documents[0]?.description || + decodeURIComponent((await props.searchParams).description || ""); + let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); let profile = await agent.getProfile({ actor: identity.atp_did }); + return ( ); diff --git a/app/api/rpc/[command]/get_leaflet_data.ts b/app/api/rpc/[command]/get_leaflet_data.ts index e8390b9e..654a0f74 100644 --- a/app/api/rpc/[command]/get_leaflet_data.ts +++ b/app/api/rpc/[command]/get_leaflet_data.ts @@ -7,6 +7,7 @@ export type GetLeafletDataReturnType = Awaited< >; const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`; +const leaflets_to_documents_query = `leaflets_to_documents(*, documents(*))`; export const get_leaflet_data = makeRoute({ route: "get_leaflet_data", input: z.object({ @@ -20,7 +21,8 @@ export const get_leaflet_data = makeRoute({ `*, permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}))), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), - ${leaflets_in_publications_query}`, + ${leaflets_in_publications_query}, + ${leaflets_to_documents_query}`, ) .eq("id", token_id) .single(); diff --git a/components/PageSWRDataProvider.tsx b/components/PageSWRDataProvider.tsx index cc03de90..e9d3abe5 100644 --- a/components/PageSWRDataProvider.tsx +++ b/components/PageSWRDataProvider.tsx @@ -66,13 +66,27 @@ let useLeafletData = () => { }; export function useLeafletPublicationData() { let { data, mutate } = useLeafletData(); + + // First check for leaflets in publications + let pubData = + data?.leaflets_in_publications?.[0] || + data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( + (p) => p.leaflets_in_publications.length, + )?.leaflets_in_publications?.[0]; + + // If not found, check for standalone documents + if (!pubData && data?.leaflets_to_documents?.[0]) { + // Transform standalone document data to match the expected format + let standaloneDoc = data.leaflets_to_documents[0]; + pubData = { + ...standaloneDoc, + publications: null, // No publication for standalone docs + doc: standaloneDoc.document, + } as any; + } + return { - data: - data?.leaflets_in_publications?.[0] || - data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( - (p) => p.leaflets_in_publications.length, - )?.leaflets_in_publications?.[0] || - null, + data: pubData || null, mutate, }; } -- 2.51.2