a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

+2220 -1202
+9
.claude/settings.local.json
···
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "mcp__acp__Edit", 5 + "mcp__acp__Write", 6 + "mcp__acp__Bash" 7 + ] 8 + } 9 + }
-1
.github/pull_request_template.md
··· 2 - it looks good on both mobile and desktop 3 - it undo's like it ought to 4 - it handles keyboard interactions reasonably well 5 - - it behaves as you would expect if you lock it 6 - no build errors!!!
··· 2 - it looks good on both mobile and desktop 3 - it undo's like it ought to 4 - it handles keyboard interactions reasonably well 5 - no build errors!!!
+5 -2
actions/getIdentityData.ts
··· 3 import { cookies } from "next/headers"; 4 import { supabaseServerClient } from "supabase/serverClient"; 5 import { cache } from "react"; 6 export const getIdentityData = cache(uncachedGetIdentityData); 7 export async function uncachedGetIdentityData() { 8 let cookieStore = await cookies(); ··· 44 if (!auth_res?.data?.identities) return null; 45 if (auth_res.data.identities.atp_did) { 46 //I should create a relationship table so I can do this in the above query 47 - let { data: publications } = await supabaseServerClient 48 .from("publications") 49 .select("*") 50 .eq("identity_did", auth_res.data.identities.atp_did); 51 return { 52 ...auth_res.data.identities, 53 - publications: publications || [], 54 }; 55 } 56
··· 3 import { cookies } from "next/headers"; 4 import { supabaseServerClient } from "supabase/serverClient"; 5 import { cache } from "react"; 6 + import { deduplicateByUri } from "src/utils/deduplicateRecords"; 7 export const getIdentityData = cache(uncachedGetIdentityData); 8 export async function uncachedGetIdentityData() { 9 let cookieStore = await cookies(); ··· 45 if (!auth_res?.data?.identities) return null; 46 if (auth_res.data.identities.atp_did) { 47 //I should create a relationship table so I can do this in the above query 48 + let { data: rawPublications } = await supabaseServerClient 49 .from("publications") 50 .select("*") 51 .eq("identity_did", auth_res.data.identities.atp_did); 52 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 53 + const publications = deduplicateByUri(rawPublications || []); 54 return { 55 ...auth_res.data.identities, 56 + publications, 57 }; 58 } 59
+115 -54
actions/publishToPublication.ts
··· 199 } 200 201 // Determine the collection to use - preserve existing schema if updating 202 - const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined; 203 const documentType = getDocumentType(existingCollection); 204 205 // Build the pages array (used by both formats) ··· 228 if (documentType === "site.standard.document") { 229 // site.standard.document format 230 // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI 231 - const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 232 233 record = { 234 $type: "site.standard.document", 235 title: title || "Untitled", 236 site: siteUri, 237 - path: rkey, 238 publishedAt: 239 publishedAt || existingRecord.publishedAt || new Date().toISOString(), 240 ...(description && { description }), ··· 903 const mentionedDids = new Set<string>(); 904 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 905 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 906 907 // Extract pages from either format 908 let pages: PubLeafletContent.Main["pages"] | undefined; ··· 917 918 if (!pages) return; 919 920 - // Extract mentions from all text blocks in all pages 921 - for (const page of pages) { 922 - if (page.$type === "pub.leaflet.pages.linearDocument") { 923 - const linearPage = page as PubLeafletPagesLinearDocument.Main; 924 - for (const blockWrapper of linearPage.blocks) { 925 - const block = blockWrapper.block; 926 - if (block.$type === "pub.leaflet.blocks.text") { 927 - const textBlock = block as PubLeafletBlocksText.Main; 928 - if (textBlock.facets) { 929 - for (const facet of textBlock.facets) { 930 - for (const feature of facet.features) { 931 - // Check for DID mentions 932 - if (PubLeafletRichtextFacet.isDidMention(feature)) { 933 - if (feature.did !== authorDid) { 934 - mentionedDids.add(feature.did); 935 - } 936 - } 937 - // Check for AT URI mentions (publications and documents) 938 - if (PubLeafletRichtextFacet.isAtMention(feature)) { 939 - const uri = new AtUri(feature.atURI); 940 941 - if (isPublicationCollection(uri.collection)) { 942 - // Get the publication owner's DID 943 - const { data: publication } = await supabaseServerClient 944 - .from("publications") 945 - .select("identity_did") 946 - .eq("uri", feature.atURI) 947 - .single(); 948 949 - if (publication && publication.identity_did !== authorDid) { 950 - mentionedPublications.set( 951 - publication.identity_did, 952 - feature.atURI, 953 - ); 954 - } 955 - } else if (isDocumentCollection(uri.collection)) { 956 - // Get the document owner's DID 957 - const { data: document } = await supabaseServerClient 958 - .from("documents") 959 - .select("uri, data") 960 - .eq("uri", feature.atURI) 961 - .single(); 962 963 - if (document) { 964 - const normalizedMentionedDoc = normalizeDocumentRecord( 965 - document.data, 966 - ); 967 - // Get the author from the document URI (the DID is the host part) 968 - const mentionedUri = new AtUri(feature.atURI); 969 - const docAuthor = mentionedUri.host; 970 - if (normalizedMentionedDoc && docAuthor !== authorDid) { 971 - mentionedDocuments.set(docAuthor, feature.atURI); 972 - } 973 - } 974 } 975 } 976 } ··· 1026 }; 1027 await supabaseServerClient.from("notifications").insert(notification); 1028 await pingIdentityToUpdateNotification(recipientDid); 1029 } 1030 }
··· 199 } 200 201 // Determine the collection to use - preserve existing schema if updating 202 + const existingCollection = existingDocUri 203 + ? new AtUri(existingDocUri).collection 204 + : undefined; 205 const documentType = getDocumentType(existingCollection); 206 207 // Build the pages array (used by both formats) ··· 230 if (documentType === "site.standard.document") { 231 // site.standard.document format 232 // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI 233 + const siteUri = 234 + publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 235 236 record = { 237 $type: "site.standard.document", 238 title: title || "Untitled", 239 site: siteUri, 240 + path: "/" + rkey, 241 publishedAt: 242 publishedAt || existingRecord.publishedAt || new Date().toISOString(), 243 ...(description && { description }), ··· 906 const mentionedDids = new Set<string>(); 907 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 908 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 909 + const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI 910 911 // Extract pages from either format 912 let pages: PubLeafletContent.Main["pages"] | undefined; ··· 921 922 if (!pages) return; 923 924 + // Helper to extract blocks from all pages (both linear and canvas) 925 + function getAllBlocks(pages: PubLeafletContent.Main["pages"]) { 926 + const blocks: ( 927 + | PubLeafletPagesLinearDocument.Block["block"] 928 + | PubLeafletPagesCanvas.Block["block"] 929 + )[] = []; 930 + for (const page of pages) { 931 + if (page.$type === "pub.leaflet.pages.linearDocument") { 932 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 933 + for (const blockWrapper of linearPage.blocks) { 934 + blocks.push(blockWrapper.block); 935 + } 936 + } else if (page.$type === "pub.leaflet.pages.canvas") { 937 + const canvasPage = page as PubLeafletPagesCanvas.Main; 938 + for (const blockWrapper of canvasPage.blocks) { 939 + blocks.push(blockWrapper.block); 940 + } 941 + } 942 + } 943 + return blocks; 944 + } 945 946 + const allBlocks = getAllBlocks(pages); 947 948 + // Extract mentions from all text blocks and embedded Bluesky posts 949 + for (const block of allBlocks) { 950 + // Check for embedded Bluesky posts 951 + if (PubLeafletBlocksBskyPost.isMain(block)) { 952 + const bskyPostUri = block.postRef.uri; 953 + // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx) 954 + const postAuthorDid = new AtUri(bskyPostUri).host; 955 + if (postAuthorDid !== authorDid) { 956 + embeddedBskyPosts.set(postAuthorDid, bskyPostUri); 957 + } 958 + } 959 960 + // Check for text blocks with mentions 961 + if (block.$type === "pub.leaflet.blocks.text") { 962 + const textBlock = block as PubLeafletBlocksText.Main; 963 + if (textBlock.facets) { 964 + for (const facet of textBlock.facets) { 965 + for (const feature of facet.features) { 966 + // Check for DID mentions 967 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 968 + if (feature.did !== authorDid) { 969 + mentionedDids.add(feature.did); 970 + } 971 + } 972 + // Check for AT URI mentions (publications and documents) 973 + if (PubLeafletRichtextFacet.isAtMention(feature)) { 974 + const uri = new AtUri(feature.atURI); 975 + 976 + if (isPublicationCollection(uri.collection)) { 977 + // Get the publication owner's DID 978 + const { data: publication } = await supabaseServerClient 979 + .from("publications") 980 + .select("identity_did") 981 + .eq("uri", feature.atURI) 982 + .single(); 983 + 984 + if (publication && publication.identity_did !== authorDid) { 985 + mentionedPublications.set( 986 + publication.identity_did, 987 + feature.atURI, 988 + ); 989 + } 990 + } else if (isDocumentCollection(uri.collection)) { 991 + // Get the document owner's DID 992 + const { data: document } = await supabaseServerClient 993 + .from("documents") 994 + .select("uri, data") 995 + .eq("uri", feature.atURI) 996 + .single(); 997 + 998 + if (document) { 999 + const normalizedMentionedDoc = normalizeDocumentRecord( 1000 + document.data, 1001 + ); 1002 + // Get the author from the document URI (the DID is the host part) 1003 + const mentionedUri = new AtUri(feature.atURI); 1004 + const docAuthor = mentionedUri.host; 1005 + if (normalizedMentionedDoc && docAuthor !== authorDid) { 1006 + mentionedDocuments.set(docAuthor, feature.atURI); 1007 } 1008 } 1009 } ··· 1059 }; 1060 await supabaseServerClient.from("notifications").insert(notification); 1061 await pingIdentityToUpdateNotification(recipientDid); 1062 + } 1063 + 1064 + // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account) 1065 + if (embeddedBskyPosts.size > 0) { 1066 + // Check which of the Bluesky post authors have Leaflet accounts 1067 + const { data: identities } = await supabaseServerClient 1068 + .from("identities") 1069 + .select("atp_did") 1070 + .in("atp_did", Array.from(embeddedBskyPosts.keys())); 1071 + 1072 + const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []); 1073 + 1074 + for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) { 1075 + // Only notify if the post author has a Leaflet account 1076 + if (leafletUserDids.has(postAuthorDid)) { 1077 + const notification: Notification = { 1078 + id: v7(), 1079 + recipient: postAuthorDid, 1080 + data: { 1081 + type: "bsky_post_embed", 1082 + document_uri: documentUri, 1083 + bsky_post_uri: bskyPostUri, 1084 + }, 1085 + }; 1086 + await supabaseServerClient.from("notifications").insert(notification); 1087 + await pingIdentityToUpdateNotification(postAuthorDid); 1088 + } 1089 + } 1090 } 1091 }
+5 -1
app/(home-pages)/discover/getPublications.ts
··· 5 normalizePublicationRow, 6 hasValidPublication, 7 } from "src/utils/normalizeRecords"; 8 9 export type Cursor = { 10 indexed_at?: string; ··· 42 return { publications: [], nextCursor: null }; 43 } 44 45 // Filter out publications without documents 46 - const allPubs = (publications || []).filter( 47 (pub) => pub.documents_in_publications.length > 0, 48 ); 49
··· 5 normalizePublicationRow, 6 hasValidPublication, 7 } from "src/utils/normalizeRecords"; 8 + import { deduplicateByUri } from "src/utils/deduplicateRecords"; 9 10 export type Cursor = { 11 indexed_at?: string; ··· 43 return { publications: [], nextCursor: null }; 44 } 45 46 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 47 + const dedupedPublications = deduplicateByUri(publications || []); 48 + 49 // Filter out publications without documents 50 + const allPubs = dedupedPublications.filter( 51 (pub) => pub.documents_in_publications.length > 0, 52 ); 53
+44
app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
···
··· 1 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 2 + import { ContentLayout, Notification } from "./Notification"; 3 + import { HydratedBskyPostEmbedNotification } from "src/notifications"; 4 + import { AtUri } from "@atproto/api"; 5 + 6 + export const BskyPostEmbedNotification = ( 7 + props: HydratedBskyPostEmbedNotification, 8 + ) => { 9 + const docRecord = props.normalizedDocument; 10 + const pubRecord = props.normalizedPublication; 11 + 12 + if (!docRecord) return null; 13 + 14 + const docUri = new AtUri(props.document.uri); 15 + const rkey = docUri.rkey; 16 + const did = docUri.host; 17 + 18 + const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; 19 + 20 + const embedder = props.documentCreatorHandle 21 + ? `@${props.documentCreatorHandle}` 22 + : "Someone"; 23 + 24 + return ( 25 + <Notification 26 + timestamp={props.created_at} 27 + href={href} 28 + icon={<BlueskyTiny />} 29 + actionText={<>{embedder} embedded your Bluesky post</>} 30 + content={ 31 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 32 + {props.bskyPostText && ( 33 + <pre 34 + style={{ wordBreak: "break-word" }} 35 + className="whitespace-pre-wrap text-secondary line-clamp-3 text-sm" 36 + > 37 + {props.bskyPostText} 38 + </pre> 39 + )} 40 + </ContentLayout> 41 + } 42 + /> 43 + ); 44 + };
+4
app/(home-pages)/notifications/NotificationList.tsx
··· 8 import { useIdentityData } from "components/IdentityProvider"; 9 import { FollowNotification } from "./FollowNotification"; 10 import { QuoteNotification } from "./QuoteNotification"; 11 import { MentionNotification } from "./MentionNotification"; 12 import { CommentMentionNotification } from "./CommentMentionNotification"; 13 ··· 47 } 48 if (n.type === "quote") { 49 return <QuoteNotification key={n.id} {...n} />; 50 } 51 if (n.type === "mention") { 52 return <MentionNotification key={n.id} {...n} />;
··· 8 import { useIdentityData } from "components/IdentityProvider"; 9 import { FollowNotification } from "./FollowNotification"; 10 import { QuoteNotification } from "./QuoteNotification"; 11 + import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification"; 12 import { MentionNotification } from "./MentionNotification"; 13 import { CommentMentionNotification } from "./CommentMentionNotification"; 14 ··· 48 } 49 if (n.type === "quote") { 50 return <QuoteNotification key={n.id} {...n} />; 51 + } 52 + if (n.type === "bsky_post_embed") { 53 + return <BskyPostEmbedNotification key={n.id} {...n} />; 54 } 55 if (n.type === "mention") { 56 return <MentionNotification key={n.id} {...n} />;
+1 -1
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 16 popover?: boolean; 17 }) => { 18 let profileRecord = props.profile; 19 - const profileUrl = `/p/${props.profile.handle}`; 20 21 const avatarElement = ( 22 <Avatar
··· 16 popover?: boolean; 17 }) => { 18 let profileRecord = props.profile; 19 + const profileUrl = `https://leaflet.pub/p/${props.profile.handle}`; 20 21 const avatarElement = ( 22 <Avatar
+6 -1
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 7 normalizeDocumentRecord, 8 normalizePublicationRecord, 9 } from "src/utils/normalizeRecords"; 10 11 export type Cursor = { 12 indexed_at: string; ··· 38 ); 39 } 40 41 - let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([ 42 query, 43 supabaseServerClient 44 .from("publications") ··· 50 .eq("did", did) 51 .single(), 52 ]); 53 54 // Build a map of publications for quick lookup 55 let pubMap = new Map<string, NonNullable<typeof pubs>[number]>();
··· 7 normalizeDocumentRecord, 8 normalizePublicationRecord, 9 } from "src/utils/normalizeRecords"; 10 + import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 11 12 export type Cursor = { 13 indexed_at: string; ··· 39 ); 40 } 41 42 + let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = await Promise.all([ 43 query, 44 supabaseServerClient 45 .from("publications") ··· 51 .eq("did", did) 52 .single(), 53 ]); 54 + 55 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 56 + const docs = deduplicateByUriOrdered(rawDocs || []); 57 + const pubs = deduplicateByUriOrdered(rawPubs || []); 58 59 // Build a map of publications for quick lookup 60 let pubMap = new Map<string, NonNullable<typeof pubs>[number]>();
+6 -2
app/(home-pages)/reader/getReaderFeed.ts
··· 14 type NormalizedDocument, 15 type NormalizedPublication, 16 } from "src/utils/normalizeRecords"; 17 18 export type Cursor = { 19 timestamp: string; ··· 45 `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 46 ); 47 } 48 - let { data: feed, error } = await query; 49 50 let posts = ( 51 await Promise.all( 52 - feed?.map(async (post) => { 53 let pub = post.documents_in_publications[0].publications!; 54 let uri = new AtUri(post.uri); 55 let handle = await idResolver.did.resolve(uri.host);
··· 14 type NormalizedDocument, 15 type NormalizedPublication, 16 } from "src/utils/normalizeRecords"; 17 + import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 18 19 export type Cursor = { 20 timestamp: string; ··· 46 `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 47 ); 48 } 49 + let { data: rawFeed, error } = await query; 50 + 51 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 52 + const feed = deduplicateByUriOrdered(rawFeed || []); 53 54 let posts = ( 55 await Promise.all( 56 + feed.map(async (post) => { 57 let pub = post.documents_in_publications[0].publications!; 58 let uri = new AtUri(post.uri); 59 let handle = await idResolver.did.resolve(uri.host);
+5 -1
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 9 normalizeDocumentRecord, 10 normalizePublicationRecord, 11 } from "src/utils/normalizeRecords"; 12 13 export async function getDocumentsByTag( 14 tag: string, 15 ): Promise<{ posts: Post[] }> { 16 // Query documents that have this tag 17 - const { data: documents, error } = await supabaseServerClient 18 .from("documents") 19 .select( 20 `*, ··· 30 console.error("Error fetching documents by tag:", error); 31 return { posts: [] }; 32 } 33 34 const posts = await Promise.all( 35 documents.map(async (doc) => {
··· 9 normalizeDocumentRecord, 10 normalizePublicationRecord, 11 } from "src/utils/normalizeRecords"; 12 + import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 13 14 export async function getDocumentsByTag( 15 tag: string, 16 ): Promise<{ posts: Post[] }> { 17 // Query documents that have this tag 18 + const { data: rawDocuments, error } = await supabaseServerClient 19 .from("documents") 20 .select( 21 `*, ··· 31 console.error("Error fetching documents by tag:", error); 32 return { posts: [] }; 33 } 34 + 35 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 36 + const documents = deduplicateByUriOrdered(rawDocuments || []); 37 38 const posts = await Promise.all( 39 documents.map(async (doc) => {
+17 -1
app/[leaflet_id]/Footer.tsx
··· 8 import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 import { PublishButton } from "./actions/PublishButton"; 10 import { useEntitySetContext } from "components/EntitySetProvider"; 11 - import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 12 import { Watermark } from "components/Watermark"; 13 import { BackToPubButton } from "./actions/BackToPubButton"; 14 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 15 import { useIdentityData } from "components/IdentityProvider"; 16 17 export function LeafletFooter(props: { entityID: string }) { 18 let focusedBlock = useUIState((s) => s.focusedEntity); 19 let entity_set = useEntitySetContext(); 20 let { identity } = useIdentityData(); 21 let { data: pub } = useLeafletPublicationData(); 22 23 return ( 24 <Media mobile className="mobileFooter w-full z-10 touch-none -mt-[54px] "> 25 {focusedBlock && 26 focusedBlock.entityType == "block" && 27 entity_set.permissions.write ? ( 28 <div 29 className="w-full z-10 p-2 flex bg-bg-page pwa-padding-bottom" ··· 34 <Toolbar 35 pageID={focusedBlock.parent} 36 blockID={focusedBlock.entityID} 37 /> 38 </div> 39 ) : entity_set.permissions.write ? (
··· 8 import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 import { PublishButton } from "./actions/PublishButton"; 10 import { useEntitySetContext } from "components/EntitySetProvider"; 11 import { Watermark } from "components/Watermark"; 12 import { BackToPubButton } from "./actions/BackToPubButton"; 13 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 14 import { useIdentityData } from "components/IdentityProvider"; 15 + import { useEntity } from "src/replicache"; 16 + import { block } from "sharp"; 17 18 + export function hasBlockToolbar(blockType: string | null | undefined) { 19 + return ( 20 + blockType === "text" || 21 + blockType === "heading" || 22 + blockType === "blockquote" || 23 + blockType === "button" || 24 + blockType === "datetime" || 25 + blockType === "image" 26 + ); 27 + } 28 export function LeafletFooter(props: { entityID: string }) { 29 let focusedBlock = useUIState((s) => s.focusedEntity); 30 + 31 let entity_set = useEntitySetContext(); 32 let { identity } = useIdentityData(); 33 let { data: pub } = useLeafletPublicationData(); 34 + let blockType = useEntity(focusedBlock?.entityID || null, "block/type")?.data 35 + .value; 36 37 return ( 38 <Media mobile className="mobileFooter w-full z-10 touch-none -mt-[54px] "> 39 {focusedBlock && 40 focusedBlock.entityType == "block" && 41 + hasBlockToolbar(blockType) && 42 entity_set.permissions.write ? ( 43 <div 44 className="w-full z-10 p-2 flex bg-bg-page pwa-padding-bottom" ··· 49 <Toolbar 50 pageID={focusedBlock.parent} 51 blockID={focusedBlock.entityID} 52 + blockType={blockType} 53 /> 54 </div> 55 ) : entity_set.permissions.write ? (
+15
app/api/inngest/client.ts
··· 26 did: string; 27 }; 28 }; 29 }; 30 31 // Create a client to send and receive events
··· 26 did: string; 27 }; 28 }; 29 + "user/cleanup-expired-oauth-sessions": { 30 + data: {}; 31 + }; 32 + "user/check-oauth-session": { 33 + data: { 34 + identityId: string; 35 + did: string; 36 + tokenCount: number; 37 + }; 38 + }; 39 + "documents/fix-publication-references": { 40 + data: { 41 + documentUris: string[]; 42 + }; 43 + }; 44 }; 45 46 // Create a client to send and receive events
+123
app/api/inngest/functions/cleanup_expired_oauth_sessions.ts
···
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { inngest } from "../client"; 3 + import { restoreOAuthSession } from "src/atproto-oauth"; 4 + 5 + // Main function that fetches identities and publishes events for each one 6 + export const cleanup_expired_oauth_sessions = inngest.createFunction( 7 + { id: "cleanup_expired_oauth_sessions" }, 8 + { event: "user/cleanup-expired-oauth-sessions" }, 9 + async ({ step }) => { 10 + // Get all identities with an atp_did (OAuth users) that have at least one auth token 11 + const identities = await step.run("fetch-oauth-identities", async () => { 12 + const { data, error } = await supabaseServerClient 13 + .from("identities") 14 + .select("id, atp_did, email_auth_tokens(count)") 15 + .not("atp_did", "is", null); 16 + 17 + if (error) { 18 + throw new Error(`Failed to fetch identities: ${error.message}`); 19 + } 20 + 21 + // Filter to only include identities with at least one auth token 22 + return (data || []) 23 + .filter((identity) => { 24 + const tokenCount = identity.email_auth_tokens?.[0]?.count ?? 0; 25 + return tokenCount > 0; 26 + }) 27 + .map((identity) => ({ 28 + id: identity.id, 29 + atp_did: identity.atp_did!, 30 + tokenCount: identity.email_auth_tokens?.[0]?.count ?? 0, 31 + })); 32 + }); 33 + 34 + console.log( 35 + `Found ${identities.length} OAuth identities with active sessions to check`, 36 + ); 37 + 38 + // Publish events for each identity in batches 39 + const BATCH_SIZE = 100; 40 + let totalSent = 0; 41 + 42 + for (let i = 0; i < identities.length; i += BATCH_SIZE) { 43 + const batch = identities.slice(i, i + BATCH_SIZE); 44 + 45 + await step.run(`send-events-batch-${i}`, async () => { 46 + const events = batch.map((identity) => ({ 47 + name: "user/check-oauth-session" as const, 48 + data: { 49 + identityId: identity.id, 50 + did: identity.atp_did, 51 + tokenCount: identity.tokenCount, 52 + }, 53 + })); 54 + 55 + await inngest.send(events); 56 + return events.length; 57 + }); 58 + 59 + totalSent += batch.length; 60 + } 61 + 62 + console.log(`Published ${totalSent} check-oauth-session events`); 63 + 64 + return { 65 + success: true, 66 + identitiesQueued: totalSent, 67 + }; 68 + }, 69 + ); 70 + 71 + // Function that checks a single identity's OAuth session and cleans up if expired 72 + export const check_oauth_session = inngest.createFunction( 73 + { id: "check_oauth_session" }, 74 + { event: "user/check-oauth-session" }, 75 + async ({ event, step }) => { 76 + const { identityId, did, tokenCount } = event.data; 77 + 78 + const result = await step.run("check-and-cleanup", async () => { 79 + console.log(`Checking OAuth session for DID: ${did} (${tokenCount} tokens)`); 80 + 81 + const sessionResult = await restoreOAuthSession(did); 82 + 83 + if (sessionResult.ok) { 84 + console.log(` Session valid for ${did}`); 85 + return { valid: true, tokensDeleted: 0 }; 86 + } 87 + 88 + // Session is expired/invalid - delete associated auth tokens 89 + console.log( 90 + ` Session expired for ${did}: ${sessionResult.error.message}`, 91 + ); 92 + 93 + const { error: deleteError } = await supabaseServerClient 94 + .from("email_auth_tokens") 95 + .delete() 96 + .eq("identity", identityId); 97 + 98 + if (deleteError) { 99 + console.error( 100 + ` Error deleting tokens for identity ${identityId}: ${deleteError.message}`, 101 + ); 102 + return { 103 + valid: false, 104 + tokensDeleted: 0, 105 + error: deleteError.message, 106 + }; 107 + } 108 + 109 + console.log(` Deleted ${tokenCount} auth tokens for identity ${identityId}`); 110 + 111 + return { 112 + valid: false, 113 + tokensDeleted: tokenCount, 114 + }; 115 + }); 116 + 117 + return { 118 + identityId, 119 + did, 120 + ...result, 121 + }; 122 + }, 123 + );
+213
app/api/inngest/functions/fix_standard_document_publications.ts
···
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { inngest } from "../client"; 3 + import { restoreOAuthSession } from "src/atproto-oauth"; 4 + import { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { Json } from "supabase/database.types"; 7 + 8 + async function createAuthenticatedAgent(did: string): Promise<AtpBaseClient> { 9 + const result = await restoreOAuthSession(did); 10 + if (!result.ok) { 11 + throw new Error(`Failed to restore OAuth session: ${result.error.message}`); 12 + } 13 + const credentialSession = result.value; 14 + return new AtpBaseClient( 15 + credentialSession.fetchHandler.bind(credentialSession), 16 + ); 17 + } 18 + 19 + /** 20 + * Fixes site.standard.document records that have stale pub.leaflet.publication 21 + * references in their site field. Updates both the PDS record and database. 22 + */ 23 + export const fix_standard_document_publications = inngest.createFunction( 24 + { id: "fix_standard_document_publications" }, 25 + { event: "documents/fix-publication-references" }, 26 + async ({ event, step }) => { 27 + const { documentUris } = event.data as { documentUris: string[] }; 28 + 29 + const stats = { 30 + documentsFixed: 0, 31 + joinEntriesFixed: 0, 32 + errors: [] as string[], 33 + }; 34 + 35 + if (!documentUris || documentUris.length === 0) { 36 + return { success: true, stats, message: "No documents to fix" }; 37 + } 38 + 39 + // Group documents by DID (author) for efficient OAuth session handling 40 + const docsByDid = new Map<string, string[]>(); 41 + for (const uri of documentUris) { 42 + try { 43 + const aturi = new AtUri(uri); 44 + const did = aturi.hostname; 45 + const existing = docsByDid.get(did) || []; 46 + existing.push(uri); 47 + docsByDid.set(did, existing); 48 + } catch (e) { 49 + stats.errors.push(`Invalid URI: ${uri}`); 50 + } 51 + } 52 + 53 + // Process each DID's documents 54 + for (const [did, uris] of docsByDid) { 55 + // Verify OAuth session for this user 56 + const oauthValid = await step.run( 57 + `verify-oauth-${did.slice(-8)}`, 58 + async () => { 59 + const result = await restoreOAuthSession(did); 60 + return result.ok; 61 + }, 62 + ); 63 + 64 + if (!oauthValid) { 65 + stats.errors.push(`No valid OAuth session for ${did}`); 66 + continue; 67 + } 68 + 69 + // Fix each document 70 + for (const docUri of uris) { 71 + const result = await step.run( 72 + `fix-doc-${docUri.slice(-12)}`, 73 + async () => { 74 + // Fetch the document 75 + const { data: doc, error: fetchError } = await supabaseServerClient 76 + .from("documents") 77 + .select("uri, data") 78 + .eq("uri", docUri) 79 + .single(); 80 + 81 + if (fetchError || !doc) { 82 + return { 83 + success: false as const, 84 + error: `Document not found: ${fetchError?.message || "no data"}`, 85 + }; 86 + } 87 + 88 + const data = doc.data as SiteStandardDocument.Record; 89 + const oldSite = data?.site; 90 + 91 + if (!oldSite || !oldSite.includes("/pub.leaflet.publication/")) { 92 + return { 93 + success: false as const, 94 + error: "Document does not have a pub.leaflet.publication site reference", 95 + }; 96 + } 97 + 98 + // Convert to new publication URI 99 + const oldPubAturi = new AtUri(oldSite); 100 + const newSite = `at://${oldPubAturi.hostname}/site.standard.publication/${oldPubAturi.rkey}`; 101 + 102 + // Update the record 103 + const updatedRecord: SiteStandardDocument.Record = { 104 + ...data, 105 + site: newSite, 106 + }; 107 + 108 + // Write to PDS 109 + const docAturi = new AtUri(docUri); 110 + const agent = await createAuthenticatedAgent(did); 111 + await agent.com.atproto.repo.putRecord({ 112 + repo: did, 113 + collection: "site.standard.document", 114 + rkey: docAturi.rkey, 115 + record: updatedRecord, 116 + validate: false, 117 + }); 118 + 119 + // Update database 120 + const { error: dbError } = await supabaseServerClient 121 + .from("documents") 122 + .update({ data: updatedRecord as Json }) 123 + .eq("uri", docUri); 124 + 125 + if (dbError) { 126 + return { 127 + success: false as const, 128 + error: `Database update failed: ${dbError.message}`, 129 + }; 130 + } 131 + 132 + return { 133 + success: true as const, 134 + oldSite, 135 + newSite, 136 + }; 137 + }, 138 + ); 139 + 140 + if (result.success) { 141 + stats.documentsFixed++; 142 + 143 + // Fix the documents_in_publications entry 144 + const joinResult = await step.run( 145 + `fix-join-${docUri.slice(-12)}`, 146 + async () => { 147 + // Find the publication URI that exists in the database 148 + const { data: doc } = await supabaseServerClient 149 + .from("documents") 150 + .select("data") 151 + .eq("uri", docUri) 152 + .single(); 153 + 154 + const newSite = (doc?.data as any)?.site; 155 + if (!newSite) { 156 + return { success: false as const, error: "Could not read updated document" }; 157 + } 158 + 159 + // Check which publication URI exists 160 + const newPubAturi = new AtUri(newSite); 161 + const oldPubUri = `at://${newPubAturi.hostname}/pub.leaflet.publication/${newPubAturi.rkey}`; 162 + 163 + const { data: pubs } = await supabaseServerClient 164 + .from("publications") 165 + .select("uri") 166 + .in("uri", [newSite, oldPubUri]); 167 + 168 + const existingPubUri = pubs?.find((p) => p.uri === newSite)?.uri || 169 + pubs?.find((p) => p.uri === oldPubUri)?.uri; 170 + 171 + if (!existingPubUri) { 172 + return { success: false as const, error: "No matching publication found" }; 173 + } 174 + 175 + // Delete any existing entries for this document 176 + await supabaseServerClient 177 + .from("documents_in_publications") 178 + .delete() 179 + .eq("document", docUri); 180 + 181 + // Insert the correct entry 182 + const { error: insertError } = await supabaseServerClient 183 + .from("documents_in_publications") 184 + .insert({ 185 + document: docUri, 186 + publication: existingPubUri, 187 + }); 188 + 189 + if (insertError) { 190 + return { success: false as const, error: insertError.message }; 191 + } 192 + 193 + return { success: true as const, publication: existingPubUri }; 194 + }, 195 + ); 196 + 197 + if (joinResult.success) { 198 + stats.joinEntriesFixed++; 199 + } else { 200 + stats.errors.push(`Join table fix failed for ${docUri}: ${"error" in joinResult ? joinResult.error : "unknown error"}`); 201 + } 202 + } else { 203 + stats.errors.push(`${docUri}: ${result.error}`); 204 + } 205 + } 206 + } 207 + 208 + return { 209 + success: stats.errors.length === 0, 210 + stats, 211 + }; 212 + }, 213 + );
+498 -291
app/api/inngest/functions/migrate_user_to_standard.ts
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 import { inngest } from "../client"; 3 import { restoreOAuthSession } from "src/atproto-oauth"; 4 - import { AtpBaseClient, SiteStandardPublication, SiteStandardDocument, SiteStandardGraphSubscription } from "lexicons/api"; 5 import { AtUri } from "@atproto/syntax"; 6 import { Json } from "supabase/database.types"; 7 - import { normalizePublicationRecord, normalizeDocumentRecord } from "src/utils/normalizeRecords"; 8 9 type MigrationResult = 10 | { success: true; oldUri: string; newUri: string; skipped?: boolean } ··· 17 } 18 const credentialSession = result.value; 19 return new AtpBaseClient( 20 - credentialSession.fetchHandler.bind(credentialSession) 21 ); 22 } 23 ··· 30 const stats = { 31 publicationsMigrated: 0, 32 documentsMigrated: 0, 33 userSubscriptionsMigrated: 0, 34 referencesUpdated: 0, 35 errors: [] as string[], 36 }; 37 38 // Step 1: Verify OAuth session is valid 39 - await step.run("verify-oauth-session", async () => { 40 const result = await restoreOAuthSession(did); 41 if (!result.ok) { 42 - throw new Error(`Failed to restore OAuth session: ${result.error.message}`); 43 } 44 return { success: true }; 45 }); 46 47 // Step 2: Get user's pub.leaflet.publication records 48 - const oldPublications = await step.run("fetch-old-publications", async () => { 49 - const { data, error } = await supabaseServerClient 50 - .from("publications") 51 - .select("*") 52 - .eq("identity_did", did) 53 - .like("uri", `at://${did}/pub.leaflet.publication/%`); 54 55 - if (error) throw new Error(`Failed to fetch publications: ${error.message}`); 56 - return data || []; 57 - }); 58 59 - // Step 3: Migrate each publication 60 const publicationUriMap: Record<string, string> = {}; // old URI -> new URI 61 62 - for (const pub of oldPublications) { 63 - const aturi = new AtUri(pub.uri); 64 65 - // Skip if already a site.standard.publication 66 - if (aturi.collection === "site.standard.publication") { 67 - publicationUriMap[pub.uri] = pub.uri; 68 - continue; 69 - } 70 - 71 - const rkey = aturi.rkey; 72 - const normalized = normalizePublicationRecord(pub.record); 73 74 - if (!normalized) { 75 - stats.errors.push(`Publication ${pub.uri}: Failed to normalize publication record`); 76 - continue; 77 - } 78 79 - // Build site.standard.publication record 80 - const newRecord: SiteStandardPublication.Record = { 81 - $type: "site.standard.publication", 82 - name: normalized.name, 83 - url: normalized.url, 84 - description: normalized.description, 85 - icon: normalized.icon, 86 - theme: normalized.theme, 87 - basicTheme: normalized.basicTheme, 88 - preferences: normalized.preferences, 89 - }; 90 91 - // Step: Write to PDS 92 - const pdsResult = await step.run(`pds-write-publication-${pub.uri}`, async () => { 93 - const agent = await createAuthenticatedAgent(did); 94 - const putResult = await agent.com.atproto.repo.putRecord({ 95 - repo: did, 96 - collection: "site.standard.publication", 97 - rkey, 98 - record: newRecord, 99 - validate: false, 100 - }); 101 - return { newUri: putResult.data.uri }; 102 - }); 103 104 - const newUri = pdsResult.newUri; 105 106 - // Step: Write to database 107 - const dbResult = await step.run(`db-write-publication-${pub.uri}`, async () => { 108 - const { error: dbError } = await supabaseServerClient 109 - .from("publications") 110 - .upsert({ 111 - uri: newUri, 112 - identity_did: did, 113 - name: normalized.name, 114 - record: newRecord as Json, 115 }); 116 117 - if (dbError) { 118 - return { success: false as const, error: dbError.message }; 119 - } 120 - return { success: true as const }; 121 - }); 122 123 - if (dbResult.success) { 124 - publicationUriMap[pub.uri] = newUri; 125 stats.publicationsMigrated++; 126 } else { 127 - stats.errors.push(`Publication ${pub.uri}: Database error: ${dbResult.error}`); 128 } 129 } 130 131 - // Step 4: Get ALL user's pub.leaflet.document records (both in publications and standalone) 132 - const oldDocuments = await step.run("fetch-old-documents", async () => { 133 - const { data, error } = await supabaseServerClient 134 - .from("documents") 135 - .select("uri, data") 136 - .like("uri", `at://${did}/pub.leaflet.document/%`); 137 138 - if (error) throw new Error(`Failed to fetch documents: ${error.message}`); 139 - return data || []; 140 - }); 141 - 142 - // Also fetch publication associations for documents 143 - const documentPublicationMap = await step.run("fetch-document-publications", async () => { 144 - const docUris = oldDocuments.map(d => d.uri); 145 - if (docUris.length === 0) return {}; 146 - 147 - const { data, error } = await supabaseServerClient 148 - .from("documents_in_publications") 149 - .select("document, publication") 150 - .in("document", docUris); 151 152 - if (error) throw new Error(`Failed to fetch document publications: ${error.message}`); 153 154 - // Create a map of document URI -> publication URI 155 - const map: Record<string, string> = {}; 156 - for (const row of data || []) { 157 - map[row.document] = row.publication; 158 - } 159 - return map; 160 - }); 161 162 const documentUriMap: Record<string, string> = {}; // old URI -> new URI 163 164 - for (const doc of oldDocuments) { 165 - const aturi = new AtUri(doc.uri); 166 167 - // Skip if already a site.standard.document 168 - if (aturi.collection === "site.standard.document") { 169 - documentUriMap[doc.uri] = doc.uri; 170 - continue; 171 - } 172 173 - const rkey = aturi.rkey; 174 - const normalized = normalizeDocumentRecord(doc.data, doc.uri); 175 176 - if (!normalized) { 177 - stats.errors.push(`Document ${doc.uri}: Failed to normalize document record`); 178 - continue; 179 - } 180 181 - // Determine the site field: 182 - // - If document is in a publication, use the new publication URI (if migrated) or old URI 183 - // - If standalone, use the HTTPS URL format 184 - const oldPubUri = documentPublicationMap[doc.uri]; 185 - let siteValue: string; 186 187 - if (oldPubUri) { 188 - // Document is in a publication - use new URI if migrated, otherwise keep old 189 - siteValue = publicationUriMap[oldPubUri] || oldPubUri; 190 - } else { 191 - // Standalone document - use HTTPS URL format 192 - siteValue = `https://leaflet.pub/p/${did}`; 193 - } 194 195 - // Build site.standard.document record 196 - const newRecord: SiteStandardDocument.Record = { 197 - $type: "site.standard.document", 198 - title: normalized.title || "Untitled", 199 - site: siteValue, 200 - path: rkey, 201 - publishedAt: normalized.publishedAt || new Date().toISOString(), 202 - description: normalized.description, 203 - content: normalized.content, 204 - tags: normalized.tags, 205 - coverImage: normalized.coverImage, 206 - bskyPostRef: normalized.bskyPostRef, 207 - }; 208 209 - // Step: Write to PDS 210 - const pdsResult = await step.run(`pds-write-document-${doc.uri}`, async () => { 211 - const agent = await createAuthenticatedAgent(did); 212 - const putResult = await agent.com.atproto.repo.putRecord({ 213 - repo: did, 214 - collection: "site.standard.document", 215 - rkey, 216 - record: newRecord, 217 - validate: false, 218 - }); 219 - return { newUri: putResult.data.uri }; 220 - }); 221 - 222 - const newUri = pdsResult.newUri; 223 224 - // Step: Write to database 225 - const dbResult = await step.run(`db-write-document-${doc.uri}`, async () => { 226 - const { error: dbError } = await supabaseServerClient 227 - .from("documents") 228 - .upsert({ 229 - uri: newUri, 230 - data: newRecord as Json, 231 }); 232 - 233 - if (dbError) { 234 - return { success: false as const, error: dbError.message }; 235 - } 236 237 - // If document was in a publication, add to documents_in_publications with new URIs 238 - if (oldPubUri) { 239 - const newPubUri = publicationUriMap[oldPubUri] || oldPubUri; 240 - await supabaseServerClient 241 - .from("documents_in_publications") 242 .upsert({ 243 - publication: newPubUri, 244 - document: newUri, 245 }); 246 - } 247 248 - return { success: true as const }; 249 - }); 250 251 - if (dbResult.success) { 252 - documentUriMap[doc.uri] = newUri; 253 stats.documentsMigrated++; 254 } else { 255 - stats.errors.push(`Document ${doc.uri}: Database error: ${dbResult.error}`); 256 } 257 } 258 259 - // Step 5: Update references in database tables 260 - await step.run("update-references", async () => { 261 - // Update leaflets_in_publications - update publication and doc references 262 - for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { 263 - const { error } = await supabaseServerClient 264 - .from("leaflets_in_publications") 265 - .update({ publication: newUri }) 266 - .eq("publication", oldUri); 267 268 - if (!error) stats.referencesUpdated++; 269 - } 270 271 - for (const [oldUri, newUri] of Object.entries(documentUriMap)) { 272 - const { error } = await supabaseServerClient 273 - .from("leaflets_in_publications") 274 - .update({ doc: newUri }) 275 - .eq("doc", oldUri); 276 277 - if (!error) stats.referencesUpdated++; 278 - } 279 280 - // Update leaflets_to_documents - update document references 281 - for (const [oldUri, newUri] of Object.entries(documentUriMap)) { 282 - const { error } = await supabaseServerClient 283 - .from("leaflets_to_documents") 284 - .update({ document: newUri }) 285 - .eq("document", oldUri); 286 287 - if (!error) stats.referencesUpdated++; 288 - } 289 290 - // Update publication_domains - update publication references 291 - for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { 292 - const { error } = await supabaseServerClient 293 - .from("publication_domains") 294 - .update({ publication: newUri }) 295 - .eq("publication", oldUri); 296 297 - if (!error) stats.referencesUpdated++; 298 - } 299 300 - // Update comments_on_documents - update document references 301 - for (const [oldUri, newUri] of Object.entries(documentUriMap)) { 302 - const { error } = await supabaseServerClient 303 - .from("comments_on_documents") 304 - .update({ document: newUri }) 305 - .eq("document", oldUri); 306 307 - if (!error) stats.referencesUpdated++; 308 - } 309 310 - // Update document_mentions_in_bsky - update document references 311 - for (const [oldUri, newUri] of Object.entries(documentUriMap)) { 312 - const { error } = await supabaseServerClient 313 - .from("document_mentions_in_bsky") 314 - .update({ document: newUri }) 315 - .eq("document", oldUri); 316 317 - if (!error) stats.referencesUpdated++; 318 - } 319 320 - // Update subscribers_to_publications - update publication references 321 - for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { 322 - const { error } = await supabaseServerClient 323 - .from("subscribers_to_publications") 324 - .update({ publication: newUri }) 325 - .eq("publication", oldUri); 326 327 - if (!error) stats.referencesUpdated++; 328 } 329 330 - // Update publication_subscriptions - update publication references for incoming subscriptions 331 - for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { 332 - const { error } = await supabaseServerClient 333 - .from("publication_subscriptions") 334 - .update({ publication: newUri }) 335 - .eq("publication", oldUri); 336 337 - if (!error) stats.referencesUpdated++; 338 - } 339 340 return stats.referencesUpdated; 341 }); 342 343 // Step 6: Migrate user's own subscriptions - subscriptions BY this user to other publications 344 - const userSubscriptions = await step.run("fetch-user-subscriptions", async () => { 345 - const { data, error } = await supabaseServerClient 346 - .from("publication_subscriptions") 347 - .select("*") 348 - .eq("identity", did) 349 - .like("uri", `at://${did}/pub.leaflet.graph.subscription/%`); 350 351 - if (error) throw new Error(`Failed to fetch user subscriptions: ${error.message}`); 352 - return data || []; 353 - }); 354 355 const userSubscriptionUriMap: Record<string, string> = {}; // old URI -> new URI 356 357 - for (const sub of userSubscriptions) { 358 - const aturi = new AtUri(sub.uri); 359 360 - // Skip if already a site.standard.graph.subscription 361 - if (aturi.collection === "site.standard.graph.subscription") { 362 - userSubscriptionUriMap[sub.uri] = sub.uri; 363 - continue; 364 - } 365 366 - const rkey = aturi.rkey; 367 368 - // Build site.standard.graph.subscription record 369 - const newRecord: SiteStandardGraphSubscription.Record = { 370 - $type: "site.standard.graph.subscription", 371 - publication: sub.publication, 372 - }; 373 374 - // Step: Write to PDS 375 - const pdsResult = await step.run(`pds-write-subscription-${sub.uri}`, async () => { 376 - const agent = await createAuthenticatedAgent(did); 377 - const putResult = await agent.com.atproto.repo.putRecord({ 378 - repo: did, 379 - collection: "site.standard.graph.subscription", 380 - rkey, 381 - record: newRecord, 382 - validate: false, 383 - }); 384 - return { newUri: putResult.data.uri }; 385 - }); 386 - 387 - const newUri = pdsResult.newUri; 388 389 - // Step: Write to database 390 - const dbResult = await step.run(`db-write-subscription-${sub.uri}`, async () => { 391 - const { error: dbError } = await supabaseServerClient 392 - .from("publication_subscriptions") 393 - .update({ 394 - uri: newUri, 395 - record: newRecord as Json, 396 - }) 397 - .eq("uri", sub.uri); 398 399 - if (dbError) { 400 - return { success: false as const, error: dbError.message }; 401 - } 402 - return { success: true as const }; 403 - }); 404 405 - if (dbResult.success) { 406 - userSubscriptionUriMap[sub.uri] = newUri; 407 stats.userSubscriptionsMigrated++; 408 } else { 409 - stats.errors.push(`User subscription ${sub.uri}: Database error: ${dbResult.error}`); 410 } 411 } 412 ··· 417 // 3. The normalization layer handles both schemas transparently for reads 418 // Old records are also kept on the user's PDS so existing AT-URI references remain valid. 419 420 return { 421 success: stats.errors.length === 0, 422 stats, ··· 424 documentUriMap, 425 userSubscriptionUriMap, 426 }; 427 - } 428 );
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 import { inngest } from "../client"; 3 import { restoreOAuthSession } from "src/atproto-oauth"; 4 + import { 5 + AtpBaseClient, 6 + SiteStandardPublication, 7 + SiteStandardDocument, 8 + SiteStandardGraphSubscription, 9 + } from "lexicons/api"; 10 import { AtUri } from "@atproto/syntax"; 11 import { Json } from "supabase/database.types"; 12 + import { 13 + normalizePublicationRecord, 14 + normalizeDocumentRecord, 15 + } from "src/utils/normalizeRecords"; 16 17 type MigrationResult = 18 | { success: true; oldUri: string; newUri: string; skipped?: boolean } ··· 25 } 26 const credentialSession = result.value; 27 return new AtpBaseClient( 28 + credentialSession.fetchHandler.bind(credentialSession), 29 ); 30 } 31 ··· 38 const stats = { 39 publicationsMigrated: 0, 40 documentsMigrated: 0, 41 + standardDocumentsFixed: 0, 42 userSubscriptionsMigrated: 0, 43 referencesUpdated: 0, 44 errors: [] as string[], 45 }; 46 47 // Step 1: Verify OAuth session is valid 48 + const oauthValid = await step.run("verify-oauth-session", async () => { 49 const result = await restoreOAuthSession(did); 50 if (!result.ok) { 51 + // Mark identity as needing migration so we can retry later 52 + await supabaseServerClient 53 + .from("identities") 54 + .update({ 55 + metadata: { needsStandardSiteMigration: true }, 56 + }) 57 + .eq("atp_did", did); 58 + 59 + return { success: false, error: result.error.message }; 60 } 61 return { success: true }; 62 }); 63 + 64 + if (!oauthValid.success) { 65 + return { 66 + success: false, 67 + error: `Failed to restore OAuth session`, 68 + stats, 69 + publicationUriMap: {}, 70 + documentUriMap: {}, 71 + userSubscriptionUriMap: {}, 72 + }; 73 + } 74 75 // Step 2: Get user's pub.leaflet.publication records 76 + const oldPublications = await step.run( 77 + "fetch-old-publications", 78 + async () => { 79 + const { data, error } = await supabaseServerClient 80 + .from("publications") 81 + .select("*") 82 + .eq("identity_did", did) 83 + .like("uri", `at://${did}/pub.leaflet.publication/%`); 84 85 + if (error) 86 + throw new Error(`Failed to fetch publications: ${error.message}`); 87 + return data || []; 88 + }, 89 + ); 90 91 + // Step 3: Migrate all publications in parallel 92 const publicationUriMap: Record<string, string> = {}; // old URI -> new URI 93 94 + // Prepare publications that need migration 95 + const publicationsToMigrate = oldPublications 96 + .map((pub) => { 97 + const aturi = new AtUri(pub.uri); 98 99 + // Skip if already a site.standard.publication 100 + if (aturi.collection === "site.standard.publication") { 101 + publicationUriMap[pub.uri] = pub.uri; 102 + return null; 103 + } 104 105 + const rkey = aturi.rkey; 106 + const normalized = normalizePublicationRecord(pub.record); 107 108 + if (!normalized) { 109 + stats.errors.push( 110 + `Publication ${pub.uri}: Failed to normalize publication record`, 111 + ); 112 + return null; 113 + } 114 115 + const newRecord: SiteStandardPublication.Record = { 116 + $type: "site.standard.publication", 117 + name: normalized.name, 118 + url: normalized.url, 119 + description: normalized.description, 120 + icon: normalized.icon, 121 + theme: normalized.theme, 122 + basicTheme: normalized.basicTheme, 123 + preferences: normalized.preferences, 124 + }; 125 126 + return { pub, rkey, normalized, newRecord }; 127 + }) 128 + .filter((x) => x !== null); 129 130 + // Run PDS + DB writes together for each publication 131 + const pubResults = await Promise.all( 132 + publicationsToMigrate.map(({ pub, rkey, normalized, newRecord }) => 133 + step.run(`migrate-publication-${pub.uri}`, async () => { 134 + // PDS write 135 + const agent = await createAuthenticatedAgent(did); 136 + const putResult = await agent.com.atproto.repo.putRecord({ 137 + repo: did, 138 + collection: "site.standard.publication", 139 + rkey, 140 + record: newRecord, 141 + validate: false, 142 }); 143 + const newUri = putResult.data.uri; 144 + 145 + // DB write 146 + const { error: dbError } = await supabaseServerClient 147 + .from("publications") 148 + .upsert({ 149 + uri: newUri, 150 + identity_did: did, 151 + name: normalized.name, 152 + record: newRecord as Json, 153 + }); 154 155 + if (dbError) { 156 + return { 157 + success: false as const, 158 + oldUri: pub.uri, 159 + newUri, 160 + error: dbError.message, 161 + }; 162 + } 163 + return { success: true as const, oldUri: pub.uri, newUri }; 164 + }), 165 + ), 166 + ); 167 168 + // Process results 169 + for (const result of pubResults) { 170 + if (result.success) { 171 + publicationUriMap[result.oldUri] = result.newUri; 172 stats.publicationsMigrated++; 173 } else { 174 + stats.errors.push( 175 + `Publication ${result.oldUri}: Database error: ${result.error}`, 176 + ); 177 } 178 } 179 180 + // Step 4: Get ALL user's documents and their publication associations in parallel 181 + const [oldDocuments, allDocumentPublications] = await Promise.all([ 182 + step.run("fetch-old-documents", async () => { 183 + const { data, error } = await supabaseServerClient 184 + .from("documents") 185 + .select("uri, data") 186 + .like("uri", `at://${did}/pub.leaflet.document/%`); 187 188 + if (error) 189 + throw new Error(`Failed to fetch documents: ${error.message}`); 190 + return data || []; 191 + }), 192 + step.run("fetch-document-publications", async () => { 193 + const { data, error } = await supabaseServerClient 194 + .from("documents_in_publications") 195 + .select("document, publication") 196 + .like("document", `at://${did}/pub.leaflet.document/%`); 197 198 + if (error) 199 + throw new Error( 200 + `Failed to fetch document publications: ${error.message}`, 201 + ); 202 + return data || []; 203 + }), 204 + ]); 205 206 + // Create a map of document URI -> publication URI 207 + const documentPublicationMap: Record<string, string> = {}; 208 + for (const row of allDocumentPublications) { 209 + documentPublicationMap[row.document] = row.publication; 210 + } 211 212 const documentUriMap: Record<string, string> = {}; // old URI -> new URI 213 214 + // Prepare documents that need migration 215 + const documentsToMigrate = oldDocuments 216 + .map((doc) => { 217 + const aturi = new AtUri(doc.uri); 218 219 + // Skip if already a site.standard.document 220 + if (aturi.collection === "site.standard.document") { 221 + documentUriMap[doc.uri] = doc.uri; 222 + return null; 223 + } 224 225 + const rkey = aturi.rkey; 226 + const normalized = normalizeDocumentRecord(doc.data, doc.uri); 227 228 + if (!normalized) { 229 + stats.errors.push( 230 + `Document ${doc.uri}: Failed to normalize document record`, 231 + ); 232 + return null; 233 + } 234 235 + // Determine the site field: 236 + // - If document is in a publication, use the new publication URI (if migrated) or old URI 237 + // - If standalone, use the HTTPS URL format 238 + const oldPubUri = documentPublicationMap[doc.uri]; 239 + let siteValue: string; 240 241 + if (oldPubUri) { 242 + // Document is in a publication - use new URI if migrated, otherwise keep old 243 + siteValue = publicationUriMap[oldPubUri] || oldPubUri; 244 + } else { 245 + // Standalone document - use HTTPS URL format 246 + siteValue = `https://leaflet.pub/p/${did}`; 247 + } 248 249 + // Build site.standard.document record 250 + const newRecord: SiteStandardDocument.Record = { 251 + $type: "site.standard.document", 252 + title: normalized.title || "Untitled", 253 + site: siteValue, 254 + path: "/" + rkey, 255 + publishedAt: normalized.publishedAt || new Date().toISOString(), 256 + description: normalized.description, 257 + content: normalized.content, 258 + tags: normalized.tags, 259 + coverImage: normalized.coverImage, 260 + bskyPostRef: normalized.bskyPostRef, 261 + }; 262 263 + return { doc, rkey, normalized, newRecord, oldPubUri }; 264 + }) 265 + .filter((x) => x !== null); 266 267 + // Run PDS + DB writes together for each document 268 + const docResults = await Promise.all( 269 + documentsToMigrate.map(({ doc, rkey, newRecord, oldPubUri }) => 270 + step.run(`migrate-document-${doc.uri}`, async () => { 271 + // PDS write 272 + const agent = await createAuthenticatedAgent(did); 273 + const putResult = await agent.com.atproto.repo.putRecord({ 274 + repo: did, 275 + collection: "site.standard.document", 276 + rkey, 277 + record: newRecord, 278 + validate: false, 279 }); 280 + const newUri = putResult.data.uri; 281 282 + // DB write 283 + const { error: dbError } = await supabaseServerClient 284 + .from("documents") 285 .upsert({ 286 + uri: newUri, 287 + data: newRecord as Json, 288 }); 289 + 290 + if (dbError) { 291 + return { 292 + success: false as const, 293 + oldUri: doc.uri, 294 + newUri, 295 + error: dbError.message, 296 + }; 297 + } 298 + 299 + // If document was in a publication, add to documents_in_publications with new URIs 300 + if (oldPubUri) { 301 + const newPubUri = publicationUriMap[oldPubUri] || oldPubUri; 302 + await supabaseServerClient 303 + .from("documents_in_publications") 304 + .upsert({ 305 + publication: newPubUri, 306 + document: newUri, 307 + }); 308 + } 309 310 + return { success: true as const, oldUri: doc.uri, newUri }; 311 + }), 312 + ), 313 + ); 314 315 + // Process results 316 + for (const result of docResults) { 317 + if (result.success) { 318 + documentUriMap[result.oldUri] = result.newUri; 319 stats.documentsMigrated++; 320 } else { 321 + stats.errors.push( 322 + `Document ${result.oldUri}: Database error: ${result.error}`, 323 + ); 324 } 325 } 326 327 + // Step 4b: Fix existing site.standard.document records that reference pub.leaflet.publication 328 + // This handles the case where site.standard.document records were created pointing to 329 + // pub.leaflet.publication URIs before the publication was migrated to site.standard.publication 330 + const existingStandardDocs = await step.run( 331 + "fetch-existing-standard-documents", 332 + async () => { 333 + const { data, error } = await supabaseServerClient 334 + .from("documents") 335 + .select("uri, data") 336 + .like("uri", `at://${did}/site.standard.document/%`); 337 + 338 + if (error) 339 + throw new Error( 340 + `Failed to fetch existing standard documents: ${error.message}`, 341 + ); 342 + return data || []; 343 + }, 344 + ); 345 346 + // Find documents that reference pub.leaflet.publication and need their site field updated 347 + const standardDocsToFix = existingStandardDocs 348 + .map((doc) => { 349 + const data = doc.data as SiteStandardDocument.Record; 350 + const site = data?.site; 351 352 + // Check if site field references a pub.leaflet.publication 353 + if (!site || !site.includes("/pub.leaflet.publication/")) { 354 + return null; 355 + } 356 357 + try { 358 + const oldPubAturi = new AtUri(site); 359 + const newPubUri = `at://${oldPubAturi.hostname}/site.standard.publication/${oldPubAturi.rkey}`; 360 361 + // Only fix if we have the new publication in our map (meaning it was migrated) 362 + // or if the new publication exists (check against all migrated publications) 363 + if ( 364 + publicationUriMap[site] || 365 + Object.values(publicationUriMap).includes(newPubUri) 366 + ) { 367 + const docAturi = new AtUri(doc.uri); 368 + const updatedRecord: SiteStandardDocument.Record = { 369 + ...data, 370 + site: newPubUri, 371 + }; 372 373 + return { 374 + doc, 375 + rkey: docAturi.rkey, 376 + oldSite: site, 377 + newSite: newPubUri, 378 + updatedRecord, 379 + }; 380 + } 381 + } catch (e) { 382 + stats.errors.push(`Invalid site URI in document ${doc.uri}: ${site}`); 383 + } 384 385 + return null; 386 + }) 387 + .filter((x) => x !== null); 388 389 + // Update these documents on PDS and in database 390 + if (standardDocsToFix.length > 0) { 391 + const fixResults = await Promise.all( 392 + standardDocsToFix.map(({ doc, rkey, oldSite, newSite, updatedRecord }) => 393 + step.run(`fix-standard-document-${doc.uri}`, async () => { 394 + // PDS write to update the site field 395 + const agent = await createAuthenticatedAgent(did); 396 + await agent.com.atproto.repo.putRecord({ 397 + repo: did, 398 + collection: "site.standard.document", 399 + rkey, 400 + record: updatedRecord, 401 + validate: false, 402 + }); 403 404 + // DB write 405 + const { error: dbError } = await supabaseServerClient 406 + .from("documents") 407 + .update({ data: updatedRecord as Json }) 408 + .eq("uri", doc.uri); 409 410 + if (dbError) { 411 + return { 412 + success: false as const, 413 + uri: doc.uri, 414 + error: dbError.message, 415 + }; 416 + } 417 418 + // Update documents_in_publications to point to new publication URI 419 + await supabaseServerClient 420 + .from("documents_in_publications") 421 + .upsert({ 422 + publication: newSite, 423 + document: doc.uri, 424 + }); 425 426 + // Remove old publication reference if different 427 + if (oldSite !== newSite) { 428 + await supabaseServerClient 429 + .from("documents_in_publications") 430 + .delete() 431 + .eq("publication", oldSite) 432 + .eq("document", doc.uri); 433 + } 434 435 + return { success: true as const, uri: doc.uri }; 436 + }), 437 + ), 438 + ); 439 440 + for (const result of fixResults) { 441 + if (result.success) { 442 + stats.standardDocumentsFixed++; 443 + } else { 444 + stats.errors.push( 445 + `Fix standard document ${result.uri}: Database error: ${result.error}`, 446 + ); 447 + } 448 } 449 + } 450 451 + // Step 5: Update references in database tables (all in parallel) 452 + await step.run("update-references", async () => { 453 + const pubEntries = Object.entries(publicationUriMap); 454 + const docEntries = Object.entries(documentUriMap); 455 456 + const updatePromises = [ 457 + // Update leaflets_in_publications - publication references 458 + ...pubEntries.map(([oldUri, newUri]) => 459 + supabaseServerClient 460 + .from("leaflets_in_publications") 461 + .update({ publication: newUri }) 462 + .eq("publication", oldUri), 463 + ), 464 + // Update leaflets_in_publications - doc references 465 + ...docEntries.map(([oldUri, newUri]) => 466 + supabaseServerClient 467 + .from("leaflets_in_publications") 468 + .update({ doc: newUri }) 469 + .eq("doc", oldUri), 470 + ), 471 + // Update leaflets_to_documents - document references 472 + ...docEntries.map(([oldUri, newUri]) => 473 + supabaseServerClient 474 + .from("leaflets_to_documents") 475 + .update({ document: newUri }) 476 + .eq("document", oldUri), 477 + ), 478 + // Update publication_domains - publication references 479 + ...pubEntries.map(([oldUri, newUri]) => 480 + supabaseServerClient 481 + .from("publication_domains") 482 + .update({ publication: newUri }) 483 + .eq("publication", oldUri), 484 + ), 485 + // Update comments_on_documents - document references 486 + ...docEntries.map(([oldUri, newUri]) => 487 + supabaseServerClient 488 + .from("comments_on_documents") 489 + .update({ document: newUri }) 490 + .eq("document", oldUri), 491 + ), 492 + // Update document_mentions_in_bsky - document references 493 + ...docEntries.map(([oldUri, newUri]) => 494 + supabaseServerClient 495 + .from("document_mentions_in_bsky") 496 + .update({ document: newUri }) 497 + .eq("document", oldUri), 498 + ), 499 + // Update subscribers_to_publications - publication references 500 + ...pubEntries.map(([oldUri, newUri]) => 501 + supabaseServerClient 502 + .from("subscribers_to_publications") 503 + .update({ publication: newUri }) 504 + .eq("publication", oldUri), 505 + ), 506 + // Update publication_subscriptions - publication references 507 + ...pubEntries.map(([oldUri, newUri]) => 508 + supabaseServerClient 509 + .from("publication_subscriptions") 510 + .update({ publication: newUri }) 511 + .eq("publication", oldUri), 512 + ), 513 + ]; 514 515 + const results = await Promise.all(updatePromises); 516 + stats.referencesUpdated = results.filter((r) => !r.error).length; 517 return stats.referencesUpdated; 518 }); 519 520 // Step 6: Migrate user's own subscriptions - subscriptions BY this user to other publications 521 + const userSubscriptions = await step.run( 522 + "fetch-user-subscriptions", 523 + async () => { 524 + const { data, error } = await supabaseServerClient 525 + .from("publication_subscriptions") 526 + .select("*") 527 + .eq("identity", did) 528 + .like("uri", `at://${did}/pub.leaflet.graph.subscription/%`); 529 530 + if (error) 531 + throw new Error( 532 + `Failed to fetch user subscriptions: ${error.message}`, 533 + ); 534 + return data || []; 535 + }, 536 + ); 537 538 const userSubscriptionUriMap: Record<string, string> = {}; // old URI -> new URI 539 540 + // Prepare subscriptions that need migration 541 + const subscriptionsToMigrate = userSubscriptions 542 + .map((sub) => { 543 + const aturi = new AtUri(sub.uri); 544 545 + // Skip if already a site.standard.graph.subscription 546 + if (aturi.collection === "site.standard.graph.subscription") { 547 + userSubscriptionUriMap[sub.uri] = sub.uri; 548 + return null; 549 + } 550 551 + const rkey = aturi.rkey; 552 + const newRecord: SiteStandardGraphSubscription.Record = { 553 + $type: "site.standard.graph.subscription", 554 + publication: sub.publication, 555 + }; 556 557 + return { sub, rkey, newRecord }; 558 + }) 559 + .filter((x) => x !== null); 560 561 + // Run PDS + DB writes together for each subscription 562 + const subResults = await Promise.all( 563 + subscriptionsToMigrate.map(({ sub, rkey, newRecord }) => 564 + step.run(`migrate-subscription-${sub.uri}`, async () => { 565 + // PDS write 566 + const agent = await createAuthenticatedAgent(did); 567 + const putResult = await agent.com.atproto.repo.putRecord({ 568 + repo: did, 569 + collection: "site.standard.graph.subscription", 570 + rkey, 571 + record: newRecord, 572 + validate: false, 573 + }); 574 + const newUri = putResult.data.uri; 575 576 + // DB write 577 + const { error: dbError } = await supabaseServerClient 578 + .from("publication_subscriptions") 579 + .update({ 580 + uri: newUri, 581 + record: newRecord as Json, 582 + }) 583 + .eq("uri", sub.uri); 584 585 + if (dbError) { 586 + return { 587 + success: false as const, 588 + oldUri: sub.uri, 589 + newUri, 590 + error: dbError.message, 591 + }; 592 + } 593 + return { success: true as const, oldUri: sub.uri, newUri }; 594 + }), 595 + ), 596 + ); 597 598 + // Process results 599 + for (const result of subResults) { 600 + if (result.success) { 601 + userSubscriptionUriMap[result.oldUri] = result.newUri; 602 stats.userSubscriptionsMigrated++; 603 } else { 604 + stats.errors.push( 605 + `User subscription ${result.oldUri}: Database error: ${result.error}`, 606 + ); 607 } 608 } 609 ··· 614 // 3. The normalization layer handles both schemas transparently for reads 615 // Old records are also kept on the user's PDS so existing AT-URI references remain valid. 616 617 + // Clear the migration flag on success 618 + if (stats.errors.length === 0) { 619 + await step.run("clear-migration-flag", async () => { 620 + await supabaseServerClient 621 + .from("identities") 622 + .update({ metadata: null }) 623 + .eq("atp_did", did); 624 + }); 625 + } 626 + 627 return { 628 success: stats.errors.length === 0, 629 stats, ··· 631 documentUriMap, 632 userSubscriptionUriMap, 633 }; 634 + }, 635 );
+8
app/api/inngest/route.tsx
··· 5 import { batched_update_profiles } from "./functions/batched_update_profiles"; 6 import { index_follows } from "./functions/index_follows"; 7 import { migrate_user_to_standard } from "./functions/migrate_user_to_standard"; 8 9 export const { GET, POST, PUT } = serve({ 10 client: inngest, ··· 14 batched_update_profiles, 15 index_follows, 16 migrate_user_to_standard, 17 ], 18 });
··· 5 import { batched_update_profiles } from "./functions/batched_update_profiles"; 6 import { index_follows } from "./functions/index_follows"; 7 import { migrate_user_to_standard } from "./functions/migrate_user_to_standard"; 8 + import { fix_standard_document_publications } from "./functions/fix_standard_document_publications"; 9 + import { 10 + cleanup_expired_oauth_sessions, 11 + check_oauth_session, 12 + } from "./functions/cleanup_expired_oauth_sessions"; 13 14 export const { GET, POST, PUT } = serve({ 15 client: inngest, ··· 19 batched_update_profiles, 20 index_follows, 21 migrate_user_to_standard, 22 + fix_standard_document_publications, 23 + cleanup_expired_oauth_sessions, 24 + check_oauth_session, 25 ], 26 });
+11
app/api/oauth/[route]/route.ts
··· 11 ActionAfterSignIn, 12 parseActionFromSearchParam, 13 } from "./afterSignInActions"; 14 15 type OauthRequestClientState = { 16 redirect: string | null; ··· 84 .single(); 85 identity = data; 86 } 87 let { data: token } = await supabaseServerClient 88 .from("email_auth_tokens") 89 .insert({
··· 11 ActionAfterSignIn, 12 parseActionFromSearchParam, 13 } from "./afterSignInActions"; 14 + import { inngest } from "app/api/inngest/client"; 15 16 type OauthRequestClientState = { 17 redirect: string | null; ··· 85 .single(); 86 identity = data; 87 } 88 + 89 + // Trigger migration if identity needs it 90 + const metadata = identity?.metadata as Record<string, unknown> | null; 91 + if (metadata?.needsStandardSiteMigration) { 92 + await inngest.send({ 93 + name: "user/migrate-to-standard", 94 + data: { did: session.did }, 95 + }); 96 + } 97 + 98 let { data: token } = await supabaseServerClient 99 .from("email_auth_tokens") 100 .insert({
+6 -2
app/api/rpc/[command]/get_profile_data.ts
··· 10 normalizePublicationRow, 11 hasValidPublication, 12 } from "src/utils/normalizeRecords"; 13 14 export type GetProfileDataReturnType = Awaited< 15 ReturnType<(typeof get_profile_data)["handler"]> ··· 58 .select("*") 59 .eq("identity_did", did); 60 61 - let [{ data: profile }, { data: publications }] = await Promise.all([ 62 profileReq, 63 publicationsReq, 64 ]); 65 66 // Normalize publication records before returning 67 - const normalizedPublications = (publications || []) 68 .map(normalizePublicationRow) 69 .filter(hasValidPublication); 70
··· 10 normalizePublicationRow, 11 hasValidPublication, 12 } from "src/utils/normalizeRecords"; 13 + import { deduplicateByUri } from "src/utils/deduplicateRecords"; 14 15 export type GetProfileDataReturnType = Awaited< 16 ReturnType<(typeof get_profile_data)["handler"]> ··· 59 .select("*") 60 .eq("identity_did", did); 61 62 + let [{ data: profile }, { data: rawPublications }] = await Promise.all([ 63 profileReq, 64 publicationsReq, 65 ]); 66 67 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 68 + const publications = deduplicateByUri(rawPublications || []); 69 + 70 // Normalize publication records before returning 71 + const normalizedPublications = publications 72 .map(normalizePublicationRow) 73 .filter(hasValidPublication); 74
+9 -2
app/api/rpc/[command]/get_publication_data.ts
··· 52 ) 53 )`, 54 ) 55 - .or(`name.eq."${publication_name}", uri.eq."${pubLeafletUri}", uri.eq."${siteStandardUri}"`) 56 .eq("identity_did", did) 57 .single(); 58 59 let leaflet_data = await getFactsFromHomeLeaflets.handler( ··· 70 const documents = (publication?.documents_in_publications || []) 71 .map((dip) => { 72 if (!dip.documents) return null; 73 - const normalized = normalizeDocumentRecord(dip.documents.data, dip.documents.uri); 74 if (!normalized) return null; 75 return { 76 uri: dip.documents.uri,
··· 52 ) 53 )`, 54 ) 55 + .or( 56 + `name.eq."${publication_name}", uri.eq."${pubLeafletUri}", uri.eq."${siteStandardUri}"`, 57 + ) 58 .eq("identity_did", did) 59 + .order("uri", { ascending: false }) 60 + .limit(1) 61 .single(); 62 63 let leaflet_data = await getFactsFromHomeLeaflets.handler( ··· 74 const documents = (publication?.documents_in_publications || []) 75 .map((dip) => { 76 if (!dip.documents) return null; 77 + const normalized = normalizeDocumentRecord( 78 + dip.documents.data, 79 + dip.documents.uri, 80 + ); 81 if (!normalized) return null; 82 return { 83 uri: dip.documents.uri,
+5 -1
app/api/rpc/[command]/search_publication_names.ts
··· 2 import { makeRoute } from "../lib"; 3 import type { Env } from "./route"; 4 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 6 export type SearchPublicationNamesReturnType = Awaited< 7 ReturnType<(typeof search_publication_names)["handler"]> ··· 15 }), 16 handler: async ({ query, limit }, { supabase }: Pick<Env, "supabase">) => { 17 // Search publications by name in record (case-insensitive partial match) 18 - const { data: publications, error } = await supabase 19 .from("publications") 20 .select("uri, record") 21 .ilike("record->>name", `%${query}%`) ··· 24 if (error) { 25 throw new Error(`Failed to search publications: ${error.message}`); 26 } 27 28 const result = publications.map((p) => { 29 const record = p.record as { name?: string };
··· 2 import { makeRoute } from "../lib"; 3 import type { Env } from "./route"; 4 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { deduplicateByUri } from "src/utils/deduplicateRecords"; 6 7 export type SearchPublicationNamesReturnType = Awaited< 8 ReturnType<(typeof search_publication_names)["handler"]> ··· 16 }), 17 handler: async ({ query, limit }, { supabase }: Pick<Env, "supabase">) => { 18 // Search publications by name in record (case-insensitive partial match) 19 + const { data: rawPublications, error } = await supabase 20 .from("publications") 21 .select("uri, record") 22 .ilike("record->>name", `%${query}%`) ··· 25 if (error) { 26 throw new Error(`Failed to search publications: ${error.message}`); 27 } 28 + 29 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 30 + const publications = deduplicateByUri(rawPublications || []); 31 32 const result = publications.map((p) => { 33 const record = p.record as { name?: string };
+2
app/globals.css
··· 274 @apply p-2; 275 @apply rounded-md; 276 @apply overflow-auto; 277 278 @media (min-width: 640px) { 279 @apply p-3;
··· 274 @apply p-2; 275 @apply rounded-md; 276 @apply overflow-auto; 277 + @apply sm:min-h-12; 278 + @apply min-h-10; 279 280 @media (min-width: 640px) { 281 @apply p-3;
+34
app/lish/[did]/[publication]/.well-known/site.standard.publication/route.ts
···
··· 1 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + 4 + export async function GET( 5 + req: Request, 6 + props: { 7 + params: Promise<{ publication: string; did: string }>; 8 + }, 9 + ) { 10 + let params = await props.params; 11 + let did = decodeURIComponent(params.did); 12 + let publication_name = decodeURIComponent(params.publication); 13 + let [{ data: publications }] = await Promise.all([ 14 + supabaseServerClient 15 + .from("publications") 16 + .select( 17 + `*, 18 + publication_subscriptions(*), 19 + documents_in_publications(documents( 20 + *, 21 + comments_on_documents(count), 22 + document_mentions_in_bsky(count) 23 + )) 24 + `, 25 + ) 26 + .eq("identity_did", did) 27 + .or(publicationNameOrUriFilter(did, publication_name)) 28 + .order("uri", { ascending: false }) 29 + .limit(1), 30 + ]); 31 + let publication = publications?.[0]; 32 + if (!did || !publication) return new Response(null, { status: 404 }); 33 + return new Response(publication.uri); 34 + }
+7 -4
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 35 sizes: "32x32", 36 type: "image/png", 37 }, 38 - other: { 39 - rel: "alternate", 40 - url: document.uri, 41 - }, 42 }, 43 title: 44 docRecord.title +
··· 35 sizes: "32x32", 36 type: "image/png", 37 }, 38 + other: [ 39 + { 40 + rel: "alternate", 41 + url: document.uri, 42 + }, 43 + { rel: "site.standard.document", url: document.uri }, 44 + ], 45 }, 46 title: 47 docRecord.title +
+6 -2
app/lish/[did]/[publication]/generateFeed.ts
··· 19 let renderToReadableStream = await import("react-dom/server").then( 20 (module) => module.renderToReadableStream, 21 ); 22 - let { data: publications } = await supabaseServerClient 23 .from("publications") 24 .select( 25 `*, ··· 31 .or(publicationNameOrUriFilter(did, publication_name)) 32 .order("uri", { ascending: false }) 33 .limit(1); 34 let publication = publications?.[0]; 35 36 const pubRecord = normalizePublicationRecord(publication?.record); ··· 54 await Promise.all( 55 publication.documents_in_publications.map(async (doc) => { 56 if (!doc.documents) return; 57 - const record = normalizeDocumentRecord(doc.documents?.data, doc.documents?.uri); 58 const uri = new AtUri(doc.documents?.uri); 59 const rkey = uri.rkey; 60 if (!record) return;
··· 19 let renderToReadableStream = await import("react-dom/server").then( 20 (module) => module.renderToReadableStream, 21 ); 22 + let { data: publications, error } = await supabaseServerClient 23 .from("publications") 24 .select( 25 `*, ··· 31 .or(publicationNameOrUriFilter(did, publication_name)) 32 .order("uri", { ascending: false }) 33 .limit(1); 34 + console.log(error); 35 let publication = publications?.[0]; 36 37 const pubRecord = normalizePublicationRecord(publication?.record); ··· 55 await Promise.all( 56 publication.documents_in_publications.map(async (doc) => { 57 if (!doc.documents) return; 58 + const record = normalizeDocumentRecord( 59 + doc.documents?.data, 60 + doc.documents?.uri, 61 + ); 62 const uri = new AtUri(doc.documents?.uri); 63 const rkey = uri.rkey; 64 if (!record) return;
-1
app/lish/[did]/[publication]/icon/route.ts
··· 14 request: NextRequest, 15 props: { params: Promise<{ did: string; publication: string }> }, 16 ) { 17 - console.log("are we getting here?"); 18 const params = await props.params; 19 try { 20 let did = decodeURIComponent(params.did);
··· 14 request: NextRequest, 15 props: { params: Promise<{ did: string; publication: string }> }, 16 ) { 17 const params = await props.params; 18 try { 19 let did = decodeURIComponent(params.did);
+1
app/lish/createPub/updatePublication.ts
··· 278 return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 279 // Build theme object 280 const themeData = { 281 backgroundImage: theme.backgroundImage 282 ? { 283 $type: "pub.leaflet.theme.backgroundImage",
··· 278 return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 279 // Build theme object 280 const themeData = { 281 + $type: "pub.leaflet.publication#theme" as const, 282 backgroundImage: theme.backgroundImage 283 ? { 284 $type: "pub.leaflet.theme.backgroundImage",
+7 -4
app/p/[didOrHandle]/[rkey]/page.tsx
··· 38 39 return { 40 icons: { 41 - other: { 42 - rel: "alternate", 43 - url: document.uri, 44 - }, 45 }, 46 title: docRecord.title, 47 description: docRecord?.description || "",
··· 38 39 return { 40 icons: { 41 + other: [ 42 + { 43 + rel: "alternate", 44 + url: document.uri, 45 + }, 46 + { rel: "site.standard.document", url: document.uri }, 47 + ], 48 }, 49 title: docRecord.title, 50 description: docRecord?.description || "",
+141 -67
components/Blocks/Block.tsx
··· 33 import { deepEquals } from "src/utils/deepEquals"; 34 import { isTextBlock } from "src/utils/isTextBlock"; 35 import { focusPage } from "src/utils/focusPage"; 36 37 export type Block = { 38 factID: string; ··· 63 // Block handles all block level events like 64 // mouse events, keyboard events and longPress, and setting AreYouSure state 65 // and shared styling like padding and flex for list layouting 66 - let { rep } = useReplicache(); 67 let mouseHandlers = useBlockMouseHandlers(props); 68 let handleDrop = useHandleDrop({ 69 parent: props.parent, ··· 72 }); 73 let entity_set = useEntitySetContext(); 74 75 - let { isLongPress, handlers } = useLongPress(() => { 76 if (isTextBlock[props.type]) return; 77 if (isLongPress.current) { 78 focusBlock( ··· 85 let selected = useUIState( 86 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 87 ); 88 89 let [areYouSure, setAreYouSure] = useState(false); 90 useEffect(() => { ··· 98 99 return ( 100 <div 101 - {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})} 102 id={ 103 !props.preview ? elementId.block(props.entityID).container : undefined 104 } ··· 117 blockWrapper relative 118 flex flex-row gap-2 119 px-3 sm:px-4 120 ${ 121 !props.nextBlock 122 ? "pb-3 sm:pb-4" ··· 255 ) => { 256 // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers 257 let BlockTypeComponent = BlockTypeComponents[props.type]; 258 - let alignment = useEntity(props.value, "block/text-alignment")?.data.value; 259 - 260 - let alignmentStyle = 261 - props.type === "button" || props.type === "image" 262 - ? "justify-center" 263 - : "justify-start"; 264 - 265 - if (alignment) 266 - alignmentStyle = { 267 - left: "justify-start", 268 - right: "justify-end", 269 - center: "justify-center", 270 - justify: "justify-start", 271 - }[alignment]; 272 273 if (!BlockTypeComponent) return <div>unknown block</div>; 274 return ( 275 - <div 276 - className={`blockContentWrapper w-full grow flex gap-2 z-1 ${alignmentStyle}`} 277 - > 278 {props.listData && <ListMarker {...props} />} 279 {props.areYouSure ? ( 280 <AreYouSure ··· 287 ) : ( 288 <BlockTypeComponent {...props} preview={props.preview} /> 289 )} 290 - </div> 291 ); 292 }; 293 ··· 326 s.selectedBlocks.length > 1, 327 ); 328 329 - let isSelected = useUIState((s) => 330 - s.selectedBlocks.find((b) => b.value === props.entityID), 331 - ); 332 - let isLocked = useEntity(props.value, "block/is-locked"); 333 - 334 let nextBlockSelected = useUIState((s) => 335 s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), 336 ); ··· 338 s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), 339 ); 340 341 - if (isMultiselected || (isLocked?.data.value && isSelected)) 342 - // not sure what multiselected and selected classes are doing (?) 343 - // use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected 344 - 345 return ( 346 <> 347 <div ··· 354 ${!prevBlockSelected && "rounded-t-md"} 355 ${!nextBlockSelected && "rounded-b-md"} 356 `} 357 - style={ 358 - isLocked?.data.value 359 - ? { 360 - maskImage: "var(--hatchSVG)", 361 - maskRepeat: "repeat repeat", 362 - } 363 - : {} 364 - } 365 - ></div> 366 - {isLocked?.data.value && ( 367 - <div 368 - className={` 369 - blockSelectionLockIndicator z-10 370 - flex items-center 371 - text-border rounded-full 372 - absolute right-3 373 - 374 - ${ 375 - props.type === "heading" || props.type === "text" 376 - ? "top-[6px]" 377 - : "top-0" 378 - }`} 379 - > 380 - <LockTiny className="bg-bg-page p-0.5 rounded-full w-5 h-5" /> 381 - </div> 382 - )} 383 </> 384 ); 385 }; 386 387 export const BlockLayout = (props: { 388 - isSelected?: boolean; 389 children: React.ReactNode; 390 className?: string; 391 hasBackground?: "accent" | "page"; 392 borderOnHover?: boolean; 393 }) => { 394 return ( 395 <div 396 - className={`block ${props.className} p-2 sm:p-3 w-full overflow-hidden 397 ${props.isSelected ? "block-border-selected " : "block-border"} 398 ${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`} 399 - style={{ 400 - backgroundColor: 401 - props.hasBackground === "accent" 402 - ? "var(--accent-light)" 403 - : props.hasBackground === "page" 404 - ? "rgb(var(--bg-page))" 405 - : "transparent", 406 - }} 407 > 408 - {props.children} 409 </div> 410 ); 411 };
··· 33 import { deepEquals } from "src/utils/deepEquals"; 34 import { isTextBlock } from "src/utils/isTextBlock"; 35 import { focusPage } from "src/utils/focusPage"; 36 + import { DeleteTiny } from "components/Icons/DeleteTiny"; 37 + import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 38 + import { Separator } from "components/Layout"; 39 + import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 40 + import { deleteBlock } from "src/utils/deleteBlock"; 41 42 export type Block = { 43 factID: string; ··· 68 // Block handles all block level events like 69 // mouse events, keyboard events and longPress, and setting AreYouSure state 70 // and shared styling like padding and flex for list layouting 71 let mouseHandlers = useBlockMouseHandlers(props); 72 let handleDrop = useHandleDrop({ 73 parent: props.parent, ··· 76 }); 77 let entity_set = useEntitySetContext(); 78 79 + let { isLongPress, longPressHandlers } = useLongPress(() => { 80 if (isTextBlock[props.type]) return; 81 if (isLongPress.current) { 82 focusBlock( ··· 89 let selected = useUIState( 90 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 91 ); 92 + let alignment = useEntity(props.value, "block/text-alignment")?.data.value; 93 + 94 + let alignmentStyle = 95 + props.type === "button" || props.type === "image" 96 + ? "justify-center" 97 + : "justify-start"; 98 + 99 + if (alignment) 100 + alignmentStyle = { 101 + left: "justify-start", 102 + right: "justify-end", 103 + center: "justify-center", 104 + justify: "justify-start", 105 + }[alignment]; 106 107 let [areYouSure, setAreYouSure] = useState(false); 108 useEffect(() => { ··· 116 117 return ( 118 <div 119 + {...(!props.preview ? { ...mouseHandlers, ...longPressHandlers } : {})} 120 id={ 121 !props.preview ? elementId.block(props.entityID).container : undefined 122 } ··· 135 blockWrapper relative 136 flex flex-row gap-2 137 px-3 sm:px-4 138 + z-1 w-full 139 + ${alignmentStyle} 140 ${ 141 !props.nextBlock 142 ? "pb-3 sm:pb-4" ··· 275 ) => { 276 // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers 277 let BlockTypeComponent = BlockTypeComponents[props.type]; 278 279 if (!BlockTypeComponent) return <div>unknown block</div>; 280 return ( 281 + <> 282 {props.listData && <ListMarker {...props} />} 283 {props.areYouSure ? ( 284 <AreYouSure ··· 291 ) : ( 292 <BlockTypeComponent {...props} preview={props.preview} /> 293 )} 294 + </> 295 ); 296 }; 297 ··· 330 s.selectedBlocks.length > 1, 331 ); 332 333 let nextBlockSelected = useUIState((s) => 334 s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), 335 ); ··· 337 s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), 338 ); 339 340 + if (isMultiselected) 341 return ( 342 <> 343 <div ··· 350 ${!prevBlockSelected && "rounded-t-md"} 351 ${!nextBlockSelected && "rounded-b-md"} 352 `} 353 + /> 354 </> 355 ); 356 }; 357 358 export const BlockLayout = (props: { 359 + isSelected: boolean; 360 children: React.ReactNode; 361 className?: string; 362 + optionsClassName?: string; 363 hasBackground?: "accent" | "page"; 364 borderOnHover?: boolean; 365 + hasAlignment?: boolean; 366 + areYouSure?: boolean; 367 + setAreYouSure?: (value: boolean) => void; 368 }) => { 369 + // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete 370 return ( 371 <div 372 + className={`nonTextBlockAndControls relative ${props.hasAlignment ? "w-fit" : "w-full"}`} 373 + > 374 + <div 375 + className={`nonTextBlock ${props.className} p-2 sm:p-3 overflow-hidden 376 + ${props.hasAlignment ? "w-fit" : "w-full"} 377 ${props.isSelected ? "block-border-selected " : "block-border"} 378 ${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`} 379 + style={{ 380 + backgroundColor: 381 + props.hasBackground === "accent" 382 + ? "var(--accent-light)" 383 + : props.hasBackground === "page" 384 + ? "rgb(var(--bg-page))" 385 + : "transparent", 386 + }} 387 + > 388 + {props.children} 389 + </div> 390 + {props.isSelected && ( 391 + <NonTextBlockOptions 392 + optionsClassName={props.optionsClassName} 393 + areYouSure={props.areYouSure} 394 + setAreYouSure={props.setAreYouSure} 395 + /> 396 + )} 397 + </div> 398 + ); 399 + }; 400 + 401 + let debounced: null | number = null; 402 + 403 + const NonTextBlockOptions = (props: { 404 + areYouSure?: boolean; 405 + setAreYouSure?: (value: boolean) => void; 406 + optionsClassName?: string; 407 + }) => { 408 + let { rep } = useReplicache(); 409 + let entity_set = useEntitySetContext(); 410 + let focusedEntity = useUIState((s) => s.focusedEntity); 411 + let focusedEntityType = useEntity( 412 + focusedEntity?.entityType === "page" 413 + ? focusedEntity.entityID 414 + : focusedEntity?.parent || null, 415 + "page/type", 416 + ); 417 + 418 + let isMultiselected = useUIState((s) => s.selectedBlocks.length > 1); 419 + if (focusedEntity?.entityType === "page") return; 420 + 421 + if (isMultiselected) return; 422 + 423 + return ( 424 + <div 425 + className={`flex gap-1 absolute -top-[25px] right-2 pb-0.5 pt-1 px-1 rounded-t-md bg-border text-bg-page ${props.optionsClassName}`} 426 > 427 + {focusedEntityType?.data.value !== "canvas" && ( 428 + <> 429 + <button 430 + onClick={async (e) => { 431 + e.stopPropagation(); 432 + 433 + if (!rep) return; 434 + await moveBlockDown(rep, entity_set.set); 435 + }} 436 + > 437 + <ArrowDownTiny /> 438 + </button> 439 + <button 440 + onClick={async (e) => { 441 + e.stopPropagation(); 442 + 443 + if (!rep) return; 444 + await moveBlockUp(rep); 445 + }} 446 + > 447 + <ArrowDownTiny className="rotate-180" /> 448 + </button> 449 + <Separator classname="border-bg-page! h-4! mx-0.5" /> 450 + </> 451 + )} 452 + <button 453 + onClick={async (e) => { 454 + e.stopPropagation(); 455 + if (!rep || !focusedEntity) return; 456 + 457 + if (props.areYouSure !== undefined && props.setAreYouSure) { 458 + if (!props.areYouSure) { 459 + props.setAreYouSure(true); 460 + debounced = window.setTimeout(() => { 461 + debounced = null; 462 + }, 300); 463 + return; 464 + } 465 + 466 + if (props.areYouSure) { 467 + if (debounced) { 468 + window.clearTimeout(debounced); 469 + debounced = window.setTimeout(() => { 470 + debounced = null; 471 + }, 300); 472 + return; 473 + } 474 + await deleteBlock([focusedEntity.entityID], rep); 475 + } 476 + } else { 477 + await deleteBlock([focusedEntity.entityID], rep); 478 + } 479 + }} 480 + > 481 + <DeleteTiny /> 482 + </button> 483 </div> 484 ); 485 };
+1 -3
components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx
··· 18 let isSelected = useUIState((s) => 19 s.selectedBlocks.find((b) => b.value === props.entityID), 20 ); 21 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 22 23 let entity_set = useEntitySetContext(); 24 let [urlValue, setUrlValue] = useState(""); ··· 91 className="w-full grow border-none outline-hidden bg-transparent " 92 placeholder="bsky.app/post-url" 93 value={urlValue} 94 - disabled={isLocked} 95 onChange={(e) => setUrlValue(e.target.value)} 96 onKeyDown={(e) => { 97 if (e.key === "Enter") { ··· 109 <button 110 type="submit" 111 id="bluesky-post-block-submit" 112 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 113 onMouseDown={(e) => { 114 e.preventDefault(); 115 errorSmokers(e.clientX + 12, e.clientY);
··· 18 let isSelected = useUIState((s) => 19 s.selectedBlocks.find((b) => b.value === props.entityID), 20 ); 21 22 let entity_set = useEntitySetContext(); 23 let [urlValue, setUrlValue] = useState(""); ··· 90 className="w-full grow border-none outline-hidden bg-transparent " 91 placeholder="bsky.app/post-url" 92 value={urlValue} 93 onChange={(e) => setUrlValue(e.target.value)} 94 onKeyDown={(e) => { 95 if (e.key === "Enter") { ··· 107 <button 108 type="submit" 109 id="bluesky-post-block-submit" 110 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 111 onMouseDown={(e) => { 112 e.preventDefault(); 113 errorSmokers(e.clientX + 12, e.clientY);
+38 -15
components/Blocks/ButtonBlock.tsx
··· 24 let isSelected = useUIState((s) => 25 s.selectedBlocks.find((b) => b.value === props.entityID), 26 ); 27 28 if (!url) { 29 if (!permissions.write) return null; ··· 31 } 32 33 return ( 34 - <a 35 - href={url?.data.value} 36 - target="_blank" 37 - className={`hover:outline-accent-contrast rounded-md! ${isSelected ? "block-border-selected border-0!" : "block-border border-transparent! border-0!"}`} 38 > 39 - <ButtonPrimary role="link" type="submit"> 40 - {text?.data.value} 41 - </ButtonPrimary> 42 - </a> 43 ); 44 }; 45 ··· 51 let isSelected = useUIState((s) => 52 s.selectedBlocks.find((b) => b.value === props.entityID), 53 ); 54 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 55 56 let [textValue, setTextValue] = useState(""); 57 let [urlValue, setUrlValue] = useState(""); 58 let text = textValue; 59 let url = urlValue; 60 61 let submit = async () => { 62 let entity = props.entityID; ··· 106 }; 107 108 return ( 109 - <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full "> 110 - <ButtonPrimary className="mx-auto"> 111 {text !== "" ? text : "Button"} 112 </ButtonPrimary> 113 <BlockLayout ··· 167 <Separator /> 168 <Input 169 type="text" 170 - autoFocus 171 className="w-full grow border-none outline-hidden bg-transparent" 172 placeholder="button text" 173 value={textValue} 174 - disabled={isLocked} 175 onChange={(e) => setTextValue(e.target.value)} 176 onKeyDown={(e) => { 177 if ( ··· 194 className="w-full grow border-none outline-hidden bg-transparent" 195 placeholder="www.example.com" 196 value={urlValue} 197 - disabled={isLocked} 198 onChange={(e) => setUrlValue(e.target.value)} 199 onKeyDown={(e) => { 200 if (e.key === "Backspace" && !e.currentTarget.value) ··· 205 <button 206 id="button-block-settings" 207 type="submit" 208 - className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 209 > 210 <div className="sm:hidden block">Save</div> 211 <CheckTiny />
··· 24 let isSelected = useUIState((s) => 25 s.selectedBlocks.find((b) => b.value === props.entityID), 26 ); 27 + let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 28 29 if (!url) { 30 if (!permissions.write) return null; ··· 32 } 33 34 return ( 35 + <BlockLayout 36 + isSelected={!!isSelected} 37 + borderOnHover 38 + hasAlignment={alignment !== "justify"} 39 + className={`p-0! rounded-md! border-none!`} 40 > 41 + <a 42 + href={url?.data.value} 43 + target="_blank" 44 + className={` ${alignment === "justify" ? "w-full" : "w-fit"}`} 45 + > 46 + <ButtonPrimary 47 + role="link" 48 + type="submit" 49 + fullWidth={alignment === "justify"} 50 + > 51 + {text?.data.value} 52 + </ButtonPrimary> 53 + </a> 54 + </BlockLayout> 55 ); 56 }; 57 ··· 63 let isSelected = useUIState((s) => 64 s.selectedBlocks.find((b) => b.value === props.entityID), 65 ); 66 67 let [textValue, setTextValue] = useState(""); 68 let [urlValue, setUrlValue] = useState(""); 69 let text = textValue; 70 let url = urlValue; 71 + let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 72 73 let submit = async () => { 74 let entity = props.entityID; ··· 118 }; 119 120 return ( 121 + <div 122 + className={`buttonBlockSettingsWrapper flex flex-col gap-2 w-full 123 + `} 124 + > 125 + <ButtonPrimary 126 + className={`relative ${ 127 + alignment === "center" 128 + ? "place-self-center" 129 + : alignment === "left" 130 + ? "place-self-start" 131 + : alignment === "right" 132 + ? "place-self-end" 133 + : "place-self-center" 134 + }`} 135 + fullWidth={alignment === "justify"} 136 + > 137 {text !== "" ? text : "Button"} 138 </ButtonPrimary> 139 <BlockLayout ··· 193 <Separator /> 194 <Input 195 type="text" 196 className="w-full grow border-none outline-hidden bg-transparent" 197 placeholder="button text" 198 value={textValue} 199 onChange={(e) => setTextValue(e.target.value)} 200 onKeyDown={(e) => { 201 if ( ··· 218 className="w-full grow border-none outline-hidden bg-transparent" 219 placeholder="www.example.com" 220 value={urlValue} 221 onChange={(e) => setUrlValue(e.target.value)} 222 onKeyDown={(e) => { 223 if (e.key === "Backspace" && !e.currentTarget.value) ··· 228 <button 229 id="button-block-settings" 230 type="submit" 231 + className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 232 > 233 <div className="sm:hidden block">Save</div> 234 <CheckTiny />
+57 -76
components/Blocks/CodeBlock.tsx
··· 14 import { flushSync } from "react-dom"; 15 import { elementId } from "src/utils/elementId"; 16 import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 17 18 export function CodeBlock(props: BlockProps) { 19 let { rep, rootEntity } = useReplicache(); ··· 42 }, [content, lang, theme]); 43 44 const onClick = useCallback((e: React.MouseEvent<HTMLElement>) => { 45 - let selection = window.getSelection(); 46 - if (!selection || selection.rangeCount === 0) return; 47 - let range = selection.getRangeAt(0); 48 - if (!range) return; 49 - let length = range.toString().length; 50 - range.setStart(e.currentTarget, 0); 51 - let end = range.toString().length; 52 - let start = end - length; 53 - 54 - flushSync(() => { 55 - useUIState.getState().setSelectedBlock(props); 56 - useUIState.getState().setFocusedBlock({ 57 - entityType: "block", 58 - entityID: props.value, 59 - parent: props.parent, 60 - }); 61 - }); 62 - let el = document.getElementById( 63 - elementId.block(props.entityID).input, 64 - ) as HTMLTextAreaElement; 65 - if (!el) return; 66 - el.focus(); 67 - el.setSelectionRange(start, end); 68 }, []); 69 return ( 70 <div className="codeBlock w-full flex flex-col rounded-md gap-0.5 "> 71 - {permissions.write && ( 72 - <div className="text-sm text-tertiary flex justify-between"> 73 - <div className="flex gap-1"> 74 - Theme:{" "} 75 - <select 76 - className="codeBlockLang text-left bg-transparent pr-1 sm:max-w-none max-w-24" 77 - onClick={(e) => { 78 - e.preventDefault(); 79 - e.stopPropagation(); 80 - }} 81 - value={theme} 82 - onChange={async (e) => { 83 - await rep?.mutate.assertFact({ 84 - attribute: "theme/code-theme", 85 - entity: rootEntity, 86 - data: { type: "string", value: e.target.value }, 87 - }); 88 - }} 89 - > 90 - {bundledThemesInfo.map((t) => ( 91 - <option key={t.id} value={t.id}> 92 - {t.displayName} 93 - </option> 94 - ))} 95 - </select> 96 - </div> 97 - <select 98 - className="codeBlockLang text-right bg-transparent pr-1 sm:max-w-none max-w-24" 99 - onClick={(e) => { 100 - e.preventDefault(); 101 - e.stopPropagation(); 102 - }} 103 - value={lang} 104 - onChange={async (e) => { 105 - localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value); 106 - await rep?.mutate.assertFact({ 107 - attribute: "block/code-language", 108 - entity: props.entityID, 109 - data: { type: "string", value: e.target.value }, 110 - }); 111 - }} 112 - > 113 - <option value="plaintext">Plaintext</option> 114 - {bundledLanguagesInfo.map((l) => ( 115 - <option key={l.id} value={l.id}> 116 - {l.name} 117 - </option> 118 - ))} 119 - </select> 120 - </div> 121 - )} 122 - 123 <BlockLayout 124 isSelected={focusedBlock} 125 hasBackground="accent" 126 borderOnHover 127 - className="p-0! min-h-[48px]" 128 > 129 {focusedBlock && permissions.write ? ( 130 <BaseTextareaBlock ··· 171 /> 172 )} 173 </BlockLayout> 174 </div> 175 ); 176 }
··· 14 import { flushSync } from "react-dom"; 15 import { elementId } from "src/utils/elementId"; 16 import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 17 + import { focusBlock } from "src/utils/focusBlock"; 18 19 export function CodeBlock(props: BlockProps) { 20 let { rep, rootEntity } = useReplicache(); ··· 43 }, [content, lang, theme]); 44 45 const onClick = useCallback((e: React.MouseEvent<HTMLElement>) => { 46 + focusBlock( 47 + { parent: props.parent, value: props.value, type: "code" }, 48 + { type: "end" }, 49 + ); 50 }, []); 51 return ( 52 <div className="codeBlock w-full flex flex-col rounded-md gap-0.5 "> 53 <BlockLayout 54 isSelected={focusedBlock} 55 hasBackground="accent" 56 borderOnHover 57 + className="p-0! min-h-10 sm:min-h-12" 58 > 59 {focusedBlock && permissions.write ? ( 60 <BaseTextareaBlock ··· 101 /> 102 )} 103 </BlockLayout> 104 + {permissions.write && ( 105 + <div className="text-sm text-tertiary flex w-full justify-between"> 106 + <div className="codeBlockTheme grow flex gap-1"> 107 + Theme:{" "} 108 + <select 109 + className="codeBlockThemeSelect text-left bg-transparent pr-1 sm:max-w-none max-w-24 w-full" 110 + onClick={(e) => { 111 + e.preventDefault(); 112 + e.stopPropagation(); 113 + }} 114 + value={theme} 115 + onChange={async (e) => { 116 + await rep?.mutate.assertFact({ 117 + attribute: "theme/code-theme", 118 + entity: rootEntity, 119 + data: { type: "string", value: e.target.value }, 120 + }); 121 + }} 122 + > 123 + {bundledThemesInfo.map((t) => ( 124 + <option key={t.id} value={t.id}> 125 + {t.displayName} 126 + </option> 127 + ))} 128 + </select> 129 + </div> 130 + <select 131 + className="codeBlockLang grow text-right bg-transparent pr-1 sm:max-w-none max-w-24 w-full" 132 + onClick={(e) => { 133 + e.preventDefault(); 134 + e.stopPropagation(); 135 + }} 136 + value={lang} 137 + onChange={async (e) => { 138 + localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value); 139 + await rep?.mutate.assertFact({ 140 + attribute: "block/code-language", 141 + entity: props.entityID, 142 + data: { type: "string", value: e.target.value }, 143 + }); 144 + }} 145 + > 146 + <option value="plaintext">Plaintext</option> 147 + {bundledLanguagesInfo.map((l) => ( 148 + <option key={l.id} value={l.id}> 149 + {l.name} 150 + </option> 151 + ))} 152 + </select> 153 + </div> 154 + )} 155 </div> 156 ); 157 }
+2 -3
components/Blocks/DateTimeBlock.tsx
··· 53 s.selectedBlocks.find((b) => b.value === props.entityID), 54 ); 55 56 - let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 57 let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 58 59 const handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { ··· 117 118 return ( 119 <Popover 120 - disabled={isLocked || !permissions.write} 121 className="w-64 z-10 px-2!" 122 trigger={ 123 <BlockLayout ··· 133 {dateFact ? ( 134 <div 135 className={`font-bold 136 - ${!permissions.write || isLocked ? "" : "group-hover/date:underline"} 137 `} 138 > 139 {selectedDate.toLocaleDateString(undefined, {
··· 53 s.selectedBlocks.find((b) => b.value === props.entityID), 54 ); 55 56 let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 57 58 const handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { ··· 116 117 return ( 118 <Popover 119 + disabled={!permissions.write} 120 className="w-64 z-10 px-2!" 121 trigger={ 122 <BlockLayout ··· 132 {dateFact ? ( 133 <div 134 className={`font-bold 135 + ${!permissions.write ? "" : "group-hover/date:underline"} 136 `} 137 > 138 {selectedDate.toLocaleDateString(undefined, {
+4 -4
components/Blocks/EmbedBlock.tsx
··· 111 <div 112 data-draggable 113 className={`resizeHandle 114 cursor-ns-resize shrink-0 z-10 w-6 h-[5px] 115 - absolute bottom-2 right-1/2 translate-x-1/2 translate-y-[2px] 116 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 117 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`} 118 {...heightHandle.handlers} ··· 129 let isSelected = useUIState((s) => 130 s.selectedBlocks.find((b) => b.value === props.entityID), 131 ); 132 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 133 134 let entity_set = useEntitySetContext(); 135 let [linkValue, setLinkValue] = useState(""); ··· 250 className="w-full grow border-none outline-hidden bg-transparent " 251 placeholder="www.example.com" 252 value={linkValue} 253 - disabled={isLocked} 254 onChange={(e) => setLinkValue(e.target.value)} 255 /> 256 <button 257 type="submit" 258 id="embed-block-submit" 259 disabled={loading} 260 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 261 onMouseDown={(e) => { 262 e.preventDefault(); 263 if (loading) return;
··· 111 <div 112 data-draggable 113 className={`resizeHandle 114 + 115 + 116 cursor-ns-resize shrink-0 z-10 w-6 h-[5px] 117 + absolute bottom-[3px] right-1/2 translate-x-1/2 118 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 119 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`} 120 {...heightHandle.handlers} ··· 131 let isSelected = useUIState((s) => 132 s.selectedBlocks.find((b) => b.value === props.entityID), 133 ); 134 135 let entity_set = useEntitySetContext(); 136 let [linkValue, setLinkValue] = useState(""); ··· 251 className="w-full grow border-none outline-hidden bg-transparent " 252 placeholder="www.example.com" 253 value={linkValue} 254 onChange={(e) => setLinkValue(e.target.value)} 255 /> 256 <button 257 type="submit" 258 id="embed-block-submit" 259 disabled={loading} 260 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 261 onMouseDown={(e) => { 262 e.preventDefault(); 263 if (loading) return;
+1 -3
components/Blocks/ExternalLinkBlock.tsx
··· 118 let isSelected = useUIState((s) => 119 s.selectedBlocks.find((b) => b.value === props.entityID), 120 ); 121 - let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 122 let entity_set = useEntitySetContext(); 123 let [linkValue, setLinkValue] = useState(""); 124 let { rep } = useReplicache(); ··· 173 !props.preview ? elementId.block(props.entityID).input : undefined 174 } 175 type="url" 176 - disabled={isLocked} 177 className="w-full grow border-none outline-hidden bg-transparent " 178 placeholder="www.example.com" 179 value={linkValue} ··· 199 <div className="flex items-center gap-3 "> 200 <button 201 autoFocus={false} 202 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 203 onMouseDown={(e) => { 204 e.preventDefault(); 205 if (!linkValue || linkValue === "") {
··· 118 let isSelected = useUIState((s) => 119 s.selectedBlocks.find((b) => b.value === props.entityID), 120 ); 121 let entity_set = useEntitySetContext(); 122 let [linkValue, setLinkValue] = useState(""); 123 let { rep } = useReplicache(); ··· 172 !props.preview ? elementId.block(props.entityID).input : undefined 173 } 174 type="url" 175 className="w-full grow border-none outline-hidden bg-transparent " 176 placeholder="www.example.com" 177 value={linkValue} ··· 197 <div className="flex items-center gap-3 "> 198 <button 199 autoFocus={false} 200 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 201 onMouseDown={(e) => { 202 e.preventDefault(); 203 if (!linkValue || linkValue === "") {
+43 -34
components/Blocks/ImageBlock.tsx
··· 19 import { ImageAltSmall } from "components/Icons/ImageAlt"; 20 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 21 import { useSubscribe } from "src/replicache/useSubscribe"; 22 - import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 23 24 export function ImageBlock(props: BlockProps & { preview?: boolean }) { 25 let { rep } = useReplicache(); ··· 28 let isSelected = useUIState((s) => 29 s.selectedBlocks.find((b) => b.value === props.value), 30 ); 31 - let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 32 let isFullBleed = useEntity(props.value, "image/full-bleed")?.data.value; 33 let isFirst = props.previousBlock === null; 34 let isLast = props.nextBlock === null; ··· 84 return ( 85 <BlockLayout 86 hasBackground="accent" 87 - isSelected={!!isSelected && !isLocked} 88 borderOnHover 89 className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 90 > 91 <label 92 className={` 93 - 94 w-full h-full hover:cursor-pointer 95 flex flex-col items-center justify-center 96 - ${props.pageType === "canvas" && "bg-bg-page"}`} 97 onMouseDown={(e) => e.preventDefault()} 98 onDragOver={(e) => { 99 e.preventDefault(); ··· 102 onDrop={async (e) => { 103 e.preventDefault(); 104 e.stopPropagation(); 105 - if (isLocked) return; 106 const files = e.dataTransfer.files; 107 if (files && files.length > 0) { 108 const file = files[0]; ··· 119 Upload An Image 120 </div> 121 <input 122 - disabled={isLocked} 123 className="h-0 w-0 hidden" 124 type="file" 125 accept="image/*" ··· 134 ); 135 } 136 137 - let imageClassName = isFullBleed 138 - ? "" 139 - : isSelected 140 - ? "block-border-selected border-transparent! " 141 - : "block-border border-transparent!"; 142 - 143 let isLocalUpload = localImages.get(image.data.src); 144 145 let blockClassName = ` 146 relative group/image border-transparent! p-0! w-fit! 147 - ${isFullBleed && "-mx-3 sm:-mx-4"} 148 ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 149 ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} 150 `; 151 152 return ( 153 - <BlockLayout isSelected={!!isSelected} className={blockClassName}> 154 {isLocalUpload || image.data.local ? ( 155 <img 156 loading="lazy" ··· 168 } 169 height={image?.data.height} 170 width={image?.data.width} 171 - className={imageClassName} 172 /> 173 )} 174 {altText !== undefined && !props.preview ? ( ··· 204 ); 205 206 // Only show if focused, in a publication, has write permissions, and no cover image is set 207 - if ( 208 - !isFocused || 209 - !pubData?.publications || 210 - !entity_set.permissions.write || 211 - coverImage 212 - ) 213 return null; 214 - 215 - return ( 216 - <div className="absolute top-2 left-2"> 217 - <button 218 - className="flex items-center gap-1 text-xs bg-bg-page/80 hover:bg-bg-page text-secondary hover:text-primary px-2 py-1 rounded-md border border-border hover:border-primary transition-colors" 219 onClick={async (e) => { 220 e.preventDefault(); 221 e.stopPropagation(); 222 await rep?.mutate.updatePublicationDraft({ 223 - cover_image: props.entityID, 224 }); 225 }} 226 > 227 - <span className="w-4 h-4 flex items-center justify-center"> 228 - <ImageCoverImage /> 229 - </span> 230 - Set as Cover 231 - </button> 232 - </div> 233 ); 234 }; 235
··· 19 import { ImageAltSmall } from "components/Icons/ImageAlt"; 20 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 21 import { useSubscribe } from "src/replicache/useSubscribe"; 22 + import { 23 + ImageCoverImage, 24 + ImageCoverImageRemove, 25 + } from "components/Icons/ImageCoverImage"; 26 + import { 27 + ButtonPrimary, 28 + ButtonSecondary, 29 + ButtonTertiary, 30 + } from "components/Buttons"; 31 + import { CheckTiny } from "components/Icons/CheckTiny"; 32 33 export function ImageBlock(props: BlockProps & { preview?: boolean }) { 34 let { rep } = useReplicache(); ··· 37 let isSelected = useUIState((s) => 38 s.selectedBlocks.find((b) => b.value === props.value), 39 ); 40 let isFullBleed = useEntity(props.value, "image/full-bleed")?.data.value; 41 let isFirst = props.previousBlock === null; 42 let isLast = props.nextBlock === null; ··· 92 return ( 93 <BlockLayout 94 hasBackground="accent" 95 + isSelected={!!isSelected} 96 borderOnHover 97 className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 98 > 99 <label 100 className={` 101 w-full h-full hover:cursor-pointer 102 flex flex-col items-center justify-center 103 + `} 104 onMouseDown={(e) => e.preventDefault()} 105 onDragOver={(e) => { 106 e.preventDefault(); ··· 109 onDrop={async (e) => { 110 e.preventDefault(); 111 e.stopPropagation(); 112 const files = e.dataTransfer.files; 113 if (files && files.length > 0) { 114 const file = files[0]; ··· 125 Upload An Image 126 </div> 127 <input 128 className="h-0 w-0 hidden" 129 type="file" 130 accept="image/*" ··· 139 ); 140 } 141 142 let isLocalUpload = localImages.get(image.data.src); 143 144 let blockClassName = ` 145 relative group/image border-transparent! p-0! w-fit! 146 + ${isFullBleed && "-mx-[14px] sm:-mx-[18px] rounded-[0px]! sm:outline-offset-[-16px]! -outline-offset[-12px]!"} 147 ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 148 ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} 149 `; 150 151 return ( 152 + <BlockLayout 153 + hasAlignment 154 + isSelected={!!isSelected} 155 + className={blockClassName} 156 + optionsClassName={isFullBleed ? "top-[-8px]!" : ""} 157 + > 158 {isLocalUpload || image.data.local ? ( 159 <img 160 loading="lazy" ··· 172 } 173 height={image?.data.height} 174 width={image?.data.width} 175 /> 176 )} 177 {altText !== undefined && !props.preview ? ( ··· 207 ); 208 209 // Only show if focused, in a publication, has write permissions, and no cover image is set 210 + if (!isFocused || !pubData?.publications || !entity_set.permissions.write) 211 return null; 212 + if (coverImage) 213 + return ( 214 + <ButtonSecondary 215 + className="absolute top-2 right-2" 216 onClick={async (e) => { 217 e.preventDefault(); 218 e.stopPropagation(); 219 await rep?.mutate.updatePublicationDraft({ 220 + cover_image: null, 221 }); 222 }} 223 > 224 + Remove Cover Image 225 + <ImageCoverImageRemove /> 226 + </ButtonSecondary> 227 + ); 228 + return ( 229 + <ButtonPrimary 230 + className="absolute top-2 right-2" 231 + onClick={async (e) => { 232 + e.preventDefault(); 233 + e.stopPropagation(); 234 + await rep?.mutate.updatePublicationDraft({ 235 + cover_image: props.entityID, 236 + }); 237 + }} 238 + > 239 + Use as Cover Image 240 + <ImageCoverImage /> 241 + </ButtonPrimary> 242 ); 243 }; 244
+18 -3
components/Blocks/MailboxBlock.tsx
··· 26 import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 27 import { InfoSmall } from "components/Icons/InfoSmall"; 28 29 - export const MailboxBlock = (props: BlockProps) => { 30 let isSubscribed = useSubscriptionStatus(props.entityID); 31 let isSelected = useUIState((s) => 32 s.selectedBlocks.find((b) => b.value === props.entityID), ··· 41 let subscriber_count = useEntity(props.entityID, "mailbox/subscriber-count"); 42 if (!permission) 43 return ( 44 - <MailboxReaderView entityID={props.entityID} parent={props.parent} /> 45 ); 46 47 return ( ··· 49 <BlockLayout 50 isSelected={!!isSelected} 51 hasBackground={"accent"} 52 className="flex gap-2 items-center justify-center" 53 > 54 <ButtonPrimary ··· 120 ); 121 }; 122 123 - const MailboxReaderView = (props: { entityID: string; parent: string }) => { 124 let isSubscribed = useSubscriptionStatus(props.entityID); 125 let isSelected = useUIState((s) => 126 s.selectedBlocks.find((b) => b.value === props.entityID), ··· 133 <BlockLayout 134 isSelected={!!isSelected} 135 hasBackground={"accent"} 136 className="`h-full flex flex-col gap-2 items-center justify-center" 137 > 138 {!isSubscribed?.confirmed ? (
··· 26 import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 27 import { InfoSmall } from "components/Icons/InfoSmall"; 28 29 + export const MailboxBlock = ( 30 + props: BlockProps & { 31 + areYouSure?: boolean; 32 + setAreYouSure?: (value: boolean) => void; 33 + }, 34 + ) => { 35 let isSubscribed = useSubscriptionStatus(props.entityID); 36 let isSelected = useUIState((s) => 37 s.selectedBlocks.find((b) => b.value === props.entityID), ··· 46 let subscriber_count = useEntity(props.entityID, "mailbox/subscriber-count"); 47 if (!permission) 48 return ( 49 + <MailboxReaderView 50 + entityID={props.entityID} 51 + parent={props.parent} 52 + /> 53 ); 54 55 return ( ··· 57 <BlockLayout 58 isSelected={!!isSelected} 59 hasBackground={"accent"} 60 + areYouSure={props.areYouSure} 61 + setAreYouSure={props.setAreYouSure} 62 className="flex gap-2 items-center justify-center" 63 > 64 <ButtonPrimary ··· 130 ); 131 }; 132 133 + const MailboxReaderView = (props: { 134 + entityID: string; 135 + parent: string; 136 + 137 + }) => { 138 let isSubscribed = useSubscriptionStatus(props.entityID); 139 let isSelected = useUIState((s) => 140 s.selectedBlocks.find((b) => b.value === props.entityID), ··· 147 <BlockLayout 148 isSelected={!!isSelected} 149 hasBackground={"accent"} 150 + 151 className="`h-full flex flex-col gap-2 items-center justify-center" 152 > 153 {!isSubscribed?.confirmed ? (
+9 -1
components/Blocks/PageLinkBlock.tsx
··· 13 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 14 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 15 16 - export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { 17 let page = useEntity(props.entityID, "block/card"); 18 let type = 19 useEntity(page?.data.value || null, "page/type")?.data.value || "doc"; ··· 32 <BlockLayout 33 hasBackground="page" 34 isSelected={!!isSelected} 35 className={`cursor-pointer 36 pageLinkBlockWrapper relative group/pageLinkBlock 37 flex overflow-clip p-0!
··· 13 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 14 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 15 16 + export function PageLinkBlock( 17 + props: BlockProps & { 18 + preview?: boolean; 19 + areYouSure?: boolean; 20 + setAreYouSure?: (value: boolean) => void; 21 + }, 22 + ) { 23 let page = useEntity(props.entityID, "block/card"); 24 let type = 25 useEntity(page?.data.value || null, "page/type")?.data.value || "doc"; ··· 38 <BlockLayout 39 hasBackground="page" 40 isSelected={!!isSelected} 41 + areYouSure={props.areYouSure} 42 + setAreYouSure={props.setAreYouSure} 43 className={`cursor-pointer 44 pageLinkBlockWrapper relative group/pageLinkBlock 45 flex overflow-clip p-0!
+14 -2
components/Blocks/PollBlock/index.tsx
··· 20 import { PublicationPollBlock } from "../PublicationPollBlock"; 21 import { usePollBlockUIState } from "./pollBlockState"; 22 23 - export const PollBlock = (props: BlockProps) => { 24 let { data: pub } = useLeafletPublicationData(); 25 if (!pub) return <LeafletPollBlock {...props} />; 26 return <PublicationPollBlock {...props} />; 27 }; 28 29 - export const LeafletPollBlock = (props: BlockProps) => { 30 let isSelected = useUIState((s) => 31 s.selectedBlocks.find((b) => b.value === props.entityID), 32 ); ··· 64 <BlockLayout 65 isSelected={!!isSelected} 66 hasBackground={"accent"} 67 className="poll flex flex-col gap-2 w-full" 68 > 69 {pollState === "editing" ? (
··· 20 import { PublicationPollBlock } from "../PublicationPollBlock"; 21 import { usePollBlockUIState } from "./pollBlockState"; 22 23 + export const PollBlock = ( 24 + props: BlockProps & { 25 + areYouSure?: boolean; 26 + setAreYouSure?: (value: boolean) => void; 27 + }, 28 + ) => { 29 let { data: pub } = useLeafletPublicationData(); 30 if (!pub) return <LeafletPollBlock {...props} />; 31 return <PublicationPollBlock {...props} />; 32 }; 33 34 + export const LeafletPollBlock = ( 35 + props: BlockProps & { 36 + areYouSure?: boolean; 37 + setAreYouSure?: (value: boolean) => void; 38 + }, 39 + ) => { 40 let isSelected = useUIState((s) => 41 s.selectedBlocks.find((b) => b.value === props.entityID), 42 ); ··· 74 <BlockLayout 75 isSelected={!!isSelected} 76 hasBackground={"accent"} 77 + areYouSure={props.areYouSure} 78 + setAreYouSure={props.setAreYouSure} 79 className="poll flex flex-col gap-2 w-full" 80 > 81 {pollState === "editing" ? (
+10 -2
components/Blocks/PublicationPollBlock.tsx
··· 21 * It allows adding/editing options when the poll hasn't been published yet, 22 * but disables adding new options once the poll record exists (indicated by pollUri). 23 */ 24 - export const PublicationPollBlock = (props: BlockProps) => { 25 - let { data: publicationData, normalizedDocument } = useLeafletPublicationData(); 26 let isSelected = useUIState((s) => 27 s.selectedBlocks.find((b) => b.value === props.entityID), 28 ); ··· 57 className="poll flex flex-col gap-2" 58 hasBackground={"accent"} 59 isSelected={!!isSelected} 60 > 61 <EditPollForPublication 62 entityID={props.entityID}
··· 21 * It allows adding/editing options when the poll hasn't been published yet, 22 * but disables adding new options once the poll record exists (indicated by pollUri). 23 */ 24 + export const PublicationPollBlock = ( 25 + props: BlockProps & { 26 + areYouSure?: boolean; 27 + setAreYouSure?: (value: boolean) => void; 28 + }, 29 + ) => { 30 + let { data: publicationData, normalizedDocument } = 31 + useLeafletPublicationData(); 32 let isSelected = useUIState((s) => 33 s.selectedBlocks.find((b) => b.value === props.entityID), 34 ); ··· 63 className="poll flex flex-col gap-2" 64 hasBackground={"accent"} 65 isSelected={!!isSelected} 66 + areYouSure={props.areYouSure} 67 + setAreYouSure={props.setAreYouSure} 68 > 69 <EditPollForPublication 70 entityID={props.entityID}
+8 -1
components/Blocks/RSVPBlock/index.tsx
··· 24 } 25 | { state: "contact_details"; status: RSVP_Status }; 26 27 - export function RSVPBlock(props: BlockProps) { 28 let isSelected = useUIState((s) => 29 s.selectedBlocks.find((b) => b.value === props.entityID), 30 ); ··· 32 <BlockLayout 33 isSelected={!!isSelected} 34 hasBackground={"accent"} 35 className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center" 36 > 37 <RSVPForm entityID={props.entityID} />
··· 24 } 25 | { state: "contact_details"; status: RSVP_Status }; 26 27 + export function RSVPBlock( 28 + props: BlockProps & { 29 + areYouSure?: boolean; 30 + setAreYouSure?: (value: boolean) => void; 31 + }, 32 + ) { 33 let isSelected = useUIState((s) => 34 s.selectedBlocks.find((b) => b.value === props.entityID), 35 ); ··· 37 <BlockLayout 38 isSelected={!!isSelected} 39 hasBackground={"accent"} 40 + areYouSure={props.areYouSure} 41 + setAreYouSure={props.setAreYouSure} 42 className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center" 43 > 44 <RSVPForm entityID={props.entityID} />
+2 -8
components/Blocks/TextBlock/index.tsx
··· 41 preview?: boolean; 42 }, 43 ) { 44 - let isLocked = useEntity(props.entityID, "block/is-locked"); 45 let initialized = useHasPageLoaded(); 46 let first = props.previousBlock === null; 47 let permission = useEntitySetContext().permissions.write; 48 49 return ( 50 <> 51 - {(!initialized || 52 - !permission || 53 - props.preview || 54 - isLocked?.data.value) && ( 55 <RenderedTextBlock 56 type={props.type} 57 entityID={props.entityID} ··· 61 previousBlock={props.previousBlock} 62 /> 63 )} 64 - {permission && !props.preview && !isLocked?.data.value && ( 65 <div 66 className={`w-full relative group ${!initialized ? "hidden" : ""}`} 67 > ··· 330 let { editorState } = props; 331 let rep = useReplicache(); 332 let smoker = useSmoker(); 333 - let isLocked = useEntity(props.entityID, "block/is-locked"); 334 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 335 336 let isBlueskyPost = ··· 340 // if its bluesky, change text to embed post 341 342 if ( 343 - !isLocked && 344 focused && 345 editorState && 346 betterIsUrl(editorState.doc.textContent) &&
··· 41 preview?: boolean; 42 }, 43 ) { 44 let initialized = useHasPageLoaded(); 45 let first = props.previousBlock === null; 46 let permission = useEntitySetContext().permissions.write; 47 48 return ( 49 <> 50 + {(!initialized || !permission || props.preview) && ( 51 <RenderedTextBlock 52 type={props.type} 53 entityID={props.entityID} ··· 57 previousBlock={props.previousBlock} 58 /> 59 )} 60 + {permission && !props.preview && ( 61 <div 62 className={`w-full relative group ${!initialized ? "hidden" : ""}`} 63 > ··· 326 let { editorState } = props; 327 let rep = useReplicache(); 328 let smoker = useSmoker(); 329 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 330 331 let isBlueskyPost = ··· 335 // if its bluesky, change text to embed post 336 337 if ( 338 focused && 339 editorState && 340 betterIsUrl(editorState.doc.textContent) &&
+1 -3
components/Blocks/index.tsx
··· 181 : null, 182 ); 183 184 - let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked"); 185 if (!entity_set.permissions.write) return null; 186 if ( 187 - ((props.lastBlock?.type === "text" && !isLocked?.data.value) || 188 - props.lastBlock?.type === "heading") && 189 (!editorState?.editor || editorState.editor.doc.content.size <= 2) 190 ) 191 return null;
··· 181 : null, 182 ); 183 184 if (!entity_set.permissions.write) return null; 185 if ( 186 + (props.lastBlock?.type === "text" || props.lastBlock?.type === "heading") && 187 (!editorState?.editor || editorState.editor.doc.content.size <= 2) 188 ) 189 return null;
+5 -15
components/Blocks/useBlockKeyboardHandlers.ts
··· 23 ) { 24 let { rep, undoManager } = useReplicache(); 25 let entity_set = useEntitySetContext(); 26 - let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 27 28 let isSelected = useUIState((s) => { 29 let selectedBlocks = s.selectedBlocks; ··· 70 entity_set, 71 areYouSure, 72 setAreYouSure, 73 - isLocked, 74 }); 75 undoManager.endGroup(); 76 }; 77 window.addEventListener("keydown", listener); 78 return () => window.removeEventListener("keydown", listener); 79 - }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure, isLocked]); 80 } 81 82 type Args = { 83 e: KeyboardEvent; 84 - isLocked: boolean; 85 props: BlockProps; 86 rep: Replicache<ReplicacheMutators>; 87 entity_set: { set: string }; ··· 133 } 134 135 let debounced: null | number = null; 136 - async function Backspace({ 137 - e, 138 - props, 139 - rep, 140 - areYouSure, 141 - setAreYouSure, 142 - isLocked, 143 - }: Args) { 144 // if this is a textBlock, let the textBlock/keymap handle the backspace 145 - if (isLocked) return; 146 // if its an input, label, or teatarea with content, do nothing (do the broswer default instead) 147 let el = e.target as HTMLElement; 148 if ( ··· 154 if ((el as HTMLInputElement).value !== "") return; 155 } 156 157 - // if the block is a card or mailbox... 158 if ( 159 props.type === "card" || 160 props.type === "mailbox" || 161 - props.type === "rsvp" 162 ) { 163 // ...and areYouSure state is false, set it to true 164 if (!areYouSure) {
··· 23 ) { 24 let { rep, undoManager } = useReplicache(); 25 let entity_set = useEntitySetContext(); 26 27 let isSelected = useUIState((s) => { 28 let selectedBlocks = s.selectedBlocks; ··· 69 entity_set, 70 areYouSure, 71 setAreYouSure, 72 }); 73 undoManager.endGroup(); 74 }; 75 window.addEventListener("keydown", listener); 76 return () => window.removeEventListener("keydown", listener); 77 + }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure]); 78 } 79 80 type Args = { 81 e: KeyboardEvent; 82 props: BlockProps; 83 rep: Replicache<ReplicacheMutators>; 84 entity_set: { set: string }; ··· 130 } 131 132 let debounced: null | number = null; 133 + async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { 134 // if this is a textBlock, let the textBlock/keymap handle the backspace 135 // if its an input, label, or teatarea with content, do nothing (do the broswer default instead) 136 let el = e.target as HTMLElement; 137 if ( ··· 143 if ((el as HTMLInputElement).value !== "") return; 144 } 145 146 + // if the block is a card, mailbox, rsvp, or poll... 147 if ( 148 props.type === "card" || 149 props.type === "mailbox" || 150 + props.type === "rsvp" || 151 + props.type === "poll" 152 ) { 153 // ...and areYouSure state is false, set it to true 154 if (!areYouSure) {
+21 -3
components/Blocks/useBlockMouseHandlers.ts
··· 1 import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 - import { MouseEvent, useCallback, useRef } from "react"; 3 import { useUIState } from "src/useUIState"; 4 import { Block } from "./Block"; 5 import { isTextBlock } from "src/utils/isTextBlock"; ··· 12 import { elementId } from "src/utils/elementId"; 13 14 let debounce: number | null = null; 15 export function useBlockMouseHandlers(props: Block) { 16 let entity_set = useEntitySetContext(); 17 let isMobile = useIsMobile(); ··· 22 if ((e.target as Element).tagName === "BUTTON") return; 23 if ((e.target as Element).tagName === "SELECT") return; 24 if ((e.target as Element).tagName === "OPTION") return; 25 - if (isMobile) return; 26 if (!entity_set.permissions.write) return; 27 useSelectingMouse.setState({ start: props.value }); 28 if (e.shiftKey) { ··· 57 ); 58 let onMouseEnter = useCallback( 59 async (e: MouseEvent) => { 60 - if (isMobile) return; 61 if (!entity_set.permissions.write) return; 62 if (debounce) window.clearTimeout(debounce); 63 debounce = window.setTimeout(async () => {
··· 1 import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 + import { MouseEvent, useCallback } from "react"; 3 import { useUIState } from "src/useUIState"; 4 import { Block } from "./Block"; 5 import { isTextBlock } from "src/utils/isTextBlock"; ··· 12 import { elementId } from "src/utils/elementId"; 13 14 let debounce: number | null = null; 15 + 16 + // Track scrolling state for mobile 17 + let isScrolling = false; 18 + let scrollTimeout: number | null = null; 19 + 20 + if (typeof window !== "undefined") { 21 + window.addEventListener( 22 + "scroll", 23 + () => { 24 + isScrolling = true; 25 + if (scrollTimeout) window.clearTimeout(scrollTimeout); 26 + scrollTimeout = window.setTimeout(() => { 27 + isScrolling = false; 28 + }, 150); 29 + }, 30 + true, 31 + ); 32 + } 33 export function useBlockMouseHandlers(props: Block) { 34 let entity_set = useEntitySetContext(); 35 let isMobile = useIsMobile(); ··· 40 if ((e.target as Element).tagName === "BUTTON") return; 41 if ((e.target as Element).tagName === "SELECT") return; 42 if ((e.target as Element).tagName === "OPTION") return; 43 + if (isMobile && isScrolling) return; 44 if (!entity_set.permissions.write) return; 45 useSelectingMouse.setState({ start: props.value }); 46 if (e.shiftKey) { ··· 75 ); 76 let onMouseEnter = useCallback( 77 async (e: MouseEvent) => { 78 + if (isMobile && isScrolling) return; 79 if (!entity_set.permissions.write) return; 80 if (debounce) window.clearTimeout(debounce); 81 debounce = window.setTimeout(async () => {
+47 -34
components/Canvas.tsx
··· 22 import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 25 26 export function Canvas(props: { 27 entityID: string; ··· 286 }, 287 [props, rep, permissions], 288 ); 289 - let { dragDelta, handlers } = useDrag({ 290 onDragEnd, 291 - delay: isMobile, 292 }); 293 294 let widthOnDragEnd = useCallback( ··· 335 ); 336 let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); 337 338 - let { isLongPress, handlers: longPressHandlers } = useLongPress(() => { 339 - if (isLongPress.current && permissions.write) { 340 - focusBlock( 341 - { 342 - type: type?.data.value || "text", 343 - value: props.entityID, 344 - parent: props.parent, 345 - }, 346 - { type: "start" }, 347 - ); 348 - } 349 - }); 350 let angle = 0; 351 if (rotateHandle.dragDelta) { 352 let originX = rect.x + rect.width / 2; ··· 383 }; 384 }, [props, type?.data.value]); 385 useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); 386 let isList = useEntity(props.entityID, "block/is-list"); 387 let isFocused = useUIState( 388 (s) => s.focusedEntity?.entityID === props.entityID, ··· 391 return ( 392 <div 393 ref={ref} 394 - {...(!props.preview ? { ...longPressHandlers } : {})} 395 - {...(isMobile && permissions.write ? { ...handlers } : {})} 396 id={props.preview ? undefined : elementId.block(props.entityID).container} 397 - className={`absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} 398 style={{ 399 top: 0, 400 left: 0, ··· 403 transform, 404 }} 405 > 406 - {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} 407 - {!props.preview && permissions.write && <Gripper {...handlers} />} 408 <div 409 - className={`contents ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 410 > 411 <BaseBlock 412 {...blockProps} ··· 424 <div 425 className={`resizeHandle 426 cursor-e-resize shrink-0 z-10 427 - hidden group-hover/canvas-block:block 428 - w-[5px] h-6 -ml-[3px] 429 - absolute top-1/2 right-3 -translate-y-1/2 translate-x-[2px] 430 - rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 431 {...widthHandle.handlers} 432 /> 433 )} ··· 436 <div 437 className={`rotateHandle 438 cursor-grab shrink-0 z-10 439 - hidden group-hover/canvas-block:block 440 - w-[8px] h-[8px] 441 - absolute bottom-0 -right-0 442 -translate-y-1/2 -translate-x-1/2 443 - rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 444 {...rotateHandle.handlers} 445 /> 446 )} ··· 561 } 562 }; 563 564 - const Gripper = (props: { onMouseDown: (e: React.MouseEvent) => void }) => { 565 return ( 566 <div 567 onMouseDown={props.onMouseDown} 568 onPointerDown={props.onMouseDown} 569 - className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none" 570 > 571 - <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 "> 572 {/* the gripper is two svg's stacked on top of each other. 573 One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */} 574 <div 575 - className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page hidden group-hover/canvas-block:block" 576 style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }} 577 /> 578 <div 579 - className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block" 580 style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }} 581 /> 582 - </Media> 583 </div> 584 ); 585 };
··· 22 import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 25 + import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; 26 27 export function Canvas(props: { 28 entityID: string; ··· 287 }, 288 [props, rep, permissions], 289 ); 290 + let { dragDelta, handlers: dragHandlers } = useDrag({ 291 onDragEnd, 292 }); 293 294 let widthOnDragEnd = useCallback( ··· 335 ); 336 let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); 337 338 + let { isLongPress, longPressHandlers: longPressHandlers } = useLongPress( 339 + () => { 340 + if (isLongPress.current && permissions.write) { 341 + focusBlock( 342 + { 343 + type: type?.data.value || "text", 344 + value: props.entityID, 345 + parent: props.parent, 346 + }, 347 + { type: "start" }, 348 + ); 349 + } 350 + }, 351 + ); 352 let angle = 0; 353 if (rotateHandle.dragDelta) { 354 let originX = rect.x + rect.width / 2; ··· 385 }; 386 }, [props, type?.data.value]); 387 useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); 388 + let mouseHandlers = useBlockMouseHandlers(blockProps); 389 + 390 let isList = useEntity(props.entityID, "block/is-list"); 391 let isFocused = useUIState( 392 (s) => s.focusedEntity?.entityID === props.entityID, ··· 395 return ( 396 <div 397 ref={ref} 398 + {...(!props.preview ? { ...longPressHandlers, ...mouseHandlers } : {})} 399 id={props.preview ? undefined : elementId.block(props.entityID).container} 400 + className={`canvasBlockWrapper absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} 401 style={{ 402 top: 0, 403 left: 0, ··· 406 transform, 407 }} 408 > 409 + {!props.preview && permissions.write && ( 410 + <Gripper isFocused={isFocused} {...dragHandlers} /> 411 + )} 412 + 413 <div 414 + className={` w-full ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 415 > 416 <BaseBlock 417 {...blockProps} ··· 429 <div 430 className={`resizeHandle 431 cursor-e-resize shrink-0 z-10 432 + group-hover/canvas-block:block 433 + sm:w-[5px] w-3 sm:h-6 h-8 434 + absolute top-1/2 sm:right-2 right-1 -translate-y-1/2 435 + rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 436 + ${isFocused ? "block" : "hidden"} 437 + 438 + `} 439 {...widthHandle.handlers} 440 /> 441 )} ··· 444 <div 445 className={`rotateHandle 446 cursor-grab shrink-0 z-10 447 + group-hover/canvas-block:block 448 + sm:w-[8px] sm:h-[8px] w-4 h-4 449 + absolute sm:bottom-0 sm:right-0 -bottom-1 -right-1 450 -translate-y-1/2 -translate-x-1/2 451 + rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 452 + ${isFocused ? "block" : "hidden"} 453 + `} 454 {...rotateHandle.handlers} 455 /> 456 )} ··· 571 } 572 }; 573 574 + const Gripper = (props: { 575 + onMouseDown: (e: React.MouseEvent) => void; 576 + isFocused: boolean; 577 + }) => { 578 return ( 579 <div 580 onMouseDown={props.onMouseDown} 581 onPointerDown={props.onMouseDown} 582 + className="gripper w-[9px] shrink-0 py-1 mr-1 cursor-grab touch-none" 583 > 584 + <div className="h-full grid grid-cols-1 grid-rows-1 "> 585 {/* the gripper is two svg's stacked on top of each other. 586 One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */} 587 <div 588 + className={`h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page group-hover/canvas-block:block ${props.isFocused ? "block" : "hidden"}`} 589 style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }} 590 /> 591 <div 592 + className={`h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary group-hover/canvas-block:block ${props.isFocused ? "block" : "hidden"}`} 593 style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }} 594 /> 595 + </div> 596 </div> 597 ); 598 };
+7
components/DesktopFooter.tsx
··· 4 import { Toolbar } from "./Toolbar"; 5 import { useEntitySetContext } from "./EntitySetProvider"; 6 import { focusBlock } from "src/utils/focusBlock"; 7 8 export function DesktopPageFooter(props: { pageID: string }) { 9 let focusedEntity = useUIState((s) => s.focusedEntity); ··· 13 : focusedEntity?.parent; 14 let entity_set = useEntitySetContext(); 15 16 return ( 17 <Media 18 mobile={false} ··· 20 > 21 {focusedEntity && 22 focusedEntity.entityType === "block" && 23 entity_set.permissions.write && 24 focusedBlockParentID === props.pageID && ( 25 <div ··· 29 }} 30 > 31 <Toolbar 32 pageID={focusedBlockParentID} 33 blockID={focusedEntity.entityID} 34 />
··· 4 import { Toolbar } from "./Toolbar"; 5 import { useEntitySetContext } from "./EntitySetProvider"; 6 import { focusBlock } from "src/utils/focusBlock"; 7 + import { hasBlockToolbar } from "app/[leaflet_id]/Footer"; 8 + import { useEntity } from "src/replicache"; 9 10 export function DesktopPageFooter(props: { pageID: string }) { 11 let focusedEntity = useUIState((s) => s.focusedEntity); ··· 15 : focusedEntity?.parent; 16 let entity_set = useEntitySetContext(); 17 18 + let blockType = useEntity(focusedEntity?.entityID || null, "block/type")?.data 19 + .value; 20 + 21 return ( 22 <Media 23 mobile={false} ··· 25 > 26 {focusedEntity && 27 focusedEntity.entityType === "block" && 28 + hasBlockToolbar(blockType) && 29 entity_set.permissions.write && 30 focusedBlockParentID === props.pageID && ( 31 <div ··· 35 }} 36 > 37 <Toolbar 38 + blockType={blockType} 39 pageID={focusedBlockParentID} 40 blockID={focusedEntity.entityID} 41 />
+19
components/Icons/DeleteTiny.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const DeleteTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M4.84013 0.946324C5.45222 0.762486 6.19573 0.729124 6.97782 0.820347C7.32039 0.860495 7.56546 1.1711 7.52568 1.51371C7.48568 1.85656 7.17517 2.10252 6.83232 2.06253C6.16005 1.98415 5.5998 2.02378 5.20048 2.14359C4.79524 2.26531 4.64378 2.43772 4.59599 2.56351C4.34149 3.23723 5.58865 4.19255 6.08232 4.51761C6.54562 4.82266 7.09489 5.10483 7.70536 5.33597C8.80491 5.75229 9.85164 5.90795 10.6409 5.84769C11.4848 5.78311 11.8008 5.50429 11.8714 5.31839C11.9189 5.19263 11.9202 4.96386 11.6976 4.60453C11.478 4.25029 11.0842 3.84861 10.5286 3.46195C10.2455 3.26479 10.1754 2.87508 10.3724 2.59183C10.5696 2.30876 10.9592 2.23946 11.2425 2.43656C11.8885 2.88612 12.4235 3.40237 12.7601 3.94535C13.0932 4.48275 13.2786 5.13116 13.0403 5.76078C12.9083 6.10935 12.6779 6.37566 12.3909 6.57621C12.366 6.82447 12.311 7.37448 12.2425 8.01957C12.1491 8.89816 12.0306 9.96291 11.9329 10.6856C11.809 11.602 11.638 12.5144 11.4153 13.4121C11.237 14.0305 10.6957 14.4725 10.0989 14.75C9.43935 15.0567 8.58506 15.2285 7.63212 15.2285C5.83513 15.2285 4.30017 14.7464 3.83818 13.3604C3.76287 13.0881 3.70695 12.8096 3.6497 12.5332C3.55128 12.058 3.42826 11.4025 3.33134 10.6856C3.23301 9.95806 3.11264 8.74578 3.01689 7.72562C2.96878 7.21315 2.92679 6.74483 2.89677 6.40531C2.87398 6.14754 2.83052 5.88349 2.86454 5.62503C2.95213 4.95974 3.29709 4.69093 3.98271 4.32035C3.46821 3.68093 3.13499 2.89278 3.42704 2.12113C3.66559 1.49131 4.23434 1.12831 4.84013 0.946324ZM4.26103 7.60843C4.35712 8.63219 4.47618 9.82018 4.5706 10.5186C4.66257 11.1989 4.7799 11.8245 4.87431 12.2803C4.92135 12.5074 4.9622 12.6916 4.9915 12.8184C5.24073 13.8967 6.74216 13.9785 7.63212 13.9785C8.44689 13.9785 9.11364 13.8297 9.57255 13.6162C9.98848 13.4227 10.1815 13.2132 10.2727 12.8184C10.3608 12.4373 10.5558 11.538 10.6936 10.5186C10.7887 9.81541 10.9059 8.76587 10.9993 7.88675C11.027 7.62595 11.0512 7.38071 11.0735 7.16507C10.278 7.59273 9.18805 7.88086 7.96318 7.88089C7.26324 7.88089 6.60019 7.80834 6.0081 7.67875C5.37465 7.54007 4.79262 7.29359 4.20732 7.02347C4.22408 7.20782 4.24189 7.4046 4.26103 7.60843ZM5.65849 9.89847C5.93369 9.87744 6.17434 10.0843 6.1956 10.3594C6.21921 10.6683 6.24342 10.9585 6.26396 11.1602C6.29655 11.4801 6.34417 11.7883 6.38407 12.0176C6.40539 12.1401 6.4278 12.2622 6.45341 12.3838C6.51991 12.6498 6.33262 12.9262 6.06962 12.9825C5.79972 13.0399 5.55695 12.8734 5.47587 12.5977C5.43009 12.442 5.42115 12.3183 5.39872 12.1895C5.3563 11.9458 5.30461 11.6127 5.26884 11.2618C5.24694 11.0467 5.22223 10.7457 5.19853 10.4356C5.17773 10.1606 5.38354 9.91976 5.65849 9.89847ZM5.59208 8.40238C6.25224 8.40238 6.25244 9.45121 5.59208 9.45121C4.93177 9.4506 4.93197 8.40299 5.59208 8.40238ZM9.06767 1.26566C9.29634 1.00742 9.69106 0.983602 9.9495 1.21195C10.2079 1.44072 10.2319 1.83535 10.0032 2.09378L9.137 3.07132L8.9622 3.54496C8.84186 3.86848 8.48103 4.03346 8.15751 3.91312C7.83456 3.79265 7.67047 3.43357 7.79032 3.11039L8.00614 2.52738C8.03292 2.45553 8.07353 2.38952 8.12431 2.33207L9.06767 1.26566Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+20
components/Icons/ImageCoverImage.tsx
··· 1 export const ImageCoverImage = () => ( 2 <svg 3 width="24" ··· 12 /> 13 </svg> 14 );
··· 1 + import { Props } from "./Props"; 2 + 3 export const ImageCoverImage = () => ( 4 <svg 5 width="24" ··· 14 /> 15 </svg> 16 ); 17 + 18 + export const ImageCoverImageRemove = (props: Props) => { 19 + return ( 20 + <svg 21 + width="24" 22 + height="24" 23 + viewBox="0 0 24 24" 24 + fill="none" 25 + xmlns="http://www.w3.org/2000/svg" 26 + {...props} 27 + > 28 + <path 29 + d="M21.7744 1.48538C22.0673 1.19249 22.542 1.19259 22.835 1.48538C23.1279 1.77824 23.1278 2.253 22.835 2.54592L2.22461 23.1602C1.93172 23.4526 1.45683 23.4528 1.16406 23.1602C0.871349 22.8674 0.871599 22.3926 1.16406 22.0996L21.7744 1.48538ZM22.9229 4.22561C23.1551 4.66244 23.2881 5.16029 23.2881 5.68948V18.3106C23.2879 20.0361 21.8886 21.4362 20.1631 21.4365H5.71582L6.96582 20.1865H20.1631C21.1982 20.1862 22.0379 19.3457 22.0381 18.3106V15.8067H11.3447L12.5947 14.5567H22.0381V10.1299L21.1738 9.42385L19.9775 10.9678C19.5515 11.5167 18.8233 11.7335 18.166 11.5078L16.3213 10.875C16.3092 10.8709 16.2965 10.8691 16.2842 10.8662L17.2705 9.87893L18.5723 10.3262C18.7238 10.378 18.892 10.3278 18.9902 10.2012L20.2061 8.63284L19.2764 7.87307L20.1641 6.9844L22.0381 8.51565V5.68948C22.0381 5.51371 22.0121 5.34397 21.9668 5.18264L22.9229 4.22561ZM17.6797 3.81448H3.83789C2.80236 3.81448 1.96289 4.65394 1.96289 5.68948V11.4805L4.8291 8.91897C5.18774 8.59894 5.70727 8.54438 6.12207 8.77346L6.20312 8.82327L10.083 11.4112L9.18164 12.3125L8.81055 12.0655L8.03027 12.8692C7.55062 13.3622 6.78587 13.438 6.21875 13.0489C6.17034 13.0156 6.10737 13.0113 6.05469 13.0371L4.79883 13.6573C4.258 13.9241 3.61319 13.8697 3.125 13.5157L2.26172 12.8887L1.96289 13.1573V14.5567H6.93945L5.68945 15.8067H1.96289V18.3115C1.96305 18.6614 2.06054 18.9882 2.22754 19.2686L1.32812 20.168C0.943293 19.6486 0.713079 19.0075 0.712891 18.3115V5.68948C0.712891 3.96359 2.112 2.56448 3.83789 2.56448H18.9287L17.6797 3.81448ZM14.4883 17.2578C14.9025 17.2578 15.2382 17.5937 15.2383 18.0078C15.2382 18.422 14.9025 18.7578 14.4883 18.7578H8.39453L9.89453 17.2578H14.4883ZM3.14941 18.3467C3.09734 18.2446 3.06545 18.1302 3.06543 18.0078C3.0655 17.5938 3.40144 17.2581 3.81543 17.2578H4.23828L3.14941 18.3467ZM4.71094 10.7012L4.70996 10.7002L3.21484 12.0362L3.85938 12.5039C3.97197 12.5854 4.12047 12.5976 4.24512 12.5362L5.50098 11.917C5.95928 11.6909 6.5044 11.7294 6.92578 12.0186C6.99104 12.0633 7.07958 12.0547 7.13477 11.9981L7.75488 11.3604L5.58984 9.91506L4.71094 10.7012ZM8.94629 4.52249C9.92559 4.52266 10.7195 5.31662 10.7197 6.29592C10.7197 7.27533 9.92567 8.06919 8.94629 8.06936C7.96687 8.06924 7.17292 7.27536 7.17285 6.29592C7.17304 5.31659 7.96694 4.52261 8.94629 4.52249ZM8.94629 5.52249C8.51923 5.52261 8.17304 5.86888 8.17285 6.29592C8.17292 6.72307 8.51915 7.06924 8.94629 7.06936C9.37338 7.06919 9.71966 6.72304 9.71973 6.29592C9.71954 5.86891 9.37331 5.52266 8.94629 5.52249Z" 30 + fill="currentColor" 31 + /> 32 + </svg> 33 + ); 34 + };
+1 -1
components/Pages/PageOptions.tsx
··· 93 94 <PageOptionButton 95 secondary 96 - onClick={() => undoManager.undo()} 97 disabled={!undoState.canRedo} 98 > 99 <RedoTiny />
··· 93 94 <PageOptionButton 95 secondary 96 + onClick={() => undoManager.redo()} 97 disabled={!undoState.canRedo} 98 > 99 <RedoTiny />
+1 -1
components/Popover/index.tsx
··· 43 <RadixPopover.Content 44 className={` 45 z-20 bg-bg-page 46 - px-3 py-2 47 max-w-(--radix-popover-content-available-width) 48 max-h-(--radix-popover-content-available-height) 49 border border-border rounded-md shadow-md
··· 43 <RadixPopover.Content 44 className={` 45 z-20 bg-bg-page 46 + px-3 py-2 text-primary 47 max-w-(--radix-popover-content-available-width) 48 max-h-(--radix-popover-content-available-height) 49 border border-border rounded-md shadow-md
+5 -67
components/SelectionManager/index.tsx
··· 17 import { schema } from "../Blocks/TextBlock/schema"; 18 import { MarkType } from "prosemirror-model"; 19 import { useSelectingMouse, getSortedSelection } from "./selectionState"; 20 21 //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 22 // How does this relate to *when dragging* ? ··· 240 shift: true, 241 key: ["ArrowDown", "J"], 242 handler: async () => { 243 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 244 - let block = sortedBlocks[0]; 245 - let nextBlock = siblings 246 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 247 - .find( 248 - (f) => 249 - f.listData && 250 - block.listData && 251 - !f.listData.path.find((f) => f.entity === block.value), 252 - ); 253 - if ( 254 - nextBlock?.listData && 255 - block.listData && 256 - nextBlock.listData.depth === block.listData.depth - 1 257 - ) { 258 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 259 - useUIState.getState().toggleFold(nextBlock.value); 260 - await rep?.mutate.moveBlock({ 261 - block: block.value, 262 - oldParent: block.listData?.parent, 263 - newParent: nextBlock.value, 264 - position: { type: "first" }, 265 - }); 266 - } else { 267 - await rep?.mutate.moveBlockDown({ 268 - entityID: block.value, 269 - parent: block.listData?.parent || block.parent, 270 - }); 271 - } 272 }, 273 }, 274 { ··· 276 shift: true, 277 key: ["ArrowUp", "K"], 278 handler: async () => { 279 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 280 - let block = sortedBlocks[0]; 281 - let previousBlock = 282 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 283 - if (previousBlock.value === block.listData?.parent) { 284 - previousBlock = 285 - siblings?.[ 286 - siblings.findIndex((s) => s.value === block.value) - 2 287 - ]; 288 - } 289 - 290 - if ( 291 - previousBlock?.listData && 292 - block.listData && 293 - block.listData.depth > 1 && 294 - !previousBlock.listData.path.find( 295 - (f) => f.entity === block.listData?.parent, 296 - ) 297 - ) { 298 - let depth = block.listData.depth; 299 - let newParent = previousBlock.listData.path.find( 300 - (f) => f.depth === depth - 1, 301 - ); 302 - if (!newParent) return; 303 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 304 - useUIState.getState().toggleFold(newParent.entity); 305 - rep?.mutate.moveBlock({ 306 - block: block.value, 307 - oldParent: block.listData?.parent, 308 - newParent: newParent.entity, 309 - position: { type: "end" }, 310 - }); 311 - } else { 312 - rep?.mutate.moveBlockUp({ 313 - entityID: block.value, 314 - parent: block.listData?.parent || block.parent, 315 - }); 316 - } 317 }, 318 }, 319
··· 17 import { schema } from "../Blocks/TextBlock/schema"; 18 import { MarkType } from "prosemirror-model"; 19 import { useSelectingMouse, getSortedSelection } from "./selectionState"; 20 + import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 21 22 //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 23 // How does this relate to *when dragging* ? ··· 241 shift: true, 242 key: ["ArrowDown", "J"], 243 handler: async () => { 244 + if (!rep) return; 245 + await moveBlockDown(rep, entity_set.set); 246 }, 247 }, 248 { ··· 250 shift: true, 251 key: ["ArrowUp", "K"], 252 handler: async () => { 253 + if (!rep) return; 254 + await moveBlockUp(rep); 255 }, 256 }, 257
components/Toolbar/BlockToolbar.1.tsx

This is a binary file and will not be displayed.

-223
components/Toolbar/BlockToolbar.tsx
··· 1 - import { useEntity, useReplicache } from "src/replicache"; 2 - import { ToolbarButton } from "."; 3 - import { Separator, ShortcutKey } from "components/Layout"; 4 - import { metaKey } from "src/utils/metaKey"; 5 - import { useUIState } from "src/useUIState"; 6 - import { LockBlockButton } from "./LockBlockButton"; 7 - import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 - import { 9 - ImageFullBleedButton, 10 - ImageAltTextButton, 11 - ImageCoverButton, 12 - } from "./ImageToolbar"; 13 - import { DeleteSmall } from "components/Icons/DeleteSmall"; 14 - import { getSortedSelection } from "components/SelectionManager/selectionState"; 15 - 16 - export const BlockToolbar = (props: { 17 - setToolbarState: ( 18 - state: "areYouSure" | "block" | "text-alignment" | "img-alt-text", 19 - ) => void; 20 - }) => { 21 - let focusedEntity = useUIState((s) => s.focusedEntity); 22 - let focusedEntityType = useEntity( 23 - focusedEntity?.entityType === "page" 24 - ? focusedEntity.entityID 25 - : focusedEntity?.parent || null, 26 - "page/type", 27 - ); 28 - let blockType = useEntity( 29 - focusedEntity?.entityType === "block" ? focusedEntity?.entityID : null, 30 - "block/type", 31 - )?.data.value; 32 - 33 - return ( 34 - <div className="flex items-center gap-2 justify-between w-full"> 35 - <div className="flex items-center gap-2"> 36 - <ToolbarButton 37 - onClick={() => { 38 - props.setToolbarState("areYouSure"); 39 - }} 40 - tooltipContent="Delete Block" 41 - > 42 - <DeleteSmall /> 43 - </ToolbarButton> 44 - <Separator classname="h-6!" /> 45 - <MoveBlockButtons /> 46 - {blockType === "image" && ( 47 - <> 48 - <TextAlignmentButton setToolbarState={props.setToolbarState} /> 49 - <ImageFullBleedButton /> 50 - <ImageAltTextButton setToolbarState={props.setToolbarState} /> 51 - <ImageCoverButton /> 52 - {focusedEntityType?.data.value !== "canvas" && ( 53 - <Separator classname="h-6!" /> 54 - )} 55 - </> 56 - )} 57 - {(blockType === "button" || blockType === "datetime") && ( 58 - <> 59 - <TextAlignmentButton setToolbarState={props.setToolbarState} /> 60 - {focusedEntityType?.data.value !== "canvas" && ( 61 - <Separator classname="h-6!" /> 62 - )} 63 - </> 64 - )} 65 - 66 - <LockBlockButton /> 67 - </div> 68 - </div> 69 - ); 70 - }; 71 - 72 - const MoveBlockButtons = () => { 73 - let { rep } = useReplicache(); 74 - return ( 75 - <> 76 - <ToolbarButton 77 - hiddenOnCanvas 78 - onClick={async () => { 79 - if (!rep) return; 80 - let [sortedBlocks, siblings] = await getSortedSelection(rep); 81 - if (sortedBlocks.length > 1) return; 82 - let block = sortedBlocks[0]; 83 - let previousBlock = 84 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 85 - if (previousBlock.value === block.listData?.parent) { 86 - previousBlock = 87 - siblings?.[ 88 - siblings.findIndex((s) => s.value === block.value) - 2 89 - ]; 90 - } 91 - 92 - if ( 93 - previousBlock?.listData && 94 - block.listData && 95 - block.listData.depth > 1 && 96 - !previousBlock.listData.path.find( 97 - (f) => f.entity === block.listData?.parent, 98 - ) 99 - ) { 100 - let depth = block.listData.depth; 101 - let newParent = previousBlock.listData.path.find( 102 - (f) => f.depth === depth - 1, 103 - ); 104 - if (!newParent) return; 105 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 106 - useUIState.getState().toggleFold(newParent.entity); 107 - rep?.mutate.moveBlock({ 108 - block: block.value, 109 - oldParent: block.listData?.parent, 110 - newParent: newParent.entity, 111 - position: { type: "end" }, 112 - }); 113 - } else { 114 - rep?.mutate.moveBlockUp({ 115 - entityID: block.value, 116 - parent: block.listData?.parent || block.parent, 117 - }); 118 - } 119 - }} 120 - tooltipContent={ 121 - <div className="flex flex-col gap-1 justify-center"> 122 - <div className="text-center">Move Up</div> 123 - <div className="flex gap-1"> 124 - <ShortcutKey>Shift</ShortcutKey> +{" "} 125 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 126 - <ShortcutKey> โ†‘ </ShortcutKey> 127 - </div> 128 - </div> 129 - } 130 - > 131 - <MoveBlockUp /> 132 - </ToolbarButton> 133 - 134 - <ToolbarButton 135 - hiddenOnCanvas 136 - onClick={async () => { 137 - if (!rep) return; 138 - let [sortedBlocks, siblings] = await getSortedSelection(rep); 139 - if (sortedBlocks.length > 1) return; 140 - let block = sortedBlocks[0]; 141 - let nextBlock = siblings 142 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 143 - .find( 144 - (f) => 145 - f.listData && 146 - block.listData && 147 - !f.listData.path.find((f) => f.entity === block.value), 148 - ); 149 - if ( 150 - nextBlock?.listData && 151 - block.listData && 152 - nextBlock.listData.depth === block.listData.depth - 1 153 - ) { 154 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 155 - useUIState.getState().toggleFold(nextBlock.value); 156 - rep?.mutate.moveBlock({ 157 - block: block.value, 158 - oldParent: block.listData?.parent, 159 - newParent: nextBlock.value, 160 - position: { type: "first" }, 161 - }); 162 - } else { 163 - rep?.mutate.moveBlockDown({ 164 - entityID: block.value, 165 - parent: block.listData?.parent || block.parent, 166 - }); 167 - } 168 - }} 169 - tooltipContent={ 170 - <div className="flex flex-col gap-1 justify-center"> 171 - <div className="text-center">Move Down</div> 172 - <div className="flex gap-1"> 173 - <ShortcutKey>Shift</ShortcutKey> +{" "} 174 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 175 - <ShortcutKey> โ†“ </ShortcutKey> 176 - </div> 177 - </div> 178 - } 179 - > 180 - <MoveBlockDown /> 181 - </ToolbarButton> 182 - <Separator classname="h-6!" /> 183 - </> 184 - ); 185 - }; 186 - 187 - const MoveBlockDown = () => { 188 - return ( 189 - <svg 190 - width="24" 191 - height="24" 192 - viewBox="0 0 24 24" 193 - fill="none" 194 - xmlns="http://www.w3.org/2000/svg" 195 - > 196 - <path 197 - fillRule="evenodd" 198 - clipRule="evenodd" 199 - d="M18.3444 3.56272L3.89705 5.84775C3.48792 5.91246 3.20871 6.29658 3.27342 6.7057L3.83176 10.2358C3.89647 10.645 4.28058 10.9242 4.68971 10.8595L19.137 8.57444C19.5462 8.50973 19.8254 8.12561 19.7607 7.71649L19.2023 4.18635C19.1376 3.77722 18.7535 3.49801 18.3444 3.56272ZM3.70177 4.61309C2.69864 4.77175 1.9884 5.65049 2.01462 6.63905C1.6067 6.92894 1.37517 7.43373 1.45854 7.96083L2.02167 11.5213C2.19423 12.6123 3.21854 13.3568 4.30955 13.1843L15.5014 11.4142L15.3472 10.4394L16.6131 10.2392L17.2948 13.9166L15.3038 12.4752C14.9683 12.2322 14.4994 12.3073 14.2565 12.6428C14.0135 12.9783 14.0886 13.4472 14.4241 13.6902L18.5417 16.6712L21.5228 12.5536C21.7658 12.2181 21.6907 11.7492 21.3552 11.5063C21.0197 11.2634 20.5508 11.3385 20.3079 11.674L18.7926 13.7669L18.0952 10.0048L19.3323 9.80909C20.4233 9.63654 21.1679 8.61222 20.9953 7.52121L20.437 3.99107C20.2644 2.90007 19.2401 2.15551 18.1491 2.32807L3.70177 4.61309ZM12.5175 14.1726C12.8583 14.118 13.0904 13.7974 13.0358 13.4566C12.9812 13.1157 12.6606 12.8837 12.3198 12.9383L4.48217 14.1937C3.37941 14.3704 2.62785 15.4065 2.80232 16.5096L3.35244 19.9878C3.52716 21.0925 4.56428 21.8463 5.66893 21.6716L20.0583 19.3958C21.1618 19.2212 21.9155 18.186 21.7426 17.0822L21.6508 16.4961C21.5974 16.1551 21.2776 15.922 20.9366 15.9754C20.5956 16.0288 20.3624 16.3486 20.4158 16.6896L20.5077 17.2757C20.5738 17.6981 20.2854 18.0943 19.8631 18.1611L5.47365 20.437C5.05089 20.5038 4.65396 20.2153 4.5871 19.7925L4.03697 16.3143C3.9702 15.8921 4.25783 15.4956 4.67988 15.428L12.5175 14.1726ZM5.48645 8.13141C5.4213 7.72235 5.70009 7.33793 6.10914 7.27278L12.7667 6.21241C13.1757 6.14726 13.5602 6.42605 13.6253 6.83511C13.6905 7.24417 13.4117 7.62859 13.0026 7.69374L6.34508 8.75411C5.93602 8.81926 5.5516 8.54047 5.48645 8.13141Z" 200 - fill="currentColor" 201 - /> 202 - </svg> 203 - ); 204 - }; 205 - 206 - const MoveBlockUp = () => { 207 - return ( 208 - <svg 209 - width="24" 210 - height="24" 211 - viewBox="0 0 24 24" 212 - fill="none" 213 - xmlns="http://www.w3.org/2000/svg" 214 - > 215 - <path 216 - fillRule="evenodd" 217 - clipRule="evenodd" 218 - d="M4.12086 10.3069C3.69777 10.3744 3.30016 10.0858 3.23323 9.66265L2.68364 6.18782C2.61677 5.76506 2.90529 5.36813 3.32805 5.30127L17.7149 3.0258C18.1378 2.95892 18.5348 3.24759 18.6015 3.67049L18.7835 4.82361C18.8373 5.16457 19.1573 5.39736 19.4983 5.34356C19.8392 5.28975 20.072 4.96974 20.0182 4.62878L19.8363 3.47566C19.6619 2.37067 18.6246 1.61639 17.5197 1.79115L3.13278 4.06661C2.02813 4.24133 1.27427 5.27845 1.44899 6.3831L1.99857 9.85793C2.17346 10.9637 3.21238 11.7177 4.31788 11.5413L11.5185 10.392C11.8594 10.3376 12.0916 10.0171 12.0372 9.67628C11.9828 9.33542 11.6624 9.1032 11.3215 9.15761L4.12086 10.3069ZM19.9004 11.6151L5.45305 13.9001C5.04392 13.9649 4.76471 14.349 4.82942 14.7581L5.38775 18.2882C5.45246 18.6974 5.83658 18.9766 6.24571 18.9119L20.6931 16.6268C21.1022 16.5621 21.3814 16.178 21.3167 15.7689L20.7583 12.2388C20.6936 11.8296 20.3095 11.5504 19.9004 11.6151ZM5.25777 12.6655C4.21806 12.8299 3.49299 13.7679 3.57645 14.8C3.17867 15.1511 2.9637 15.6918 3.05264 16.2541L3.57767 19.5737C3.75023 20.6647 4.77455 21.4093 5.86556 21.2367L19.9927 19.0023C20.7197 18.8873 21.2751 18.3524 21.4519 17.6846C22.2223 17.3097 22.6921 16.4638 22.5513 15.5736L21.993 12.0435C21.8204 10.9525 20.7961 10.2079 19.7051 10.3805L17.9019 10.6657L17.3957 7.46986L19.3483 8.96297C19.6773 9.21457 20.148 9.1518 20.3996 8.82276C20.6512 8.49373 20.5885 8.02302 20.2594 7.77141L16.2213 4.68355L13.1334 8.72172C12.8818 9.05076 12.9445 9.52146 13.2736 9.77307C13.6026 10.0247 14.0733 9.96191 14.3249 9.63287L15.8945 7.58034L16.4203 10.9L5.25777 12.6655ZM7.66514 15.3252C7.25609 15.3903 6.97729 15.7748 7.04245 16.1838C7.1076 16.5929 7.49202 16.8717 7.90108 16.8065L14.5586 15.7461C14.9677 15.681 15.2465 15.2966 15.1813 14.8875C15.1162 14.4785 14.7317 14.1997 14.3227 14.2648L7.66514 15.3252Z" 219 - fill="currentColor" 220 - /> 221 - </svg> 222 - ); 223 - };
···
+36 -8
components/Toolbar/ImageToolbar.tsx
··· 6 import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt"; 7 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 import { useSubscribe } from "src/replicache/useSubscribe"; 9 - import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 10 11 export const ImageFullBleedButton = (props: {}) => { 12 let { rep } = useReplicache(); ··· 36 ); 37 }; 38 39 - export const ImageAltTextButton = (props: { 40 - setToolbarState: (s: "img-alt-text") => void; 41 - }) => { 42 let { rep } = useReplicache(); 43 let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null; 44 ··· 48 let altEditorOpen = useUIState((s) => s.openPopover === focusedBlock); 49 let hasSrc = useEntity(focusedBlock, "block/image")?.data; 50 if (!hasSrc) return null; 51 - 52 return ( 53 <ToolbarButton 54 active={altText !== undefined} 55 onClick={async (e) => { 56 e.preventDefault(); 57 if (!focusedBlock) return; 58 - if (!altText) { 59 await rep?.mutate.assertFact({ 60 entity: focusedBlock, 61 attribute: "image/alt", ··· 109 } 110 }} 111 tooltipContent={ 112 - <div>{isCoverImage ? "Remove Cover Image" : "Set as Cover Image"}</div> 113 } 114 > 115 - <ImageCoverImage /> 116 </ToolbarButton> 117 ); 118 };
··· 6 import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt"; 7 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 import { useSubscribe } from "src/replicache/useSubscribe"; 9 + import { 10 + ImageCoverImage, 11 + ImageCoverImageRemove, 12 + } from "components/Icons/ImageCoverImage"; 13 + import { Separator } from "components/Layout"; 14 + import { TextAlignmentButton } from "./TextAlignmentToolbar"; 15 + 16 + export const ImageToolbar = (props: { 17 + setToolbarState: (state: "image" | "text-alignment") => void; 18 + }) => { 19 + let focusedEntity = useUIState((s) => s.focusedEntity); 20 + let focusedEntityType = useEntity( 21 + focusedEntity?.entityType === "page" 22 + ? focusedEntity.entityID 23 + : focusedEntity?.parent || null, 24 + "page/type", 25 + ); 26 + 27 + return ( 28 + <div className="flex items-center gap-2 justify-between w-full"> 29 + <div className="flex items-center gap-2"> 30 + <TextAlignmentButton setToolbarState={props.setToolbarState} /> 31 + <ImageAltTextButton /> 32 + <ImageFullBleedButton /> 33 + <ImageCoverButton /> 34 + {focusedEntityType?.data.value !== "canvas" && ( 35 + <Separator classname="h-6!" /> 36 + )} 37 + </div> 38 + </div> 39 + ); 40 + }; 41 42 export const ImageFullBleedButton = (props: {}) => { 43 let { rep } = useReplicache(); ··· 67 ); 68 }; 69 70 + export const ImageAltTextButton = (props: {}) => { 71 let { rep } = useReplicache(); 72 let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null; 73 ··· 77 let altEditorOpen = useUIState((s) => s.openPopover === focusedBlock); 78 let hasSrc = useEntity(focusedBlock, "block/image")?.data; 79 if (!hasSrc) return null; 80 return ( 81 <ToolbarButton 82 active={altText !== undefined} 83 onClick={async (e) => { 84 e.preventDefault(); 85 if (!focusedBlock) return; 86 + if (altText === undefined) { 87 await rep?.mutate.assertFact({ 88 entity: focusedBlock, 89 attribute: "image/alt", ··· 137 } 138 }} 139 tooltipContent={ 140 + <div>{isCoverImage ? "Remove Cover Image" : "Use as Cover Image"}</div> 141 } 142 > 143 + {isCoverImage ? <ImageCoverImageRemove /> : <ImageCoverImage />} 144 </ToolbarButton> 145 ); 146 };
-103
components/Toolbar/LockBlockButton.tsx
··· 1 - import { useUIState } from "src/useUIState"; 2 - import { ToolbarButton } from "."; 3 - import { useEntity, useReplicache } from "src/replicache"; 4 - 5 - import { focusBlock } from "src/utils/focusBlock"; 6 - import { Props } from "components/Icons/Props"; 7 - 8 - export function LockBlockButton() { 9 - let focusedBlock = useUIState((s) => s.focusedEntity); 10 - let selectedBlocks = useUIState((s) => s.selectedBlocks); 11 - let type = useEntity(focusedBlock?.entityID || null, "block/type"); 12 - let locked = useEntity(focusedBlock?.entityID || null, "block/is-locked"); 13 - let { rep } = useReplicache(); 14 - if (focusedBlock?.entityType !== "block") return; 15 - return ( 16 - <ToolbarButton 17 - disabled={false} 18 - onClick={async () => { 19 - if (!locked?.data.value) { 20 - await rep?.mutate.assertFact({ 21 - entity: focusedBlock.entityID, 22 - attribute: "block/is-locked", 23 - data: { value: true, type: "boolean" }, 24 - }); 25 - if (selectedBlocks.length > 1) { 26 - for (let block of selectedBlocks) { 27 - await rep?.mutate.assertFact({ 28 - attribute: "block/is-locked", 29 - entity: block.value, 30 - data: { value: true, type: "boolean" }, 31 - }); 32 - } 33 - } 34 - } else { 35 - await rep?.mutate.retractFact({ factID: locked.id }); 36 - if (selectedBlocks.length > 1) { 37 - for (let block of selectedBlocks) { 38 - await rep?.mutate.retractAttribute({ 39 - attribute: "block/is-locked", 40 - entity: block.value, 41 - }); 42 - } 43 - } else { 44 - type && 45 - focusBlock( 46 - { 47 - type: type.data.value, 48 - parent: focusedBlock.parent, 49 - value: focusedBlock.entityID, 50 - }, 51 - { type: "end" }, 52 - ); 53 - } 54 - } 55 - }} 56 - tooltipContent={ 57 - <span>{!locked?.data.value ? "Lock Editing" : " Unlock to Edit"}</span> 58 - } 59 - > 60 - {!locked?.data.value ? <LockSmall /> : <UnlockSmall />} 61 - </ToolbarButton> 62 - ); 63 - } 64 - 65 - const LockSmall = (props: Props) => { 66 - return ( 67 - <svg 68 - width="24" 69 - height="24" 70 - viewBox="0 0 24 24" 71 - fill="none" 72 - xmlns="http://www.w3.org/2000/svg" 73 - {...props} 74 - > 75 - <path 76 - fillRule="evenodd" 77 - clipRule="evenodd" 78 - d="M12 3.9657C9.73217 3.9657 7.89374 5.80413 7.89374 8.07196V10.1794H7.78851C6.82201 10.1794 6.03851 10.9629 6.03851 11.9294V17C6.03851 18.6569 7.38166 20 9.03851 20H14.9615C16.6184 20 17.9615 18.6569 17.9615 17V11.9294C17.9615 10.9629 17.178 10.1794 16.2115 10.1794H16.1063V8.07196C16.1063 5.80413 14.2678 3.9657 12 3.9657ZM14.3563 10.1794V8.07196C14.3563 6.77063 13.3013 5.7157 12 5.7157C10.6987 5.7157 9.64374 6.77063 9.64374 8.07196V10.1794H14.3563ZM12.5824 15.3512C12.9924 15.1399 13.2727 14.7123 13.2727 14.2193C13.2727 13.5165 12.7029 12.9467 12 12.9467C11.2972 12.9467 10.7274 13.5165 10.7274 14.2193C10.7274 14.7271 11.0247 15.1654 11.4548 15.3696L11.2418 17.267C11.2252 17.4152 11.3411 17.5449 11.4902 17.5449H12.5147C12.6621 17.5449 12.7774 17.4181 12.7636 17.2714L12.5824 15.3512Z" 79 - fill="currentColor" 80 - /> 81 - </svg> 82 - ); 83 - }; 84 - 85 - const UnlockSmall = (props: Props) => { 86 - return ( 87 - <svg 88 - width="24" 89 - height="24" 90 - viewBox="0 0 24 24" 91 - fill="none" 92 - xmlns="http://www.w3.org/2000/svg" 93 - {...props} 94 - > 95 - <path 96 - fillRule="evenodd" 97 - clipRule="evenodd" 98 - d="M7.89376 6.62482C7.89376 4.35699 9.7322 2.51855 12 2.51855C14.2678 2.51855 16.1063 4.35699 16.1063 6.62482V10.1794H16.2115C17.178 10.1794 17.9615 10.9629 17.9615 11.9294V17C17.9615 18.6569 16.6184 20 14.9615 20H9.03854C7.38168 20 6.03854 18.6569 6.03854 17V11.9294C6.03854 10.9629 6.82204 10.1794 7.78854 10.1794H14.3563V6.62482C14.3563 5.32349 13.3013 4.26855 12 4.26855C10.6987 4.26855 9.64376 5.32349 9.64376 6.62482V7.72078C9.64376 8.20403 9.25201 8.59578 8.76876 8.59578C8.28551 8.59578 7.89376 8.20403 7.89376 7.72078V6.62482ZM13.1496 14.2193C13.1496 14.7123 12.8693 15.1399 12.4593 15.3512L12.6405 17.2714C12.6544 17.4181 12.539 17.5449 12.3916 17.5449H11.3672C11.218 17.5449 11.1021 17.4152 11.1187 17.267L11.3317 15.3696C10.9016 15.1654 10.6043 14.7271 10.6043 14.2193C10.6043 13.5165 11.1741 12.9467 11.8769 12.9467C12.5798 12.9467 13.1496 13.5165 13.1496 14.2193ZM5.62896 5.3862C5.4215 5.20395 5.10558 5.2244 4.92333 5.43186C4.74109 5.63932 4.76153 5.95525 4.969 6.13749L6.06209 7.09771C6.26955 7.27996 6.58548 7.25951 6.76772 7.05205C6.94997 6.84458 6.92952 6.52866 6.72206 6.34642L5.62896 5.3862ZM3.5165 6.64283C3.25418 6.55657 2.97159 6.69929 2.88533 6.96161C2.79906 7.22393 2.94178 7.50652 3.20411 7.59278L5.54822 8.36366C5.81054 8.44992 6.09313 8.3072 6.1794 8.04488C6.26566 7.78256 6.12294 7.49997 5.86062 7.41371L3.5165 6.64283ZM3.54574 9.42431C3.52207 9.14918 3.72592 8.90696 4.00105 8.8833L5.52254 8.75244C5.79766 8.72878 6.03988 8.93263 6.06354 9.20776C6.08721 9.48288 5.88335 9.7251 5.60823 9.74876L4.08674 9.87962C3.81162 9.90329 3.5694 9.69943 3.54574 9.42431Z" 99 - fill="currentColor" 100 - /> 101 - </svg> 102 - ); 103 - };
···
+33 -12
components/Toolbar/MultiSelectToolbar.tsx
··· 2 import { ReplicacheMutators, useReplicache } from "src/replicache"; 3 import { ToolbarButton } from "./index"; 4 import { copySelection } from "src/utils/copySelection"; 5 - import { useSmoker } from "components/Toast"; 6 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 7 - import { Replicache } from "replicache"; 8 - import { LockBlockButton } from "./LockBlockButton"; 9 import { Props } from "components/Icons/Props"; 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 import { getSortedSelection } from "components/SelectionManager/selectionState"; 12 13 export const MultiselectToolbar = (props: { 14 - setToolbarState: ( 15 - state: "areYouSure" | "multiselect" | "text-alignment", 16 - ) => void; 17 }) => { 18 - const { rep } = useReplicache(); 19 const smoker = useSmoker(); 20 21 const handleCopy = async (event: React.MouseEvent) => { 22 if (!rep) return; 23 - const [sortedSelection] = await getSortedSelection(rep); 24 await copySelection(rep, sortedSelection); 25 smoker({ 26 position: { x: event.clientX, y: event.clientY }, ··· 33 <div className="flex items-center gap-2"> 34 <ToolbarButton 35 tooltipContent="Delete Selected Blocks" 36 - onClick={() => { 37 - props.setToolbarState("areYouSure"); 38 }} 39 > 40 <TrashSmall /> ··· 47 <CopySmall /> 48 </ToolbarButton> 49 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 50 - <LockBlockButton /> 51 </div> 52 </div> 53 );
··· 2 import { ReplicacheMutators, useReplicache } from "src/replicache"; 3 import { ToolbarButton } from "./index"; 4 import { copySelection } from "src/utils/copySelection"; 5 + import { useSmoker, useToaster } from "components/Toast"; 6 + 7 import { Props } from "components/Icons/Props"; 8 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 9 import { getSortedSelection } from "components/SelectionManager/selectionState"; 10 + import { deleteBlock } from "src/utils/deleteBlock"; 11 + import { Separator, ShortcutKey } from "components/Layout"; 12 13 export const MultiselectToolbar = (props: { 14 + setToolbarState: (state: "multiselect" | "text-alignment") => void; 15 }) => { 16 + const { rep, undoManager } = useReplicache(); 17 const smoker = useSmoker(); 18 + const toaster = useToaster(); 19 20 const handleCopy = async (event: React.MouseEvent) => { 21 if (!rep) return; 22 + let [sortedSelection] = await getSortedSelection(rep); 23 await copySelection(rep, sortedSelection); 24 smoker({ 25 position: { x: event.clientX, y: event.clientY }, ··· 32 <div className="flex items-center gap-2"> 33 <ToolbarButton 34 tooltipContent="Delete Selected Blocks" 35 + onClick={async (e) => { 36 + e.stopPropagation(); 37 + if (!rep) return; 38 + let [sortedSelection] = await getSortedSelection(rep); 39 + await deleteBlock( 40 + sortedSelection.map((b) => b.value), 41 + rep, 42 + undoManager, 43 + ); 44 + 45 + toaster({ 46 + content: ( 47 + <div className="font-bold items-center flex"> 48 + {sortedSelection.length} block 49 + {sortedSelection.length === 1 ? "" : "s"} deleted!{" "} 50 + <span className="px-2 flex"> 51 + <ShortcutKey>Ctrl</ShortcutKey> 52 + <ShortcutKey>Z</ShortcutKey>{" "} 53 + </span> 54 + to undo. 55 + </div> 56 + ), 57 + type: "success", 58 + }); 59 }} 60 > 61 <TrashSmall /> ··· 68 <CopySmall /> 69 </ToolbarButton> 70 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 71 + <Separator classname="h-6!" /> 72 </div> 73 </div> 74 );
+8
components/Toolbar/TextAlignmentToolbar.tsx
··· 7 export function TextAlignmentToolbar() { 8 let focusedBlock = useUIState((s) => s.focusedEntity); 9 let { rep } = useReplicache(); 10 let setAlignment = useCallback( 11 (alignment: Fact<"block/text-alignment">["data"]["value"]) => { 12 let blocks = useUIState.getState().selectedBlocks; ··· 26 <ToolbarButton 27 onClick={() => setAlignment("left")} 28 tooltipContent="Align Left" 29 > 30 <AlignLeftSmall /> 31 </ToolbarButton> 32 <ToolbarButton 33 onClick={() => setAlignment("center")} 34 tooltipContent="Align Center" 35 > 36 <AlignCenterSmall /> 37 </ToolbarButton> 38 <ToolbarButton 39 onClick={() => setAlignment("right")} 40 tooltipContent="Align Right" 41 > 42 <AlignRightSmall /> 43 </ToolbarButton> ··· 45 <ToolbarButton 46 onClick={() => setAlignment("justify")} 47 tooltipContent="Align Justified" 48 > 49 <AlignJustifiedSmall /> 50 </ToolbarButton>
··· 7 export function TextAlignmentToolbar() { 8 let focusedBlock = useUIState((s) => s.focusedEntity); 9 let { rep } = useReplicache(); 10 + let alignment = useEntity( 11 + focusedBlock?.entityID || null, 12 + "block/text-alignment", 13 + )?.data.value; 14 let setAlignment = useCallback( 15 (alignment: Fact<"block/text-alignment">["data"]["value"]) => { 16 let blocks = useUIState.getState().selectedBlocks; ··· 30 <ToolbarButton 31 onClick={() => setAlignment("left")} 32 tooltipContent="Align Left" 33 + active={alignment === "left"} 34 > 35 <AlignLeftSmall /> 36 </ToolbarButton> 37 <ToolbarButton 38 onClick={() => setAlignment("center")} 39 tooltipContent="Align Center" 40 + active={alignment === "center"} 41 > 42 <AlignCenterSmall /> 43 </ToolbarButton> 44 <ToolbarButton 45 onClick={() => setAlignment("right")} 46 tooltipContent="Align Right" 47 + active={alignment === "right"} 48 > 49 <AlignRightSmall /> 50 </ToolbarButton> ··· 52 <ToolbarButton 53 onClick={() => setAlignment("justify")} 54 tooltipContent="Align Justified" 55 + active={alignment === "justify"} 56 > 57 <AlignJustifiedSmall /> 58 </ToolbarButton>
-3
components/Toolbar/TextToolbar.tsx
··· 8 import { ToolbarTypes } from "."; 9 import { schema } from "components/Blocks/TextBlock/schema"; 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { LockBlockButton } from "./LockBlockButton"; 12 import { Props } from "components/Icons/Props"; 13 import { isMac } from "src/utils/isDevice"; 14 ··· 81 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 82 <ListButton setToolbarState={props.setToolbarState} /> 83 <Separator classname="h-6!" /> 84 - 85 - <LockBlockButton /> 86 </> 87 ); 88 };
··· 8 import { ToolbarTypes } from "."; 9 import { schema } from "components/Blocks/TextBlock/schema"; 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 import { Props } from "components/Icons/Props"; 12 import { isMac } from "src/utils/isDevice"; 13 ··· 80 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 81 <ListButton setToolbarState={props.setToolbarState} /> 82 <Separator classname="h-6!" /> 83 </> 84 ); 85 };
+57 -87
components/Toolbar/index.tsx
··· 11 import { ListToolbar } from "./ListToolbar"; 12 import { HighlightToolbar } from "./HighlightToolbar"; 13 import { TextToolbar } from "./TextToolbar"; 14 - import { BlockToolbar } from "./BlockToolbar"; 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 - import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 - import { deleteBlock } from "src/utils/deleteBlock"; 18 import { TooltipButton } from "components/Buttons"; 19 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 20 import { useIsMobile } from "src/hooks/isMobile"; 21 import { CloseTiny } from "components/Icons/CloseTiny"; 22 23 export type ToolbarTypes = 24 - | "areYouSure" 25 | "default" 26 - | "block" 27 | "multiselect" 28 | "highlight" 29 | "link" ··· 31 | "text-alignment" 32 | "list" 33 | "linkBlock" 34 - | "img-alt-text"; 35 - 36 - export const Toolbar = (props: { pageID: string; blockID: string }) => { 37 - let { rep } = useReplicache(); 38 39 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 40 41 - let focusedEntity = useUIState((s) => s.focusedEntity); 42 - let selectedBlocks = useUIState((s) => s.selectedBlocks); 43 let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 44 - 45 - let blockType = useEntity(props.blockID, "block/type")?.data.value; 46 47 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); 48 let setLastUsedHighlight = (color: "1" | "2" | "3") => ··· 64 }; 65 }, [toolbarState]); 66 67 - useEffect(() => { 68 - if (!blockType) return; 69 - if ( 70 - blockType !== "heading" && 71 - blockType !== "text" && 72 - blockType !== "blockquote" 73 - ) { 74 - setToolbarState("block"); 75 - } else { 76 - setToolbarState("default"); 77 - } 78 - }, [blockType]); 79 80 useEffect(() => { 81 - if ( 82 - selectedBlocks.length > 1 && 83 - !["areYousure", "text-alignment"].includes(toolbarState) 84 - ) { 85 setToolbarState("multiselect"); 86 - } else if (toolbarState === "multiselect") { 87 setToolbarState("default"); 88 } 89 - }, [selectedBlocks.length, toolbarState]); 90 - let isMobile = useIsMobile(); 91 92 return ( 93 <Tooltip.Provider> 94 <div ··· 125 <TextBlockTypeToolbar onClose={() => setToolbarState("default")} /> 126 ) : toolbarState === "text-alignment" ? ( 127 <TextAlignmentToolbar /> 128 - ) : toolbarState === "block" ? ( 129 - <BlockToolbar setToolbarState={setToolbarState} /> 130 ) : toolbarState === "multiselect" ? ( 131 <MultiselectToolbar setToolbarState={setToolbarState} /> 132 - ) : toolbarState === "areYouSure" ? ( 133 - <AreYouSure 134 - compact 135 - type={blockType} 136 - entityID={selectedBlocks.map((b) => b.value)} 137 - onClick={() => { 138 - rep && 139 - deleteBlock( 140 - selectedBlocks.map((b) => b.value), 141 - rep, 142 - ); 143 - }} 144 - closeAreYouSure={() => { 145 - setToolbarState( 146 - selectedBlocks.length > 1 147 - ? "multiselect" 148 - : blockType !== "heading" && blockType !== "text" 149 - ? "block" 150 - : "default", 151 - ); 152 - }} 153 - /> 154 ) : null} 155 </div> 156 {/* if the thing is are you sure state, don't show the x... is each thing handling its own are you sure? theres no need for that */} 157 - {toolbarState !== "areYouSure" && ( 158 - <button 159 - className="toolbarBackToDefault hover:text-accent-contrast" 160 - onMouseDown={(e) => { 161 - e.preventDefault(); 162 - if ( 163 - toolbarState === "multiselect" || 164 - toolbarState === "block" || 165 - toolbarState === "default" 166 - ) { 167 - useUIState.setState(() => ({ 168 - focusedEntity: { 169 - entityType: "page", 170 - entityID: props.pageID, 171 - }, 172 - selectedBlocks: [], 173 - })); 174 - } else { 175 - if (blockType !== "heading" && blockType !== "text") { 176 - setToolbarState("block"); 177 - } else { 178 - setToolbarState("default"); 179 - } 180 } 181 - }} 182 - > 183 - <CloseTiny /> 184 - </button> 185 - )} 186 </div> 187 </Tooltip.Provider> 188 ); ··· 198 hiddenOnCanvas?: boolean; 199 }) => { 200 let focusedEntity = useUIState((s) => s.focusedEntity); 201 - let isLocked = useEntity(focusedEntity?.entityID || null, "block/is-locked"); 202 - let isDisabled = 203 - props.disabled === undefined ? !!isLocked?.data.value : props.disabled; 204 205 let focusedEntityType = useEntity( 206 focusedEntity?.entityType === "page"
··· 11 import { ListToolbar } from "./ListToolbar"; 12 import { HighlightToolbar } from "./HighlightToolbar"; 13 import { TextToolbar } from "./TextToolbar"; 14 + import { ImageToolbar } from "./ImageToolbar"; 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 import { TooltipButton } from "components/Buttons"; 17 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 18 import { useIsMobile } from "src/hooks/isMobile"; 19 import { CloseTiny } from "components/Icons/CloseTiny"; 20 21 export type ToolbarTypes = 22 | "default" 23 | "multiselect" 24 | "highlight" 25 | "link" ··· 27 | "text-alignment" 28 | "list" 29 | "linkBlock" 30 + | "img-alt-text" 31 + | "image"; 32 33 + export const Toolbar = (props: { 34 + pageID: string; 35 + blockID: string; 36 + blockType: string | null | undefined; 37 + }) => { 38 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 39 40 let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 41 + let selectedBlocks = useUIState((s) => s.selectedBlocks); 42 43 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); 44 let setLastUsedHighlight = (color: "1" | "2" | "3") => ··· 60 }; 61 }, [toolbarState]); 62 63 + let isTextBlock = 64 + props.blockType === "heading" || 65 + props.blockType === "text" || 66 + props.blockType === "blockquote"; 67 68 useEffect(() => { 69 + if (selectedBlocks.length > 1) { 70 setToolbarState("multiselect"); 71 + return; 72 + } 73 + if (isTextBlock) { 74 setToolbarState("default"); 75 } 76 + if (props.blockType === "image") { 77 + setToolbarState("image"); 78 + } 79 + if (props.blockType === "button" || props.blockType === "datetime") { 80 + setToolbarState("text-alignment"); 81 + } else null; 82 + }, [props.blockType, selectedBlocks]); 83 84 + let isMobile = useIsMobile(); 85 return ( 86 <Tooltip.Provider> 87 <div ··· 118 <TextBlockTypeToolbar onClose={() => setToolbarState("default")} /> 119 ) : toolbarState === "text-alignment" ? ( 120 <TextAlignmentToolbar /> 121 + ) : toolbarState === "image" ? ( 122 + <ImageToolbar setToolbarState={setToolbarState} /> 123 ) : toolbarState === "multiselect" ? ( 124 <MultiselectToolbar setToolbarState={setToolbarState} /> 125 ) : null} 126 </div> 127 {/* if the thing is are you sure state, don't show the x... is each thing handling its own are you sure? theres no need for that */} 128 + 129 + <button 130 + className="toolbarBackToDefault hover:text-accent-contrast" 131 + onMouseDown={(e) => { 132 + e.preventDefault(); 133 + if ( 134 + toolbarState === "multiselect" || 135 + toolbarState === "image" || 136 + toolbarState === "default" 137 + ) { 138 + // close the toolbar 139 + useUIState.setState(() => ({ 140 + focusedEntity: { 141 + entityType: "page", 142 + entityID: props.pageID, 143 + }, 144 + selectedBlocks: [], 145 + })); 146 + } else { 147 + if (props.blockType === "image") { 148 + setToolbarState("image"); 149 } 150 + if (isTextBlock) { 151 + setToolbarState("default"); 152 + } 153 + } 154 + }} 155 + > 156 + <CloseTiny /> 157 + </button> 158 </div> 159 </Tooltip.Provider> 160 ); ··· 170 hiddenOnCanvas?: boolean; 171 }) => { 172 let focusedEntity = useUIState((s) => s.focusedEntity); 173 + let isDisabled = props.disabled; 174 175 let focusedEntityType = useEntity( 176 focusedEntity?.entityType === "page"
+1
drizzle/schema.ts
··· 140 email: text("email"), 141 atp_did: text("atp_did"), 142 interface_state: jsonb("interface_state"), 143 }, 144 (table) => { 145 return {
··· 140 email: text("email"), 141 atp_did: text("atp_did"), 142 interface_state: jsonb("interface_state"), 143 + metadata: jsonb("metadata"), 144 }, 145 (table) => { 146 return {
+10 -10
lexicons/api/lexicons.ts
··· 1441 properties: { 1442 title: { 1443 type: 'string', 1444 - maxLength: 1280, 1445 - maxGraphemes: 128, 1446 }, 1447 postRef: { 1448 type: 'ref', ··· 1450 }, 1451 description: { 1452 type: 'string', 1453 - maxLength: 3000, 1454 - maxGraphemes: 300, 1455 }, 1456 publishedAt: { 1457 type: 'string', ··· 2128 type: 'blob', 2129 }, 2130 description: { 2131 - maxGraphemes: 300, 2132 - maxLength: 3000, 2133 type: 'string', 2134 }, 2135 path: { ··· 2165 type: 'ref', 2166 }, 2167 title: { 2168 - maxGraphemes: 128, 2169 - maxLength: 1280, 2170 type: 'string', 2171 }, 2172 updatedAt: { ··· 2215 type: 'ref', 2216 }, 2217 theme: { 2218 - type: 'ref', 2219 - ref: 'lex:pub.leaflet.publication#theme', 2220 }, 2221 description: { 2222 maxGraphemes: 300,
··· 1441 properties: { 1442 title: { 1443 type: 'string', 1444 + maxLength: 5000, 1445 + maxGraphemes: 500, 1446 }, 1447 postRef: { 1448 type: 'ref', ··· 1450 }, 1451 description: { 1452 type: 'string', 1453 + maxLength: 30000, 1454 + maxGraphemes: 3000, 1455 }, 1456 publishedAt: { 1457 type: 'string', ··· 2128 type: 'blob', 2129 }, 2130 description: { 2131 + maxGraphemes: 3000, 2132 + maxLength: 30000, 2133 type: 'string', 2134 }, 2135 path: { ··· 2165 type: 'ref', 2166 }, 2167 title: { 2168 + maxGraphemes: 500, 2169 + maxLength: 5000, 2170 type: 'string', 2171 }, 2172 updatedAt: { ··· 2215 type: 'ref', 2216 }, 2217 theme: { 2218 + type: 'union', 2219 + refs: ['lex:pub.leaflet.publication#theme'], 2220 }, 2221 description: { 2222 maxGraphemes: 300,
+1 -1
lexicons/api/types/site/standard/publication.ts
··· 15 export interface Record { 16 $type: 'site.standard.publication' 17 basicTheme?: SiteStandardThemeBasic.Main 18 - theme?: PubLeafletPublication.Theme 19 description?: string 20 icon?: BlobRef 21 name: string
··· 15 export interface Record { 16 $type: 'site.standard.publication' 17 basicTheme?: SiteStandardThemeBasic.Main 18 + theme?: $Typed<PubLeafletPublication.Theme> | { $type: string } 19 description?: string 20 icon?: BlobRef 21 name: string
+4 -4
lexicons/pub/leaflet/document.json
··· 18 "properties": { 19 "title": { 20 "type": "string", 21 - "maxLength": 1280, 22 - "maxGraphemes": 128 23 }, 24 "postRef": { 25 "type": "ref", ··· 27 }, 28 "description": { 29 "type": "string", 30 - "maxLength": 3000, 31 - "maxGraphemes": 300 32 }, 33 "publishedAt": { 34 "type": "string",
··· 18 "properties": { 19 "title": { 20 "type": "string", 21 + "maxLength": 5000, 22 + "maxGraphemes": 500 23 }, 24 "postRef": { 25 "type": "ref", ··· 27 }, 28 "description": { 29 "type": "string", 30 + "maxLength": 30000, 31 + "maxGraphemes": 3000 32 }, 33 "publishedAt": { 34 "type": "string",
+4 -4
lexicons/site/standard/document.json
··· 19 "type": "blob" 20 }, 21 "description": { 22 - "maxGraphemes": 300, 23 - "maxLength": 3000, 24 "type": "string" 25 }, 26 "path": { ··· 53 "type": "ref" 54 }, 55 "title": { 56 - "maxGraphemes": 128, 57 - "maxLength": 1280, 58 "type": "string" 59 }, 60 "updatedAt": {
··· 19 "type": "blob" 20 }, 21 "description": { 22 + "maxGraphemes": 3000, 23 + "maxLength": 30000, 24 "type": "string" 25 }, 26 "path": { ··· 53 "type": "ref" 54 }, 55 "title": { 56 + "maxGraphemes": 500, 57 + "maxLength": 5000, 58 "type": "string" 59 }, 60 "updatedAt": {
+2 -2
lexicons/site/standard/publication.json
··· 9 "type": "ref" 10 }, 11 "theme": { 12 - "type": "ref", 13 - "ref": "pub.leaflet.publication#theme" 14 }, 15 "description": { 16 "maxGraphemes": 300,
··· 9 "type": "ref" 10 }, 11 "theme": { 12 + "type": "union", 13 + "refs": ["pub.leaflet.publication#theme"] 14 }, 15 "description": { 16 "maxGraphemes": 300,
+2 -2
lexicons/src/document.ts
··· 16 type: "object", 17 required: ["pages", "author", "title"], 18 properties: { 19 - title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, 20 postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, 21 - description: { type: "string", maxLength: 3000, maxGraphemes: 300 }, 22 publishedAt: { type: "string", format: "datetime" }, 23 publication: { type: "string", format: "at-uri" }, 24 author: { type: "string", format: "at-identifier" },
··· 16 type: "object", 17 required: ["pages", "author", "title"], 18 properties: { 19 + title: { type: "string", maxLength: 5000, maxGraphemes: 500 }, 20 postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, 21 + description: { type: "string", maxLength: 30000, maxGraphemes: 3000 }, 22 publishedAt: { type: "string", format: "datetime" }, 23 publication: { type: "string", format: "at-uri" }, 24 author: { type: "string", format: "at-identifier" },
+40 -5
lexicons/src/normalize.ts
··· 14 */ 15 16 import type * as PubLeafletDocument from "../api/types/pub/leaflet/document"; 17 - import type * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 18 import type * as PubLeafletContent from "../api/types/pub/leaflet/content"; 19 import type * as SiteStandardDocument from "../api/types/site/standard/document"; 20 import type * as SiteStandardPublication from "../api/types/site/standard/publication"; ··· 31 }; 32 33 // Normalized publication type - uses the generated site.standard.publication type 34 - export type NormalizedPublication = SiteStandardPublication.Record; 35 36 /** 37 * Checks if the record is a pub.leaflet.document ··· 210 ): NormalizedPublication | null { 211 if (!record || typeof record !== "object") return null; 212 213 - // Pass through site.standard records directly 214 if (isStandardPublication(record)) { 215 - return record; 216 } 217 218 if (isLeafletPublication(record)) { ··· 225 226 const basicTheme = leafletThemeToBasicTheme(record.theme); 227 228 // Convert preferences to site.standard format (strip/replace $type) 229 const preferences: SiteStandardPublication.Preferences | undefined = 230 record.preferences ··· 243 description: record.description, 244 icon: record.icon, 245 basicTheme, 246 - theme: record.theme, 247 preferences, 248 }; 249 }
··· 14 */ 15 16 import type * as PubLeafletDocument from "../api/types/pub/leaflet/document"; 17 + import * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 18 import type * as PubLeafletContent from "../api/types/pub/leaflet/content"; 19 import type * as SiteStandardDocument from "../api/types/site/standard/document"; 20 import type * as SiteStandardPublication from "../api/types/site/standard/publication"; ··· 31 }; 32 33 // Normalized publication type - uses the generated site.standard.publication type 34 + // with the theme narrowed to only the valid pub.leaflet.publication#theme type 35 + // (isTheme validates that $type is present, so we use $Typed) 36 + // Note: We explicitly list fields rather than using Omit because the generated Record type 37 + // has an index signature [k: string]: unknown that interferes with property typing 38 + export type NormalizedPublication = { 39 + $type: "site.standard.publication"; 40 + name: string; 41 + url: string; 42 + description?: string; 43 + icon?: SiteStandardPublication.Record["icon"]; 44 + basicTheme?: SiteStandardThemeBasic.Main; 45 + theme?: $Typed<PubLeafletPublication.Theme>; 46 + preferences?: SiteStandardPublication.Preferences; 47 + }; 48 49 /** 50 * Checks if the record is a pub.leaflet.document ··· 223 ): NormalizedPublication | null { 224 if (!record || typeof record !== "object") return null; 225 226 + // Pass through site.standard records directly, but validate the theme 227 if (isStandardPublication(record)) { 228 + // Validate theme - only keep if it's a valid pub.leaflet.publication#theme 229 + const theme = PubLeafletPublication.isTheme(record.theme) 230 + ? (record.theme as $Typed<PubLeafletPublication.Theme>) 231 + : undefined; 232 + return { 233 + ...record, 234 + theme, 235 + }; 236 } 237 238 if (isLeafletPublication(record)) { ··· 245 246 const basicTheme = leafletThemeToBasicTheme(record.theme); 247 248 + // Validate theme - only keep if it's a valid pub.leaflet.publication#theme with $type set 249 + // For legacy records without $type, add it during normalization 250 + let theme: $Typed<PubLeafletPublication.Theme> | undefined; 251 + if (record.theme) { 252 + if (PubLeafletPublication.isTheme(record.theme)) { 253 + theme = record.theme as $Typed<PubLeafletPublication.Theme>; 254 + } else { 255 + // Legacy theme without $type - add it 256 + theme = { 257 + ...record.theme, 258 + $type: "pub.leaflet.publication#theme", 259 + }; 260 + } 261 + } 262 + 263 // Convert preferences to site.standard format (strip/replace $type) 264 const preferences: SiteStandardPublication.Preferences | undefined = 265 record.preferences ··· 278 description: record.description, 279 icon: record.icon, 280 basicTheme, 281 + theme, 282 preferences, 283 }; 284 }
+1
package.json
··· 4 "description": "", 5 "main": "index.js", 6 "scripts": { 7 "dev": "TZ=UTC next dev --turbo", 8 "publish-lexicons": "tsx lexicons/publish.ts", 9 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta",
··· 4 "description": "", 5 "main": "index.js", 6 "scripts": { 7 + "lint": "next lint", 8 "dev": "TZ=UTC next dev --turbo", 9 "publish-lexicons": "tsx lexicons/publish.ts", 10 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta",
+1 -1
src/hooks/useLongPress.ts
··· 90 return useMemo( 91 () => ({ 92 isLongPress: isLongPress, 93 - handlers: { 94 onPointerDown, 95 onPointerUp: end, 96 onClickCapture: click,
··· 90 return useMemo( 91 () => ({ 92 isLongPress: isLongPress, 93 + longPressHandlers: { 94 onPointerDown, 95 onPointerUp: end, 96 onClickCapture: click,
+106 -2
src/notifications.ts
··· 21 | { type: "comment"; comment_uri: string; parent_uri?: string } 22 | { type: "subscribe"; subscription_uri: string } 23 | { type: "quote"; bsky_post_uri: string; document_uri: string } 24 | { type: "mention"; document_uri: string; mention_type: "did" } 25 | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 26 | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } ··· 32 | HydratedCommentNotification 33 | HydratedSubscribeNotification 34 | HydratedQuoteNotification 35 | HydratedMentionNotification 36 | HydratedCommentMentionNotification; 37 export async function hydrateNotifications( 38 notifications: NotificationRow[], 39 ): Promise<Array<HydratedNotification>> { 40 // Call all hydrators in parallel 41 - const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 42 hydrateCommentNotifications(notifications), 43 hydrateSubscribeNotifications(notifications), 44 hydrateQuoteNotifications(notifications), 45 hydrateMentionNotifications(notifications), 46 hydrateCommentMentionNotifications(notifications), 47 ]); 48 49 // Combine all hydrated notifications 50 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 51 52 // Sort by created_at to maintain order 53 allHydrated.sort( ··· 198 document_uri: notification.data.document_uri, 199 bskyPost, 200 document, 201 normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 202 normalizedPublication: normalizePublicationRecord( 203 document.documents_in_publications[0]?.publications?.record,
··· 21 | { type: "comment"; comment_uri: string; parent_uri?: string } 22 | { type: "subscribe"; subscription_uri: string } 23 | { type: "quote"; bsky_post_uri: string; document_uri: string } 24 + | { type: "bsky_post_embed"; document_uri: string; bsky_post_uri: string } 25 | { type: "mention"; document_uri: string; mention_type: "did" } 26 | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 27 | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } ··· 33 | HydratedCommentNotification 34 | HydratedSubscribeNotification 35 | HydratedQuoteNotification 36 + | HydratedBskyPostEmbedNotification 37 | HydratedMentionNotification 38 | HydratedCommentMentionNotification; 39 export async function hydrateNotifications( 40 notifications: NotificationRow[], 41 ): Promise<Array<HydratedNotification>> { 42 // Call all hydrators in parallel 43 + const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 44 hydrateCommentNotifications(notifications), 45 hydrateSubscribeNotifications(notifications), 46 hydrateQuoteNotifications(notifications), 47 + hydrateBskyPostEmbedNotifications(notifications), 48 hydrateMentionNotifications(notifications), 49 hydrateCommentMentionNotifications(notifications), 50 ]); 51 52 // Combine all hydrated notifications 53 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications]; 54 55 // Sort by created_at to maintain order 56 allHydrated.sort( ··· 201 document_uri: notification.data.document_uri, 202 bskyPost, 203 document, 204 + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 205 + normalizedPublication: normalizePublicationRecord( 206 + document.documents_in_publications[0]?.publications?.record, 207 + ), 208 + }; 209 + }) 210 + .filter((n) => n !== null); 211 + } 212 + 213 + export type HydratedBskyPostEmbedNotification = Awaited< 214 + ReturnType<typeof hydrateBskyPostEmbedNotifications> 215 + >[0]; 216 + 217 + async function hydrateBskyPostEmbedNotifications(notifications: NotificationRow[]) { 218 + const bskyPostEmbedNotifications = notifications.filter( 219 + (n): n is NotificationRow & { data: ExtractNotificationType<"bsky_post_embed"> } => 220 + (n.data as NotificationData)?.type === "bsky_post_embed", 221 + ); 222 + 223 + if (bskyPostEmbedNotifications.length === 0) { 224 + return []; 225 + } 226 + 227 + // Fetch document data (the leaflet that embedded the post) 228 + const documentUris = bskyPostEmbedNotifications.map((n) => n.data.document_uri); 229 + const bskyPostUris = bskyPostEmbedNotifications.map((n) => n.data.bsky_post_uri); 230 + 231 + const [{ data: documents }, { data: cachedBskyPosts }] = await Promise.all([ 232 + supabaseServerClient 233 + .from("documents") 234 + .select("*, documents_in_publications(publications(*))") 235 + .in("uri", documentUris), 236 + supabaseServerClient 237 + .from("bsky_posts") 238 + .select("*") 239 + .in("uri", bskyPostUris), 240 + ]); 241 + 242 + // Find which posts we need to fetch from the API 243 + const cachedPostUris = new Set(cachedBskyPosts?.map((p) => p.uri) ?? []); 244 + const missingPostUris = bskyPostUris.filter((uri) => !cachedPostUris.has(uri)); 245 + 246 + // Fetch missing posts from Bluesky API 247 + const fetchedPosts = new Map<string, { text: string } | null>(); 248 + if (missingPostUris.length > 0) { 249 + try { 250 + const { AtpAgent } = await import("@atproto/api"); 251 + const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 252 + const response = await agent.app.bsky.feed.getPosts({ uris: missingPostUris }); 253 + for (const post of response.data.posts) { 254 + const record = post.record as { text?: string }; 255 + fetchedPosts.set(post.uri, { text: record.text ?? "" }); 256 + } 257 + } catch (error) { 258 + console.error("Failed to fetch Bluesky posts:", error); 259 + } 260 + } 261 + 262 + // Extract unique DIDs from document URIs to resolve handles 263 + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; 264 + 265 + // Resolve DIDs to handles in parallel 266 + const didToHandleMap = new Map<string, string | null>(); 267 + await Promise.all( 268 + documentCreatorDids.map(async (did) => { 269 + try { 270 + const resolved = await idResolver.did.resolve(did); 271 + const handle = resolved?.alsoKnownAs?.[0] 272 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 273 + : null; 274 + didToHandleMap.set(did, handle); 275 + } catch (error) { 276 + console.error(`Failed to resolve DID ${did}:`, error); 277 + didToHandleMap.set(did, null); 278 + } 279 + }), 280 + ); 281 + 282 + return bskyPostEmbedNotifications 283 + .map((notification) => { 284 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 285 + if (!document) return null; 286 + 287 + const documentCreatorDid = new AtUri(notification.data.document_uri).host; 288 + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 289 + 290 + // Get post text from cache or fetched data 291 + const cachedPost = cachedBskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri); 292 + const postView = cachedPost?.post_view as { record?: { text?: string } } | undefined; 293 + const bskyPostText = postView?.record?.text ?? fetchedPosts.get(notification.data.bsky_post_uri)?.text ?? null; 294 + 295 + return { 296 + id: notification.id, 297 + recipient: notification.recipient, 298 + created_at: notification.created_at, 299 + type: "bsky_post_embed" as const, 300 + document_uri: notification.data.document_uri, 301 + bsky_post_uri: notification.data.bsky_post_uri, 302 + document, 303 + documentCreatorHandle, 304 + bskyPostText, 305 normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 306 normalizedPublication: normalizePublicationRecord( 307 document.documents_in_publications[0]?.publications?.record,
+7
src/replicache/clientMutationContext.ts
··· 67 textData.value = base64.fromByteArray(updateBytes); 68 } 69 } 70 } 71 if (!ignoreUndo) 72 undoManager.add({
··· 67 textData.value = base64.fromByteArray(updateBytes); 68 } 69 } 70 + } else if (f.id) { 71 + // For cardinality "many" with an explicit ID, fetch the existing fact 72 + // so undo can restore it instead of deleting 73 + let fact = await tx.get(f.id); 74 + if (fact) { 75 + existingFact = [fact as Fact<any>]; 76 + } 77 } 78 if (!ignoreUndo) 79 undoManager.add({
+31 -10
src/replicache/mutations.ts
··· 4 import { SupabaseClient } from "@supabase/supabase-js"; 5 import { Database } from "supabase/database.types"; 6 import { generateKeyBetween } from "fractional-indexing"; 7 8 export type MutationContext = { 9 permission_token_id: string; ··· 307 { blockEntity: string } | { blockEntity: string }[] 308 > = async (args, ctx) => { 309 for (let block of [args].flat()) { 310 - let [isLocked] = await ctx.scanIndex.eav( 311 - block.blockEntity, 312 - "block/is-locked", 313 - ); 314 - if (isLocked?.data.value) continue; 315 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image"); 316 await ctx.runOnServer(async ({ supabase }) => { 317 if (image) { ··· 427 }, 428 }); 429 }; 430 - const moveBlockDown: Mutation<{ entityID: string; parent: string }> = async ( 431 - args, 432 - ctx, 433 - ) => { 434 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( 435 (a, b) => (a.data.position > b.data.position ? 1 : -1), 436 ); 437 let index = children.findIndex((f) => f.data.value === args.entityID); 438 if (index === -1) return; 439 let next = children[index + 1]; 440 - if (!next) return; 441 await ctx.retractFact(children[index].id); 442 await ctx.assertFact({ 443 id: children[index].id,
··· 4 import { SupabaseClient } from "@supabase/supabase-js"; 5 import { Database } from "supabase/database.types"; 6 import { generateKeyBetween } from "fractional-indexing"; 7 + import { v7 } from "uuid"; 8 9 export type MutationContext = { 10 permission_token_id: string; ··· 308 { blockEntity: string } | { blockEntity: string }[] 309 > = async (args, ctx) => { 310 for (let block of [args].flat()) { 311 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image"); 312 await ctx.runOnServer(async ({ supabase }) => { 313 if (image) { ··· 423 }, 424 }); 425 }; 426 + const moveBlockDown: Mutation<{ 427 + entityID: string; 428 + parent: string; 429 + permission_set?: string; 430 + }> = async (args, ctx) => { 431 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( 432 (a, b) => (a.data.position > b.data.position ? 1 : -1), 433 ); 434 let index = children.findIndex((f) => f.data.value === args.entityID); 435 if (index === -1) return; 436 let next = children[index + 1]; 437 + if (!next) { 438 + // If this is the last block, create a new empty block above it using the addBlock helper 439 + if (!args.permission_set) return; // Can't create block without permission_set 440 + 441 + let newEntityID = v7(); 442 + let previousBlock = children[index - 1]; 443 + let position = generateKeyBetween( 444 + previousBlock?.data.position || null, 445 + children[index].data.position, 446 + ); 447 + 448 + // Call the addBlock mutation helper directly 449 + await addBlock( 450 + { 451 + parent: args.parent, 452 + permission_set: args.permission_set, 453 + factID: v7(), 454 + type: "text", 455 + newEntityID: newEntityID, 456 + position: position, 457 + }, 458 + ctx, 459 + ); 460 + return; 461 + } 462 await ctx.retractFact(children[index].id); 463 await ctx.assertFact({ 464 id: children[index].id,
+122
src/utils/deduplicateRecords.ts
···
··· 1 + /** 2 + * Utilities for deduplicating records that may exist under both 3 + * pub.leaflet.* and site.standard.* namespaces. 4 + * 5 + * After the migration to site.standard.*, records can exist in both namespaces 6 + * with the same DID and rkey. This utility deduplicates them, preferring 7 + * site.standard.* records when available. 8 + */ 9 + 10 + import { AtUri } from "@atproto/syntax"; 11 + 12 + /** 13 + * Extracts the identity key (DID + rkey) from an AT URI. 14 + * This key uniquely identifies a record across namespaces. 15 + * 16 + * @example 17 + * getRecordIdentityKey("at://did:plc:abc/pub.leaflet.document/3abc") 18 + * // Returns: "did:plc:abc/3abc" 19 + * 20 + * getRecordIdentityKey("at://did:plc:abc/site.standard.document/3abc") 21 + * // Returns: "did:plc:abc/3abc" (same key, different namespace) 22 + */ 23 + function getRecordIdentityKey(uri: string): string | null { 24 + try { 25 + const parsed = new AtUri(uri); 26 + return `${parsed.host}/${parsed.rkey}`; 27 + } catch { 28 + return null; 29 + } 30 + } 31 + 32 + /** 33 + * Checks if a URI is from the site.standard namespace. 34 + */ 35 + function isSiteStandardUri(uri: string): boolean { 36 + return uri.includes("/site.standard."); 37 + } 38 + 39 + /** 40 + * Deduplicates an array of records that have a `uri` property. 41 + * 42 + * When records exist under both pub.leaflet.* and site.standard.* namespaces 43 + * (same DID and rkey), this function keeps only the site.standard version. 44 + * 45 + * @param records - Array of records with a `uri` property 46 + * @returns Deduplicated array, preferring site.standard records 47 + * 48 + * @example 49 + * const docs = [ 50 + * { uri: "at://did:plc:abc/pub.leaflet.document/3abc", data: {...} }, 51 + * { uri: "at://did:plc:abc/site.standard.document/3abc", data: {...} }, 52 + * { uri: "at://did:plc:abc/pub.leaflet.document/3def", data: {...} }, 53 + * ]; 54 + * const deduped = deduplicateByUri(docs); 55 + * // Returns: [ 56 + * // { uri: "at://did:plc:abc/site.standard.document/3abc", data: {...} }, 57 + * // { uri: "at://did:plc:abc/pub.leaflet.document/3def", data: {...} }, 58 + * // ] 59 + */ 60 + export function deduplicateByUri<T extends { uri: string }>(records: T[]): T[] { 61 + const recordsByKey = new Map<string, T>(); 62 + 63 + for (const record of records) { 64 + const key = getRecordIdentityKey(record.uri); 65 + if (!key) { 66 + // Invalid URI, keep the record as-is 67 + continue; 68 + } 69 + 70 + const existing = recordsByKey.get(key); 71 + if (!existing) { 72 + recordsByKey.set(key, record); 73 + } else { 74 + // Prefer site.standard records over pub.leaflet records 75 + if (isSiteStandardUri(record.uri) && !isSiteStandardUri(existing.uri)) { 76 + recordsByKey.set(key, record); 77 + } 78 + // If both are same namespace or existing is already site.standard, keep existing 79 + } 80 + } 81 + 82 + return Array.from(recordsByKey.values()); 83 + } 84 + 85 + /** 86 + * Deduplicates records while preserving the original order based on the first 87 + * occurrence of each unique record. 88 + * 89 + * Same deduplication logic as deduplicateByUri, but maintains insertion order. 90 + * 91 + * @param records - Array of records with a `uri` property 92 + * @returns Deduplicated array in original order, preferring site.standard records 93 + */ 94 + export function deduplicateByUriOrdered<T extends { uri: string }>( 95 + records: T[] 96 + ): T[] { 97 + const recordsByKey = new Map<string, { record: T; index: number }>(); 98 + 99 + for (let i = 0; i < records.length; i++) { 100 + const record = records[i]; 101 + const key = getRecordIdentityKey(record.uri); 102 + if (!key) { 103 + continue; 104 + } 105 + 106 + const existing = recordsByKey.get(key); 107 + if (!existing) { 108 + recordsByKey.set(key, { record, index: i }); 109 + } else { 110 + // Prefer site.standard records over pub.leaflet records 111 + if (isSiteStandardUri(record.uri) && !isSiteStandardUri(existing.record.uri)) { 112 + // Replace with site.standard but keep original position 113 + recordsByKey.set(key, { record, index: existing.index }); 114 + } 115 + } 116 + } 117 + 118 + // Sort by original index to maintain order 119 + return Array.from(recordsByKey.values()) 120 + .sort((a, b) => a.index - b.index) 121 + .map((entry) => entry.record); 122 + }
+10 -2
src/utils/deleteBlock.ts
··· 4 import { scanIndex } from "src/replicache/utils"; 5 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 import { focusBlock } from "src/utils/focusBlock"; 7 8 export async function deleteBlock( 9 entities: string[], 10 rep: Replicache<ReplicacheMutators>, 11 ) { 12 // get what pagess we need to close as a result of deleting this block 13 let pagesToClose = [] as string[]; ··· 32 } 33 } 34 35 - // the next and previous blocks in the block list 36 - // if the focused thing is a page and not a block, return 37 let focusedBlock = useUIState.getState().focusedEntity; 38 let parent = 39 focusedBlock?.entityType === "page" ··· 44 let parentType = await rep?.query((tx) => 45 scanIndex(tx).eav(parent, "page/type"), 46 ); 47 if (parentType[0]?.data.value === "canvas") { 48 useUIState 49 .getState() 50 .setFocusedBlock({ entityType: "page", entityID: parent }); 51 useUIState.getState().setSelectedBlocks([]); 52 } else { 53 let siblings = 54 (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 55 ··· 105 } 106 } 107 108 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 109 await Promise.all( 110 entities.map((entity) => 111 rep?.mutate.removeBlock({ ··· 113 }), 114 ), 115 ); 116 }
··· 4 import { scanIndex } from "src/replicache/utils"; 5 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 import { focusBlock } from "src/utils/focusBlock"; 7 + import { UndoManager } from "src/undoManager"; 8 9 export async function deleteBlock( 10 entities: string[], 11 rep: Replicache<ReplicacheMutators>, 12 + undoManager?: UndoManager, 13 ) { 14 // get what pagess we need to close as a result of deleting this block 15 let pagesToClose = [] as string[]; ··· 34 } 35 } 36 37 + // figure out what to focus 38 let focusedBlock = useUIState.getState().focusedEntity; 39 let parent = 40 focusedBlock?.entityType === "page" ··· 45 let parentType = await rep?.query((tx) => 46 scanIndex(tx).eav(parent, "page/type"), 47 ); 48 + // if the page is a canvas, focus the page 49 if (parentType[0]?.data.value === "canvas") { 50 useUIState 51 .getState() 52 .setFocusedBlock({ entityType: "page", entityID: parent }); 53 useUIState.getState().setSelectedBlocks([]); 54 } else { 55 + // if the page is a doc, focus the previous block (or if there isn't a prev block, focus the next block) 56 let siblings = 57 (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 58 ··· 108 } 109 } 110 111 + // close the pages 112 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 113 + undoManager && undoManager.startGroup(); 114 + 115 + // delete the blocks 116 await Promise.all( 117 entities.map((entity) => 118 rep?.mutate.removeBlock({ ··· 120 }), 121 ), 122 ); 123 + undoManager && undoManager.endGroup(); 124 }
+3 -1
src/utils/focusBlock.ts
··· 48 } 49 50 if (pos?.offset !== undefined) { 51 - el?.focus(); 52 requestAnimationFrame(() => { 53 el?.setSelectionRange(pos.offset, pos.offset); 54 });
··· 48 } 49 50 if (pos?.offset !== undefined) { 51 + // trying to focus the block in a subpage causes the page to flash and scroll back to the parent page. 52 + // idk how to fix so i'm giving up -- celine 53 + // el?.focus(); 54 requestAnimationFrame(() => { 55 el?.setSelectionRange(pos.offset, pos.offset); 56 });
+81
src/utils/moveBlock.ts
···
··· 1 + import { Replicache } from "replicache"; 2 + import { ReplicacheMutators } from "src/replicache"; 3 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 4 + import { useUIState } from "src/useUIState"; 5 + 6 + export const moveBlockUp = async (rep: Replicache<ReplicacheMutators>) => { 7 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 8 + if (sortedBlocks.length > 1) return; 9 + let block = sortedBlocks[0]; 10 + let previousBlock = 11 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 12 + if (previousBlock.value === block.listData?.parent) { 13 + previousBlock = 14 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 2]; 15 + } 16 + 17 + if ( 18 + previousBlock?.listData && 19 + block.listData && 20 + block.listData.depth > 1 && 21 + !previousBlock.listData.path.find( 22 + (f) => f.entity === block.listData?.parent, 23 + ) 24 + ) { 25 + let depth = block.listData.depth; 26 + let newParent = previousBlock.listData.path.find( 27 + (f) => f.depth === depth - 1, 28 + ); 29 + if (!newParent) return; 30 + if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 31 + useUIState.getState().toggleFold(newParent.entity); 32 + rep?.mutate.moveBlock({ 33 + block: block.value, 34 + oldParent: block.listData?.parent, 35 + newParent: newParent.entity, 36 + position: { type: "end" }, 37 + }); 38 + } else { 39 + rep?.mutate.moveBlockUp({ 40 + entityID: block.value, 41 + parent: block.listData?.parent || block.parent, 42 + }); 43 + } 44 + }; 45 + 46 + export const moveBlockDown = async ( 47 + rep: Replicache<ReplicacheMutators>, 48 + permission_set: string, 49 + ) => { 50 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 51 + if (sortedBlocks.length > 1) return; 52 + let block = sortedBlocks[0]; 53 + let nextBlock = siblings 54 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 55 + .find( 56 + (f) => 57 + f.listData && 58 + block.listData && 59 + !f.listData.path.find((f) => f.entity === block.value), 60 + ); 61 + if ( 62 + nextBlock?.listData && 63 + block.listData && 64 + nextBlock.listData.depth === block.listData.depth - 1 65 + ) { 66 + if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 67 + useUIState.getState().toggleFold(nextBlock.value); 68 + rep?.mutate.moveBlock({ 69 + block: block.value, 70 + oldParent: block.listData?.parent, 71 + newParent: nextBlock.value, 72 + position: { type: "first" }, 73 + }); 74 + } else { 75 + rep?.mutate.moveBlockDown({ 76 + entityID: block.value, 77 + parent: block.listData?.parent || block.parent, 78 + permission_set: permission_set, 79 + }); 80 + } 81 + };
+19 -5
src/utils/uriHelpers.ts
··· 18 * or site.standard.publication namespaces. 19 */ 20 export function publicationUriFilter(did: string, rkey: string): string { 21 - const standard = AtUri.make(did, ids.SiteStandardPublication, rkey).toString(); 22 const legacy = AtUri.make(did, ids.PubLeafletPublication, rkey).toString(); 23 return `uri.eq.${standard},uri.eq.${legacy}`; 24 } ··· 27 * Returns an OR filter string for Supabase queries to match a publication by name 28 * or by either namespace URI. Used when the rkey might be the publication name. 29 */ 30 - export function publicationNameOrUriFilter(did: string, nameOrRkey: string): string { 31 - const standard = AtUri.make(did, ids.SiteStandardPublication, nameOrRkey).toString(); 32 - const legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString(); 33 - return `name.eq.${nameOrRkey},uri.eq.${standard},uri.eq.${legacy}`; 34 }
··· 18 * or site.standard.publication namespaces. 19 */ 20 export function publicationUriFilter(did: string, rkey: string): string { 21 + const standard = AtUri.make( 22 + did, 23 + ids.SiteStandardPublication, 24 + rkey, 25 + ).toString(); 26 const legacy = AtUri.make(did, ids.PubLeafletPublication, rkey).toString(); 27 return `uri.eq.${standard},uri.eq.${legacy}`; 28 } ··· 31 * Returns an OR filter string for Supabase queries to match a publication by name 32 * or by either namespace URI. Used when the rkey might be the publication name. 33 */ 34 + export function publicationNameOrUriFilter( 35 + did: string, 36 + nameOrRkey: string, 37 + ): string { 38 + let standard, legacy; 39 + if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(nameOrRkey)) { 40 + standard = AtUri.make( 41 + did, 42 + ids.SiteStandardPublication, 43 + nameOrRkey, 44 + ).toString(); 45 + legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString(); 46 + } 47 + return `name.eq."${nameOrRkey}"",uri.eq."${standard}",uri.eq."${legacy}"`; 48 }
+3
supabase/database.types.ts
··· 551 home_page: string 552 id: string 553 interface_state: Json | null 554 } 555 Insert: { 556 atp_did?: string | null ··· 559 home_page?: string 560 id?: string 561 interface_state?: Json | null 562 } 563 Update: { 564 atp_did?: string | null ··· 567 home_page?: string 568 id?: string 569 interface_state?: Json | null 570 } 571 Relationships: [ 572 {
··· 551 home_page: string 552 id: string 553 interface_state: Json | null 554 + metadata: Json | null 555 } 556 Insert: { 557 atp_did?: string | null ··· 560 home_page?: string 561 id?: string 562 interface_state?: Json | null 563 + metadata?: Json | null 564 } 565 Update: { 566 atp_did?: string | null ··· 569 home_page?: string 570 id?: string 571 interface_state?: Json | null 572 + metadata?: Json | null 573 } 574 Relationships: [ 575 {
+1
supabase/migrations/20260123000000_add_metadata_to_identities.sql
···
··· 1 + alter table "public"."identities" add column "metadata" jsonb;