a tool for shared writing and social publishing

make leaflets_to_documents nullable

+133 -52
+26 -18
actions/deleteLeaflet.ts
··· 23 23 // Check publication and document ownership in one query 24 24 let { data: tokenData } = await supabaseServerClient 25 25 .from("permission_tokens") 26 - .select(` 26 + .select( 27 + ` 27 28 id, 28 29 leaflets_in_publications(publication, publications!inner(identity_did)), 29 30 leaflets_to_documents(document, documents!inner(uri)) 30 - `) 31 + `, 32 + ) 31 33 .eq("id", permission_token.id) 32 34 .single(); 33 35 ··· 36 38 const leafletInPubs = tokenData.leaflets_in_publications || []; 37 39 if (leafletInPubs.length > 0) { 38 40 if (!identity) { 39 - throw new Error("Unauthorized: You must be logged in to delete a leaflet in a publication"); 41 + throw new Error( 42 + "Unauthorized: You must be logged in to delete a leaflet in a publication", 43 + ); 40 44 } 41 45 const isOwner = leafletInPubs.some( 42 - (pub: any) => pub.publications.identity_did === identity.atp_did 46 + (pub: any) => pub.publications.identity_did === identity.atp_did, 43 47 ); 44 48 if (!isOwner) { 45 - throw new Error("Unauthorized: You must own the publication to delete this leaflet"); 49 + throw new Error( 50 + "Unauthorized: You must own the publication to delete this leaflet", 51 + ); 46 52 } 47 53 } 48 54 49 55 // Check if there's a standalone published document 50 - const leafletDocs = tokenData.leaflets_to_documents || []; 51 - if (leafletDocs.length > 0) { 52 - if (!identity) { 53 - throw new Error("Unauthorized: You must be logged in to delete a published leaflet"); 56 + const leafletDoc = tokenData.leaflets_to_documents; 57 + if (leafletDoc && leafletDoc.document) { 58 + if (!identity || !identity.atp_did) { 59 + throw new Error( 60 + "Unauthorized: You must be logged in to delete a published leaflet", 61 + ); 54 62 } 55 - for (let leafletDoc of leafletDocs) { 56 - const docUri = leafletDoc.documents?.uri; 57 - // Extract the DID from the document URI (format: at://did:plc:xxx/...) 58 - if (docUri && !docUri.includes(identity.atp_did)) { 59 - throw new Error("Unauthorized: You must own the published document to delete this leaflet"); 60 - } 63 + const docUri = leafletDoc.documents?.uri; 64 + // Extract the DID from the document URI (format: at://did:plc:xxx/...) 65 + if (docUri && !docUri.includes(identity.atp_did)) { 66 + throw new Error( 67 + "Unauthorized: You must own the published document to delete this leaflet", 68 + ); 61 69 } 62 70 } 63 71 } ··· 73 81 .where(eq(permission_tokens.id, permission_token.id)); 74 82 75 83 if (!token?.permission_token_rights?.write) return; 76 - await tx 77 - .delete(entities) 78 - .where(eq(entities.set, token.permission_token_rights.entity_set)); 84 + const entitySet = token.permission_token_rights.entity_set; 85 + if (!entitySet) return; 86 + await tx.delete(entities).where(eq(entities.set, entitySet)); 79 87 await tx 80 88 .delete(permission_tokens) 81 89 .where(eq(permission_tokens.id, permission_token.id));
+3
actions/publications/moveLeafletToPublication.ts
··· 11 11 ) { 12 12 let identity = await getIdentityData(); 13 13 if (!identity || !identity.atp_did) return null; 14 + 15 + // Verify publication ownership 14 16 let { data: publication } = await supabaseServerClient 15 17 .from("publications") 16 18 .select("*") ··· 18 20 .single(); 19 21 if (publication?.identity_did !== identity.atp_did) return; 20 22 23 + // Save as a publication draft 21 24 await supabaseServerClient.from("leaflets_in_publications").insert({ 22 25 publication: publication_uri, 23 26 leaflet: leaflet_id,
+26
actions/publications/saveLeafletDraft.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + 6 + export async function saveLeafletDraft( 7 + leaflet_id: string, 8 + metadata: { title: string; description: string }, 9 + entitiesToDelete: string[], 10 + ) { 11 + let identity = await getIdentityData(); 12 + if (!identity || !identity.atp_did) return null; 13 + 14 + // Save as a looseleaf draft in leaflets_to_documents with null document 15 + await supabaseServerClient.from("leaflets_to_documents").upsert({ 16 + leaflet: leaflet_id, 17 + document: null, 18 + title: metadata.title, 19 + description: metadata.description, 20 + }); 21 + 22 + await supabaseServerClient 23 + .from("entities") 24 + .delete() 25 + .in("id", entitiesToDelete); 26 + }
+3 -3
app/(home-pages)/home/HomeLayout.tsx
··· 136 136 (acc, tok) => { 137 137 let title = 138 138 tok.permission_tokens.leaflets_in_publications[0]?.title || 139 - tok.permission_tokens.leaflets_to_documents[0]?.title; 139 + tok.permission_tokens.leaflets_to_documents?.title; 140 140 if (title) acc[tok.permission_tokens.root_entity] = title; 141 141 return acc; 142 142 }, ··· 233 233 value={{ 234 234 ...leaflet, 235 235 leaflets_in_publications: leaflet.leaflets_in_publications || [], 236 - leaflets_to_documents: leaflet.leaflets_to_documents || [], 236 + leaflets_to_documents: leaflet.leaflets_to_documents || null, 237 237 blocked_by_admin: null, 238 238 custom_domain_routes: [], 239 239 }} ··· 292 292 ({ token: leaflet, archived: archived }) => { 293 293 let published = 294 294 !!leaflet.leaflets_in_publications?.find((l) => l.doc) || 295 - !!leaflet.leaflets_to_documents?.find((l) => l.document); 295 + !!leaflet.leaflets_to_documents?.document; 296 296 let drafts = !!leaflet.leaflets_in_publications?.length && !published; 297 297 let docs = !leaflet.leaflets_in_publications?.length && !archived; 298 298 // If no filters are active, show all
+1 -1
app/(home-pages)/home/page.tsx
··· 30 30 (acc, tok) => { 31 31 let title = 32 32 tok.permission_tokens.leaflets_in_publications[0]?.title || 33 - tok.permission_tokens.leaflets_to_documents[0]?.title; 33 + tok.permission_tokens.leaflets_to_documents?.title; 34 34 if (title) acc[tok.permission_tokens.root_entity] = title; 35 35 return acc; 36 36 },
+4 -2
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
··· 111 111 (acc, tok) => { 112 112 let title = 113 113 tok.permission_tokens.leaflets_in_publications[0]?.title || 114 - tok.permission_tokens.leaflets_to_documents[0]?.title; 114 + tok.permission_tokens.leaflets_to_documents?.title; 115 115 if (title) acc[tok.permission_tokens.root_entity] = title; 116 116 return acc; 117 117 }, ··· 127 127 let leaflets: Leaflet[] = identity 128 128 ? identity.permission_token_on_homepage 129 129 .filter( 130 - (ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0, 130 + (ptoh) => 131 + ptoh.permission_tokens.leaflets_to_documents && 132 + ptoh.permission_tokens.leaflets_to_documents.document, 131 133 ) 132 134 .map((ptoh) => ({ 133 135 added_at: ptoh.created_at,
+1 -1
app/(home-pages)/looseleafs/page.tsx
··· 34 34 (acc, tok) => { 35 35 let title = 36 36 tok.permission_tokens.leaflets_in_publications[0]?.title || 37 - tok.permission_tokens.leaflets_to_documents[0]?.title; 37 + tok.permission_tokens.leaflets_to_documents?.title; 38 38 if (title) acc[tok.permission_tokens.root_entity] = title; 39 39 return acc; 40 40 },
+1 -1
app/[leaflet_id]/actions/HomeButton.tsx
··· 53 53 archived: null, 54 54 permission_tokens: { 55 55 ...permission_token, 56 - leaflets_to_documents: [], 56 + leaflets_to_documents: null, 57 57 leaflets_in_publications: [], 58 58 }, 59 59 });
+19 -7
app/[leaflet_id]/actions/PublishButton.tsx
··· 37 37 import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 38 38 import { BlueskyLogin } from "app/login/LoginForm"; 39 39 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 40 + import { saveLeafletDraft } from "actions/publications/saveLeafletDraft"; 40 41 import { AddTiny } from "components/Icons/AddTiny"; 41 42 42 43 export const PublishButton = (props: { entityID: string }) => { ··· 176 177 <hr className="border-border-light mt-3 mb-2" /> 177 178 178 179 <div className="flex gap-2 items-center place-self-end"> 179 - {selectedPub !== "looseleaf" && selectedPub && ( 180 + {selectedPub && selectedPub !== "create" && ( 180 181 <SaveAsDraftButton 181 182 selectedPub={selectedPub} 182 183 leafletId={permission_token.id} ··· 229 230 if (props.selectedPub === "create") return; 230 231 e.preventDefault(); 231 232 setIsLoading(true); 232 - await moveLeafletToPublication( 233 - props.leafletId, 234 - props.selectedPub, 235 - props.metadata, 236 - props.entitiesToDelete, 237 - ); 233 + 234 + // Use different actions for looseleaf vs publication 235 + if (props.selectedPub === "looseleaf") { 236 + await saveLeafletDraft( 237 + props.leafletId, 238 + props.metadata, 239 + props.entitiesToDelete, 240 + ); 241 + } else { 242 + await moveLeafletToPublication( 243 + props.leafletId, 244 + props.selectedPub, 245 + props.metadata, 246 + props.entitiesToDelete, 247 + ); 248 + } 249 + 238 250 await Promise.all([rep?.pull(), mutate()]); 239 251 setIsLoading(false); 240 252 }}
+2 -2
app/[leaflet_id]/publish/page.tsx
··· 76 76 // Get title and description from either source 77 77 let title = 78 78 data.leaflets_in_publications[0]?.title || 79 - data.leaflets_to_documents[0]?.title || 79 + data.leaflets_to_documents?.title || 80 80 decodeURIComponent((await props.searchParams).title || ""); 81 81 let description = 82 82 data.leaflets_in_publications[0]?.description || 83 - data.leaflets_to_documents[0]?.description || 83 + data.leaflets_to_documents?.description || 84 84 decodeURIComponent((await props.searchParams).description || ""); 85 85 86 86 let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
+1 -1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 107 107 : null, 108 108 }, 109 109 ], 110 - leaflets_to_documents: [], 110 + leaflets_to_documents: null, 111 111 blocked_by_admin: null, 112 112 custom_domain_routes: [], 113 113 }}
+3 -1
components/ActionBar/Publications.tsx
··· 24 24 }) => { 25 25 let { identity } = useIdentityData(); 26 26 let hasLooseleafs = identity?.permission_token_on_homepage.find( 27 - (f) => f.permission_tokens.leaflets_to_documents[0], 27 + (f) => 28 + f.permission_tokens.leaflets_to_documents && 29 + f.permission_tokens.leaflets_to_documents.document, 28 30 ); 29 31 console.log(hasLooseleafs); 30 32
+8 -4
components/PageSWRDataProvider.tsx
··· 90 90 const publishedInPublication = data.leaflets_in_publications?.find( 91 91 (l) => l.doc, 92 92 ); 93 - const publishedStandalone = data.leaflets_to_documents?.find( 94 - (l) => !!l.documents, 95 - ); 93 + const publishedStandalone = 94 + data.leaflets_to_documents && data.leaflets_to_documents.documents 95 + ? data.leaflets_to_documents 96 + : null; 96 97 97 98 const documentUri = 98 99 publishedInPublication?.documents?.uri ?? publishedStandalone?.document; 99 100 100 101 // Compute the full post URL for sharing 101 102 let postShareLink: string | undefined; 102 - if (publishedInPublication?.publications && publishedInPublication.documents) { 103 + if ( 104 + publishedInPublication?.publications && 105 + publishedInPublication.documents 106 + ) { 103 107 // Published in a publication - use publication URL + document rkey 104 108 const docUri = new AtUri(publishedInPublication.documents.uri); 105 109 postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
+31 -7
src/utils/getPublicationMetadataFromLeafletData.ts
··· 32 32 (p) => p.leaflets_in_publications?.length, 33 33 )?.leaflets_in_publications?.[0]; 34 34 35 - // If not found, check for standalone documents 36 - let standaloneDoc = 37 - data?.leaflets_to_documents?.[0] || 38 - data?.permission_token_rights[0].entity_sets?.permission_tokens.find( 39 - (p) => p.leaflets_to_documents?.length, 40 - )?.leaflets_to_documents?.[0]; 41 - if (!pubData && standaloneDoc) { 35 + // If not found, check for standalone documents (looseleafs) 36 + let standaloneDoc = data?.leaflets_to_documents; 37 + 38 + // Only use standaloneDoc if it exists and has meaningful data 39 + // (either published with a document, or saved as draft with a title) 40 + if ( 41 + !pubData && 42 + standaloneDoc && 43 + (standaloneDoc.document || standaloneDoc.title) 44 + ) { 42 45 // Transform standalone document data to match the expected format 43 46 pubData = { 44 47 ...standaloneDoc, 45 48 publications: null, // No publication for standalone docs 46 49 doc: standaloneDoc.document, 50 + leaflet: data.id, 47 51 }; 48 52 } 53 + 54 + // Also check nested permission tokens for looseleafs 55 + if (!pubData) { 56 + let nestedStandaloneDoc = 57 + data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 58 + (p) => 59 + p.leaflets_to_documents && 60 + (p.leaflets_to_documents.document || p.leaflets_to_documents.title), 61 + )?.leaflets_to_documents; 62 + 63 + if (nestedStandaloneDoc) { 64 + pubData = { 65 + ...nestedStandaloneDoc, 66 + publications: null, 67 + doc: nestedStandaloneDoc.document, 68 + leaflet: data.id, 69 + }; 70 + } 71 + } 72 + 49 73 return pubData; 50 74 }
+4 -4
supabase/database.types.ts
··· 631 631 Row: { 632 632 created_at: string 633 633 description: string 634 - document: string 634 + document: string | null 635 635 leaflet: string 636 636 title: string 637 637 } 638 638 Insert: { 639 639 created_at?: string 640 640 description?: string 641 - document: string 641 + document?: string | null 642 642 leaflet: string 643 643 title?: string 644 644 } 645 645 Update: { 646 646 created_at?: string 647 647 description?: string 648 - document?: string 648 + document?: string | null 649 649 leaflet?: string 650 650 title?: string 651 651 } ··· 660 660 { 661 661 foreignKeyName: "leaflets_to_documents_leaflet_fkey" 662 662 columns: ["leaflet"] 663 - isOneToOne: false 663 + isOneToOne: true 664 664 referencedRelation: "permission_tokens" 665 665 referencedColumns: ["id"] 666 666 },