a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

+2353 -3405
-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 2 - it looks good on both mobile and desktop 3 3 - it undo's like it ought to 4 4 - it handles keyboard interactions reasonably well 5 + - it behaves as you would expect if you lock it 5 6 - no build errors!!!
+55 -116
actions/publishToPublication.ts
··· 65 65 } from "src/utils/collectionHelpers"; 66 66 67 67 type PublishResult = 68 - | { success: true; rkey: string; record: SiteStandardDocument.Record } 68 + | { success: true; rkey: string; record: PubLeafletDocument.Record } 69 69 | { success: false; error: OAuthSessionError }; 70 70 71 71 export async function publishToPublication({ ··· 199 199 } 200 200 201 201 // Determine the collection to use - preserve existing schema if updating 202 - const existingCollection = existingDocUri 203 - ? new AtUri(existingDocUri).collection 204 - : undefined; 202 + const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined; 205 203 const documentType = getDocumentType(existingCollection); 206 204 207 205 // Build the pages array (used by both formats) ··· 230 228 if (documentType === "site.standard.document") { 231 229 // site.standard.document format 232 230 // 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}`; 231 + const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 235 232 236 233 record = { 237 234 $type: "site.standard.document", 238 235 title: title || "Untitled", 239 236 site: siteUri, 240 - path: "/" + rkey, 237 + path: rkey, 241 238 publishedAt: 242 239 publishedAt || existingRecord.publishedAt || new Date().toISOString(), 243 240 ...(description && { description }), ··· 906 903 const mentionedDids = new Set<string>(); 907 904 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 908 905 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 906 911 907 // Extract pages from either format 912 908 let pages: PubLeafletContent.Main["pages"] | undefined; ··· 921 917 922 918 if (!pages) return; 923 919 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); 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); 975 940 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(); 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(); 983 948 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(); 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(); 997 962 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); 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 + } 1007 974 } 1008 975 } 1009 976 } ··· 1059 1026 }; 1060 1027 await supabaseServerClient.from("notifications").insert(notification); 1061 1028 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 1029 } 1091 1030 }
+1 -1
app/(home-pages)/discover/PubListing.tsx
··· 62 62 <p> 63 63 Updated{" "} 64 64 {timeAgo( 65 - props.documents_in_publications?.[0]?.documents?.sort_date || 65 + props.documents_in_publications?.[0]?.documents?.indexed_at || 66 66 "", 67 67 )} 68 68 </p>
+8 -8
app/(home-pages)/discover/getPublications.ts
··· 8 8 import { deduplicateByUri } from "src/utils/deduplicateRecords"; 9 9 10 10 export type Cursor = { 11 - sort_date?: string; 11 + indexed_at?: string; 12 12 count?: number; 13 13 uri: string; 14 14 }; ··· 32 32 .or( 33 33 "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 34 34 ) 35 - .order("documents(sort_date)", { 35 + .order("indexed_at", { 36 36 referencedTable: "documents_in_publications", 37 37 ascending: false, 38 38 }) ··· 64 64 } else { 65 65 // recentlyUpdated 66 66 const aDate = new Date( 67 - a.documents_in_publications[0]?.documents?.sort_date || 0, 67 + a.documents_in_publications[0]?.indexed_at || 0, 68 68 ).getTime(); 69 69 const bDate = new Date( 70 - b.documents_in_publications[0]?.documents?.sort_date || 0, 70 + b.documents_in_publications[0]?.indexed_at || 0, 71 71 ).getTime(); 72 72 if (bDate !== aDate) { 73 73 return bDate - aDate; ··· 89 89 (pubCount === cursor.count && pub.uri < cursor.uri) 90 90 ); 91 91 } else { 92 - const pubDate = pub.documents_in_publications[0]?.documents?.sort_date || ""; 92 + const pubDate = pub.documents_in_publications[0]?.indexed_at || ""; 93 93 // Find first pub after cursor 94 94 return ( 95 - pubDate < (cursor.sort_date || "") || 96 - (pubDate === cursor.sort_date && pub.uri < cursor.uri) 95 + pubDate < (cursor.indexed_at || "") || 96 + (pubDate === cursor.indexed_at && pub.uri < cursor.uri) 97 97 ); 98 98 } 99 99 }); ··· 117 117 normalizedPage.length > 0 && startIndex + limit < allPubs.length 118 118 ? order === "recentlyUpdated" 119 119 ? { 120 - sort_date: lastItem.documents_in_publications[0]?.documents?.sort_date, 120 + indexed_at: lastItem.documents_in_publications[0]?.indexed_at, 121 121 uri: lastItem.uri, 122 122 } 123 123 : {
-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 - };
+1 -1
app/(home-pages)/notifications/FollowNotification.tsx
··· 23 23 <Notification 24 24 timestamp={props.created_at} 25 25 href={pubRecord ? pubRecord.url : "#"} 26 - icon={<Avatar src={avatarSrc} displayName={displayName} size="tiny" />} 26 + icon={<Avatar src={avatarSrc} displayName={displayName} tiny />} 27 27 actionText={ 28 28 <> 29 29 {displayName} subscribed to {pubRecord?.name}!
-4
app/(home-pages)/notifications/NotificationList.tsx
··· 8 8 import { useIdentityData } from "components/IdentityProvider"; 9 9 import { FollowNotification } from "./FollowNotification"; 10 10 import { QuoteNotification } from "./QuoteNotification"; 11 - import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification"; 12 11 import { MentionNotification } from "./MentionNotification"; 13 12 import { CommentMentionNotification } from "./CommentMentionNotification"; 14 13 ··· 48 47 } 49 48 if (n.type === "quote") { 50 49 return <QuoteNotification key={n.id} {...n} />; 51 - } 52 - if (n.type === "bsky_post_embed") { 53 - return <BskyPostEmbedNotification key={n.id} {...n} />; 54 50 } 55 51 if (n.type === "mention") { 56 52 return <MentionNotification key={n.id} {...n} />;
+3 -6
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 16 16 popover?: boolean; 17 17 }) => { 18 18 let profileRecord = props.profile; 19 - const profileUrl = `https://leaflet.pub/p/${props.profile.handle}`; 19 + const profileUrl = `/p/${props.profile.handle}`; 20 20 21 21 const avatarElement = ( 22 22 <Avatar 23 23 src={profileRecord.avatar} 24 24 displayName={profileRecord.displayName} 25 25 className="profileAvatar mx-auto mt-3 sm:mt-4" 26 - size="giant" 26 + giant 27 27 /> 28 28 ); 29 29 ··· 100 100 </div> 101 101 ); 102 102 }; 103 - const PublicationCard = (props: { 104 - record: NormalizedPublication; 105 - uri: string; 106 - }) => { 103 + const PublicationCard = (props: { record: NormalizedPublication; uri: string }) => { 107 104 const { record, uri } = props; 108 105 const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); 109 106
+5 -5
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 10 10 import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 11 11 12 12 export type Cursor = { 13 - sort_date: string; 13 + indexed_at: string; 14 14 uri: string; 15 15 }; 16 16 ··· 29 29 documents_in_publications(publications(*))`, 30 30 ) 31 31 .like("uri", `at://${did}/%`) 32 - .order("sort_date", { ascending: false }) 32 + .order("indexed_at", { ascending: false }) 33 33 .order("uri", { ascending: false }) 34 34 .limit(limit); 35 35 36 36 if (cursor) { 37 37 query = query.or( 38 - `sort_date.lt.${cursor.sort_date},and(sort_date.eq.${cursor.sort_date},uri.lt.${cursor.uri})`, 38 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 39 39 ); 40 40 } 41 41 ··· 79 79 documents: { 80 80 data: normalizedData, 81 81 uri: doc.uri, 82 - sort_date: doc.sort_date, 82 + indexed_at: doc.indexed_at, 83 83 comments_on_documents: doc.comments_on_documents, 84 84 document_mentions_in_bsky: doc.document_mentions_in_bsky, 85 85 }, ··· 99 99 const nextCursor = 100 100 posts.length === limit 101 101 ? { 102 - sort_date: posts[posts.length - 1].documents.sort_date, 102 + indexed_at: posts[posts.length - 1].documents.indexed_at, 103 103 uri: posts[posts.length - 1].documents.uri, 104 104 } 105 105 : null;
+5 -5
app/(home-pages)/reader/getReaderFeed.ts
··· 38 38 "documents_in_publications.publications.publication_subscriptions.identity", 39 39 auth_res.atp_did, 40 40 ) 41 - .order("sort_date", { ascending: false }) 41 + .order("indexed_at", { ascending: false }) 42 42 .order("uri", { ascending: false }) 43 43 .limit(25); 44 44 if (cursor) { 45 45 query = query.or( 46 - `sort_date.lt.${cursor.timestamp},and(sort_date.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 46 + `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 47 47 ); 48 48 } 49 49 let { data: rawFeed, error } = await query; ··· 78 78 document_mentions_in_bsky: post.document_mentions_in_bsky, 79 79 data: normalizedData, 80 80 uri: post.uri, 81 - sort_date: post.sort_date, 81 + indexed_at: post.indexed_at, 82 82 }, 83 83 }; 84 84 return p; ··· 88 88 const nextCursor = 89 89 posts.length > 0 90 90 ? { 91 - timestamp: posts[posts.length - 1].documents.sort_date, 91 + timestamp: posts[posts.length - 1].documents.indexed_at, 92 92 uri: posts[posts.length - 1].documents.uri, 93 93 } 94 94 : null; ··· 109 109 documents: { 110 110 data: NormalizedDocument | null; 111 111 uri: string; 112 - sort_date: string; 112 + indexed_at: string; 113 113 comments_on_documents: { count: number }[] | undefined; 114 114 document_mentions_in_bsky: { count: number }[] | undefined; 115 115 };
+2 -2
app/(home-pages)/reader/getSubscriptions.ts
··· 32 32 .select(`*, publications(*, documents_in_publications(*, documents(*)))`) 33 33 .order(`created_at`, { ascending: false }) 34 34 .order(`uri`, { ascending: false }) 35 - .order("documents(sort_date)", { 35 + .order("indexed_at", { 36 36 ascending: false, 37 37 referencedTable: "publications.documents_in_publications", 38 38 }) ··· 85 85 record: NormalizedPublication; 86 86 uri: string; 87 87 documents_in_publications: { 88 - documents: { data?: Json; sort_date: string } | null; 88 + documents: { data?: Json; indexed_at: string } | null; 89 89 }[]; 90 90 };
+2 -2
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 24 24 documents_in_publications(publications(*))`, 25 25 ) 26 26 .contains("data->tags", `["${tag}"]`) 27 - .order("sort_date", { ascending: false }) 27 + .order("indexed_at", { ascending: false }) 28 28 .limit(50); 29 29 30 30 if (error) { ··· 69 69 document_mentions_in_bsky: doc.document_mentions_in_bsky, 70 70 data: normalizedData, 71 71 uri: doc.uri, 72 - sort_date: doc.sort_date, 72 + indexed_at: doc.indexed_at, 73 73 }, 74 74 }; 75 75 return post;
+1 -17
app/[leaflet_id]/Footer.tsx
··· 8 8 import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 9 import { PublishButton } from "./actions/PublishButton"; 10 10 import { useEntitySetContext } from "components/EntitySetProvider"; 11 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 11 12 import { Watermark } from "components/Watermark"; 12 13 import { BackToPubButton } from "./actions/BackToPubButton"; 13 14 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 14 15 import { useIdentityData } from "components/IdentityProvider"; 15 - import { useEntity } from "src/replicache"; 16 - import { block } from "sharp"; 17 16 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 17 export function LeafletFooter(props: { entityID: string }) { 29 18 let focusedBlock = useUIState((s) => s.focusedEntity); 30 - 31 19 let entity_set = useEntitySetContext(); 32 20 let { identity } = useIdentityData(); 33 21 let { data: pub } = useLeafletPublicationData(); 34 - let blockType = useEntity(focusedBlock?.entityID || null, "block/type")?.data 35 - .value; 36 22 37 23 return ( 38 24 <Media mobile className="mobileFooter w-full z-10 touch-none -mt-[54px] "> 39 25 {focusedBlock && 40 26 focusedBlock.entityType == "block" && 41 - hasBlockToolbar(blockType) && 42 27 entity_set.permissions.write ? ( 43 28 <div 44 29 className="w-full z-10 p-2 flex bg-bg-page pwa-padding-bottom" ··· 49 34 <Toolbar 50 35 pageID={focusedBlock.parent} 51 36 blockID={focusedBlock.entityID} 52 - blockType={blockType} 53 37 /> 54 38 </div> 55 39 ) : entity_set.permissions.write ? (
+7 -1
app/[leaflet_id]/Leaflet.tsx
··· 18 18 token: PermissionToken; 19 19 initialFacts: Fact<Attribute>[]; 20 20 leaflet_id: string; 21 + initialHeadingFontId?: string; 22 + initialBodyFontId?: string; 21 23 }) { 22 24 return ( 23 25 <ReplicacheProvider ··· 29 31 <EntitySetProvider 30 32 set={props.token.permission_token_rights[0].entity_set} 31 33 > 32 - <ThemeProvider entityID={props.leaflet_id}> 34 + <ThemeProvider 35 + entityID={props.leaflet_id} 36 + initialHeadingFontId={props.initialHeadingFontId} 37 + initialBodyFontId={props.initialBodyFontId} 38 + > 33 39 <ThemeBackgroundProvider entityID={props.leaflet_id}> 34 40 <UpdateLeafletTitle entityID={props.leaflet_id} /> 35 41 <AddLeafletToHomepage />
+23 -12
app/[leaflet_id]/page.tsx
··· 14 14 import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 15 15 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 16 16 import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 17 + import { FontLoader, extractFontsFromFacts } from "components/FontLoader"; 17 18 18 19 export const preferredRegion = ["sfo1"]; 19 20 export const dynamic = "force-dynamic"; ··· 48 49 getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 49 50 ]); 50 51 let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 52 + 53 + // Extract font settings from facts for server-side font loading 54 + const { headingFontId, bodyFontId } = extractFontsFromFacts(initialFacts as any, rootEntity); 55 + 51 56 return ( 52 - <PageSWRDataProvider 53 - rsvp_data={rsvp_data} 54 - poll_data={poll_data} 55 - leaflet_id={res.data.id} 56 - leaflet_data={res} 57 - > 58 - <Leaflet 59 - initialFacts={initialFacts} 60 - leaflet_id={rootEntity} 61 - token={res.data} 62 - /> 63 - </PageSWRDataProvider> 57 + <> 58 + {/* Server-side font loading with preload and @font-face */} 59 + <FontLoader headingFontId={headingFontId} bodyFontId={bodyFontId} /> 60 + <PageSWRDataProvider 61 + rsvp_data={rsvp_data} 62 + poll_data={poll_data} 63 + leaflet_id={res.data.id} 64 + leaflet_data={res} 65 + > 66 + <Leaflet 67 + initialFacts={initialFacts} 68 + leaflet_id={rootEntity} 69 + token={res.data} 70 + initialHeadingFontId={headingFontId} 71 + initialBodyFontId={bodyFontId} 72 + /> 73 + </PageSWRDataProvider> 74 + </> 64 75 ); 65 76 } 66 77
+7 -4
app/[leaflet_id]/publish/publishBskyPost.ts
··· 8 8 import sharp from "sharp"; 9 9 import { TID } from "@atproto/common"; 10 10 import { getIdentityData } from "actions/getIdentityData"; 11 - import { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; 12 - import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 11 + import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; 12 + import { 13 + restoreOAuthSession, 14 + OAuthSessionError, 15 + } from "src/atproto-oauth"; 13 16 import { supabaseServerClient } from "supabase/serverClient"; 14 17 import { Json } from "supabase/database.types"; 15 18 import { ··· 27 30 url: string; 28 31 title: string; 29 32 description: string; 30 - document_record: SiteStandardDocument.Record; 33 + document_record: PubLeafletDocument.Record; 31 34 rkey: string; 32 35 facets: AppBskyRichtextFacet.Main[]; 33 36 }): Promise<PublishBskyResult> { ··· 112 115 }, 113 116 ); 114 117 let record = args.document_record; 115 - record.bskyPostRef = post; 118 + record.postRef = post; 116 119 117 120 let { data: result } = await agent.com.atproto.repo.putRecord({ 118 121 rkey: args.rkey,
-25
app/api/inngest/client.ts
··· 26 26 did: string; 27 27 }; 28 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 - "documents/fix-incorrect-site-values": { 45 - data: { 46 - did: string; 47 - }; 48 - }; 49 - "documents/fix-postref": { 50 - data: { 51 - documentUris?: string[]; 52 - }; 53 - }; 54 29 }; 55 30 56 31 // 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 - );
-300
app/api/inngest/functions/fix_incorrect_site_values.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 - * Build set of valid site values for a publication. 21 - * A site value is valid if it matches the publication or its legacy equivalent. 22 - */ 23 - function buildValidSiteValues(pubUri: string): Set<string> { 24 - const validValues = new Set<string>([pubUri]); 25 - 26 - try { 27 - const aturi = new AtUri(pubUri); 28 - 29 - if (pubUri.includes("/site.standard.publication/")) { 30 - // Also accept legacy pub.leaflet.publication 31 - validValues.add( 32 - `at://${aturi.hostname}/pub.leaflet.publication/${aturi.rkey}`, 33 - ); 34 - } else if (pubUri.includes("/pub.leaflet.publication/")) { 35 - // Also accept new site.standard.publication 36 - validValues.add( 37 - `at://${aturi.hostname}/site.standard.publication/${aturi.rkey}`, 38 - ); 39 - } 40 - } catch (e) { 41 - // Invalid URI, just use the original 42 - } 43 - 44 - return validValues; 45 - } 46 - 47 - /** 48 - * This function finds and fixes documents that have incorrect site values. 49 - * A document has an incorrect site value if its `site` field doesn't match 50 - * the publication it belongs to (via documents_in_publications). 51 - * 52 - * Takes a DID as input and processes publications owned by that identity. 53 - */ 54 - export const fix_incorrect_site_values = inngest.createFunction( 55 - { id: "fix_incorrect_site_values" }, 56 - { event: "documents/fix-incorrect-site-values" }, 57 - async ({ event, step }) => { 58 - const { did } = event.data; 59 - 60 - const stats = { 61 - publicationsChecked: 0, 62 - documentsChecked: 0, 63 - documentsWithIncorrectSite: 0, 64 - documentsFixed: 0, 65 - documentsMissingSite: 0, 66 - errors: [] as string[], 67 - }; 68 - 69 - // Step 1: Get all publications owned by this identity 70 - const publications = await step.run("fetch-publications", async () => { 71 - const { data, error } = await supabaseServerClient 72 - .from("publications") 73 - .select("uri") 74 - .eq("identity_did", did); 75 - 76 - if (error) { 77 - throw new Error(`Failed to fetch publications: ${error.message}`); 78 - } 79 - return data || []; 80 - }); 81 - 82 - stats.publicationsChecked = publications.length; 83 - 84 - if (publications.length === 0) { 85 - return { 86 - success: true, 87 - message: "No publications found for this identity", 88 - stats, 89 - }; 90 - } 91 - 92 - // Step 2: Get all documents_in_publications entries for these publications 93 - const publicationUris = publications.map((p) => p.uri); 94 - 95 - const joinEntries = await step.run( 96 - "fetch-documents-in-publications", 97 - async () => { 98 - const { data, error } = await supabaseServerClient 99 - .from("documents_in_publications") 100 - .select("document, publication") 101 - .in("publication", publicationUris); 102 - 103 - if (error) { 104 - throw new Error( 105 - `Failed to fetch documents_in_publications: ${error.message}`, 106 - ); 107 - } 108 - return data || []; 109 - }, 110 - ); 111 - 112 - if (joinEntries.length === 0) { 113 - return { 114 - success: true, 115 - message: "No documents found in publications", 116 - stats, 117 - }; 118 - } 119 - 120 - // Create a map of document URI -> expected publication URI 121 - const documentToPublication = new Map<string, string>(); 122 - for (const row of joinEntries) { 123 - documentToPublication.set(row.document, row.publication); 124 - } 125 - 126 - // Step 3: Fetch all document records 127 - const documentUris = Array.from(documentToPublication.keys()); 128 - 129 - const allDocuments = await step.run("fetch-documents", async () => { 130 - const { data, error } = await supabaseServerClient 131 - .from("documents") 132 - .select("uri, data") 133 - .in("uri", documentUris); 134 - 135 - if (error) { 136 - throw new Error(`Failed to fetch documents: ${error.message}`); 137 - } 138 - return data || []; 139 - }); 140 - 141 - stats.documentsChecked = allDocuments.length; 142 - 143 - // Step 4: Find documents with incorrect site values 144 - const documentsToFix: Array<{ 145 - uri: string; 146 - currentSite: string | null; 147 - correctSite: string; 148 - docData: SiteStandardDocument.Record; 149 - }> = []; 150 - 151 - for (const doc of allDocuments) { 152 - const expectedPubUri = documentToPublication.get(doc.uri); 153 - if (!expectedPubUri) continue; 154 - 155 - const data = doc.data as unknown as SiteStandardDocument.Record; 156 - const currentSite = data?.site; 157 - 158 - if (!currentSite) { 159 - stats.documentsMissingSite++; 160 - continue; 161 - } 162 - 163 - const validSiteValues = buildValidSiteValues(expectedPubUri); 164 - 165 - if (!validSiteValues.has(currentSite)) { 166 - // Document has incorrect site value - determine the correct one 167 - // Prefer the site.standard.publication format if the doc is site.standard.document 168 - let correctSite = expectedPubUri; 169 - 170 - if (doc.uri.includes("/site.standard.document/")) { 171 - // For site.standard.document, use site.standard.publication format 172 - try { 173 - const pubAturi = new AtUri(expectedPubUri); 174 - if (expectedPubUri.includes("/pub.leaflet.publication/")) { 175 - correctSite = `at://${pubAturi.hostname}/site.standard.publication/${pubAturi.rkey}`; 176 - } 177 - } catch (e) { 178 - // Use as-is 179 - } 180 - } 181 - 182 - documentsToFix.push({ 183 - uri: doc.uri, 184 - currentSite, 185 - correctSite, 186 - docData: data, 187 - }); 188 - } 189 - } 190 - 191 - stats.documentsWithIncorrectSite = documentsToFix.length; 192 - 193 - if (documentsToFix.length === 0) { 194 - return { 195 - success: true, 196 - message: "All documents have correct site values", 197 - stats, 198 - }; 199 - } 200 - 201 - // Step 5: Group documents by author DID for efficient OAuth session handling 202 - const docsByDid = new Map<string, typeof documentsToFix>(); 203 - for (const doc of documentsToFix) { 204 - try { 205 - const aturi = new AtUri(doc.uri); 206 - const authorDid = aturi.hostname; 207 - const existing = docsByDid.get(authorDid) || []; 208 - existing.push(doc); 209 - docsByDid.set(authorDid, existing); 210 - } catch (e) { 211 - stats.errors.push(`Invalid URI: ${doc.uri}`); 212 - } 213 - } 214 - 215 - // Step 6: Process each author's documents 216 - for (const [authorDid, docs] of docsByDid) { 217 - // Verify OAuth session for this author 218 - const oauthValid = await step.run( 219 - `verify-oauth-${authorDid.slice(-8)}`, 220 - async () => { 221 - const result = await restoreOAuthSession(authorDid); 222 - return result.ok; 223 - }, 224 - ); 225 - 226 - if (!oauthValid) { 227 - stats.errors.push(`No valid OAuth session for ${authorDid}`); 228 - continue; 229 - } 230 - 231 - // Fix each document for this author 232 - for (const docToFix of docs) { 233 - const result = await step.run( 234 - `fix-doc-${docToFix.uri.slice(-12)}`, 235 - async () => { 236 - try { 237 - const docAturi = new AtUri(docToFix.uri); 238 - 239 - // Build updated record 240 - const updatedRecord: SiteStandardDocument.Record = { 241 - ...docToFix.docData, 242 - site: docToFix.correctSite, 243 - }; 244 - 245 - // Update on PDS 246 - const agent = await createAuthenticatedAgent(authorDid); 247 - await agent.com.atproto.repo.putRecord({ 248 - repo: authorDid, 249 - collection: docAturi.collection, 250 - rkey: docAturi.rkey, 251 - record: updatedRecord, 252 - validate: false, 253 - }); 254 - 255 - // Update in database 256 - const { error: dbError } = await supabaseServerClient 257 - .from("documents") 258 - .update({ data: updatedRecord as Json }) 259 - .eq("uri", docToFix.uri); 260 - 261 - if (dbError) { 262 - return { 263 - success: false as const, 264 - error: `Database update failed: ${dbError.message}`, 265 - }; 266 - } 267 - 268 - return { 269 - success: true as const, 270 - oldSite: docToFix.currentSite, 271 - newSite: docToFix.correctSite, 272 - }; 273 - } catch (e) { 274 - return { 275 - success: false as const, 276 - error: e instanceof Error ? e.message : String(e), 277 - }; 278 - } 279 - }, 280 - ); 281 - 282 - if (result.success) { 283 - stats.documentsFixed++; 284 - } else { 285 - stats.errors.push(`${docToFix.uri}: ${result.error}`); 286 - } 287 - } 288 - } 289 - 290 - return { 291 - success: stats.errors.length === 0, 292 - stats, 293 - documentsToFix: documentsToFix.map((d) => ({ 294 - uri: d.uri, 295 - oldSite: d.currentSite, 296 - newSite: d.correctSite, 297 - })), 298 - }; 299 - }, 300 - );
-196
app/api/inngest/functions/fix_standard_document_postref.ts
··· 1 - import { supabaseServerClient } from "supabase/serverClient"; 2 - import { inngest } from "../client"; 3 - import { restoreOAuthSession } from "src/atproto-oauth"; 4 - import { 5 - AtpBaseClient, 6 - SiteStandardDocument, 7 - ComAtprotoRepoStrongRef, 8 - } from "lexicons/api"; 9 - import { AtUri } from "@atproto/syntax"; 10 - import { Json } from "supabase/database.types"; 11 - 12 - async function createAuthenticatedAgent(did: string): Promise<AtpBaseClient> { 13 - const result = await restoreOAuthSession(did); 14 - if (!result.ok) { 15 - throw new Error(`Failed to restore OAuth session: ${result.error.message}`); 16 - } 17 - const credentialSession = result.value; 18 - return new AtpBaseClient( 19 - credentialSession.fetchHandler.bind(credentialSession), 20 - ); 21 - } 22 - 23 - /** 24 - * Fixes site.standard.document records that have the legacy `postRef` field set. 25 - * Migrates the value to `bskyPostRef` (the correct field for site.standard.document) 26 - * and removes the legacy `postRef` field. 27 - * 28 - * Can be triggered with specific document URIs, or will find all affected documents 29 - * if no URIs are provided. 30 - */ 31 - export const fix_standard_document_postref = inngest.createFunction( 32 - { id: "fix_standard_document_postref" }, 33 - { event: "documents/fix-postref" }, 34 - async ({ event, step }) => { 35 - const { documentUris: providedUris } = event.data as { 36 - documentUris?: string[]; 37 - }; 38 - 39 - const stats = { 40 - documentsFound: 0, 41 - documentsFixed: 0, 42 - documentsSkipped: 0, 43 - errors: [] as string[], 44 - }; 45 - 46 - // Step 1: Find documents to fix (either provided or query for them) 47 - const documentUris = await step.run("find-documents", async () => { 48 - if (providedUris && providedUris.length > 0) { 49 - return providedUris; 50 - } 51 - 52 - // Find all site.standard.document records with postRef set 53 - const { data: documents, error } = await supabaseServerClient 54 - .from("documents") 55 - .select("uri") 56 - .like("uri", "at://%/site.standard.document/%") 57 - .not("data->postRef", "is", null); 58 - 59 - if (error) { 60 - throw new Error(`Failed to query documents: ${error.message}`); 61 - } 62 - 63 - return (documents || []).map((d) => d.uri); 64 - }); 65 - 66 - stats.documentsFound = documentUris.length; 67 - 68 - if (documentUris.length === 0) { 69 - return { 70 - success: true, 71 - message: "No documents found with postRef field", 72 - stats, 73 - }; 74 - } 75 - 76 - // Step 2: Group documents by DID for efficient OAuth session handling 77 - const docsByDid = new Map<string, string[]>(); 78 - for (const uri of documentUris) { 79 - try { 80 - const aturi = new AtUri(uri); 81 - const did = aturi.hostname; 82 - const existing = docsByDid.get(did) || []; 83 - existing.push(uri); 84 - docsByDid.set(did, existing); 85 - } catch (e) { 86 - stats.errors.push(`Invalid URI: ${uri}`); 87 - } 88 - } 89 - 90 - // Step 3: Process each DID's documents 91 - for (const [did, uris] of docsByDid) { 92 - // Verify OAuth session for this user 93 - const oauthValid = await step.run( 94 - `verify-oauth-${did.slice(-8)}`, 95 - async () => { 96 - const result = await restoreOAuthSession(did); 97 - return result.ok; 98 - }, 99 - ); 100 - 101 - if (!oauthValid) { 102 - stats.errors.push(`No valid OAuth session for ${did}`); 103 - stats.documentsSkipped += uris.length; 104 - continue; 105 - } 106 - 107 - // Fix each document 108 - for (const docUri of uris) { 109 - const result = await step.run( 110 - `fix-doc-${docUri.slice(-12)}`, 111 - async () => { 112 - // Fetch the document 113 - const { data: doc, error: fetchError } = await supabaseServerClient 114 - .from("documents") 115 - .select("uri, data") 116 - .eq("uri", docUri) 117 - .single(); 118 - 119 - if (fetchError || !doc) { 120 - return { 121 - success: false as const, 122 - error: `Document not found: ${fetchError?.message || "no data"}`, 123 - }; 124 - } 125 - 126 - const data = doc.data as Record<string, unknown>; 127 - const postRef = data.postRef as 128 - | ComAtprotoRepoStrongRef.Main 129 - | undefined; 130 - 131 - if (!postRef) { 132 - return { 133 - success: false as const, 134 - skipped: true, 135 - error: "Document does not have postRef field", 136 - }; 137 - } 138 - 139 - // Build updated record: move postRef to bskyPostRef 140 - const { postRef: _, ...restData } = data; 141 - let updatedRecord: SiteStandardDocument.Record = { 142 - ...(restData as SiteStandardDocument.Record), 143 - }; 144 - 145 - updatedRecord.bskyPostRef = data.bskyPostRef 146 - ? (data.bskyPostRef as ComAtprotoRepoStrongRef.Main) 147 - : postRef; 148 - 149 - // Write to PDS 150 - const docAturi = new AtUri(docUri); 151 - const agent = await createAuthenticatedAgent(did); 152 - await agent.com.atproto.repo.putRecord({ 153 - repo: did, 154 - collection: "site.standard.document", 155 - rkey: docAturi.rkey, 156 - record: updatedRecord, 157 - validate: false, 158 - }); 159 - 160 - // Update database 161 - const { error: dbError } = await supabaseServerClient 162 - .from("documents") 163 - .update({ data: updatedRecord as Json }) 164 - .eq("uri", docUri); 165 - 166 - if (dbError) { 167 - return { 168 - success: false as const, 169 - error: `Database update failed: ${dbError.message}`, 170 - }; 171 - } 172 - 173 - return { 174 - success: true as const, 175 - postRef, 176 - bskyPostRef: updatedRecord.bskyPostRef, 177 - }; 178 - }, 179 - ); 180 - 181 - if (result.success) { 182 - stats.documentsFixed++; 183 - } else if ("skipped" in result && result.skipped) { 184 - stats.documentsSkipped++; 185 - } else { 186 - stats.errors.push(`${docUri}: ${result.error}`); 187 - } 188 - } 189 - } 190 - 191 - return { 192 - success: stats.errors.length === 0, 193 - stats, 194 - }; 195 - }, 196 - );
-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 - );
+52 -186
app/api/inngest/functions/migrate_user_to_standard.ts
··· 38 38 const stats = { 39 39 publicationsMigrated: 0, 40 40 documentsMigrated: 0, 41 - standardDocumentsFixed: 0, 42 41 userSubscriptionsMigrated: 0, 43 42 referencesUpdated: 0, 44 43 errors: [] as string[], 45 44 }; 46 45 47 46 // Step 1: Verify OAuth session is valid 48 - const oauthValid = await step.run("verify-oauth-session", async () => { 47 + await step.run("verify-oauth-session", async () => { 49 48 const result = await restoreOAuthSession(did); 50 49 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 }; 50 + throw new Error( 51 + `Failed to restore OAuth session: ${result.error.message}`, 52 + ); 60 53 } 61 54 return { success: true }; 62 55 }); 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 56 75 57 // Step 2: Get user's pub.leaflet.publication records 76 58 const oldPublications = await step.run( ··· 127 109 }) 128 110 .filter((x) => x !== null); 129 111 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 112 + // Run all PDS writes in parallel 113 + const pubPdsResults = await Promise.all( 114 + publicationsToMigrate.map(({ pub, rkey, newRecord }) => 115 + step.run(`pds-write-publication-${pub.uri}`, async () => { 135 116 const agent = await createAuthenticatedAgent(did); 136 117 const putResult = await agent.com.atproto.repo.putRecord({ 137 118 repo: did, ··· 140 121 record: newRecord, 141 122 validate: false, 142 123 }); 143 - const newUri = putResult.data.uri; 124 + return { oldUri: pub.uri, newUri: putResult.data.uri }; 125 + }), 126 + ), 127 + ); 144 128 145 - // DB write 129 + // Run all DB writes in parallel 130 + const pubDbResults = await Promise.all( 131 + publicationsToMigrate.map(({ pub, normalized, newRecord }, index) => { 132 + const newUri = pubPdsResults[index].newUri; 133 + return step.run(`db-write-publication-${pub.uri}`, async () => { 146 134 const { error: dbError } = await supabaseServerClient 147 135 .from("publications") 148 136 .upsert({ ··· 161 149 }; 162 150 } 163 151 return { success: true as const, oldUri: pub.uri, newUri }; 164 - }), 165 - ), 152 + }); 153 + }), 166 154 ); 167 155 168 156 // Process results 169 - for (const result of pubResults) { 157 + for (const result of pubDbResults) { 170 158 if (result.success) { 171 159 publicationUriMap[result.oldUri] = result.newUri; 172 160 stats.publicationsMigrated++; ··· 251 239 $type: "site.standard.document", 252 240 title: normalized.title || "Untitled", 253 241 site: siteValue, 254 - path: "/" + rkey, 242 + path: rkey, 255 243 publishedAt: normalized.publishedAt || new Date().toISOString(), 256 244 description: normalized.description, 257 245 content: normalized.content, ··· 264 252 }) 265 253 .filter((x) => x !== null); 266 254 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 255 + // Run all PDS writes in parallel 256 + const docPdsResults = await Promise.all( 257 + documentsToMigrate.map(({ doc, rkey, newRecord }) => 258 + step.run(`pds-write-document-${doc.uri}`, async () => { 272 259 const agent = await createAuthenticatedAgent(did); 273 260 const putResult = await agent.com.atproto.repo.putRecord({ 274 261 repo: did, ··· 277 264 record: newRecord, 278 265 validate: false, 279 266 }); 280 - const newUri = putResult.data.uri; 267 + return { oldUri: doc.uri, newUri: putResult.data.uri }; 268 + }), 269 + ), 270 + ); 281 271 282 - // DB write 272 + // Run all DB writes in parallel 273 + const docDbResults = await Promise.all( 274 + documentsToMigrate.map(({ doc, newRecord, oldPubUri }, index) => { 275 + const newUri = docPdsResults[index].newUri; 276 + return step.run(`db-write-document-${doc.uri}`, async () => { 283 277 const { error: dbError } = await supabaseServerClient 284 278 .from("documents") 285 279 .upsert({ ··· 308 302 } 309 303 310 304 return { success: true as const, oldUri: doc.uri, newUri }; 311 - }), 312 - ), 305 + }); 306 + }), 313 307 ); 314 308 315 309 // Process results 316 - for (const result of docResults) { 310 + for (const result of docDbResults) { 317 311 if (result.success) { 318 312 documentUriMap[result.oldUri] = result.newUri; 319 313 stats.documentsMigrated++; ··· 324 318 } 325 319 } 326 320 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 321 // Step 5: Update references in database tables (all in parallel) 452 322 await step.run("update-references", async () => { 453 323 const pubEntries = Object.entries(publicationUriMap); ··· 558 428 }) 559 429 .filter((x) => x !== null); 560 430 561 - // Run PDS + DB writes together for each subscription 562 - const subResults = await Promise.all( 431 + // Run all PDS writes in parallel 432 + const subPdsResults = await Promise.all( 563 433 subscriptionsToMigrate.map(({ sub, rkey, newRecord }) => 564 - step.run(`migrate-subscription-${sub.uri}`, async () => { 565 - // PDS write 434 + step.run(`pds-write-subscription-${sub.uri}`, async () => { 566 435 const agent = await createAuthenticatedAgent(did); 567 436 const putResult = await agent.com.atproto.repo.putRecord({ 568 437 repo: did, ··· 571 440 record: newRecord, 572 441 validate: false, 573 442 }); 574 - const newUri = putResult.data.uri; 443 + return { oldUri: sub.uri, newUri: putResult.data.uri }; 444 + }), 445 + ), 446 + ); 575 447 576 - // DB write 448 + // Run all DB writes in parallel 449 + const subDbResults = await Promise.all( 450 + subscriptionsToMigrate.map(({ sub, newRecord }, index) => { 451 + const newUri = subPdsResults[index].newUri; 452 + return step.run(`db-write-subscription-${sub.uri}`, async () => { 577 453 const { error: dbError } = await supabaseServerClient 578 454 .from("publication_subscriptions") 579 455 .update({ ··· 591 467 }; 592 468 } 593 469 return { success: true as const, oldUri: sub.uri, newUri }; 594 - }), 595 - ), 470 + }); 471 + }), 596 472 ); 597 473 598 474 // Process results 599 - for (const result of subResults) { 475 + for (const result of subDbResults) { 600 476 if (result.success) { 601 477 userSubscriptionUriMap[result.oldUri] = result.newUri; 602 478 stats.userSubscriptionsMigrated++; ··· 613 489 // 2. External references (e.g., from other AT Proto apps) to old URIs continue to work 614 490 // 3. The normalization layer handles both schemas transparently for reads 615 491 // 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 492 627 493 return { 628 494 success: stats.errors.length === 0,
-12
app/api/inngest/route.tsx
··· 5 5 import { batched_update_profiles } from "./functions/batched_update_profiles"; 6 6 import { index_follows } from "./functions/index_follows"; 7 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 { fix_incorrect_site_values } from "./functions/fix_incorrect_site_values"; 10 - import { fix_standard_document_postref } from "./functions/fix_standard_document_postref"; 11 - import { 12 - cleanup_expired_oauth_sessions, 13 - check_oauth_session, 14 - } from "./functions/cleanup_expired_oauth_sessions"; 15 8 16 9 export const { GET, POST, PUT } = serve({ 17 10 client: inngest, ··· 21 14 batched_update_profiles, 22 15 index_follows, 23 16 migrate_user_to_standard, 24 - fix_standard_document_publications, 25 - fix_incorrect_site_values, 26 - fix_standard_document_postref, 27 - cleanup_expired_oauth_sessions, 28 - check_oauth_session, 29 17 ], 30 18 });
-11
app/api/oauth/[route]/route.ts
··· 11 11 ActionAfterSignIn, 12 12 parseActionFromSearchParam, 13 13 } from "./afterSignInActions"; 14 - import { inngest } from "app/api/inngest/client"; 15 14 16 15 type OauthRequestClientState = { 17 16 redirect: string | null; ··· 85 84 .single(); 86 85 identity = data; 87 86 } 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 87 let { data: token } = await supabaseServerClient 99 88 .from("email_auth_tokens") 100 89 .insert({
-1
app/api/rpc/[command]/get_publication_data.ts
··· 83 83 uri: dip.documents.uri, 84 84 record: normalized, 85 85 indexed_at: dip.documents.indexed_at, 86 - sort_date: dip.documents.sort_date, 87 86 data: dip.documents.data, 88 87 commentsCount: dip.documents.comments_on_documents[0]?.count || 0, 89 88 mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0,
+9 -4
app/globals.css
··· 62 62 --shadow-md: 1.2px 2.5px 2.7px -1.8px rgba(var(--primary), 0.1), 63 63 5.6px 11.6px 12.5px -3.5px rgba(var(--primary), 0.15); 64 64 65 - --font-sans: var(--font-quattro); 65 + --font-sans: var(--theme-font, var(--font-quattro)); 66 66 --font-serif: Garamond; 67 67 } 68 68 ··· 158 158 } 159 159 160 160 /* START FONT STYLING */ 161 + h1, 162 + h2, 163 + h3, 164 + h4 { 165 + font-family: var(--theme-heading-font, var(--theme-font)); 166 + } 167 + 161 168 h1 { 162 169 @apply text-2xl; 163 170 @apply font-bold; ··· 194 201 } 195 202 196 203 pre { 197 - font-family: var(--font-quattro); 204 + font-family: var(--theme-font, --font-quattro); 198 205 } 199 206 200 207 p { ··· 274 281 @apply p-2; 275 282 @apply rounded-md; 276 283 @apply overflow-auto; 277 - @apply sm:min-h-12; 278 - @apply min-h-10; 279 284 280 285 @media (min-width: 640px) { 281 286 @apply p-3;
+12 -10
app/layout.tsx
··· 36 36 const quattro = localFont({ 37 37 src: [ 38 38 { 39 - path: "../public/fonts/iAWriterQuattroV.ttf", 39 + path: "../public/fonts/iaw-quattro-vf.woff2", 40 40 style: "normal", 41 41 }, 42 42 { 43 - path: "../public/fonts/iAWriterQuattroV-Italic.ttf", 43 + path: "../public/fonts/iaw-quattro-vf-Italic.woff2", 44 44 style: "italic", 45 45 }, 46 46 ], ··· 48 48 variable: "--font-quattro", 49 49 }); 50 50 51 - export default async function RootLayout( 52 - { 53 - children, 54 - }: { 55 - children: React.ReactNode; 56 - } 57 - ) { 51 + export default async function RootLayout({ 52 + children, 53 + }: { 54 + children: React.ReactNode; 55 + }) { 58 56 let headersList = await headers(); 59 57 let ipLocation = headersList.get("X-Vercel-IP-Country"); 60 58 let acceptLanguage = headersList.get("accept-language"); ··· 80 78 <InitialPageLoad> 81 79 <PopUpProvider> 82 80 <IdentityProviderServer> 83 - <RequestHeadersProvider country={ipLocation} language={acceptLanguage} timezone={ipTimezone}> 81 + <RequestHeadersProvider 82 + country={ipLocation} 83 + language={acceptLanguage} 84 + timezone={ipTimezone} 85 + > 84 86 <ViewportSizeLayout>{children}</ViewportSizeLayout> 85 87 <RouteUIStateManager /> 86 88 </RequestHeadersProvider>
-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 - }
+133 -14
app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
··· 1 1 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 2 2 import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 - import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 4 - import { BskyPostContent } from "../BskyPostContent"; 3 + import { Separator } from "components/Layout"; 4 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 5 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 + import { CommentTiny } from "components/Icons/CommentTiny"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 + import { ThreadLink, QuotesLink } from "../PostLinks"; 9 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 + import { 11 + BlueskyEmbed, 12 + PostNotAvailable, 13 + } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 14 + import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 15 + import { openPage } from "../PostPages"; 5 16 6 17 export const PubBlueskyPostBlock = (props: { 7 18 post: PostView; ··· 10 21 }) => { 11 22 let post = props.post; 12 23 24 + const handleOpenThread = () => { 25 + openPage(props.pageId ? { type: "doc", id: props.pageId } : undefined, { 26 + type: "thread", 27 + uri: post.uri, 28 + }); 29 + }; 30 + 13 31 switch (true) { 14 32 case AppBskyFeedDefs.isBlockedPost(post) || 15 33 AppBskyFeedDefs.isBlockedAuthor(post) || ··· 31 49 32 50 //getting the url to the post 33 51 let postId = post.uri.split("/")[4]; 34 - let postView = post as PostView; 35 - 36 52 let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 37 53 38 54 const parent = props.pageId ··· 40 56 : undefined; 41 57 42 58 return ( 43 - <BskyPostContent 44 - post={postView} 45 - parent={undefined} 46 - showBlueskyLink={true} 47 - showEmbed={true} 48 - avatarSize="large" 49 - quoteEnabled 50 - replyEnabled 51 - className="text-sm text-secondary block-border sm:px-3 sm:py-2 px-2 py-1 bg-bg-page mb-2 hover:border-accent-contrast!" 52 - /> 59 + <div 60 + onClick={handleOpenThread} 61 + className={` 62 + ${props.className} 63 + block-border 64 + mb-2 65 + flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 66 + cursor-pointer hover:border-accent-contrast 67 + `} 68 + > 69 + {post.author && record && ( 70 + <> 71 + <div className="bskyAuthor w-full flex items-center gap-2"> 72 + {post.author.avatar && ( 73 + <img 74 + src={post.author?.avatar} 75 + alt={`${post.author?.displayName}'s avatar`} 76 + className="shink-0 w-8 h-8 rounded-full border border-border-light" 77 + /> 78 + )} 79 + <div className="grow flex flex-col gap-0.5 leading-tight"> 80 + <div className=" font-bold text-secondary"> 81 + {post.author?.displayName} 82 + </div> 83 + <a 84 + className="text-xs text-tertiary hover:underline" 85 + target="_blank" 86 + href={`https://bsky.app/profile/${post.author?.handle}`} 87 + onClick={(e) => e.stopPropagation()} 88 + > 89 + @{post.author?.handle} 90 + </a> 91 + </div> 92 + </div> 93 + 94 + <div className="flex flex-col gap-2 "> 95 + <div> 96 + <pre className="whitespace-pre-wrap"> 97 + {BlueskyRichText({ 98 + record: record as AppBskyFeedPost.Record | null, 99 + })} 100 + </pre> 101 + </div> 102 + {post.embed && ( 103 + <div onClick={(e) => e.stopPropagation()}> 104 + <BlueskyEmbed embed={post.embed} postUrl={url} /> 105 + </div> 106 + )} 107 + </div> 108 + </> 109 + )} 110 + <div className="w-full flex gap-2 items-center justify-between"> 111 + <ClientDate date={timestamp} /> 112 + <div className="flex gap-2 items-center"> 113 + {post.replyCount != null && post.replyCount > 0 && ( 114 + <> 115 + <ThreadLink 116 + threadUri={post.uri} 117 + parent={parent} 118 + className="flex items-center gap-1 hover:text-accent-contrast" 119 + onClick={(e) => e.stopPropagation()} 120 + > 121 + {post.replyCount} 122 + <CommentTiny /> 123 + </ThreadLink> 124 + <Separator classname="h-4" /> 125 + </> 126 + )} 127 + {post.quoteCount != null && post.quoteCount > 0 && ( 128 + <> 129 + <QuotesLink 130 + postUri={post.uri} 131 + parent={parent} 132 + className="flex items-center gap-1 hover:text-accent-contrast" 133 + onClick={(e) => e.stopPropagation()} 134 + > 135 + {post.quoteCount} 136 + <QuoteTiny /> 137 + </QuotesLink> 138 + <Separator classname="h-4" /> 139 + </> 140 + )} 141 + 142 + <a 143 + className="" 144 + target="_blank" 145 + href={url} 146 + onClick={(e) => e.stopPropagation()} 147 + > 148 + <BlueskyTiny /> 149 + </a> 150 + </div> 151 + </div> 152 + </div> 53 153 ); 54 154 } 55 155 }; 156 + 157 + const ClientDate = (props: { date?: string }) => { 158 + let pageLoaded = useHasPageLoaded(); 159 + const formattedDate = useLocalizedDate( 160 + props.date || new Date().toISOString(), 161 + { 162 + month: "short", 163 + day: "numeric", 164 + year: "numeric", 165 + hour: "numeric", 166 + minute: "numeric", 167 + hour12: true, 168 + }, 169 + ); 170 + 171 + if (!pageLoaded) return null; 172 + 173 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 174 + };
+31 -30
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
··· 7 7 import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 8 import { openPage } from "./PostPages"; 9 9 import { BskyPostContent } from "./BskyPostContent"; 10 - import { 11 - QuotesLink, 12 - getQuotesKey, 13 - fetchQuotes, 14 - prefetchQuotes, 15 - } from "./PostLinks"; 10 + import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks"; 16 11 17 12 // Re-export for backwards compatibility 18 13 export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; ··· 32 27 data: quotesData, 33 28 isLoading, 34 29 error, 35 - } = useSWR(postUri ? getQuotesKey(postUri) : null, () => 36 - fetchQuotes(postUri), 37 - ); 30 + } = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri)); 38 31 39 32 return ( 40 33 <PageWrapper 41 34 pageType="doc" 42 35 fullPageScroll={false} 43 36 id={`post-page-${pageId}`} 44 - drawerOpen={false} 37 + drawerOpen={!!drawer} 45 38 pageOptions={pageOptions} 46 - fixedWidth 47 39 > 48 40 <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 49 - <h4 className="text-secondary font-bold mb-2">Bluesky Quotes</h4> 41 + <div className="text-secondary font-bold mb-3 flex items-center gap-2"> 42 + <QuoteTiny /> 43 + Bluesky Quotes 44 + </div> 50 45 {isLoading ? ( 51 46 <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 52 47 <span>loading quotes</span> ··· 73 68 74 69 return ( 75 70 <div className="flex flex-col gap-0"> 76 - {posts.map((post, index) => ( 77 - <> 78 - <QuotePost key={post.uri} post={post} quotesUri={postUri} /> 79 - {posts.length !== index + 1 && ( 80 - <hr className="border-border-light my-4" /> 81 - )} 82 - </> 71 + {posts.map((post) => ( 72 + <QuotePost 73 + key={post.uri} 74 + post={post} 75 + quotesUri={postUri} 76 + /> 83 77 ))} 84 78 </div> 85 79 ); 86 80 } 87 81 88 - function QuotePost(props: { post: PostView; quotesUri: string }) { 82 + function QuotePost(props: { 83 + post: PostView; 84 + quotesUri: string; 85 + }) { 89 86 const { post, quotesUri } = props; 90 87 const parent = { type: "quotes" as const, uri: quotesUri }; 91 88 92 89 return ( 93 - <BskyPostContent 94 - post={post} 95 - parent={parent} 96 - showEmbed={true} 97 - compactEmbed 98 - showBlueskyLink={true} 99 - quoteEnabled 100 - replyEnabled 101 - className="relative rounded text-sm" 102 - /> 90 + <div 91 + className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 92 + onClick={() => openPage(parent, { type: "thread", uri: post.uri })} 93 + > 94 + <BskyPostContent 95 + post={post} 96 + parent={parent} 97 + linksEnabled={true} 98 + showEmbed={true} 99 + showBlueskyLink={true} 100 + onLinkClick={(e) => e.stopPropagation()} 101 + onEmbedClick={(e) => e.stopPropagation()} 102 + /> 103 + </div> 103 104 ); 104 105 }
+104 -234
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 1 1 "use client"; 2 2 import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 - import { BlueskyEmbed } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 3 + import { 4 + BlueskyEmbed, 5 + } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 4 6 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 5 7 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 8 import { CommentTiny } from "components/Icons/CommentTiny"; ··· 8 10 import { Separator } from "components/Layout"; 9 11 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 12 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 11 - import { OpenPage, openPage } from "./PostPages"; 13 + import { OpenPage } from "./PostPages"; 12 14 import { ThreadLink, QuotesLink } from "./PostLinks"; 13 - import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; 14 - import { Avatar } from "components/Avatar"; 15 - import { timeAgo } from "src/utils/timeAgo"; 16 - import { ProfilePopover } from "components/ProfilePopover"; 17 15 18 16 type PostView = AppBskyFeedDefs.PostView; 19 17 20 18 export function BskyPostContent(props: { 21 19 post: PostView; 22 - parent: OpenPage | undefined; 23 - avatarSize?: "tiny" | "small" | "medium" | "large" | "giant"; 24 - className?: string; 20 + parent?: OpenPage; 21 + linksEnabled?: boolean; 22 + avatarSize?: "sm" | "md"; 25 23 showEmbed?: boolean; 26 - compactEmbed?: boolean; 27 24 showBlueskyLink?: boolean; 28 - quoteEnabled?: boolean; 29 - replyEnabled?: boolean; 30 - replyOnClick?: (e: React.MouseEvent) => void; 25 + onEmbedClick?: (e: React.MouseEvent) => void; 26 + onLinkClick?: (e: React.MouseEvent) => void; 31 27 }) { 32 28 const { 33 29 post, 34 30 parent, 35 - avatarSize = "medium", 31 + linksEnabled = true, 32 + avatarSize = "md", 36 33 showEmbed = true, 37 - compactEmbed = false, 38 34 showBlueskyLink = true, 39 - quoteEnabled, 40 - replyEnabled, 41 - replyOnClick, 35 + onEmbedClick, 36 + onLinkClick, 42 37 } = props; 43 38 44 39 const record = post.record as AppBskyFeedPost.Record; 45 40 const postId = post.uri.split("/")[4]; 46 41 const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 42 + 43 + const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10"; 47 44 48 45 return ( 49 - <div className={`bskyPost relative flex flex-col w-full `}> 50 - <button 51 - className="absolute inset-0" 52 - onClick={() => { 53 - openPage(parent, { type: "thread", uri: post.uri }); 54 - }} 55 - /> 56 - 57 - <div 58 - className={`flex gap-2 text-left w-full pointer-events-none ${props.className}`} 59 - > 60 - <div className="flex flex-col items-start shrink-0 w-fit pointer-events-auto"> 61 - <Avatar 46 + <> 47 + <div className="flex flex-col items-center shrink-0"> 48 + {post.author.avatar ? ( 49 + <img 62 50 src={post.author.avatar} 63 - displayName={post.author.displayName} 64 - size={avatarSize ? avatarSize : "medium"} 51 + alt={`${post.author.displayName}'s avatar`} 52 + className={`${avatarClass} rounded-full border border-border-light`} 65 53 /> 66 - </div> 67 - <div className={`flex flex-col min-w-0 w-full mb-2`}> 68 - <div 69 - className={`bskyPostTextContent flex flex-col grow text-left w-full ${props.avatarSize === "small" ? "mt-0.5" : props.avatarSize === "large" ? "mt-2" : "mt-1"}`} 70 - > 71 - <PostInfo 72 - displayName={post.author.displayName} 73 - handle={post.author.handle} 74 - createdAt={record.createdAt} 75 - /> 54 + ) : ( 55 + <div className={`${avatarClass} rounded-full border border-border-light bg-border`} /> 56 + )} 57 + </div> 76 58 77 - <div className={`postContent flex flex-col gap-2 mt-0.5`}> 78 - <div className="text-secondary"> 79 - <BlueskyRichText record={record} /> 80 - </div> 81 - {showEmbed && post.embed && ( 82 - <div 83 - className="pointer-events-auto relative" 84 - onClick={(e) => e.stopPropagation()} 85 - > 86 - <BlueskyEmbed 87 - parent={parent} 88 - embed={post.embed} 89 - compact={compactEmbed} 90 - postUrl={url} 91 - className="text-sm" 92 - /> 93 - </div> 94 - )} 95 - </div> 59 + <div className="flex flex-col grow min-w-0"> 60 + <div className={`flex items-center gap-2 leading-tight ${avatarSize === "sm" ? "text-sm" : ""}`}> 61 + <div className="font-bold text-secondary"> 62 + {post.author.displayName} 96 63 </div> 97 - {props.showBlueskyLink || 98 - (props.post.quoteCount && props.post.quoteCount > 0) || 99 - (props.post.replyCount && props.post.replyCount > 0) ? ( 100 - <div 101 - className={`postCountsAndLink flex gap-2 items-center justify-between mt-2 pointer-events-auto`} 102 - > 103 - <PostCounts 104 - post={post} 105 - parent={parent} 106 - replyEnabled={replyEnabled} 107 - replyOnClick={replyOnClick} 108 - quoteEnabled={quoteEnabled} 109 - showBlueskyLink={showBlueskyLink} 110 - url={url} 111 - /> 112 - 113 - <div className="flex gap-3 items-center"> 114 - {showBlueskyLink && ( 115 - <> 116 - <a 117 - className="text-tertiary hover:text-accent-contrast" 118 - target="_blank" 119 - href={url} 120 - > 121 - <BlueskyLinkTiny /> 122 - </a> 123 - </> 124 - )} 125 - </div> 126 - </div> 127 - ) : null} 128 - </div> 129 - </div> 130 - </div> 131 - ); 132 - } 133 - 134 - export function CompactBskyPostContent(props: { 135 - post: PostView; 136 - parent: OpenPage; 137 - className?: string; 138 - quoteEnabled?: boolean; 139 - replyEnabled?: boolean; 140 - replyOnClick?: (e: React.MouseEvent) => void; 141 - }) { 142 - const { post, parent, quoteEnabled, replyEnabled, replyOnClick } = props; 143 - 144 - const record = post.record as AppBskyFeedPost.Record; 145 - const postId = post.uri.split("/")[4]; 146 - const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 147 - 148 - return ( 149 - <div className="bskyPost relative flex flex-col w-full"> 150 - <button 151 - className="absolute inset-0 " 152 - onClick={() => { 153 - openPage(parent, { type: "thread", uri: post.uri }); 154 - }} 155 - /> 156 - <div className={`flex gap-2 text-left w-full ${props.className}`}> 157 - <Avatar 158 - src={post.author.avatar} 159 - displayName={post.author.displayName} 160 - size="small" 161 - /> 162 - <div className={`flex flex-col min-w-0 w-full`}> 163 - <button 164 - className="bskyPostTextContent flex flex-col grow mt-0.5 text-left text-xs text-tertiary" 165 - onClick={() => { 166 - openPage(parent, { type: "thread", uri: post.uri }); 167 - }} 64 + <a 65 + className="text-xs text-tertiary hover:underline" 66 + target="_blank" 67 + href={`https://bsky.app/profile/${post.author.handle}`} 68 + onClick={onLinkClick} 168 69 > 169 - <PostInfo 170 - displayName={post.author.displayName} 171 - handle={post.author.handle} 172 - createdAt={record.createdAt} 173 - compact 174 - /> 70 + @{post.author.handle} 71 + </a> 72 + </div> 175 73 176 - <div className="postContent flex flex-col gap-2 mt-0.5"> 177 - <div className="line-clamp-3 text-tertiary text-xs"> 178 - <BlueskyRichText record={record} /> 179 - </div> 74 + <div className={`flex flex-col gap-2 ${avatarSize === "sm" ? "mt-0.5" : "mt-1"}`}> 75 + <div className="text-sm text-secondary"> 76 + <BlueskyRichText record={record} /> 77 + </div> 78 + {showEmbed && post.embed && ( 79 + <div onClick={onEmbedClick}> 80 + <BlueskyEmbed embed={post.embed} postUrl={url} /> 180 81 </div> 181 - </button> 182 - {(post.quoteCount && post.quoteCount > 0) || 183 - (post.replyCount && post.replyCount > 0) ? ( 184 - <div className="postCountsAndLink flex gap-2 items-center justify-between mt-2"> 185 - <PostCounts 186 - post={post} 187 - parent={parent} 188 - replyEnabled={replyEnabled} 189 - replyOnClick={replyOnClick} 190 - quoteEnabled={quoteEnabled} 191 - showBlueskyLink={false} 192 - url={url} 193 - /> 194 - </div> 195 - ) : null} 82 + )} 196 83 </div> 197 - </div> 198 - </div> 199 - ); 200 - } 201 84 202 - function PostInfo(props: { 203 - displayName?: string; 204 - handle: string; 205 - createdAt: string; 206 - compact?: boolean; 207 - }) { 208 - const { displayName, handle, createdAt, compact = false } = props; 209 - 210 - return ( 211 - <div className="postInfo flex items-center gap-2 leading-tight w-full"> 212 - <div className="flex gap-2 items-center min-w-0"> 213 - <div className={`font-bold text-secondary truncate`}> 214 - {displayName} 215 - </div> 216 - <div className="truncate items-end flex pointer-events-auto"> 217 - <ProfilePopover 218 - trigger={ 219 - <div 220 - className={`${compact ? "text-xs" : "text-sm"} text-tertiary hover:underline w-full truncate `} 221 - > 222 - @{handle} 223 - </div> 224 - } 225 - didOrHandle={handle} 85 + <div className={`flex gap-2 items-center ${avatarSize === "sm" ? "mt-1" : "mt-2"}`}> 86 + <ClientDate date={record.createdAt} /> 87 + <PostCounts 88 + post={post} 89 + parent={parent} 90 + linksEnabled={linksEnabled} 91 + showBlueskyLink={showBlueskyLink} 92 + url={url} 93 + onLinkClick={onLinkClick} 226 94 /> 227 95 </div> 228 96 </div> 229 - <div className="w-1 h-1 rounded-full bg-border shrink-0" /> 230 - <div 231 - className={`${compact ? "text-xs" : "text-sm"} text-tertiary shrink-0`} 232 - > 233 - {timeAgo(createdAt, { compact: true })} 234 - </div> 235 - </div> 97 + </> 236 98 ); 237 99 } 238 100 239 101 function PostCounts(props: { 240 102 post: PostView; 241 103 parent?: OpenPage; 242 - quoteEnabled?: boolean; 243 - replyEnabled?: boolean; 244 - replyOnClick?: (e: React.MouseEvent) => void; 104 + linksEnabled: boolean; 245 105 showBlueskyLink: boolean; 246 106 url: string; 107 + onLinkClick?: (e: React.MouseEvent) => void; 247 108 }) { 248 - const replyContent = props.post.replyCount != null && 249 - props.post.replyCount > 0 && ( 250 - <div className="postRepliesCount flex items-center gap-1 text-xs"> 251 - <CommentTiny /> 252 - {props.post.replyCount} 253 - </div> 254 - ); 255 - 256 - const quoteContent = props.post.quoteCount != null && 257 - props.post.quoteCount > 0 && ( 258 - <div className="postQuoteCount flex items-center gap-1 text-xs"> 259 - <QuoteTiny /> 260 - {props.post.quoteCount} 261 - </div> 262 - ); 109 + const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props; 263 110 264 111 return ( 265 - <div className="postCounts flex gap-2 items-center w-full text-tertiary"> 266 - {replyContent && 267 - (props.replyEnabled ? ( 268 - <ThreadLink 269 - postUri={props.post.uri} 270 - parent={props.parent} 271 - className="relative postRepliesLink hover:text-accent-contrast" 272 - onClick={props.replyOnClick} 273 - > 274 - {replyContent} 275 - </ThreadLink> 276 - ) : ( 277 - replyContent 278 - ))} 279 - {quoteContent && 280 - (props.quoteEnabled ? ( 112 + <div className="flex gap-2 items-center"> 113 + {post.replyCount != null && post.replyCount > 0 && ( 114 + <> 115 + <Separator classname="h-3" /> 116 + {linksEnabled ? ( 117 + <ThreadLink 118 + threadUri={post.uri} 119 + parent={parent} 120 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 121 + onClick={onLinkClick} 122 + > 123 + {post.replyCount} 124 + <CommentTiny /> 125 + </ThreadLink> 126 + ) : ( 127 + <div className="flex items-center gap-1 text-tertiary text-xs"> 128 + {post.replyCount} 129 + <CommentTiny /> 130 + </div> 131 + )} 132 + </> 133 + )} 134 + {post.quoteCount != null && post.quoteCount > 0 && ( 135 + <> 136 + <Separator classname="h-3" /> 281 137 <QuotesLink 282 - postUri={props.post.uri} 283 - parent={props.parent} 284 - className="relative hover:text-accent-contrast" 138 + postUri={post.uri} 139 + parent={parent} 140 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 141 + onClick={onLinkClick} 285 142 > 286 - {quoteContent} 143 + {post.quoteCount} 144 + <QuoteTiny /> 287 145 </QuotesLink> 288 - ) : ( 289 - quoteContent 290 - ))} 146 + </> 147 + )} 148 + {showBlueskyLink && ( 149 + <> 150 + <Separator classname="h-3" /> 151 + <a 152 + className="text-tertiary" 153 + target="_blank" 154 + href={url} 155 + onClick={onLinkClick} 156 + > 157 + <BlueskyTiny /> 158 + </a> 159 + </> 160 + )} 291 161 </div> 292 162 ); 293 163 }
+4 -14
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 15 15 import { extractCodeBlocks } from "./extractCodeBlocks"; 16 16 import { LeafletLayout } from "components/LeafletLayout"; 17 17 import { fetchPollData } from "./fetchPollData"; 18 - import { 19 - getDocumentPages, 20 - hasLeafletContent, 21 - } from "src/utils/normalizeRecords"; 18 + import { getDocumentPages, hasLeafletContent } from "src/utils/normalizeRecords"; 22 19 import { DocumentProvider } from "contexts/DocumentContext"; 23 20 import { LeafletContentProvider } from "contexts/LeafletContentContext"; 24 21 ··· 121 118 return ( 122 119 <DocumentProvider value={document}> 123 120 <LeafletContentProvider value={{ pages }}> 124 - <PublicationThemeProvider 125 - theme={document.theme} 126 - pub_creator={pub_creator} 127 - isStandalone={isStandalone} 128 - > 129 - <PublicationBackgroundProvider 130 - theme={document.theme} 131 - pub_creator={pub_creator} 132 - > 121 + <PublicationThemeProvider theme={document.theme} pub_creator={pub_creator} isStandalone={isStandalone}> 122 + <PublicationBackgroundProvider theme={document.theme} pub_creator={pub_creator}> 133 123 <LeafletLayout> 134 124 <PostPages 135 125 document_uri={document.uri} ··· 137 127 pubRecord={pubRecord} 138 128 profile={JSON.parse(JSON.stringify(profile.data))} 139 129 document={document} 140 - bskyPostData={JSON.parse(JSON.stringify(bskyPostData))} 130 + bskyPostData={bskyPostData} 141 131 did={did} 142 132 prerenderedCodeBlocks={prerenderedCodeBlocks} 143 133 pollData={pollData}
+13 -11
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 25 25 uri: string; 26 26 bsky_profiles: { record: Json; did: string } | null; 27 27 }; 28 - export function CommentsDrawerContent(props: { 28 + export function Comments(props: { 29 29 document_uri: string; 30 30 comments: Comment[]; 31 31 pageId?: string; ··· 55 55 id={"commentsDrawer"} 56 56 className="flex flex-col gap-2 relative text-sm text-secondary" 57 57 > 58 - <div className="w-full flex justify-between"> 59 - <h4> Comments</h4> 58 + <div className="w-full flex justify-between text-secondary font-bold"> 59 + Comments 60 60 <button 61 61 className="text-tertiary" 62 62 onClick={() => ··· 75 75 </div> 76 76 )} 77 77 <hr className="border-border-light" /> 78 - <div className="flex flex-col gap-4 py-2"> 78 + <div className="flex flex-col gap-6 py-2"> 79 79 {comments 80 80 .sort((a, b) => { 81 81 let aRecord = a.record as PubLeafletComment.Record; ··· 119 119 }) => { 120 120 const did = props.comment.bsky_profiles?.did; 121 121 122 - let timeAgoDate = timeAgo(props.record.createdAt, { compact: true }); 122 + let timeAgoDate = timeAgo(props.record.createdAt); 123 + const formattedDate = useLocalizedDate(props.record.createdAt, { 124 + year: "numeric", 125 + month: "long", 126 + day: "2-digit", 127 + }); 123 128 124 129 return ( 125 130 <div id={props.comment.uri} className="comment"> 126 - <div className="flex gap-2 items-center"> 131 + <div className="flex gap-2"> 127 132 {did ? ( 128 133 <ProfilePopover 129 134 didOrHandle={did} 130 135 trigger={ 131 - <div className="text-sm text-secondary font-bold hover:underline"> 136 + <div className="text-sm text-tertiary font-bold hover:underline"> 132 137 {props.profile.displayName} 133 138 </div> 134 139 } 135 140 /> 136 141 ) : null} 137 - 138 - <div className="w-1 h-1 rounded-full bg-border shrink-0" /> 139 142 <div className="text-sm text-tertiary">{timeAgoDate}</div> 140 143 </div> 141 144 {props.record.attachment && ··· 207 210 setReplyBoxOpen(false); 208 211 }} 209 212 > 210 - <CommentTiny className="text-border" />{" "} 211 - {replies.length !== 0 && replies.length} 213 + <CommentTiny className="text-border" /> {replies.length} 212 214 </button> 213 215 {identity?.atp_did && ( 214 216 <>
+4 -7
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 1 "use client"; 2 2 import { Media } from "components/Media"; 3 - import { MentionsDrawerContent } from "./Quotes"; 3 + import { Quotes } from "./Quotes"; 4 4 import { InteractionState, useInteractionState } from "./Interactions"; 5 5 import { Json } from "supabase/database.types"; 6 - import { Comment, CommentsDrawerContent } from "./Comments"; 6 + import { Comment, Comments } from "./Comments"; 7 7 import { useSearchParams } from "next/navigation"; 8 8 import { SandwichSpacer } from "components/LeafletLayout"; 9 9 import { decodeQuotePosition } from "../quotePosition"; ··· 42 42 className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`} 43 43 > 44 44 {drawer.drawer === "quotes" ? ( 45 - <MentionsDrawerContent 46 - {...props} 47 - quotesAndMentions={filteredQuotesAndMentions} 48 - /> 45 + <Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} /> 49 46 ) : ( 50 - <CommentsDrawerContent 47 + <Comments 51 48 document_uri={props.document_uri} 52 49 comments={filteredComments} 53 50 pageId={props.pageId}
+134 -104
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 24 24 import { CommentTiny } from "components/Icons/CommentTiny"; 25 25 import { QuoteTiny } from "components/Icons/QuoteTiny"; 26 26 import { ThreadLink, QuotesLink } from "../PostLinks"; 27 - import { BskyPostContent } from "../BskyPostContent"; 28 27 29 28 // Helper to get SWR key for quotes 30 29 export function getQuotesSWRKey(uris: string[]) { ··· 62 61 } 63 62 } 64 63 65 - export const MentionsDrawerContent = (props: { 64 + export const Quotes = (props: { 66 65 quotesAndMentions: { uri: string; link?: string }[]; 67 66 did: string; 68 67 }) => { ··· 86 85 }); 87 86 88 87 return ( 89 - <div className="relative w-full flex justify-between "> 90 - <button 91 - className="text-tertiary absolute top-0 right-0" 92 - onClick={() => setInteractionState(document_uri, { drawerOpen: false })} 93 - > 94 - <CloseTiny /> 95 - </button> 88 + <div className="flex flex-col gap-2"> 89 + <div className="w-full flex justify-between text-secondary font-bold"> 90 + Quotes 91 + <button 92 + className="text-tertiary" 93 + onClick={() => 94 + setInteractionState(document_uri, { drawerOpen: false }) 95 + } 96 + > 97 + <CloseTiny /> 98 + </button> 99 + </div> 96 100 {props.quotesAndMentions.length === 0 ? ( 97 101 <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 98 102 <div className="font-bold">no quotes yet!</div> ··· 104 108 <DotLoader /> 105 109 </div> 106 110 ) : ( 107 - <div className="flex flex-col gap-8 w-full"> 108 - {quotesWithLinks.length > 0 && ( 109 - <div className="flex flex-col w-full"> 110 - <h4 className="mb-2">Quotes on Bluesky</h4> 111 - {/* Quotes with links (quoted content) */} 112 - {quotesWithLinks.map((q, index) => { 113 - return ( 114 - <> 115 - <Quote 116 - key={q.uri} 117 - q={q} 118 - index={index} 119 - did={props.did} 120 - postViewMap={postViewMap} 121 - /> 122 - {quotesWithLinks.length !== index + 1 && ( 123 - <hr className="border-border-light my-4" /> 124 - )} 125 - </> 126 - ); 127 - })} 128 - </div> 129 - )} 111 + <div className="quotes flex flex-col gap-8"> 112 + {/* Quotes with links (quoted content) */} 113 + {quotesWithLinks.map((q, index) => { 114 + const pv = postViewMap.get(q.uri); 115 + if (!pv || !q.link) return null; 116 + const url = new URL(q.link); 117 + const quoteParam = url.pathname.split("/l-quote/")[1]; 118 + if (!quoteParam) return null; 119 + const quotePosition = decodeQuotePosition(quoteParam); 120 + if (!quotePosition) return null; 121 + return ( 122 + <div key={`quote-${index}`} className="flex flex-col "> 123 + <QuoteContent 124 + index={index} 125 + did={props.did} 126 + position={quotePosition} 127 + /> 128 + 129 + <div className="h-5 w-1 ml-5 border-l border-border-light" /> 130 + <BskyPost 131 + uri={pv.uri} 132 + rkey={new AtUri(pv.uri).rkey} 133 + content={pv.record.text as string} 134 + user={pv.author.displayName || pv.author.handle} 135 + profile={pv.author} 136 + handle={pv.author.handle} 137 + replyCount={pv.replyCount} 138 + quoteCount={pv.quoteCount} 139 + /> 140 + </div> 141 + ); 142 + })} 143 + 130 144 {/* Direct post mentions (without quoted content) */} 131 145 {directMentions.length > 0 && ( 132 - <div className="flex flex-col"> 133 - <h4 className="mb-2">Mentions on Bluesky</h4> 134 - {directMentions.map((q, index) => { 135 - const post = postViewMap.get(q.uri); 136 - if (!post) return null; 137 - 138 - const parent = { type: "thread" as const, uri: q.uri }; 139 - return ( 140 - <> 141 - <BskyPostContent 146 + <div className="flex flex-col gap-4"> 147 + <div className="text-secondary font-bold">Post Mentions</div> 148 + <div className="flex flex-col gap-8"> 149 + {directMentions.map((q, index) => { 150 + const pv = postViewMap.get(q.uri); 151 + if (!pv) return null; 152 + return ( 153 + <BskyPost 142 154 key={`mention-${index}`} 143 - post={post} 144 - parent={parent} 145 - showBlueskyLink={true} 146 - showEmbed={true} 147 - avatarSize="medium" 148 - quoteEnabled 149 - replyEnabled 150 - className="text-sm" 151 - compactEmbed 155 + uri={pv.uri} 156 + rkey={new AtUri(pv.uri).rkey} 157 + content={pv.record.text as string} 158 + user={pv.author.displayName || pv.author.handle} 159 + profile={pv.author} 160 + handle={pv.author.handle} 161 + replyCount={pv.replyCount} 162 + quoteCount={pv.quoteCount} 152 163 /> 153 - {directMentions.length !== index + 1 && ( 154 - <hr className="border-border-light my-4" /> 155 - )} 156 - </> 157 - ); 158 - })} 164 + ); 165 + })} 166 + </div> 159 167 </div> 160 168 )} 161 169 </div> ··· 164 172 ); 165 173 }; 166 174 167 - const Quote = (props: { 168 - q: { 169 - uri: string; 170 - link?: string; 171 - }; 172 - index: number; 173 - did: string; 174 - postViewMap: Map<string, PostView>; 175 - }) => { 176 - const post = props.postViewMap.get(props.q.uri); 177 - if (!post || !props.q.link) return null; 178 - const parent = { type: "thread" as const, uri: props.q.uri }; 179 - const url = new URL(props.q.link); 180 - const quoteParam = url.pathname.split("/l-quote/")[1]; 181 - if (!quoteParam) return null; 182 - const quotePosition = decodeQuotePosition(quoteParam); 183 - if (!quotePosition) return null; 184 - 185 - return ( 186 - <div key={`quote-${props.index}`} className="flex flex-col w-full"> 187 - <QuoteContent 188 - index={props.index} 189 - did={props.did} 190 - position={quotePosition} 191 - /> 192 - 193 - <div className="h-3 w-1 ml-[11px] border-l border-border-light" /> 194 - <BskyPostContent 195 - post={post} 196 - parent={parent} 197 - showBlueskyLink={true} 198 - showEmbed={false} 199 - avatarSize="medium" 200 - quoteEnabled 201 - replyEnabled 202 - className="text-sm" 203 - /> 204 - </div> 205 - ); 206 - }; 207 - 208 175 export const QuoteContent = (props: { 209 176 position: QuotePosition; 210 177 index: number; ··· 239 206 className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" 240 207 onClick={(e) => { 241 208 if (props.position.pageId) 242 - flushSync(() => 243 - openPage(undefined, { type: "doc", id: props.position.pageId! }), 244 - ); 209 + flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! })); 245 210 let scrollMargin = isMobile 246 211 ? 16 247 212 : e.currentTarget.getBoundingClientRect().top; 248 213 let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`; 249 - let scrollContainer = 250 - window.document.getElementById(scrollContainerId); 214 + let scrollContainer = window.document.getElementById(scrollContainerId); 251 215 let el = window.document.getElementById( 252 216 props.position.start.block.join("."), 253 217 ); ··· 272 236 blocks={content} 273 237 did={props.did} 274 238 preview 275 - className="py-0! px-0! text-tertiary" 239 + className="py-0!" 276 240 /> 241 + </div> 242 + </div> 243 + </div> 244 + ); 245 + }; 246 + 247 + export const BskyPost = (props: { 248 + uri: string; 249 + rkey: string; 250 + content: string; 251 + user: string; 252 + handle: string; 253 + profile: ProfileViewBasic; 254 + replyCount?: number; 255 + quoteCount?: number; 256 + }) => { 257 + const handleOpenThread = () => { 258 + openPage(undefined, { type: "thread", uri: props.uri }); 259 + }; 260 + 261 + return ( 262 + <div 263 + onClick={handleOpenThread} 264 + className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal cursor-pointer hover:bg-bg-page rounded" 265 + > 266 + {props.profile.avatar && ( 267 + <img 268 + className="rounded-full w-6 h-6 shrink-0" 269 + src={props.profile.avatar} 270 + alt={props.profile.displayName} 271 + /> 272 + )} 273 + <div className="flex flex-col min-w-0"> 274 + <div className="flex items-center gap-2 flex-wrap"> 275 + <div className="font-bold">{props.user}</div> 276 + <a 277 + className="text-tertiary hover:underline" 278 + href={`https://bsky.app/profile/${props.handle}`} 279 + target="_blank" 280 + onClick={(e) => e.stopPropagation()} 281 + > 282 + @{props.handle} 283 + </a> 284 + </div> 285 + <div className="text-primary">{props.content}</div> 286 + <div className="flex gap-2 items-center mt-1"> 287 + {props.replyCount != null && props.replyCount > 0 && ( 288 + <ThreadLink 289 + threadUri={props.uri} 290 + onClick={(e) => e.stopPropagation()} 291 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 292 + > 293 + <CommentTiny /> 294 + {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} 295 + </ThreadLink> 296 + )} 297 + {props.quoteCount != null && props.quoteCount > 0 && ( 298 + <QuotesLink 299 + postUri={props.uri} 300 + onClick={(e) => e.stopPropagation()} 301 + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" 302 + > 303 + <QuoteTiny /> 304 + {props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"} 305 + </QuotesLink> 306 + )} 277 307 </div> 278 308 </div> 279 309 </div>
+2
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 28 28 import { PubCodeBlock } from "./Blocks/PubCodeBlock"; 29 29 import { AppBskyFeedDefs } from "@atproto/api"; 30 30 import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock"; 31 + import { openPage } from "./PostPages"; 32 + import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; 31 33 import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock"; 32 34 import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; 33 35 import { PollData } from "./fetchPollData";
+5 -6
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
··· 55 55 56 56 // Link component for opening thread pages with prefetching 57 57 export function ThreadLink(props: { 58 - postUri: string; 58 + threadUri: string; 59 59 parent?: OpenPage; 60 60 children: React.ReactNode; 61 61 className?: string; 62 62 onClick?: (e: React.MouseEvent) => void; 63 63 }) { 64 - const { postUri, parent, children, className, onClick } = props; 64 + const { threadUri, parent, children, className, onClick } = props; 65 65 66 66 const handleClick = (e: React.MouseEvent) => { 67 - e.stopPropagation(); 68 67 onClick?.(e); 69 68 if (e.defaultPrevented) return; 70 - openPage(parent, { type: "thread", uri: postUri }); 69 + openPage(parent, { type: "thread", uri: threadUri }); 71 70 }; 72 71 73 72 const handlePrefetch = () => { 74 - prefetchThread(postUri); 73 + prefetchThread(threadUri); 75 74 }; 76 75 77 76 return ( ··· 97 96 const { postUri, parent, children, className, onClick } = props; 98 97 99 98 const handleClick = (e: React.MouseEvent) => { 100 - e.stopPropagation(); 101 99 onClick?.(e); 102 100 if (e.defaultPrevented) return; 103 101 openPage(parent, { type: "quotes", uri: postUri }); ··· 106 104 const handlePrefetch = () => { 107 105 prefetchQuotes(postUri); 108 106 }; 107 + 109 108 return ( 110 109 <button 111 110 className={className}
+2 -20
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 111 111 const pageKey = getPageKey(page); 112 112 const parentKey = parent ? getPageKey(parent) : undefined; 113 113 114 - // Check if the page is already open 115 - const currentState = usePostPageUIState.getState(); 116 - const existingPageIndex = currentState.pages.findIndex( 117 - (p) => getPageKey(p) === pageKey, 118 - ); 119 - 120 - // If page is already open, just scroll to it 121 - if (existingPageIndex !== -1) { 122 - if (options?.scrollIntoView !== false) { 123 - scrollIntoView(`post-page-${pageKey}`); 124 - } 125 - return; 126 - } 127 - 128 114 flushSync(() => { 129 115 usePostPageUIState.setState((state) => { 130 116 let parentPosition = state.pages.findIndex( 131 117 (s) => getPageKey(s) === parentKey, 132 118 ); 133 - // Close any pages after the parent and add the new page 134 119 return { 135 120 pages: 136 121 parentPosition === -1 ··· 142 127 }); 143 128 144 129 if (options?.scrollIntoView !== false) { 145 - // Use requestAnimationFrame to ensure the DOM has been painted before scrolling 146 - requestAnimationFrame(() => { 147 - scrollIntoView(`post-page-${pageKey}`); 148 - }); 130 + scrollIntoView(`post-page-${pageKey}`); 149 131 } 150 132 }; 151 133 ··· 315 297 <Fragment key={pageKey}> 316 298 <SandwichSpacer /> 317 299 <ThreadPageComponent 318 - parentUri={openPage.uri} 300 + threadUri={openPage.uri} 319 301 pageId={pageKey} 320 302 hasPageBackground={hasPageBackground} 321 303 pageOptions={
+137 -151
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 6 6 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 7 7 import { DotLoader } from "components/utils/DotLoader"; 8 8 import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 9 + import { openPage } from "./PostPages"; 9 10 import { useThreadState } from "src/useThreadState"; 10 - import { 11 - BskyPostContent, 12 - CompactBskyPostContent, 13 - ClientDate, 14 - } from "./BskyPostContent"; 11 + import { BskyPostContent, ClientDate } from "./BskyPostContent"; 15 12 import { 16 13 ThreadLink, 17 14 getThreadKey, ··· 28 25 type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 29 26 30 27 export function ThreadPage(props: { 31 - parentUri: string; 28 + threadUri: string; 32 29 pageId: string; 33 30 pageOptions?: React.ReactNode; 34 31 hasPageBackground: boolean; 35 32 }) { 36 - const { parentUri, pageId, pageOptions } = props; 37 - const drawer = useDrawerOpen(parentUri); 33 + const { threadUri, pageId, pageOptions } = props; 34 + const drawer = useDrawerOpen(threadUri); 38 35 39 36 const { 40 37 data: thread, 41 38 isLoading, 42 39 error, 43 - } = useSWR(parentUri ? getThreadKey(parentUri) : null, () => 44 - fetchThread(parentUri), 40 + } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => 41 + fetchThread(threadUri), 45 42 ); 46 43 47 44 return ( ··· 49 46 pageType="doc" 50 47 fullPageScroll={false} 51 48 id={`post-page-${pageId}`} 52 - drawerOpen={false} 49 + drawerOpen={!!drawer} 53 50 pageOptions={pageOptions} 54 - fixedWidth 55 51 > 56 - <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 w-full"> 52 + <div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4"> 57 53 {isLoading ? ( 58 54 <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 59 55 <span>loading thread</span> ··· 64 60 Failed to load thread 65 61 </div> 66 62 ) : thread ? ( 67 - <ThreadContent post={thread} parentUri={parentUri} /> 63 + <ThreadContent thread={thread} threadUri={threadUri} /> 68 64 ) : null} 69 65 </div> 70 66 </PageWrapper> 71 67 ); 72 68 } 73 69 74 - function ThreadContent(props: { post: ThreadType; parentUri: string }) { 75 - const { post, parentUri } = props; 70 + function ThreadContent(props: { thread: ThreadType; threadUri: string }) { 71 + const { thread, threadUri } = props; 76 72 const mainPostRef = useRef<HTMLDivElement>(null); 77 73 78 74 // Scroll the main post into view when the thread loads ··· 85 81 } 86 82 }, []); 87 83 88 - if (AppBskyFeedDefs.isNotFoundPost(post)) { 84 + if (AppBskyFeedDefs.isNotFoundPost(thread)) { 89 85 return <PostNotAvailable />; 90 86 } 91 87 92 - if (AppBskyFeedDefs.isBlockedPost(post)) { 88 + if (AppBskyFeedDefs.isBlockedPost(thread)) { 93 89 return ( 94 90 <div className="text-tertiary italic text-sm text-center py-8"> 95 91 This post is blocked ··· 97 93 ); 98 94 } 99 95 100 - if (!AppBskyFeedDefs.isThreadViewPost(post)) { 96 + if (!AppBskyFeedDefs.isThreadViewPost(thread)) { 101 97 return <PostNotAvailable />; 102 98 } 103 99 104 100 // Collect all parent posts in order (oldest first) 105 101 const parents: ThreadViewPost[] = []; 106 - let currentParent = post.parent; 102 + let currentParent = thread.parent; 107 103 while (currentParent && AppBskyFeedDefs.isThreadViewPost(currentParent)) { 108 104 parents.unshift(currentParent); 109 105 currentParent = currentParent.parent; 110 106 } 111 107 112 108 return ( 113 - <div 114 - className={`threadContent flex flex-col gap-0 w-full ${parents.length !== 0 && "pt-1"}`} 115 - > 116 - {/* grandparent posts, if any */} 117 - {parents.map((parentPost, index) => ( 118 - <ThreadPost 119 - key={parentPost.post.uri} 120 - post={parentPost} 121 - isMainPost={false} 122 - pageUri={parentUri} 123 - /> 109 + <div className="flex flex-col gap-0"> 110 + {/* Parent posts */} 111 + {parents.map((parent, index) => ( 112 + <div key={parent.post.uri} className="flex flex-col"> 113 + <ThreadPost 114 + post={parent} 115 + isMainPost={false} 116 + showReplyLine={index < parents.length - 1 || true} 117 + threadUri={threadUri} 118 + /> 119 + </div> 124 120 ))} 125 121 126 122 {/* Main post */} 127 123 <div ref={mainPostRef}> 128 - <ThreadPost post={post} isMainPost={true} pageUri={parentUri} /> 124 + <ThreadPost 125 + post={thread} 126 + isMainPost={true} 127 + showReplyLine={false} 128 + threadUri={threadUri} 129 + /> 129 130 </div> 130 131 131 132 {/* Replies */} 132 - {post.replies && post.replies.length > 0 && ( 133 - <div className="threadReplies flex flex-col mt-4 pt-4 border-t border-border-light w-full"> 133 + {thread.replies && thread.replies.length > 0 && ( 134 + <div className="flex flex-col mt-2 pt-2 border-t border-border-light"> 135 + <div className="text-tertiary text-xs font-bold mb-2 px-2"> 136 + Replies 137 + </div> 134 138 <Replies 135 - replies={post.replies as any[]} 136 - pageUri={post.post.uri} 137 - parentPostUri={post.post.uri} 139 + replies={thread.replies as any[]} 140 + threadUri={threadUri} 138 141 depth={0} 139 - parentAuthorDid={post.post.author.did} 142 + parentAuthorDid={thread.post.author.did} 140 143 /> 141 144 </div> 142 145 )} ··· 147 150 function ThreadPost(props: { 148 151 post: ThreadViewPost; 149 152 isMainPost: boolean; 150 - pageUri: string; 153 + showReplyLine: boolean; 154 + threadUri: string; 151 155 }) { 152 - const { post, isMainPost, pageUri } = props; 156 + const { post, isMainPost, showReplyLine, threadUri } = props; 153 157 const postView = post.post; 154 - const page = { type: "thread" as const, uri: pageUri }; 155 - 156 - if (isMainPost) { 157 - return ( 158 - <div className="threadMainPost flex gap-2 relative w-full"> 159 - <BskyPostContent 160 - post={postView} 161 - parent={page} 162 - avatarSize="large" 163 - showBlueskyLink={true} 164 - showEmbed={true} 165 - compactEmbed 166 - quoteEnabled 167 - /> 168 - </div> 169 - ); 170 - } 158 + const parent = { type: "thread" as const, uri: threadUri }; 171 159 172 160 return ( 173 - <div className="threadGrandparentPost flex gap-2 relative w-full pl-[6px] pb-2"> 174 - <div className="absolute top-0 bottom-0 left-[6px] w-5 "> 175 - <div className="bg-border-light w-[2px] h-full mx-auto" /> 176 - </div> 177 - <CompactBskyPostContent 161 + <div className="flex gap-2 relative"> 162 + {/* Reply line connector */} 163 + {showReplyLine && ( 164 + <div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" /> 165 + )} 166 + 167 + <BskyPostContent 178 168 post={postView} 179 - parent={page} 180 - quoteEnabled 181 - replyEnabled 169 + parent={parent} 170 + linksEnabled={!isMainPost} 171 + showBlueskyLink={true} 172 + showEmbed={true} 182 173 /> 183 174 </div> 184 175 ); ··· 186 177 187 178 function Replies(props: { 188 179 replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 180 + threadUri: string; 189 181 depth: number; 190 182 parentAuthorDid?: string; 191 - pageUri: string; 192 - parentPostUri: string; 193 183 }) { 194 - const { replies, depth, parentAuthorDid, pageUri, parentPostUri } = props; 184 + const { replies, threadUri, depth, parentAuthorDid } = props; 195 185 const collapsedThreads = useThreadState((s) => s.collapsedThreads); 196 186 const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 197 187 ··· 211 201 : replies; 212 202 213 203 return ( 214 - <div className="replies flex flex-col gap-0 w-full"> 204 + <div className="flex flex-col gap-0"> 215 205 {sortedReplies.map((reply, index) => { 216 206 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 217 207 return ( 218 208 <div 219 209 key={`not-found-${index}`} 220 - className="text-tertiary italic text-sm px-t py-6 opaque-container text-center justify-center my-2" 210 + className="text-tertiary italic text-xs py-2 px-2" 221 211 > 222 212 Post not found 223 213 </div> ··· 228 218 return ( 229 219 <div 230 220 key={`blocked-${index}`} 231 - className="text-tertiary italic text-sm px-t py-6 opaque-container text-center justify-center my-2" 221 + className="text-tertiary italic text-xs py-2 px-2" 232 222 > 233 223 Post blocked 234 224 </div> ··· 241 231 242 232 const hasReplies = reply.replies && reply.replies.length > 0; 243 233 const isCollapsed = collapsedThreads.has(reply.post.uri); 234 + const replyCount = reply.replies?.length ?? 0; 244 235 245 236 return ( 246 - <ReplyPost 247 - key={reply.post.uri} 248 - post={reply} 249 - isLast={index === replies.length - 1 && !hasReplies} 250 - pageUri={pageUri} 251 - parentPostUri={parentPostUri} 252 - toggleCollapsed={(uri) => toggleCollapsed(uri)} 253 - isCollapsed={isCollapsed} 254 - depth={props.depth} 255 - /> 237 + <div key={reply.post.uri} className="flex flex-col"> 238 + <ReplyPost 239 + post={reply} 240 + showReplyLine={hasReplies || index < replies.length - 1} 241 + isLast={index === replies.length - 1 && !hasReplies} 242 + threadUri={threadUri} 243 + /> 244 + {hasReplies && depth < 3 && ( 245 + <div className="ml-2 flex"> 246 + {/* Clickable collapse line - w-8 matches avatar width, centered line aligns with avatar center */} 247 + <button 248 + onClick={(e) => { 249 + e.stopPropagation(); 250 + toggleCollapsed(reply.post.uri); 251 + }} 252 + className="group w-8 flex justify-center cursor-pointer shrink-0" 253 + aria-label={ 254 + isCollapsed ? "Expand replies" : "Collapse replies" 255 + } 256 + > 257 + <div className="w-0.5 h-full bg-border-light group-hover:bg-accent-contrast group-hover:w-1 transition-all" /> 258 + </button> 259 + {isCollapsed ? ( 260 + <button 261 + onClick={(e) => { 262 + e.stopPropagation(); 263 + toggleCollapsed(reply.post.uri); 264 + }} 265 + className="text-xs text-accent-contrast hover:underline py-1 pl-1" 266 + > 267 + Show {replyCount} {replyCount === 1 ? "reply" : "replies"} 268 + </button> 269 + ) : ( 270 + <div className="grow"> 271 + <Replies 272 + replies={reply.replies as any[]} 273 + threadUri={threadUri} 274 + depth={depth + 1} 275 + parentAuthorDid={reply.post.author.did} 276 + /> 277 + </div> 278 + )} 279 + </div> 280 + )} 281 + {hasReplies && depth >= 3 && ( 282 + <ThreadLink 283 + threadUri={reply.post.uri} 284 + parent={{ type: "thread", uri: threadUri }} 285 + className="ml-12 text-xs text-accent-contrast hover:underline py-1" 286 + > 287 + View more replies 288 + </ThreadLink> 289 + )} 290 + </div> 256 291 ); 257 292 })} 258 - {pageUri && depth > 0 && replies.length > 3 && ( 259 - <ThreadLink 260 - postUri={pageUri} 261 - parent={{ type: "thread", uri: pageUri }} 262 - className="flex justify-start text-sm text-accent-contrast h-fit hover:underline" 263 - > 264 - <div className="mx-[19px] w-0.5 h-[24px] bg-border-light" /> 265 - View {replies.length - 3} more{" "} 266 - {replies.length === 4 ? "reply" : "replies"} 267 - </ThreadLink> 268 - )} 269 293 </div> 270 294 ); 271 295 } 272 296 273 - const ReplyPost = (props: { 297 + function ReplyPost(props: { 274 298 post: ThreadViewPost; 299 + showReplyLine: boolean; 275 300 isLast: boolean; 276 - pageUri: string; 277 - parentPostUri: string; 278 - toggleCollapsed: (uri: string) => void; 279 - isCollapsed: boolean; 280 - depth: number; 281 - }) => { 282 - const { post, pageUri, parentPostUri } = props; 301 + threadUri: string; 302 + }) { 303 + const { post, threadUri } = props; 283 304 const postView = post.post; 284 - 285 - const hasReplies = props.post.replies && props.post.replies.length > 0; 305 + const parent = { type: "thread" as const, uri: threadUri }; 286 306 287 307 return ( 288 - <div className="flex h-fit relative"> 289 - {props.depth > 0 && ( 290 - <> 291 - <div className="absolute replyLine top-0 bottom-0 left-0 w-6 pointer-events-none "> 292 - <div className="bg-border-light w-[2px] h-full mx-auto" /> 293 - </div> 294 - <button 295 - className="absolute top-0 bottom-0 left-0 w-6 z-10" 296 - onClick={(e) => { 297 - e.preventDefault(); 298 - e.stopPropagation(); 299 - 300 - props.toggleCollapsed(parentPostUri); 301 - console.log("reply clicked"); 302 - }} 303 - /> 304 - </> 305 - )} 306 - <div 307 - className={`reply relative flex flex-col w-full ${props.depth === 0 && "mb-3"}`} 308 - > 309 - <BskyPostContent 310 - post={postView} 311 - parent={{ type: "thread", uri: pageUri }} 312 - showEmbed={false} 313 - showBlueskyLink={false} 314 - quoteEnabled 315 - replyEnabled 316 - replyOnClick={(e) => { 317 - e.preventDefault(); 318 - props.toggleCollapsed(post.post.uri); 319 - }} 320 - className="text-sm" 321 - /> 322 - {hasReplies && props.depth < 3 && ( 323 - <div className="ml-[28px] flex grow "> 324 - {!props.isCollapsed && ( 325 - <Replies 326 - pageUri={pageUri} 327 - parentPostUri={post.post.uri} 328 - replies={props.post.replies as any[]} 329 - depth={props.depth + 1} 330 - parentAuthorDid={props.post.post.author.did} 331 - /> 332 - )} 333 - </div> 334 - )} 335 - </div> 308 + <div 309 + className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" 310 + onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} 311 + > 312 + <BskyPostContent 313 + post={postView} 314 + parent={parent} 315 + linksEnabled={true} 316 + avatarSize="sm" 317 + showEmbed={false} 318 + showBlueskyLink={false} 319 + onLinkClick={(e) => e.stopPropagation()} 320 + onEmbedClick={(e) => e.stopPropagation()} 321 + /> 336 322 </div> 337 323 ); 338 - }; 324 + }
+4 -7
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 35 35 sizes: "32x32", 36 36 type: "image/png", 37 37 }, 38 - other: [ 39 - { 40 - rel: "alternate", 41 - url: document.uri, 42 - }, 43 - { rel: "site.standard.document", url: document.uri }, 44 - ], 38 + other: { 39 + rel: "alternate", 40 + url: document.uri, 41 + }, 45 42 }, 46 43 title: 47 44 docRecord.title +
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 111 111 documents: { 112 112 uri: doc.uri, 113 113 indexed_at: doc.indexed_at, 114 - sort_date: doc.sort_date, 115 114 data: doc.data, 116 115 }, 117 116 },
+1
app/lish/[did]/[publication]/icon/route.ts
··· 14 14 request: NextRequest, 15 15 props: { params: Promise<{ did: string; publication: string }> }, 16 16 ) { 17 + console.log("are we getting here?"); 17 18 const params = await props.params; 18 19 try { 19 20 let did = decodeURIComponent(params.did);
+3 -7
app/lish/createPub/getPublicationURL.ts
··· 25 25 } 26 26 27 27 // Fall back to checking raw record for legacy base_path 28 - if ( 29 - isLeafletPublication(pub.record) && 30 - pub.record.base_path && 31 - isProductionDomain() 32 - ) { 28 + if (isLeafletPublication(pub.record) && pub.record.base_path && isProductionDomain()) { 33 29 return `https://${pub.record.base_path}`; 34 30 } 35 31 ··· 40 36 const normalized = normalizePublicationRecord(pub.record); 41 37 const aturi = new AtUri(pub.uri); 42 38 43 - //use rkey, fallback to name 44 - const name = aturi.rkey || normalized?.name; 39 + // Use normalized name if available, fall back to rkey 40 + const name = normalized?.name || aturi.rkey; 45 41 return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`; 46 42 }
+4 -1
app/lish/createPub/updatePublication.ts
··· 273 273 showPageBackground: boolean; 274 274 accentBackground: Color; 275 275 accentText: Color; 276 + headingFont?: string; 277 + bodyFont?: string; 276 278 }; 277 279 }): Promise<UpdatePublicationResult> { 278 280 return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 279 281 // Build theme object 280 282 const themeData = { 281 - $type: "pub.leaflet.publication#theme" as const, 282 283 backgroundImage: theme.backgroundImage 283 284 ? { 284 285 $type: "pub.leaflet.theme.backgroundImage", ··· 313 314 accentText: { 314 315 ...theme.accentText, 315 316 }, 317 + headingFont: theme.headingFont, 318 + bodyFont: theme.bodyFont, 316 319 }; 317 320 318 321 // Derive basicTheme from the theme colors for site.standard.publication
+4 -7
app/p/[didOrHandle]/[rkey]/page.tsx
··· 38 38 39 39 return { 40 40 icons: { 41 - other: [ 42 - { 43 - rel: "alternate", 44 - url: document.uri, 45 - }, 46 - { rel: "site.standard.document", url: document.uri }, 47 - ], 41 + other: { 42 + rel: "alternate", 43 + url: document.uri, 44 + }, 48 45 }, 49 46 title: docRecord.title, 50 47 description: docRecord?.description || "",
-24
appview/index.ts
··· 11 11 PubLeafletComment, 12 12 PubLeafletPollVote, 13 13 PubLeafletPollDefinition, 14 - PubLeafletInteractionsRecommend, 15 14 SiteStandardDocument, 16 15 SiteStandardPublication, 17 16 SiteStandardGraphSubscription, ··· 49 48 ids.PubLeafletComment, 50 49 ids.PubLeafletPollVote, 51 50 ids.PubLeafletPollDefinition, 52 - ids.PubLeafletInteractionsRecommend, 53 51 // ids.AppBskyActorProfile, 54 52 "app.bsky.feed.post", 55 53 ids.SiteStandardDocument, ··· 208 206 if (evt.event === "delete") { 209 207 await supabase 210 208 .from("atp_poll_records") 211 - .delete() 212 - .eq("uri", evt.uri.toString()); 213 - } 214 - } 215 - if (evt.collection === ids.PubLeafletInteractionsRecommend) { 216 - if (evt.event === "create" || evt.event === "update") { 217 - let record = PubLeafletInteractionsRecommend.validateRecord(evt.record); 218 - if (!record.success) return; 219 - await supabase 220 - .from("identities") 221 - .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 222 - let { error } = await supabase.from("recommends_on_documents").upsert({ 223 - uri: evt.uri.toString(), 224 - recommender_did: evt.did, 225 - document: record.value.subject, 226 - record: record.value as Json, 227 - }); 228 - if (error) console.log("Error upserting recommend:", error); 229 - } 230 - if (evt.event === "delete") { 231 - await supabase 232 - .from("recommends_on_documents") 233 209 .delete() 234 210 .eq("uri", evt.uri.toString()); 235 211 }
+6 -25
components/Avatar.tsx
··· 4 4 src: string | undefined; 5 5 displayName: string | undefined; 6 6 className?: string; 7 - size?: "tiny" | "small" | "medium" | "large" | "giant"; 7 + tiny?: boolean; 8 + large?: boolean; 9 + giant?: boolean; 8 10 }) => { 9 - let sizeClassName = 10 - props.size === "tiny" 11 - ? "w-4 h-4" 12 - : props.size === "small" 13 - ? "w-5 h-5" 14 - : props.size === "medium" 15 - ? "h-6 w-6" 16 - : props.size === "large" 17 - ? "w-8 h-8" 18 - : props.size === "giant" 19 - ? "h-16 w-16" 20 - : "w-6 h-6"; 21 - 22 11 if (props.src) 23 12 return ( 24 13 <img 25 - className={`${sizeClassName} relative rounded-full shrink-0 border border-border-light ${props.className}`} 14 + className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`} 26 15 src={props.src} 27 16 alt={ 28 17 props.displayName ··· 34 23 else 35 24 return ( 36 25 <div 37 - className={` relative bg-[var(--accent-light)] flex rounded-full shrink-0 border border-border-light place-items-center justify-center text-accent-1 ${sizeClassName}`} 26 + className={`bg-[var(--accent-light)] flex rounded-full shrink-0 border border-border-light place-items-center justify-center text-accent-1 ${props.tiny ? "w-4 h-4" : "w-5 h-5"}`} 38 27 > 39 - <AccountTiny 40 - className={ 41 - props.size === "tiny" 42 - ? "scale-80" 43 - : props.size === "small" 44 - ? "scale-90" 45 - : "" 46 - } 47 - /> 28 + <AccountTiny className={props.tiny ? "scale-80" : "scale-90"} /> 48 29 </div> 49 30 ); 50 31 };
+67 -141
components/Blocks/Block.tsx
··· 33 33 import { deepEquals } from "src/utils/deepEquals"; 34 34 import { isTextBlock } from "src/utils/isTextBlock"; 35 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 36 42 37 export type Block = { 43 38 factID: string; ··· 68 63 // Block handles all block level events like 69 64 // mouse events, keyboard events and longPress, and setting AreYouSure state 70 65 // and shared styling like padding and flex for list layouting 66 + let { rep } = useReplicache(); 71 67 let mouseHandlers = useBlockMouseHandlers(props); 72 68 let handleDrop = useHandleDrop({ 73 69 parent: props.parent, ··· 76 72 }); 77 73 let entity_set = useEntitySetContext(); 78 74 79 - let { isLongPress, longPressHandlers } = useLongPress(() => { 75 + let { isLongPress, handlers } = useLongPress(() => { 80 76 if (isTextBlock[props.type]) return; 81 77 if (isLongPress.current) { 82 78 focusBlock( ··· 89 85 let selected = useUIState( 90 86 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 91 87 ); 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 88 107 89 let [areYouSure, setAreYouSure] = useState(false); 108 90 useEffect(() => { ··· 116 98 117 99 return ( 118 100 <div 119 - {...(!props.preview ? { ...mouseHandlers, ...longPressHandlers } : {})} 101 + {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})} 120 102 id={ 121 103 !props.preview ? elementId.block(props.entityID).container : undefined 122 104 } ··· 135 117 blockWrapper relative 136 118 flex flex-row gap-2 137 119 px-3 sm:px-4 138 - z-1 w-full 139 - ${alignmentStyle} 140 120 ${ 141 121 !props.nextBlock 142 122 ? "pb-3 sm:pb-4" ··· 275 255 ) => { 276 256 // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers 277 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]; 278 272 279 273 if (!BlockTypeComponent) return <div>unknown block</div>; 280 274 return ( 281 - <> 275 + <div 276 + className={`blockContentWrapper w-full grow flex gap-2 z-1 ${alignmentStyle}`} 277 + > 282 278 {props.listData && <ListMarker {...props} />} 283 279 {props.areYouSure ? ( 284 280 <AreYouSure ··· 291 287 ) : ( 292 288 <BlockTypeComponent {...props} preview={props.preview} /> 293 289 )} 294 - </> 290 + </div> 295 291 ); 296 292 }; 297 293 ··· 330 326 s.selectedBlocks.length > 1, 331 327 ); 332 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 + 333 334 let nextBlockSelected = useUIState((s) => 334 335 s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), 335 336 ); ··· 337 338 s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), 338 339 ); 339 340 340 - if (isMultiselected) 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 + 341 345 return ( 342 346 <> 343 347 <div ··· 350 354 ${!prevBlockSelected && "rounded-t-md"} 351 355 ${!nextBlockSelected && "rounded-b-md"} 352 356 `} 353 - /> 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 + )} 354 383 </> 355 384 ); 356 385 }; 357 386 358 387 export const BlockLayout = (props: { 359 - isSelected: boolean; 388 + isSelected?: boolean; 360 389 children: React.ReactNode; 361 390 className?: string; 362 - optionsClassName?: string; 363 391 hasBackground?: "accent" | "page"; 364 392 borderOnHover?: boolean; 365 - hasAlignment?: boolean; 366 - areYouSure?: boolean; 367 - setAreYouSure?: (value: boolean) => void; 368 393 }) => { 369 - // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete 370 394 return ( 371 395 <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"} 396 + className={`block ${props.className} p-2 sm:p-3 w-full overflow-hidden 377 397 ${props.isSelected ? "block-border-selected " : "block-border"} 378 398 ${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}`} 399 + style={{ 400 + backgroundColor: 401 + props.hasBackground === "accent" 402 + ? "var(--accent-light)" 403 + : props.hasBackground === "page" 404 + ? "rgb(var(--bg-page))" 405 + : "transparent", 406 + }} 426 407 > 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> 408 + {props.children} 483 409 </div> 484 410 ); 485 411 };
+45 -70
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 10 10 AppBskyGraphDefs, 11 11 AppBskyLabelerDefs, 12 12 } from "@atproto/api"; 13 - import { Avatar } from "components/Avatar"; 14 - import { 15 - OpenPage, 16 - openPage, 17 - } from "app/lish/[did]/[publication]/[rkey]/PostPages"; 18 13 19 14 export const BlueskyEmbed = (props: { 20 15 embed: Exclude<AppBskyFeedDefs.PostView["embed"], undefined>; 21 16 postUrl?: string; 22 - className?: string; 23 - compact?: boolean; 24 - parent?: OpenPage; 25 17 }) => { 26 18 // check this file from bluesky for ref 27 19 // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx ··· 29 21 case AppBskyEmbedImages.isView(props.embed): 30 22 let imageEmbed = props.embed; 31 23 return ( 32 - <div className="imageEmbed flex flex-wrap rounded-md w-full overflow-hidden"> 24 + <div className="flex flex-wrap rounded-md w-full overflow-hidden"> 33 25 {imageEmbed.images.map( 34 26 ( 35 27 image: { ··· 76 68 let isGif = externalEmbed.external.uri.includes(".gif"); 77 69 if (isGif) { 78 70 return ( 79 - <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video w-full "> 71 + <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video"> 80 72 <img 81 73 src={externalEmbed.external.uri} 82 74 alt={externalEmbed.external.title} ··· 89 81 <a 90 82 href={externalEmbed.external.uri} 91 83 target="_blank" 92 - className={`externalLinkEmbed group border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border w-full ${props.compact ? "flex items-stretch" : "flex flex-col"} 93 - ${props.className}`} 84 + className="group flex flex-col border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border" 94 85 > 95 86 {externalEmbed.external.thumb === undefined ? null : ( 96 87 <> 97 - <div 98 - className={` overflow-hidden shrink-0 ${props.compact ? "aspect-square h-[113px] hidden sm:block" : "aspect-[1.91/1] w-full "}`} 99 - > 88 + <div className="w-full aspect-[1.91/1] overflow-hidden"> 100 89 <img 101 90 src={externalEmbed.external.thumb} 102 91 alt={externalEmbed.external.title} 103 - className={`object-cover ${props.compact ? "h-full" : "w-full h-full"}`} 92 + className="w-full h-full object-cover" 104 93 /> 105 94 </div> 106 - {!props.compact && <hr className="border-border-light" />} 95 + <hr className="border-border-light" /> 107 96 </> 108 97 )} 109 - <div 110 - className={`p-2 flex flex-col w-full min-w-0 ${props.compact && "sm:pl-3 py-1"}`} 111 - > 112 - <h4 className="truncate shrink-0" style={{ fontSize: "inherit" }}> 113 - {externalEmbed.external.title}{" "} 114 - </h4> 115 - <div className="grow"> 116 - <p className="text-secondary line-clamp-2"> 98 + <div className="p-2 flex flex-col gap-1"> 99 + <div className="flex flex-col"> 100 + <h4>{externalEmbed.external.title}</h4> 101 + <p className="text-secondary"> 117 102 {externalEmbed.external.description} 118 103 </p> 119 104 </div> 120 - 121 - <hr className="border-border-light my-1" /> 122 - <div className="text-tertiary text-xs shrink-0 sm:group-hover:text-accent-contrast truncate"> 105 + <hr className="border-border-light mt-1" /> 106 + <div className="text-tertiary text-xs sm:group-hover:text-accent-contrast"> 123 107 {externalEmbed.external.uri} 124 108 </div> 125 109 </div> ··· 132 116 : 16 / 9; 133 117 return ( 134 118 <div 135 - className={`videoEmbed rounded-md overflow-hidden relative w-full ${props.className}`} 119 + className="rounded-md overflow-hidden relative w-full" 136 120 style={{ aspectRatio: String(videoAspectRatio) }} 137 121 > 138 122 <img ··· 163 147 text = (record.value as AppBskyFeedPost.Record).text; 164 148 } 165 149 return ( 166 - <button 167 - className={`bskyPostEmbed text-left w-full flex gap-2 items-start relative overflow-hidden p-2! text-xs block-border hover:border-accent-contrast! `} 168 - onClick={(e) => { 169 - e.preventDefault(); 170 - e.stopPropagation(); 171 - 172 - openPage(props.parent, { type: "thread", uri: record.uri }); 173 - }} 150 + <div 151 + className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`} 174 152 > 175 - <Avatar 176 - src={record.author?.avatar} 177 - displayName={record.author?.displayName} 178 - size="small" 179 - /> 180 - <div className="flex flex-col "> 181 - <div className="flex gap-1"> 182 - <div className=" font-bold text-secondary mr-1"> 183 - {record.author?.displayName} 184 - </div> 185 - <a 186 - className="text-xs text-tertiary hover:underline" 187 - target="_blank" 188 - href={`https://bsky.app/profile/${record.author?.handle}`} 189 - > 190 - @{record.author?.handle} 191 - </a> 153 + <div className="bskyAuthor w-full flex items-center "> 154 + {record.author.avatar && ( 155 + <img 156 + src={record.author?.avatar} 157 + alt={`${record.author?.displayName}'s avatar`} 158 + className="shink-0 w-6 h-6 rounded-full border border-border-light mr-[6px]" 159 + /> 160 + )} 161 + <div className=" font-bold text-secondary mr-1"> 162 + {record.author?.displayName} 192 163 </div> 193 - <div className="flex flex-col gap-2 "> 194 - {text && ( 195 - <pre 196 - className={`whitespace-pre-wrap text-secondary ${props.compact ? "line-clamp-6" : ""}`} 197 - > 198 - {text} 199 - </pre> 200 - )} 201 - {/*{record.embeds !== undefined 202 - ? record.embeds.map((embed, index) => ( 203 - <BlueskyEmbed embed={embed} key={index} compact /> 204 - )) 205 - : null}*/} 206 - </div> 164 + <a 165 + className="text-xs text-tertiary hover:underline" 166 + target="_blank" 167 + href={`https://bsky.app/profile/${record.author?.handle}`} 168 + > 169 + @{record.author?.handle} 170 + </a> 171 + </div> 172 + 173 + <div className="flex flex-col gap-2 "> 174 + {text && ( 175 + <pre className="whitespace-pre-wrap text-secondary">{text}</pre> 176 + )} 177 + {record.embeds !== undefined 178 + ? record.embeds.map((embed, index) => ( 179 + <BlueskyEmbed embed={embed} key={index} /> 180 + )) 181 + : null} 207 182 </div> 208 - </button> 183 + </div> 209 184 ); 210 185 } 211 186 ··· 232 207 case AppBskyEmbedRecordWithMedia.isView(props.embed) && 233 208 AppBskyEmbedRecord.isViewRecord(props.embed.record.record): 234 209 return ( 235 - <div className={`bskyEmbed flex flex-col gap-2`}> 210 + <div className={`flex flex-col gap-2`}> 236 211 <BlueskyEmbed embed={props.embed.media} /> 237 212 <BlueskyEmbed 238 213 embed={{
+3 -1
components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx
··· 18 18 let isSelected = useUIState((s) => 19 19 s.selectedBlocks.find((b) => b.value === props.entityID), 20 20 ); 21 + let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 21 22 22 23 let entity_set = useEntitySetContext(); 23 24 let [urlValue, setUrlValue] = useState(""); ··· 90 91 className="w-full grow border-none outline-hidden bg-transparent " 91 92 placeholder="bsky.app/post-url" 92 93 value={urlValue} 94 + disabled={isLocked} 93 95 onChange={(e) => setUrlValue(e.target.value)} 94 96 onKeyDown={(e) => { 95 97 if (e.key === "Enter") { ··· 107 109 <button 108 110 type="submit" 109 111 id="bluesky-post-block-submit" 110 - className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 112 + className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 111 113 onMouseDown={(e) => { 112 114 e.preventDefault(); 113 115 errorSmokers(e.clientX + 12, e.clientY);
+65 -15
components/Blocks/BlueskyPostBlock/index.tsx
··· 6 6 import { elementId } from "src/utils/elementId"; 7 7 import { focusBlock } from "src/utils/focusBlock"; 8 8 import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; 9 - import { PostNotAvailable } from "./BlueskyEmbed"; 9 + import { BlueskyEmbed, PostNotAvailable } from "./BlueskyEmbed"; 10 10 import { BlueskyPostEmpty } from "./BlueskyEmpty"; 11 - 11 + import { BlueskyRichText } from "./BlueskyRichText"; 12 12 import { Separator } from "components/Layout"; 13 13 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 14 14 import { CommentTiny } from "components/Icons/CommentTiny"; 15 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 - import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent"; 17 - import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 18 16 19 17 export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => { 20 18 let { permissions } = useEntitySetContext(); ··· 78 76 79 77 //getting the url to the post 80 78 let postId = post.post.uri.split("/")[4]; 81 - let postView = post.post as PostView; 82 79 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 83 80 84 81 return ( 85 82 <BlockLayout 86 83 isSelected={!!isSelected} 87 84 hasBackground="page" 88 - borderOnHover 89 - className="blueskyPostBlock sm:px-3! sm:py-2! px-2! py-1!" 85 + className="flex flex-col gap-2 relative overflow-hidden group/blueskyPostBlock text-sm text-secondary" 90 86 > 91 - <BskyPostContent 92 - post={postView} 93 - parent={undefined} 94 - showBlueskyLink={true} 95 - showEmbed={true} 96 - avatarSize="large" 97 - className="text-sm text-secondary " 98 - /> 87 + {post.post.author && record && ( 88 + <> 89 + <div className="bskyAuthor w-full flex items-center gap-2"> 90 + {post.post.author?.avatar ? ( 91 + <img 92 + src={post.post.author?.avatar} 93 + alt={`${post.post.author?.displayName}'s avatar`} 94 + className="shrink-0 w-8 h-8 rounded-full border border-border-light" 95 + /> 96 + ) : ( 97 + <div className="shrink-0 w-8 h-8 rounded-full border border-border-light bg-border"></div> 98 + )} 99 + <div className="grow flex flex-col gap-0.5 leading-tight"> 100 + <div className=" font-bold text-secondary"> 101 + {post.post.author?.displayName} 102 + </div> 103 + <a 104 + className="text-xs text-tertiary hover:underline" 105 + target="_blank" 106 + href={`https://bsky.app/profile/${post.post.author?.handle}`} 107 + > 108 + @{post.post.author?.handle} 109 + </a> 110 + </div> 111 + </div> 112 + 113 + <div className="flex flex-col gap-2 "> 114 + <div> 115 + <pre className="whitespace-pre-wrap"> 116 + {BlueskyRichText({ 117 + record: record as AppBskyFeedPost.Record | null, 118 + })} 119 + </pre> 120 + </div> 121 + {post.post.embed && ( 122 + <BlueskyEmbed embed={post.post.embed} postUrl={url} /> 123 + )} 124 + </div> 125 + </> 126 + )} 127 + <div className="w-full flex gap-2 items-center justify-between"> 128 + {timestamp && <PostDate timestamp={timestamp} />} 129 + <div className="flex gap-2 items-center"> 130 + {post.post.replyCount != null && post.post.replyCount > 0 && ( 131 + <> 132 + <a 133 + className="flex items-center gap-1 hover:no-underline" 134 + target="_blank" 135 + href={url} 136 + > 137 + {post.post.replyCount} 138 + <CommentTiny /> 139 + </a> 140 + <Separator classname="h-4" /> 141 + </> 142 + )} 143 + 144 + <a className="" target="_blank" href={url}> 145 + <BlueskyTiny /> 146 + </a> 147 + </div> 148 + </div> 99 149 </BlockLayout> 100 150 ); 101 151 }
+15 -38
components/Blocks/ButtonBlock.tsx
··· 24 24 let isSelected = useUIState((s) => 25 25 s.selectedBlocks.find((b) => b.value === props.entityID), 26 26 ); 27 - let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 28 27 29 28 if (!url) { 30 29 if (!permissions.write) return null; ··· 32 31 } 33 32 34 33 return ( 35 - <BlockLayout 36 - isSelected={!!isSelected} 37 - borderOnHover 38 - hasAlignment={alignment !== "justify"} 39 - className={`p-0! rounded-md! border-none!`} 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!"}`} 40 38 > 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> 39 + <ButtonPrimary role="link" type="submit"> 40 + {text?.data.value} 41 + </ButtonPrimary> 42 + </a> 55 43 ); 56 44 }; 57 45 ··· 63 51 let isSelected = useUIState((s) => 64 52 s.selectedBlocks.find((b) => b.value === props.entityID), 65 53 ); 54 + let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 66 55 67 56 let [textValue, setTextValue] = useState(""); 68 57 let [urlValue, setUrlValue] = useState(""); 69 58 let text = textValue; 70 59 let url = urlValue; 71 - let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 72 60 73 61 let submit = async () => { 74 62 let entity = props.entityID; ··· 118 106 }; 119 107 120 108 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 - > 109 + <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full "> 110 + <ButtonPrimary className="mx-auto"> 137 111 {text !== "" ? text : "Button"} 138 112 </ButtonPrimary> 139 113 <BlockLayout ··· 193 167 <Separator /> 194 168 <Input 195 169 type="text" 170 + autoFocus 196 171 className="w-full grow border-none outline-hidden bg-transparent" 197 172 placeholder="button text" 198 173 value={textValue} 174 + disabled={isLocked} 199 175 onChange={(e) => setTextValue(e.target.value)} 200 176 onKeyDown={(e) => { 201 177 if ( ··· 218 194 className="w-full grow border-none outline-hidden bg-transparent" 219 195 placeholder="www.example.com" 220 196 value={urlValue} 197 + disabled={isLocked} 221 198 onChange={(e) => setUrlValue(e.target.value)} 222 199 onKeyDown={(e) => { 223 200 if (e.key === "Backspace" && !e.currentTarget.value) ··· 228 205 <button 229 206 id="button-block-settings" 230 207 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"}`} 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"}`} 232 209 > 233 210 <div className="sm:hidden block">Save</div> 234 211 <CheckTiny />
+76 -57
components/Blocks/CodeBlock.tsx
··· 14 14 import { flushSync } from "react-dom"; 15 15 import { elementId } from "src/utils/elementId"; 16 16 import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 17 - import { focusBlock } from "src/utils/focusBlock"; 18 17 19 18 export function CodeBlock(props: BlockProps) { 20 19 let { rep, rootEntity } = useReplicache(); ··· 43 42 }, [content, lang, theme]); 44 43 45 44 const onClick = useCallback((e: React.MouseEvent<HTMLElement>) => { 46 - focusBlock( 47 - { parent: props.parent, value: props.value, type: "code" }, 48 - { type: "end" }, 49 - ); 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); 50 68 }, []); 51 69 return ( 52 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 + 53 123 <BlockLayout 54 124 isSelected={focusedBlock} 55 125 hasBackground="accent" 56 126 borderOnHover 57 - className="p-0! min-h-10 sm:min-h-12" 127 + className="p-0! min-h-[48px]" 58 128 > 59 129 {focusedBlock && permissions.write ? ( 60 130 <BaseTextareaBlock ··· 101 171 /> 102 172 )} 103 173 </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 174 </div> 156 175 ); 157 176 }
+3 -2
components/Blocks/DateTimeBlock.tsx
··· 53 53 s.selectedBlocks.find((b) => b.value === props.entityID), 54 54 ); 55 55 56 + let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 56 57 let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 57 58 58 59 const handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { ··· 116 117 117 118 return ( 118 119 <Popover 119 - disabled={!permissions.write} 120 + disabled={isLocked || !permissions.write} 120 121 className="w-64 z-10 px-2!" 121 122 trigger={ 122 123 <BlockLayout ··· 132 133 {dateFact ? ( 133 134 <div 134 135 className={`font-bold 135 - ${!permissions.write ? "" : "group-hover/date:underline"} 136 + ${!permissions.write || isLocked ? "" : "group-hover/date:underline"} 136 137 `} 137 138 > 138 139 {selectedDate.toLocaleDateString(undefined, {
+4 -4
components/Blocks/EmbedBlock.tsx
··· 111 111 <div 112 112 data-draggable 113 113 className={`resizeHandle 114 - 115 - 116 114 cursor-ns-resize shrink-0 z-10 w-6 h-[5px] 117 - absolute bottom-[3px] right-1/2 translate-x-1/2 115 + absolute bottom-2 right-1/2 translate-x-1/2 translate-y-[2px] 118 116 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 119 117 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`} 120 118 {...heightHandle.handlers} ··· 131 129 let isSelected = useUIState((s) => 132 130 s.selectedBlocks.find((b) => b.value === props.entityID), 133 131 ); 132 + let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 134 133 135 134 let entity_set = useEntitySetContext(); 136 135 let [linkValue, setLinkValue] = useState(""); ··· 251 250 className="w-full grow border-none outline-hidden bg-transparent " 252 251 placeholder="www.example.com" 253 252 value={linkValue} 253 + disabled={isLocked} 254 254 onChange={(e) => setLinkValue(e.target.value)} 255 255 /> 256 256 <button 257 257 type="submit" 258 258 id="embed-block-submit" 259 259 disabled={loading} 260 - className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 260 + className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 261 261 onMouseDown={(e) => { 262 262 e.preventDefault(); 263 263 if (loading) return;
+3 -1
components/Blocks/ExternalLinkBlock.tsx
··· 118 118 let isSelected = useUIState((s) => 119 119 s.selectedBlocks.find((b) => b.value === props.entityID), 120 120 ); 121 + let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 121 122 let entity_set = useEntitySetContext(); 122 123 let [linkValue, setLinkValue] = useState(""); 123 124 let { rep } = useReplicache(); ··· 172 173 !props.preview ? elementId.block(props.entityID).input : undefined 173 174 } 174 175 type="url" 176 + disabled={isLocked} 175 177 className="w-full grow border-none outline-hidden bg-transparent " 176 178 placeholder="www.example.com" 177 179 value={linkValue} ··· 197 199 <div className="flex items-center gap-3 "> 198 200 <button 199 201 autoFocus={false} 200 - className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 202 + className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 201 203 onMouseDown={(e) => { 202 204 e.preventDefault(); 203 205 if (!linkValue || linkValue === "") {
+34 -43
components/Blocks/ImageBlock.tsx
··· 19 19 import { ImageAltSmall } from "components/Icons/ImageAlt"; 20 20 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 21 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"; 22 + import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 32 23 33 24 export function ImageBlock(props: BlockProps & { preview?: boolean }) { 34 25 let { rep } = useReplicache(); ··· 37 28 let isSelected = useUIState((s) => 38 29 s.selectedBlocks.find((b) => b.value === props.value), 39 30 ); 31 + let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 40 32 let isFullBleed = useEntity(props.value, "image/full-bleed")?.data.value; 41 33 let isFirst = props.previousBlock === null; 42 34 let isLast = props.nextBlock === null; ··· 92 84 return ( 93 85 <BlockLayout 94 86 hasBackground="accent" 95 - isSelected={!!isSelected} 87 + isSelected={!!isSelected && !isLocked} 96 88 borderOnHover 97 89 className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 98 90 > 99 91 <label 100 92 className={` 93 + 101 94 w-full h-full hover:cursor-pointer 102 95 flex flex-col items-center justify-center 103 - `} 96 + ${props.pageType === "canvas" && "bg-bg-page"}`} 104 97 onMouseDown={(e) => e.preventDefault()} 105 98 onDragOver={(e) => { 106 99 e.preventDefault(); ··· 109 102 onDrop={async (e) => { 110 103 e.preventDefault(); 111 104 e.stopPropagation(); 105 + if (isLocked) return; 112 106 const files = e.dataTransfer.files; 113 107 if (files && files.length > 0) { 114 108 const file = files[0]; ··· 125 119 Upload An Image 126 120 </div> 127 121 <input 122 + disabled={isLocked} 128 123 className="h-0 w-0 hidden" 129 124 type="file" 130 125 accept="image/*" ··· 139 134 ); 140 135 } 141 136 137 + let imageClassName = isFullBleed 138 + ? "" 139 + : isSelected 140 + ? "block-border-selected border-transparent! " 141 + : "block-border border-transparent!"; 142 + 142 143 let isLocalUpload = localImages.get(image.data.src); 143 144 144 145 let blockClassName = ` 145 146 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 && "-mx-3 sm:-mx-4"} 147 148 ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 148 149 ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} 149 150 `; 150 151 151 152 return ( 152 - <BlockLayout 153 - hasAlignment 154 - isSelected={!!isSelected} 155 - className={blockClassName} 156 - optionsClassName={isFullBleed ? "top-[-8px]!" : ""} 157 - > 153 + <BlockLayout isSelected={!!isSelected} className={blockClassName}> 158 154 {isLocalUpload || image.data.local ? ( 159 155 <img 160 156 loading="lazy" ··· 172 168 } 173 169 height={image?.data.height} 174 170 width={image?.data.width} 171 + className={imageClassName} 175 172 /> 176 173 )} 177 174 {altText !== undefined && !props.preview ? ( ··· 207 204 ); 208 205 209 206 // 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) 207 + if ( 208 + !isFocused || 209 + !pubData?.publications || 210 + !entity_set.permissions.write || 211 + coverImage 212 + ) 211 213 return null; 212 - if (coverImage) 213 - return ( 214 - <ButtonSecondary 215 - className="absolute top-2 right-2" 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" 216 219 onClick={async (e) => { 217 220 e.preventDefault(); 218 221 e.stopPropagation(); 219 222 await rep?.mutate.updatePublicationDraft({ 220 - cover_image: null, 223 + cover_image: props.entityID, 221 224 }); 222 225 }} 223 226 > 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> 227 + <span className="w-4 h-4 flex items-center justify-center"> 228 + <ImageCoverImage /> 229 + </span> 230 + Set as Cover 231 + </button> 232 + </div> 242 233 ); 243 234 }; 244 235
+3 -18
components/Blocks/MailboxBlock.tsx
··· 26 26 import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 27 27 import { InfoSmall } from "components/Icons/InfoSmall"; 28 28 29 - export const MailboxBlock = ( 30 - props: BlockProps & { 31 - areYouSure?: boolean; 32 - setAreYouSure?: (value: boolean) => void; 33 - }, 34 - ) => { 29 + export const MailboxBlock = (props: BlockProps) => { 35 30 let isSubscribed = useSubscriptionStatus(props.entityID); 36 31 let isSelected = useUIState((s) => 37 32 s.selectedBlocks.find((b) => b.value === props.entityID), ··· 46 41 let subscriber_count = useEntity(props.entityID, "mailbox/subscriber-count"); 47 42 if (!permission) 48 43 return ( 49 - <MailboxReaderView 50 - entityID={props.entityID} 51 - parent={props.parent} 52 - /> 44 + <MailboxReaderView entityID={props.entityID} parent={props.parent} /> 53 45 ); 54 46 55 47 return ( ··· 57 49 <BlockLayout 58 50 isSelected={!!isSelected} 59 51 hasBackground={"accent"} 60 - areYouSure={props.areYouSure} 61 - setAreYouSure={props.setAreYouSure} 62 52 className="flex gap-2 items-center justify-center" 63 53 > 64 54 <ButtonPrimary ··· 130 120 ); 131 121 }; 132 122 133 - const MailboxReaderView = (props: { 134 - entityID: string; 135 - parent: string; 136 - 137 - }) => { 123 + const MailboxReaderView = (props: { entityID: string; parent: string }) => { 138 124 let isSubscribed = useSubscriptionStatus(props.entityID); 139 125 let isSelected = useUIState((s) => 140 126 s.selectedBlocks.find((b) => b.value === props.entityID), ··· 147 133 <BlockLayout 148 134 isSelected={!!isSelected} 149 135 hasBackground={"accent"} 150 - 151 136 className="`h-full flex flex-col gap-2 items-center justify-center" 152 137 > 153 138 {!isSubscribed?.confirmed ? (
+1 -9
components/Blocks/PageLinkBlock.tsx
··· 13 13 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 14 14 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 15 15 16 - export function PageLinkBlock( 17 - props: BlockProps & { 18 - preview?: boolean; 19 - areYouSure?: boolean; 20 - setAreYouSure?: (value: boolean) => void; 21 - }, 22 - ) { 16 + export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { 23 17 let page = useEntity(props.entityID, "block/card"); 24 18 let type = 25 19 useEntity(page?.data.value || null, "page/type")?.data.value || "doc"; ··· 38 32 <BlockLayout 39 33 hasBackground="page" 40 34 isSelected={!!isSelected} 41 - areYouSure={props.areYouSure} 42 - setAreYouSure={props.setAreYouSure} 43 35 className={`cursor-pointer 44 36 pageLinkBlockWrapper relative group/pageLinkBlock 45 37 flex overflow-clip p-0!
+2 -14
components/Blocks/PollBlock/index.tsx
··· 20 20 import { PublicationPollBlock } from "../PublicationPollBlock"; 21 21 import { usePollBlockUIState } from "./pollBlockState"; 22 22 23 - export const PollBlock = ( 24 - props: BlockProps & { 25 - areYouSure?: boolean; 26 - setAreYouSure?: (value: boolean) => void; 27 - }, 28 - ) => { 23 + export const PollBlock = (props: BlockProps) => { 29 24 let { data: pub } = useLeafletPublicationData(); 30 25 if (!pub) return <LeafletPollBlock {...props} />; 31 26 return <PublicationPollBlock {...props} />; 32 27 }; 33 28 34 - export const LeafletPollBlock = ( 35 - props: BlockProps & { 36 - areYouSure?: boolean; 37 - setAreYouSure?: (value: boolean) => void; 38 - }, 39 - ) => { 29 + export const LeafletPollBlock = (props: BlockProps) => { 40 30 let isSelected = useUIState((s) => 41 31 s.selectedBlocks.find((b) => b.value === props.entityID), 42 32 ); ··· 74 64 <BlockLayout 75 65 isSelected={!!isSelected} 76 66 hasBackground={"accent"} 77 - areYouSure={props.areYouSure} 78 - setAreYouSure={props.setAreYouSure} 79 67 className="poll flex flex-col gap-2 w-full" 80 68 > 81 69 {pollState === "editing" ? (
+2 -10
components/Blocks/PublicationPollBlock.tsx
··· 21 21 * It allows adding/editing options when the poll hasn't been published yet, 22 22 * but disables adding new options once the poll record exists (indicated by pollUri). 23 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(); 24 + export const PublicationPollBlock = (props: BlockProps) => { 25 + let { data: publicationData, normalizedDocument } = useLeafletPublicationData(); 32 26 let isSelected = useUIState((s) => 33 27 s.selectedBlocks.find((b) => b.value === props.entityID), 34 28 ); ··· 63 57 className="poll flex flex-col gap-2" 64 58 hasBackground={"accent"} 65 59 isSelected={!!isSelected} 66 - areYouSure={props.areYouSure} 67 - setAreYouSure={props.setAreYouSure} 68 60 > 69 61 <EditPollForPublication 70 62 entityID={props.entityID}
+1 -8
components/Blocks/RSVPBlock/index.tsx
··· 24 24 } 25 25 | { state: "contact_details"; status: RSVP_Status }; 26 26 27 - export function RSVPBlock( 28 - props: BlockProps & { 29 - areYouSure?: boolean; 30 - setAreYouSure?: (value: boolean) => void; 31 - }, 32 - ) { 27 + export function RSVPBlock(props: BlockProps) { 33 28 let isSelected = useUIState((s) => 34 29 s.selectedBlocks.find((b) => b.value === props.entityID), 35 30 ); ··· 37 32 <BlockLayout 38 33 isSelected={!!isSelected} 39 34 hasBackground={"accent"} 40 - areYouSure={props.areYouSure} 41 - setAreYouSure={props.setAreYouSure} 42 35 className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center" 43 36 > 44 37 <RSVPForm entityID={props.entityID} />
+11 -5
components/Blocks/TextBlock/index.tsx
··· 30 30 import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 31 31 32 32 const HeadingStyle = { 33 - 1: "text-xl font-bold", 34 - 2: "text-lg font-bold", 35 - 3: "text-base font-bold text-secondary ", 33 + 1: "text-xl font-bold [font-family:var(--theme-heading-font)]", 34 + 2: "text-lg font-bold [font-family:var(--theme-heading-font)]", 35 + 3: "text-base font-bold text-secondary [font-family:var(--theme-heading-font)]", 36 36 } as { [level: number]: string }; 37 37 38 38 export function TextBlock( ··· 41 41 preview?: boolean; 42 42 }, 43 43 ) { 44 + let isLocked = useEntity(props.entityID, "block/is-locked"); 44 45 let initialized = useHasPageLoaded(); 45 46 let first = props.previousBlock === null; 46 47 let permission = useEntitySetContext().permissions.write; 47 48 48 49 return ( 49 50 <> 50 - {(!initialized || !permission || props.preview) && ( 51 + {(!initialized || 52 + !permission || 53 + props.preview || 54 + isLocked?.data.value) && ( 51 55 <RenderedTextBlock 52 56 type={props.type} 53 57 entityID={props.entityID} ··· 57 61 previousBlock={props.previousBlock} 58 62 /> 59 63 )} 60 - {permission && !props.preview && ( 64 + {permission && !props.preview && !isLocked?.data.value && ( 61 65 <div 62 66 className={`w-full relative group ${!initialized ? "hidden" : ""}`} 63 67 > ··· 326 330 let { editorState } = props; 327 331 let rep = useReplicache(); 328 332 let smoker = useSmoker(); 333 + let isLocked = useEntity(props.entityID, "block/is-locked"); 329 334 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 330 335 331 336 let isBlueskyPost = ··· 335 340 // if its bluesky, change text to embed post 336 341 337 342 if ( 343 + !isLocked && 338 344 focused && 339 345 editorState && 340 346 betterIsUrl(editorState.doc.textContent) &&
+3 -1
components/Blocks/index.tsx
··· 181 181 : null, 182 182 ); 183 183 184 + let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked"); 184 185 if (!entity_set.permissions.write) return null; 185 186 if ( 186 - (props.lastBlock?.type === "text" || props.lastBlock?.type === "heading") && 187 + ((props.lastBlock?.type === "text" && !isLocked?.data.value) || 188 + props.lastBlock?.type === "heading") && 187 189 (!editorState?.editor || editorState.editor.doc.content.size <= 2) 188 190 ) 189 191 return null;
+15 -5
components/Blocks/useBlockKeyboardHandlers.ts
··· 23 23 ) { 24 24 let { rep, undoManager } = useReplicache(); 25 25 let entity_set = useEntitySetContext(); 26 + let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 26 27 27 28 let isSelected = useUIState((s) => { 28 29 let selectedBlocks = s.selectedBlocks; ··· 69 70 entity_set, 70 71 areYouSure, 71 72 setAreYouSure, 73 + isLocked, 72 74 }); 73 75 undoManager.endGroup(); 74 76 }; 75 77 window.addEventListener("keydown", listener); 76 78 return () => window.removeEventListener("keydown", listener); 77 - }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure]); 79 + }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure, isLocked]); 78 80 } 79 81 80 82 type Args = { 81 83 e: KeyboardEvent; 84 + isLocked: boolean; 82 85 props: BlockProps; 83 86 rep: Replicache<ReplicacheMutators>; 84 87 entity_set: { set: string }; ··· 130 133 } 131 134 132 135 let debounced: null | number = null; 133 - async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { 136 + async function Backspace({ 137 + e, 138 + props, 139 + rep, 140 + areYouSure, 141 + setAreYouSure, 142 + isLocked, 143 + }: Args) { 134 144 // if this is a textBlock, let the textBlock/keymap handle the backspace 145 + if (isLocked) return; 135 146 // if its an input, label, or teatarea with content, do nothing (do the broswer default instead) 136 147 let el = e.target as HTMLElement; 137 148 if ( ··· 143 154 if ((el as HTMLInputElement).value !== "") return; 144 155 } 145 156 146 - // if the block is a card, mailbox, rsvp, or poll... 157 + // if the block is a card or mailbox... 147 158 if ( 148 159 props.type === "card" || 149 160 props.type === "mailbox" || 150 - props.type === "rsvp" || 151 - props.type === "poll" 161 + props.type === "rsvp" 152 162 ) { 153 163 // ...and areYouSure state is false, set it to true 154 164 if (!areYouSure) {
+3 -21
components/Blocks/useBlockMouseHandlers.ts
··· 1 1 import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 - import { MouseEvent, useCallback } from "react"; 2 + import { MouseEvent, useCallback, useRef } from "react"; 3 3 import { useUIState } from "src/useUIState"; 4 4 import { Block } from "./Block"; 5 5 import { isTextBlock } from "src/utils/isTextBlock"; ··· 12 12 import { elementId } from "src/utils/elementId"; 13 13 14 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 15 export function useBlockMouseHandlers(props: Block) { 34 16 let entity_set = useEntitySetContext(); 35 17 let isMobile = useIsMobile(); ··· 40 22 if ((e.target as Element).tagName === "BUTTON") return; 41 23 if ((e.target as Element).tagName === "SELECT") return; 42 24 if ((e.target as Element).tagName === "OPTION") return; 43 - if (isMobile && isScrolling) return; 25 + if (isMobile) return; 44 26 if (!entity_set.permissions.write) return; 45 27 useSelectingMouse.setState({ start: props.value }); 46 28 if (e.shiftKey) { ··· 75 57 ); 76 58 let onMouseEnter = useCallback( 77 59 async (e: MouseEvent) => { 78 - if (isMobile && isScrolling) return; 60 + if (isMobile) return; 79 61 if (!entity_set.permissions.write) return; 80 62 if (debounce) window.clearTimeout(debounce); 81 63 debounce = window.setTimeout(async () => {
+34 -47
components/Canvas.tsx
··· 22 22 import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 25 - import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; 26 25 27 26 export function Canvas(props: { 28 27 entityID: string; ··· 287 286 }, 288 287 [props, rep, permissions], 289 288 ); 290 - let { dragDelta, handlers: dragHandlers } = useDrag({ 289 + let { dragDelta, handlers } = useDrag({ 291 290 onDragEnd, 291 + delay: isMobile, 292 292 }); 293 293 294 294 let widthOnDragEnd = useCallback( ··· 335 335 ); 336 336 let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); 337 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 - ); 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 + }); 352 350 let angle = 0; 353 351 if (rotateHandle.dragDelta) { 354 352 let originX = rect.x + rect.width / 2; ··· 385 383 }; 386 384 }, [props, type?.data.value]); 387 385 useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); 388 - let mouseHandlers = useBlockMouseHandlers(blockProps); 389 - 390 386 let isList = useEntity(props.entityID, "block/is-list"); 391 387 let isFocused = useUIState( 392 388 (s) => s.focusedEntity?.entityID === props.entityID, ··· 395 391 return ( 396 392 <div 397 393 ref={ref} 398 - {...(!props.preview ? { ...longPressHandlers, ...mouseHandlers } : {})} 394 + {...(!props.preview ? { ...longPressHandlers } : {})} 395 + {...(isMobile && permissions.write ? { ...handlers } : {})} 399 396 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 `} 397 + className={`absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} 401 398 style={{ 402 399 top: 0, 403 400 left: 0, ··· 406 403 transform, 407 404 }} 408 405 > 409 - {!props.preview && permissions.write && ( 410 - <Gripper isFocused={isFocused} {...dragHandlers} /> 411 - )} 412 - 406 + {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} 407 + {!props.preview && permissions.write && <Gripper {...handlers} />} 413 408 <div 414 - className={` w-full ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 409 + className={`contents ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 415 410 > 416 411 <BaseBlock 417 412 {...blockProps} ··· 429 424 <div 430 425 className={`resizeHandle 431 426 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 - `} 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]`} 439 431 {...widthHandle.handlers} 440 432 /> 441 433 )} ··· 444 436 <div 445 437 className={`rotateHandle 446 438 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 439 + hidden group-hover/canvas-block:block 440 + w-[8px] h-[8px] 441 + absolute bottom-0 -right-0 450 442 -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 - `} 443 + rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 454 444 {...rotateHandle.handlers} 455 445 /> 456 446 )} ··· 571 561 } 572 562 }; 573 563 574 - const Gripper = (props: { 575 - onMouseDown: (e: React.MouseEvent) => void; 576 - isFocused: boolean; 577 - }) => { 564 + const Gripper = (props: { onMouseDown: (e: React.MouseEvent) => void }) => { 578 565 return ( 579 566 <div 580 567 onMouseDown={props.onMouseDown} 581 568 onPointerDown={props.onMouseDown} 582 - className="gripper w-[9px] shrink-0 py-1 mr-1 cursor-grab touch-none" 569 + className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none" 583 570 > 584 - <div className="h-full grid grid-cols-1 grid-rows-1 "> 571 + <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 "> 585 572 {/* the gripper is two svg's stacked on top of each other. 586 573 One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */} 587 574 <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"}`} 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" 589 576 style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }} 590 577 /> 591 578 <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"}`} 579 + className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block" 593 580 style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }} 594 581 /> 595 - </div> 582 + </Media> 596 583 </div> 597 584 ); 598 585 };
-7
components/DesktopFooter.tsx
··· 4 4 import { Toolbar } from "./Toolbar"; 5 5 import { useEntitySetContext } from "./EntitySetProvider"; 6 6 import { focusBlock } from "src/utils/focusBlock"; 7 - import { hasBlockToolbar } from "app/[leaflet_id]/Footer"; 8 - import { useEntity } from "src/replicache"; 9 7 10 8 export function DesktopPageFooter(props: { pageID: string }) { 11 9 let focusedEntity = useUIState((s) => s.focusedEntity); ··· 15 13 : focusedEntity?.parent; 16 14 let entity_set = useEntitySetContext(); 17 15 18 - let blockType = useEntity(focusedEntity?.entityID || null, "block/type")?.data 19 - .value; 20 - 21 16 return ( 22 17 <Media 23 18 mobile={false} ··· 25 20 > 26 21 {focusedEntity && 27 22 focusedEntity.entityType === "block" && 28 - hasBlockToolbar(blockType) && 29 23 entity_set.permissions.write && 30 24 focusedBlockParentID === props.pageID && ( 31 25 <div ··· 35 29 }} 36 30 > 37 31 <Toolbar 38 - blockType={blockType} 39 32 pageID={focusedBlockParentID} 40 33 blockID={focusedEntity.entityID} 41 34 />
+120
components/FontLoader.tsx
··· 1 + // Server-side font loading component 2 + // Following Google's best practices: https://web.dev/articles/font-best-practices 3 + // - Preconnect to font origins for early connection 4 + // - Use font-display: swap (shows fallback immediately, swaps when ready) 5 + // - Don't block rendering - some FOUT is acceptable and better UX than invisible text 6 + 7 + import { 8 + getFontConfig, 9 + generateFontFaceCSS, 10 + getFontPreloadLinks, 11 + getGoogleFontsUrl, 12 + getFontFamilyValue, 13 + } from "src/fonts"; 14 + 15 + type FontLoaderProps = { 16 + headingFontId: string | undefined; 17 + bodyFontId: string | undefined; 18 + }; 19 + 20 + export function FontLoader({ headingFontId, bodyFontId }: FontLoaderProps) { 21 + const headingFont = getFontConfig(headingFontId); 22 + const bodyFont = getFontConfig(bodyFontId); 23 + 24 + // Collect all unique fonts to load 25 + const fontsToLoad = headingFont.id === bodyFont.id 26 + ? [headingFont] 27 + : [headingFont, bodyFont]; 28 + 29 + // Collect preload links (deduplicated) 30 + const preloadLinksSet = new Set<string>(); 31 + const preloadLinks: { href: string; type: string }[] = []; 32 + for (const font of fontsToLoad) { 33 + for (const link of getFontPreloadLinks(font)) { 34 + if (!preloadLinksSet.has(link.href)) { 35 + preloadLinksSet.add(link.href); 36 + preloadLinks.push(link); 37 + } 38 + } 39 + } 40 + 41 + // Collect font-face CSS 42 + const fontFaceCSS = fontsToLoad 43 + .map((font) => generateFontFaceCSS(font)) 44 + .filter(Boolean) 45 + .join("\n\n"); 46 + 47 + // Collect Google Fonts URLs (deduplicated) 48 + const googleFontsUrls = [...new Set( 49 + fontsToLoad 50 + .map((font) => getGoogleFontsUrl(font)) 51 + .filter((url): url is string => url !== null) 52 + )]; 53 + 54 + const headingFontValue = getFontFamilyValue(headingFont); 55 + const bodyFontValue = getFontFamilyValue(bodyFont); 56 + 57 + // Generate CSS that sets the font family via CSS variables 58 + // --theme-font is used for body text (keeps backwards compatibility) 59 + // --theme-heading-font is used for headings 60 + const fontVariableCSS = ` 61 + :root { 62 + --theme-heading-font: ${headingFontValue}; 63 + --theme-font: ${bodyFontValue}; 64 + } 65 + `.trim(); 66 + 67 + return ( 68 + <> 69 + {/* 70 + Google Fonts best practice: preconnect to both origins 71 + - fonts.googleapis.com serves the CSS 72 + - fonts.gstatic.com serves the font files (needs crossorigin for CORS) 73 + Place these as early as possible in <head> 74 + */} 75 + {googleFontsUrls.length > 0 && ( 76 + <> 77 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 78 + <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> 79 + {googleFontsUrls.map((url) => ( 80 + <link key={url} rel="stylesheet" href={url} /> 81 + ))} 82 + </> 83 + )} 84 + {/* Preload local font files for early discovery */} 85 + {preloadLinks.map((link) => ( 86 + <link 87 + key={link.href} 88 + rel="preload" 89 + href={link.href} 90 + as="font" 91 + type={link.type} 92 + crossOrigin="anonymous" 93 + /> 94 + ))} 95 + {/* @font-face declarations (for local fonts) and CSS variable */} 96 + <style 97 + dangerouslySetInnerHTML={{ 98 + __html: `${fontFaceCSS}\n\n${fontVariableCSS}`, 99 + }} 100 + /> 101 + </> 102 + ); 103 + } 104 + 105 + // Helper to extract fonts from facts array (for server-side use) 106 + export function extractFontsFromFacts( 107 + facts: Array<{ entity: string; attribute: string; data: { value: string } }>, 108 + rootEntity: string 109 + ): { headingFontId: string | undefined; bodyFontId: string | undefined } { 110 + const headingFontFact = facts.find( 111 + (f) => f.entity === rootEntity && f.attribute === "theme/heading-font" 112 + ); 113 + const bodyFontFact = facts.find( 114 + (f) => f.entity === rootEntity && f.attribute === "theme/body-font" 115 + ); 116 + return { 117 + headingFontId: headingFontFact?.data?.value, 118 + bodyFontId: bodyFontFact?.data?.value, 119 + }; 120 + }
-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 - import { Props } from "./Props"; 2 - 3 1 export const ImageCoverImage = () => ( 4 2 <svg 5 3 width="24" ··· 14 12 /> 15 13 </svg> 16 14 ); 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 -2
components/Pages/Page.tsx
··· 80 80 onClickAction?: (e: React.MouseEvent) => void; 81 81 pageType: "canvas" | "doc"; 82 82 drawerOpen: boolean | undefined; 83 - fixedWidth?: boolean; 84 83 }) => { 85 84 const cardBorderHidden = useCardBorderHidden(); 86 85 let { ref } = usePreserveScroll<HTMLDivElement>(props.id); ··· 113 112 } 114 113 ${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 115 114 ${props.fullPageScroll && "max-w-full "} 116 - ${props.pageType === "doc" && !props.fullPageScroll ? (props.fixedWidth ? "w-[10000px] sm:max-w-prose max-w-[var(--page-width-units)]" : "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]") : ""} 115 + ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 117 116 ${ 118 117 props.pageType === "canvas" && 119 118 !props.fullPageScroll &&
+1 -1
components/Pages/PageOptions.tsx
··· 93 93 94 94 <PageOptionButton 95 95 secondary 96 - onClick={() => undoManager.redo()} 96 + onClick={() => undoManager.undo()} 97 97 disabled={!undoState.canRedo} 98 98 > 99 99 <RedoTiny />
+1 -1
components/Popover/index.tsx
··· 43 43 <RadixPopover.Content 44 44 className={` 45 45 z-20 bg-bg-page 46 - px-3 py-2 text-primary 46 + px-3 py-2 47 47 max-w-(--radix-popover-content-available-width) 48 48 max-h-(--radix-popover-content-available-height) 49 49 border border-border rounded-md shadow-md
+67 -5
components/SelectionManager/index.tsx
··· 17 17 import { schema } from "../Blocks/TextBlock/schema"; 18 18 import { MarkType } from "prosemirror-model"; 19 19 import { useSelectingMouse, getSortedSelection } from "./selectionState"; 20 - import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 21 20 22 21 //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 23 22 // How does this relate to *when dragging* ? ··· 241 240 shift: true, 242 241 key: ["ArrowDown", "J"], 243 242 handler: async () => { 244 - if (!rep) return; 245 - await moveBlockDown(rep, entity_set.set); 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 + } 246 272 }, 247 273 }, 248 274 { ··· 250 276 shift: true, 251 277 key: ["ArrowUp", "K"], 252 278 handler: async () => { 253 - if (!rep) return; 254 - await moveBlockUp(rep); 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 + } 255 317 }, 256 318 }, 257 319
+137
components/ThemeManager/Pickers/TextPickers.tsx
··· 1 + "use client"; 2 + 3 + import { Color } from "react-aria-components"; 4 + import { Input } from "components/Input"; 5 + import { useState } from "react"; 6 + import { useEntity, useReplicache } from "src/replicache"; 7 + import { Menu } from "components/Menu"; 8 + import { pickers } from "../ThemeSetter"; 9 + import { ColorPicker } from "./ColorPicker"; 10 + import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 11 + import { useIsMobile } from "src/hooks/isMobile"; 12 + import { fonts, defaultFontId, FontConfig } from "src/fonts"; 13 + 14 + export const TextColorPicker = (props: { 15 + openPicker: pickers; 16 + setOpenPicker: (thisPicker: pickers) => void; 17 + value: Color; 18 + setValue: (c: Color) => void; 19 + }) => { 20 + return ( 21 + <ColorPicker 22 + label="Text" 23 + value={props.value} 24 + setValue={props.setValue} 25 + thisPicker={"text"} 26 + openPicker={props.openPicker} 27 + setOpenPicker={props.setOpenPicker} 28 + closePicker={() => props.setOpenPicker("null")} 29 + /> 30 + ); 31 + }; 32 + 33 + type FontAttribute = "theme/heading-font" | "theme/body-font"; 34 + 35 + export const FontPicker = (props: { 36 + label: string; 37 + entityID: string; 38 + attribute: FontAttribute; 39 + }) => { 40 + let isMobile = useIsMobile(); 41 + let { rep } = useReplicache(); 42 + let [searchValue, setSearchValue] = useState(""); 43 + let currentFont = useEntity(props.entityID, props.attribute); 44 + let fontId = currentFont?.data.value || defaultFontId; 45 + let font = fonts[fontId] || fonts[defaultFontId]; 46 + 47 + let fontList = Object.values(fonts); 48 + let filteredFonts = fontList 49 + .filter((f) => { 50 + const matchesSearch = f.displayName 51 + .toLocaleLowerCase() 52 + .includes(searchValue.toLocaleLowerCase()); 53 + return matchesSearch; 54 + }) 55 + .sort((a, b) => { 56 + return a.displayName.localeCompare(b.displayName); 57 + }); 58 + 59 + return ( 60 + <Menu 61 + asChild 62 + trigger={ 63 + <button className="flex gap-2 items-center w-full !outline-none min-w-0"> 64 + <div 65 + className={`w-6 h-6 rounded-md border border-border relative text-sm bg-bg-page shrink-0 ${props.label === "Heading" ? "font-bold" : "text-secondary"}`} 66 + > 67 + <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 "> 68 + Aa 69 + </div> 70 + </div> 71 + <div className="font-bold shrink-0">{props.label}</div> 72 + <div className="truncate">{font.displayName}</div> 73 + </button> 74 + } 75 + side={isMobile ? "bottom" : "right"} 76 + align="start" 77 + className="w-[250px] !gap-0 !outline-none max-h-72 " 78 + > 79 + <Input 80 + value={searchValue} 81 + className="px-3 pb-1 appearance-none !outline-none bg-transparent" 82 + placeholder="search..." 83 + onChange={(e) => { 84 + setSearchValue(e.currentTarget.value); 85 + }} 86 + /> 87 + <hr className="mx-2 border-border" /> 88 + <div className="flex flex-col h-full overflow-auto gap-0 pt-1"> 89 + {filteredFonts.map((fontOption) => { 90 + return ( 91 + <FontOption 92 + key={fontOption.id} 93 + onSelect={() => { 94 + rep?.mutate.assertFact({ 95 + entity: props.entityID, 96 + attribute: props.attribute, 97 + data: { type: "string", value: fontOption.id }, 98 + }); 99 + }} 100 + font={fontOption} 101 + selected={fontOption.id === fontId} 102 + /> 103 + ); 104 + })} 105 + </div> 106 + </Menu> 107 + ); 108 + }; 109 + 110 + const FontOption = (props: { 111 + onSelect: () => void; 112 + font: FontConfig; 113 + selected: boolean; 114 + }) => { 115 + return ( 116 + <DropdownMenu.RadioItem 117 + value={props.font.id} 118 + onSelect={props.onSelect} 119 + className={` 120 + fontOption 121 + z-10 px-1 py-0.5 122 + text-left text-secondary 123 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 124 + hover:bg-border-light hover:text-secondary 125 + outline-none 126 + cursor-pointer 127 + 128 + `} 129 + > 130 + <div 131 + className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 132 + > 133 + {props.font.displayName} 134 + </div> 135 + </DropdownMenu.RadioItem> 136 + ); 137 + };
+106
components/ThemeManager/PubPickers/PubFontPicker.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { Menu } from "components/Menu"; 5 + import { Input } from "components/Input"; 6 + import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 7 + import { useIsMobile } from "src/hooks/isMobile"; 8 + import { fonts, defaultFontId, FontConfig } from "src/fonts"; 9 + 10 + export const PubFontPicker = (props: { 11 + label: string; 12 + value: string | undefined; 13 + onChange: (fontId: string) => void; 14 + }) => { 15 + let isMobile = useIsMobile(); 16 + let [searchValue, setSearchValue] = useState(""); 17 + let fontId = props.value || defaultFontId; 18 + let font = fonts[fontId] || fonts[defaultFontId]; 19 + 20 + let fontList = Object.values(fonts); 21 + let filteredFonts = fontList 22 + .filter((f) => { 23 + const matchesSearch = f.displayName 24 + .toLocaleLowerCase() 25 + .includes(searchValue.toLocaleLowerCase()); 26 + return matchesSearch; 27 + }) 28 + .sort((a, b) => { 29 + return a.displayName.localeCompare(b.displayName); 30 + }); 31 + 32 + return ( 33 + <Menu 34 + asChild 35 + trigger={ 36 + <button className="flex gap-2 items-center w-full !outline-none min-w-0"> 37 + <div 38 + className={`w-6 h-6 rounded-md border border-border relative text-sm bg-bg-page shrink-0 ${props.label === "Heading" ? "font-bold" : "text-secondary"}`} 39 + > 40 + <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 "> 41 + Aa 42 + </div> 43 + </div> 44 + <div className="font-bold shrink-0">{props.label}</div> 45 + <div className="truncate">{font.displayName}</div> 46 + </button> 47 + } 48 + side={isMobile ? "bottom" : "right"} 49 + align="start" 50 + className="w-[250px] !gap-0 !outline-none max-h-72 " 51 + > 52 + <Input 53 + value={searchValue} 54 + className="px-3 pb-1 appearance-none !outline-none bg-transparent" 55 + placeholder="search..." 56 + onChange={(e) => { 57 + setSearchValue(e.currentTarget.value); 58 + }} 59 + /> 60 + <hr className="mx-2 border-border" /> 61 + <div className="flex flex-col h-full overflow-auto gap-0 pt-1"> 62 + {filteredFonts.map((fontOption) => { 63 + return ( 64 + <FontOption 65 + key={fontOption.id} 66 + onSelect={() => { 67 + props.onChange(fontOption.id); 68 + }} 69 + font={fontOption} 70 + selected={fontOption.id === fontId} 71 + /> 72 + ); 73 + })} 74 + </div> 75 + </Menu> 76 + ); 77 + }; 78 + 79 + const FontOption = (props: { 80 + onSelect: () => void; 81 + font: FontConfig; 82 + selected: boolean; 83 + }) => { 84 + return ( 85 + <DropdownMenu.RadioItem 86 + value={props.font.id} 87 + onSelect={props.onSelect} 88 + className={` 89 + fontOption 90 + z-10 px-1 py-0.5 91 + text-left text-secondary 92 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 93 + hover:bg-border-light hover:text-secondary 94 + outline-none 95 + cursor-pointer 96 + 97 + `} 98 + > 99 + <div 100 + className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 101 + > 102 + {props.font.displayName} 103 + </div> 104 + </DropdownMenu.RadioItem> 105 + ); 106 + };
-14
components/ThemeManager/PubPickers/PubTextPickers.tsx
··· 26 26 openPicker={props.openPicker} 27 27 setOpenPicker={props.setOpenPicker} 28 28 /> 29 - {/* FONT PICKERS HIDDEN FOR NOW */} 30 - {/* <hr className="border-border-light" /> 31 - <div className="flex gap-2"> 32 - <div className="w-6 h-6 font-bold text-center rounded-md bg-border-light"> 33 - Aa 34 - </div> 35 - <div className="font-bold">Header</div> <div>iA Writer</div> 36 - </div> 37 - <div className="flex gap-2"> 38 - <div className="w-6 h-6 place-items-center text-center rounded-md bg-border-light"> 39 - Aa 40 - </div>{" "} 41 - <div className="font-bold">Body</div> <div>iA Writer</div> 42 - </div> */} 43 29 </div> 44 30 ); 45 31 };
+17
components/ThemeManager/PubThemeSetter.tsx
··· 20 20 import { useToaster } from "components/Toast"; 21 21 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 22 22 import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter"; 23 + import { PubFontPicker } from "./PubPickers/PubFontPicker"; 23 24 24 25 export type ImageState = { 25 26 src: string; ··· 60 61 let [pageWidth, setPageWidth] = useState<number>( 61 62 record?.theme?.pageWidth || 624, 62 63 ); 64 + let [headingFont, setHeadingFont] = useState<string | undefined>(record?.theme?.headingFont); 65 + let [bodyFont, setBodyFont] = useState<string | undefined>(record?.theme?.bodyFont); 63 66 let pubBGImage = image?.src || null; 64 67 let leafletBGRepeat = image?.repeat || null; 65 68 let toaster = useToaster(); ··· 85 88 primary: ColorToRGB(localPubTheme.primary), 86 89 accentBackground: ColorToRGB(localPubTheme.accent1), 87 90 accentText: ColorToRGB(localPubTheme.accent2), 91 + headingFont: headingFont, 92 + bodyFont: bodyFont, 88 93 }, 89 94 }); 90 95 ··· 189 194 setOpenPicker={(pickers) => setOpenPicker(pickers)} 190 195 hasPageBackground={showPageBackground} 191 196 /> 197 + <div className="bg-bg-page p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))] flex flex-col gap-1"> 198 + <PubFontPicker 199 + label="Heading" 200 + value={headingFont} 201 + onChange={setHeadingFont} 202 + /> 203 + <PubFontPicker 204 + label="Body" 205 + value={bodyFont} 206 + onChange={setBodyFont} 207 + /> 208 + </div> 192 209 <PubAccentPickers 193 210 accent1={localPubTheme.accent1} 194 211 setAccent1={(color) => {
+70 -1
components/ThemeManager/ThemeProvider.tsx
··· 22 22 PublicationThemeProvider, 23 23 } from "./PublicationThemeProvider"; 24 24 import { getColorDifference } from "./themeUtils"; 25 + import { getFontConfig, getGoogleFontsUrl, getFontFamilyValue } from "src/fonts"; 25 26 26 27 // define a function to set an Aria Color to a CSS Variable in RGB 27 28 function setCSSVariableToColor( ··· 38 39 local?: boolean; 39 40 children: React.ReactNode; 40 41 className?: string; 42 + initialHeadingFontId?: string; 43 + initialBodyFontId?: string; 41 44 }) { 42 45 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 43 46 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; ··· 56 59 entityID: string | null; 57 60 local?: boolean; 58 61 children: React.ReactNode; 62 + initialHeadingFontId?: string; 63 + initialBodyFontId?: string; 59 64 }) { 60 65 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 61 66 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); ··· 76 81 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 77 82 78 83 let pageWidth = useEntity(props.entityID, "theme/page-width"); 84 + // Use initial font IDs as fallback until Replicache syncs 85 + let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value ?? props.initialHeadingFontId; 86 + let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value ?? props.initialBodyFontId; 79 87 80 88 return ( 81 89 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> ··· 92 100 showPageBackground={showPageBackground} 93 101 pageWidth={pageWidth?.data.value} 94 102 hasBackgroundImage={hasBackgroundImage} 103 + headingFontId={headingFontId} 104 + bodyFontId={bodyFontId} 95 105 > 96 106 {props.children} 97 107 </BaseThemeProvider> ··· 113 123 showPageBackground, 114 124 pageWidth, 115 125 hasBackgroundImage, 126 + headingFontId, 127 + bodyFontId, 116 128 children, 117 129 }: { 118 130 local?: boolean; ··· 127 139 highlight2: AriaColor; 128 140 highlight3: AriaColor; 129 141 pageWidth?: number; 142 + headingFontId?: string; 143 + bodyFontId?: string; 130 144 children: React.ReactNode; 131 145 }) => { 132 146 // When showPageBackground is false and there's no background image, ··· 167 181 accentContrast = sortedAccents[0]; 168 182 } 169 183 184 + // Get font configs for CSS variables 185 + const headingFontConfig = getFontConfig(headingFontId); 186 + const bodyFontConfig = getFontConfig(bodyFontId); 187 + const headingFontValue = getFontFamilyValue(headingFontConfig); 188 + const bodyFontValue = getFontFamilyValue(bodyFontConfig); 189 + const headingGoogleFontsUrl = getGoogleFontsUrl(headingFontConfig); 190 + const bodyGoogleFontsUrl = getGoogleFontsUrl(bodyFontConfig); 191 + 192 + // Dynamically load Google Fonts when fonts change 193 + useEffect(() => { 194 + const loadGoogleFont = (url: string | null, fontFamily: string) => { 195 + if (!url) return; 196 + 197 + // Check if this font stylesheet is already in the document 198 + const existingLink = document.querySelector(`link[href="${url}"]`); 199 + if (existingLink) return; 200 + 201 + // Add preconnect hints if not present 202 + if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) { 203 + const preconnect1 = document.createElement("link"); 204 + preconnect1.rel = "preconnect"; 205 + preconnect1.href = "https://fonts.googleapis.com"; 206 + document.head.appendChild(preconnect1); 207 + 208 + const preconnect2 = document.createElement("link"); 209 + preconnect2.rel = "preconnect"; 210 + preconnect2.href = "https://fonts.gstatic.com"; 211 + preconnect2.crossOrigin = "anonymous"; 212 + document.head.appendChild(preconnect2); 213 + } 214 + 215 + // Load the Google Font stylesheet 216 + const link = document.createElement("link"); 217 + link.rel = "stylesheet"; 218 + link.href = url; 219 + document.head.appendChild(link); 220 + 221 + // Wait for the font to actually load before it gets applied 222 + if (document.fonts?.load) { 223 + document.fonts.load(`1em "${fontFamily}"`); 224 + } 225 + }; 226 + 227 + loadGoogleFont(headingGoogleFontsUrl, headingFontConfig.fontFamily); 228 + loadGoogleFont(bodyGoogleFontsUrl, bodyFontConfig.fontFamily); 229 + }, [headingGoogleFontsUrl, bodyGoogleFontsUrl, headingFontConfig.fontFamily, bodyFontConfig.fontFamily]); 230 + 170 231 useEffect(() => { 171 232 if (local) return; 172 233 let el = document.querySelector(":root") as HTMLElement; ··· 215 276 "--page-width-setting", 216 277 (pageWidth || 624).toString(), 217 278 ); 279 + 280 + // Set theme font CSS variables 281 + el?.style.setProperty("--theme-heading-font", headingFontValue); 282 + el?.style.setProperty("--theme-font", bodyFontValue); 218 283 }, [ 219 284 local, 220 285 bgLeaflet, ··· 227 292 accent2, 228 293 accentContrast, 229 294 pageWidth, 230 - ]); 295 + headingFontValue, 296 + bodyFontValue, 297 + ]); // bodyFontValue sets --theme-font 231 298 return ( 232 299 <div 233 300 className="leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch " ··· 249 316 "--page-width-setting": pageWidth || 624, 250 317 "--page-width-unitless": pageWidth || 624, 251 318 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 319 + "--theme-heading-font": headingFontValue, 320 + "--theme-font": bodyFontValue, 252 321 } as CSSProperties 253 322 } 254 323 >
+9 -1
components/ThemeManager/ThemeSetter.tsx
··· 22 22 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 23 23 import { useIsMobile } from "src/hooks/isMobile"; 24 24 import { Toggle } from "components/Toggle"; 25 + import { FontPicker } from "./Pickers/TextPickers"; 25 26 26 27 export type pickers = 27 28 | "null" ··· 157 158 openPicker={openPicker} 158 159 setOpenPicker={(pickers) => setOpenPicker(pickers)} 159 160 /> 161 + {!props.home && ( 162 + <div className="flex flex-col gap-1 bg-bg-page p-2 rounded-md border border-primary -mt-2"> 163 + <FontPicker label="Heading" entityID={props.entityID} attribute="theme/heading-font" /> 164 + <FontPicker label="Body" entityID={props.entityID} attribute="theme/body-font" /> 165 + </div> 166 + )} 160 167 <div className="flex flex-col -gap-[6px]"> 161 168 <div className={`flex flex-col z-10 -mb-[6px] `}> 162 169 <AccentPickers ··· 187 194 </div> 188 195 ); 189 196 }; 197 + 190 198 function WatermarkSetter(props: { entityID: string }) { 191 199 let { rep } = useReplicache(); 192 200 let checked = useEntity(props.entityID, "theme/page-leaflet-watermark"); ··· 300 308 onClick={() => { 301 309 props.setOpenPicker("text"); 302 310 }} 303 - className="cursor-pointer font-bold w-fit" 311 + className="cursor-pointer font-bold w-fit [font-family:var(--theme-heading-font)]" 304 312 > 305 313 Hello! 306 314 </p>
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 + };
+8 -36
components/Toolbar/ImageToolbar.tsx
··· 6 6 import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt"; 7 7 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 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 - }; 9 + import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 41 10 42 11 export const ImageFullBleedButton = (props: {}) => { 43 12 let { rep } = useReplicache(); ··· 67 36 ); 68 37 }; 69 38 70 - export const ImageAltTextButton = (props: {}) => { 39 + export const ImageAltTextButton = (props: { 40 + setToolbarState: (s: "img-alt-text") => void; 41 + }) => { 71 42 let { rep } = useReplicache(); 72 43 let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null; 73 44 ··· 77 48 let altEditorOpen = useUIState((s) => s.openPopover === focusedBlock); 78 49 let hasSrc = useEntity(focusedBlock, "block/image")?.data; 79 50 if (!hasSrc) return null; 51 + 80 52 return ( 81 53 <ToolbarButton 82 54 active={altText !== undefined} 83 55 onClick={async (e) => { 84 56 e.preventDefault(); 85 57 if (!focusedBlock) return; 86 - if (altText === undefined) { 58 + if (!altText) { 87 59 await rep?.mutate.assertFact({ 88 60 entity: focusedBlock, 89 61 attribute: "image/alt", ··· 137 109 } 138 110 }} 139 111 tooltipContent={ 140 - <div>{isCoverImage ? "Remove Cover Image" : "Use as Cover Image"}</div> 112 + <div>{isCoverImage ? "Remove Cover Image" : "Set as Cover Image"}</div> 141 113 } 142 114 > 143 - {isCoverImage ? <ImageCoverImageRemove /> : <ImageCoverImage />} 115 + <ImageCoverImage /> 144 116 </ToolbarButton> 145 117 ); 146 118 };
+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 + };
+12 -33
components/Toolbar/MultiSelectToolbar.tsx
··· 2 2 import { ReplicacheMutators, useReplicache } from "src/replicache"; 3 3 import { ToolbarButton } from "./index"; 4 4 import { copySelection } from "src/utils/copySelection"; 5 - import { useSmoker, useToaster } from "components/Toast"; 6 - 5 + import { useSmoker } from "components/Toast"; 6 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 7 + import { Replicache } from "replicache"; 8 + import { LockBlockButton } from "./LockBlockButton"; 7 9 import { Props } from "components/Icons/Props"; 8 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 9 11 import { getSortedSelection } from "components/SelectionManager/selectionState"; 10 - import { deleteBlock } from "src/utils/deleteBlock"; 11 - import { Separator, ShortcutKey } from "components/Layout"; 12 12 13 13 export const MultiselectToolbar = (props: { 14 - setToolbarState: (state: "multiselect" | "text-alignment") => void; 14 + setToolbarState: ( 15 + state: "areYouSure" | "multiselect" | "text-alignment", 16 + ) => void; 15 17 }) => { 16 - const { rep, undoManager } = useReplicache(); 18 + const { rep } = useReplicache(); 17 19 const smoker = useSmoker(); 18 - const toaster = useToaster(); 19 20 20 21 const handleCopy = async (event: React.MouseEvent) => { 21 22 if (!rep) return; 22 - let [sortedSelection] = await getSortedSelection(rep); 23 + const [sortedSelection] = await getSortedSelection(rep); 23 24 await copySelection(rep, sortedSelection); 24 25 smoker({ 25 26 position: { x: event.clientX, y: event.clientY }, ··· 32 33 <div className="flex items-center gap-2"> 33 34 <ToolbarButton 34 35 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 - }); 36 + onClick={() => { 37 + props.setToolbarState("areYouSure"); 59 38 }} 60 39 > 61 40 <TrashSmall /> ··· 68 47 <CopySmall /> 69 48 </ToolbarButton> 70 49 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 71 - <Separator classname="h-6!" /> 50 + <LockBlockButton /> 72 51 </div> 73 52 </div> 74 53 );
-8
components/Toolbar/TextAlignmentToolbar.tsx
··· 7 7 export function TextAlignmentToolbar() { 8 8 let focusedBlock = useUIState((s) => s.focusedEntity); 9 9 let { rep } = useReplicache(); 10 - let alignment = useEntity( 11 - focusedBlock?.entityID || null, 12 - "block/text-alignment", 13 - )?.data.value; 14 10 let setAlignment = useCallback( 15 11 (alignment: Fact<"block/text-alignment">["data"]["value"]) => { 16 12 let blocks = useUIState.getState().selectedBlocks; ··· 30 26 <ToolbarButton 31 27 onClick={() => setAlignment("left")} 32 28 tooltipContent="Align Left" 33 - active={alignment === "left"} 34 29 > 35 30 <AlignLeftSmall /> 36 31 </ToolbarButton> 37 32 <ToolbarButton 38 33 onClick={() => setAlignment("center")} 39 34 tooltipContent="Align Center" 40 - active={alignment === "center"} 41 35 > 42 36 <AlignCenterSmall /> 43 37 </ToolbarButton> 44 38 <ToolbarButton 45 39 onClick={() => setAlignment("right")} 46 40 tooltipContent="Align Right" 47 - active={alignment === "right"} 48 41 > 49 42 <AlignRightSmall /> 50 43 </ToolbarButton> ··· 52 45 <ToolbarButton 53 46 onClick={() => setAlignment("justify")} 54 47 tooltipContent="Align Justified" 55 - active={alignment === "justify"} 56 48 > 57 49 <AlignJustifiedSmall /> 58 50 </ToolbarButton>
+3
components/Toolbar/TextToolbar.tsx
··· 8 8 import { ToolbarTypes } from "."; 9 9 import { schema } from "components/Blocks/TextBlock/schema"; 10 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 + import { LockBlockButton } from "./LockBlockButton"; 11 12 import { Props } from "components/Icons/Props"; 12 13 import { isMac } from "src/utils/isDevice"; 13 14 ··· 80 81 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 81 82 <ListButton setToolbarState={props.setToolbarState} /> 82 83 <Separator classname="h-6!" /> 84 + 85 + <LockBlockButton /> 83 86 </> 84 87 ); 85 88 };
+87 -57
components/Toolbar/index.tsx
··· 11 11 import { ListToolbar } from "./ListToolbar"; 12 12 import { HighlightToolbar } from "./HighlightToolbar"; 13 13 import { TextToolbar } from "./TextToolbar"; 14 - import { ImageToolbar } from "./ImageToolbar"; 14 + import { BlockToolbar } from "./BlockToolbar"; 15 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 + import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 + import { deleteBlock } from "src/utils/deleteBlock"; 16 18 import { TooltipButton } from "components/Buttons"; 17 19 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 18 20 import { useIsMobile } from "src/hooks/isMobile"; 19 21 import { CloseTiny } from "components/Icons/CloseTiny"; 20 22 21 23 export type ToolbarTypes = 24 + | "areYouSure" 22 25 | "default" 26 + | "block" 23 27 | "multiselect" 24 28 | "highlight" 25 29 | "link" ··· 27 31 | "text-alignment" 28 32 | "list" 29 33 | "linkBlock" 30 - | "img-alt-text" 31 - | "image"; 34 + | "img-alt-text"; 32 35 33 - export const Toolbar = (props: { 34 - pageID: string; 35 - blockID: string; 36 - blockType: string | null | undefined; 37 - }) => { 36 + export const Toolbar = (props: { pageID: string; blockID: string }) => { 37 + let { rep } = useReplicache(); 38 + 38 39 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 39 40 40 - let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 41 + let focusedEntity = useUIState((s) => s.focusedEntity); 41 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; 42 46 43 47 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); 44 48 let setLastUsedHighlight = (color: "1" | "2" | "3") => ··· 60 64 }; 61 65 }, [toolbarState]); 62 66 63 - let isTextBlock = 64 - props.blockType === "heading" || 65 - props.blockType === "text" || 66 - props.blockType === "blockquote"; 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]); 67 79 68 80 useEffect(() => { 69 - if (selectedBlocks.length > 1) { 81 + if ( 82 + selectedBlocks.length > 1 && 83 + !["areYousure", "text-alignment"].includes(toolbarState) 84 + ) { 70 85 setToolbarState("multiselect"); 71 - return; 72 - } 73 - if (isTextBlock) { 86 + } else if (toolbarState === "multiselect") { 74 87 setToolbarState("default"); 75 88 } 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]); 89 + }, [selectedBlocks.length, toolbarState]); 90 + let isMobile = useIsMobile(); 83 91 84 - let isMobile = useIsMobile(); 85 92 return ( 86 93 <Tooltip.Provider> 87 94 <div ··· 118 125 <TextBlockTypeToolbar onClose={() => setToolbarState("default")} /> 119 126 ) : toolbarState === "text-alignment" ? ( 120 127 <TextAlignmentToolbar /> 121 - ) : toolbarState === "image" ? ( 122 - <ImageToolbar setToolbarState={setToolbarState} /> 128 + ) : toolbarState === "block" ? ( 129 + <BlockToolbar setToolbarState={setToolbarState} /> 123 130 ) : toolbarState === "multiselect" ? ( 124 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 + /> 125 154 ) : null} 126 155 </div> 127 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 */} 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"); 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 + } 152 180 } 153 - } 154 - }} 155 - > 156 - <CloseTiny /> 157 - </button> 181 + }} 182 + > 183 + <CloseTiny /> 184 + </button> 185 + )} 158 186 </div> 159 187 </Tooltip.Provider> 160 188 ); ··· 170 198 hiddenOnCanvas?: boolean; 171 199 }) => { 172 200 let focusedEntity = useUIState((s) => s.focusedEntity); 173 - let isDisabled = props.disabled; 201 + let isLocked = useEntity(focusedEntity?.entityID || null, "block/is-locked"); 202 + let isDisabled = 203 + props.disabled === undefined ? !!isLocked?.data.value : props.disabled; 174 204 175 205 let focusedEntityType = useEntity( 176 206 focusedEntity?.entityType === "page"
-1
drizzle/schema.ts
··· 140 140 email: text("email"), 141 141 atp_did: text("atp_did"), 142 142 interface_state: jsonb("interface_state"), 143 - metadata: jsonb("metadata"), 144 143 }, 145 144 (table) => { 146 145 return {
+3 -3
feeds/index.ts
··· 116 116 } 117 117 query = query 118 118 .or("data->postRef.not.is.null,data->bskyPostRef.not.is.null") 119 - .order("sort_date", { ascending: false }) 119 + .order("indexed_at", { ascending: false }) 120 120 .order("uri", { ascending: false }) 121 121 .limit(25); 122 122 if (parsedCursor) 123 123 query = query.or( 124 - `sort_date.lt.${parsedCursor.date},and(sort_date.eq.${parsedCursor.date},uri.lt.${parsedCursor.uri})`, 124 + `indexed_at.lt.${parsedCursor.date},and(indexed_at.eq.${parsedCursor.date},uri.lt.${parsedCursor.uri})`, 125 125 ); 126 126 127 127 let { data, error } = await query; ··· 131 131 posts = posts || []; 132 132 133 133 let lastPost = posts[posts.length - 1]; 134 - let newCursor = lastPost ? `${lastPost.sort_date}::${lastPost.uri}` : null; 134 + let newCursor = lastPost ? `${lastPost.indexed_at}::${lastPost.uri}` : null; 135 135 return c.json({ 136 136 cursor: newCursor || cursor, 137 137 feed: posts.flatMap((p) => {
-97
lexicons/api/index.ts
··· 41 41 import * as PubLeafletContent from './types/pub/leaflet/content' 42 42 import * as PubLeafletDocument from './types/pub/leaflet/document' 43 43 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 44 - import * as PubLeafletInteractionsRecommend from './types/pub/leaflet/interactions/recommend' 45 44 import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 46 45 import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 47 46 import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' ··· 88 87 export * as PubLeafletContent from './types/pub/leaflet/content' 89 88 export * as PubLeafletDocument from './types/pub/leaflet/document' 90 89 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 91 - export * as PubLeafletInteractionsRecommend from './types/pub/leaflet/interactions/recommend' 92 90 export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 93 91 export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 94 92 export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' ··· 410 408 publication: PubLeafletPublicationRecord 411 409 blocks: PubLeafletBlocksNS 412 410 graph: PubLeafletGraphNS 413 - interactions: PubLeafletInteractionsNS 414 411 pages: PubLeafletPagesNS 415 412 poll: PubLeafletPollNS 416 413 richtext: PubLeafletRichtextNS ··· 420 417 this._client = client 421 418 this.blocks = new PubLeafletBlocksNS(client) 422 419 this.graph = new PubLeafletGraphNS(client) 423 - this.interactions = new PubLeafletInteractionsNS(client) 424 420 this.pages = new PubLeafletPagesNS(client) 425 421 this.poll = new PubLeafletPollNS(client) 426 422 this.richtext = new PubLeafletRichtextNS(client) ··· 527 523 'com.atproto.repo.deleteRecord', 528 524 undefined, 529 525 { collection: 'pub.leaflet.graph.subscription', ...params }, 530 - { headers }, 531 - ) 532 - } 533 - } 534 - 535 - export class PubLeafletInteractionsNS { 536 - _client: XrpcClient 537 - recommend: PubLeafletInteractionsRecommendRecord 538 - 539 - constructor(client: XrpcClient) { 540 - this._client = client 541 - this.recommend = new PubLeafletInteractionsRecommendRecord(client) 542 - } 543 - } 544 - 545 - export class PubLeafletInteractionsRecommendRecord { 546 - _client: XrpcClient 547 - 548 - constructor(client: XrpcClient) { 549 - this._client = client 550 - } 551 - 552 - async list( 553 - params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 554 - ): Promise<{ 555 - cursor?: string 556 - records: { uri: string; value: PubLeafletInteractionsRecommend.Record }[] 557 - }> { 558 - const res = await this._client.call('com.atproto.repo.listRecords', { 559 - collection: 'pub.leaflet.interactions.recommend', 560 - ...params, 561 - }) 562 - return res.data 563 - } 564 - 565 - async get( 566 - params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 567 - ): Promise<{ 568 - uri: string 569 - cid: string 570 - value: PubLeafletInteractionsRecommend.Record 571 - }> { 572 - const res = await this._client.call('com.atproto.repo.getRecord', { 573 - collection: 'pub.leaflet.interactions.recommend', 574 - ...params, 575 - }) 576 - return res.data 577 - } 578 - 579 - async create( 580 - params: OmitKey< 581 - ComAtprotoRepoCreateRecord.InputSchema, 582 - 'collection' | 'record' 583 - >, 584 - record: Un$Typed<PubLeafletInteractionsRecommend.Record>, 585 - headers?: Record<string, string>, 586 - ): Promise<{ uri: string; cid: string }> { 587 - const collection = 'pub.leaflet.interactions.recommend' 588 - const res = await this._client.call( 589 - 'com.atproto.repo.createRecord', 590 - undefined, 591 - { collection, ...params, record: { ...record, $type: collection } }, 592 - { encoding: 'application/json', headers }, 593 - ) 594 - return res.data 595 - } 596 - 597 - async put( 598 - params: OmitKey< 599 - ComAtprotoRepoPutRecord.InputSchema, 600 - 'collection' | 'record' 601 - >, 602 - record: Un$Typed<PubLeafletInteractionsRecommend.Record>, 603 - headers?: Record<string, string>, 604 - ): Promise<{ uri: string; cid: string }> { 605 - const collection = 'pub.leaflet.interactions.recommend' 606 - const res = await this._client.call( 607 - 'com.atproto.repo.putRecord', 608 - undefined, 609 - { collection, ...params, record: { ...record, $type: collection } }, 610 - { encoding: 'application/json', headers }, 611 - ) 612 - return res.data 613 - } 614 - 615 - async delete( 616 - params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 617 - headers?: Record<string, string>, 618 - ): Promise<void> { 619 - await this._client.call( 620 - 'com.atproto.repo.deleteRecord', 621 - undefined, 622 - { collection: 'pub.leaflet.interactions.recommend', ...params }, 623 526 { headers }, 624 527 ) 625 528 }
+10 -28
lexicons/api/lexicons.ts
··· 1517 1517 }, 1518 1518 }, 1519 1519 }, 1520 - PubLeafletInteractionsRecommend: { 1521 - lexicon: 1, 1522 - id: 'pub.leaflet.interactions.recommend', 1523 - defs: { 1524 - main: { 1525 - type: 'record', 1526 - key: 'tid', 1527 - description: 'Record representing a recommend on a document', 1528 - record: { 1529 - type: 'object', 1530 - required: ['subject', 'createdAt'], 1531 - properties: { 1532 - subject: { 1533 - type: 'string', 1534 - format: 'at-uri', 1535 - }, 1536 - createdAt: { 1537 - type: 'string', 1538 - format: 'datetime', 1539 - }, 1540 - }, 1541 - }, 1542 - }, 1543 - }, 1544 - }, 1545 1520 PubLeafletPagesCanvas: { 1546 1521 lexicon: 1, 1547 1522 id: 'pub.leaflet.pages.canvas', ··· 1921 1896 'lex:pub.leaflet.theme.color#rgb', 1922 1897 ], 1923 1898 }, 1899 + headingFont: { 1900 + type: 'string', 1901 + maxLength: 100, 1902 + }, 1903 + bodyFont: { 1904 + type: 'string', 1905 + maxLength: 100, 1906 + }, 1924 1907 }, 1925 1908 }, 1926 1909 }, ··· 2240 2223 type: 'ref', 2241 2224 }, 2242 2225 theme: { 2243 - type: 'union', 2244 - refs: ['lex:pub.leaflet.publication#theme'], 2226 + type: 'ref', 2227 + ref: 'lex:pub.leaflet.publication#theme', 2245 2228 }, 2246 2229 description: { 2247 2230 maxGraphemes: 300, ··· 2443 2426 PubLeafletContent: 'pub.leaflet.content', 2444 2427 PubLeafletDocument: 'pub.leaflet.document', 2445 2428 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 2446 - PubLeafletInteractionsRecommend: 'pub.leaflet.interactions.recommend', 2447 2429 PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', 2448 2430 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 2449 2431 PubLeafletPollDefinition: 'pub.leaflet.poll.definition',
-32
lexicons/api/types/pub/leaflet/interactions/recommend.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 - import { CID } from 'multiformats/cid' 6 - import { validate as _validate } from '../../../../lexicons' 7 - import { 8 - type $Typed, 9 - is$typed as _is$typed, 10 - type OmitKey, 11 - } from '../../../../util' 12 - 13 - const is$typed = _is$typed, 14 - validate = _validate 15 - const id = 'pub.leaflet.interactions.recommend' 16 - 17 - export interface Record { 18 - $type: 'pub.leaflet.interactions.recommend' 19 - subject: string 20 - createdAt: string 21 - [k: string]: unknown 22 - } 23 - 24 - const hashRecord = 'main' 25 - 26 - export function isRecord<V>(v: V) { 27 - return is$typed(v, id, hashRecord) 28 - } 29 - 30 - export function validateRecord<V>(v: V) { 31 - return validate<Record & V>(v, id, hashRecord, true) 32 - }
+2
lexicons/api/types/pub/leaflet/publication.ts
··· 76 76 | $Typed<PubLeafletThemeColor.Rgba> 77 77 | $Typed<PubLeafletThemeColor.Rgb> 78 78 | { $type: string } 79 + headingFont?: string 80 + bodyFont?: string 79 81 } 80 82 81 83 const hashTheme = 'theme'
+1 -1
lexicons/api/types/site/standard/publication.ts
··· 15 15 export interface Record { 16 16 $type: 'site.standard.publication' 17 17 basicTheme?: SiteStandardThemeBasic.Main 18 - theme?: $Typed<PubLeafletPublication.Theme> | { $type: string } 18 + theme?: PubLeafletPublication.Theme 19 19 description?: string 20 20 icon?: BlobRef 21 21 name: string
-2
lexicons/build.ts
··· 3 3 import { PubLeafletDocument } from "./src/document"; 4 4 import * as PublicationLexicons from "./src/publication"; 5 5 import * as PollLexicons from "./src/polls"; 6 - import * as InteractionsLexicons from "./src/interactions"; 7 6 import { ThemeLexicons } from "./src/theme"; 8 7 9 8 import * as fs from "fs"; ··· 32 31 ...BlockLexicons, 33 32 ...Object.values(PublicationLexicons), 34 33 ...Object.values(PollLexicons), 35 - ...Object.values(InteractionsLexicons), 36 34 ]; 37 35 38 36 // Write each lexicon to a file
+1 -2
lexicons/pub/leaflet/authFullPermissions.json
··· 21 21 "pub.leaflet.comment", 22 22 "pub.leaflet.poll.definition", 23 23 "pub.leaflet.poll.vote", 24 - "pub.leaflet.graph.subscription", 25 - "pub.leaflet.interactions.recommend" 24 + "pub.leaflet.graph.subscription" 26 25 ] 27 26 } 28 27 ]
-28
lexicons/pub/leaflet/interactions/recommend.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "pub.leaflet.interactions.recommend", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "description": "Record representing a recommend on a document", 9 - "record": { 10 - "type": "object", 11 - "required": [ 12 - "subject", 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "subject": { 17 - "type": "string", 18 - "format": "at-uri" 19 - }, 20 - "createdAt": { 21 - "type": "string", 22 - "format": "datetime" 23 - } 24 - } 25 - } 26 - } 27 - } 28 - }
+8
lexicons/pub/leaflet/publication.json
··· 112 112 "pub.leaflet.theme.color#rgba", 113 113 "pub.leaflet.theme.color#rgb" 114 114 ] 115 + }, 116 + "headingFont": { 117 + "type": "string", 118 + "maxLength": 100 119 + }, 120 + "bodyFont": { 121 + "type": "string", 122 + "maxLength": 100 115 123 } 116 124 } 117 125 }
+2 -2
lexicons/site/standard/publication.json
··· 9 9 "type": "ref" 10 10 }, 11 11 "theme": { 12 - "type": "union", 13 - "refs": ["pub.leaflet.publication#theme"] 12 + "type": "ref", 13 + "ref": "pub.leaflet.publication#theme" 14 14 }, 15 15 "description": { 16 16 "maxGraphemes": 300,
-2
lexicons/src/authFullPermissions.ts
··· 6 6 } from "./publication"; 7 7 import { PubLeafletComment } from "./comment"; 8 8 import { PubLeafletPollDefinition, PubLeafletPollVote } from "./polls"; 9 - import { PubLeafletInteractionsRecommend } from "./interactions"; 10 9 11 10 export const PubLeafletAuthFullPermissions: LexiconDoc = { 12 11 lexicon: 1, ··· 29 28 PubLeafletPollDefinition.id, 30 29 PubLeafletPollVote.id, 31 30 PubLeafletPublicationSubscription.id, 32 - PubLeafletInteractionsRecommend.id, 33 31 ], 34 32 }, 35 33 ],
-21
lexicons/src/interactions/index.ts
··· 1 - import { LexiconDoc } from "@atproto/lexicon"; 2 - 3 - export const PubLeafletInteractionsRecommend: LexiconDoc = { 4 - lexicon: 1, 5 - id: "pub.leaflet.interactions.recommend", 6 - defs: { 7 - main: { 8 - type: "record", 9 - key: "tid", 10 - description: "Record representing a recommend on a document", 11 - record: { 12 - type: "object", 13 - required: ["subject", "createdAt"], 14 - properties: { 15 - subject: { type: "string", format: "at-uri" }, 16 - createdAt: { type: "string", format: "datetime" }, 17 - }, 18 - }, 19 - }, 20 - }, 21 - };
+5 -40
lexicons/src/normalize.ts
··· 14 14 */ 15 15 16 16 import type * as PubLeafletDocument from "../api/types/pub/leaflet/document"; 17 - import * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 17 + import type * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 18 18 import type * as PubLeafletContent from "../api/types/pub/leaflet/content"; 19 19 import type * as SiteStandardDocument from "../api/types/site/standard/document"; 20 20 import type * as SiteStandardPublication from "../api/types/site/standard/publication"; ··· 31 31 }; 32 32 33 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 - }; 34 + export type NormalizedPublication = SiteStandardPublication.Record; 48 35 49 36 /** 50 37 * Checks if the record is a pub.leaflet.document ··· 223 210 ): NormalizedPublication | null { 224 211 if (!record || typeof record !== "object") return null; 225 212 226 - // Pass through site.standard records directly, but validate the theme 213 + // Pass through site.standard records directly 227 214 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 - }; 215 + return record; 236 216 } 237 217 238 218 if (isLeafletPublication(record)) { ··· 245 225 246 226 const basicTheme = leafletThemeToBasicTheme(record.theme); 247 227 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 228 // Convert preferences to site.standard format (strip/replace $type) 264 229 const preferences: SiteStandardPublication.Preferences | undefined = 265 230 record.preferences ··· 278 243 description: record.description, 279 244 icon: record.icon, 280 245 basicTheme, 281 - theme, 246 + theme: record.theme, 282 247 preferences, 283 248 }; 284 249 }
+2
lexicons/src/publication.ts
··· 49 49 showPageBackground: { type: "boolean", default: false }, 50 50 accentBackground: ColorUnion, 51 51 accentText: ColorUnion, 52 + headingFont: { type: "string", maxLength: 100 }, 53 + bodyFont: { type: "string", maxLength: 100 }, 52 54 }, 53 55 }, 54 56 },
public/fonts/AtkinsonHyperlegibleNext-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/AtkinsonHyperlegibleNext-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/Lora-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/Lora-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/NotoSans-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/NotoSans-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/SourceSans3-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/SourceSans3-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/iAWriterQuattroV-Italic.ttf

This is a binary file and will not be displayed.

public/fonts/iAWriterQuattroV.ttf

This is a binary file and will not be displayed.

public/fonts/iaw-quattro-vf-Italic.woff2

This is a binary file and will not be displayed.

public/fonts/iaw-quattro-vf.woff2

This is a binary file and will not be displayed.

+154
src/fonts.ts
··· 1 + // Font configuration for self-hosted and Google Fonts 2 + // This replicates what next/font does but allows dynamic selection per-leaflet 3 + 4 + export type FontConfig = { 5 + id: string; 6 + displayName: string; 7 + fontFamily: string; 8 + fallback: string[]; 9 + } & ( 10 + | { 11 + // Self-hosted fonts with local files 12 + type: "local"; 13 + files: { 14 + path: string; 15 + style: "normal" | "italic"; 16 + weight?: string; 17 + }[]; 18 + } 19 + | { 20 + // Google Fonts loaded via CDN 21 + type: "google"; 22 + googleFontsFamily: string; // e.g., "Open+Sans:ital,wght@0,400;0,700;1,400;1,700" 23 + } 24 + | { 25 + // System fonts (no loading required) 26 + type: "system"; 27 + } 28 + ); 29 + 30 + export const fonts: Record<string, FontConfig> = { 31 + // Self-hosted variable fonts (WOFF2) 32 + quattro: { 33 + id: "quattro", 34 + displayName: "iA Writer Quattro", 35 + fontFamily: "iA Writer Quattro V", 36 + type: "local", 37 + files: [ 38 + { path: "/fonts/iaw-quattro-vf.woff2", style: "normal", weight: "400 700" }, 39 + { path: "/fonts/iaw-quattro-vf-Italic.woff2", style: "italic", weight: "400 700" }, 40 + ], 41 + fallback: ["system-ui", "sans-serif"], 42 + }, 43 + lora: { 44 + id: "lora", 45 + displayName: "Lora", 46 + fontFamily: "Lora", 47 + type: "local", 48 + files: [ 49 + { path: "/fonts/Lora-Variable.woff2", style: "normal", weight: "400 700" }, 50 + { path: "/fonts/Lora-Italic-Variable.woff2", style: "italic", weight: "400 700" }, 51 + ], 52 + fallback: ["Georgia", "serif"], 53 + }, 54 + "source-sans": { 55 + id: "source-sans", 56 + displayName: "Source Sans", 57 + fontFamily: "Source Sans 3", 58 + type: "local", 59 + files: [ 60 + { path: "/fonts/SourceSans3-Variable.woff2", style: "normal", weight: "200 900" }, 61 + { path: "/fonts/SourceSans3-Italic-Variable.woff2", style: "italic", weight: "200 900" }, 62 + ], 63 + fallback: ["system-ui", "sans-serif"], 64 + }, 65 + "atkinson-hyperlegible": { 66 + id: "atkinson-hyperlegible", 67 + displayName: "Atkinson Hyperlegible", 68 + fontFamily: "Atkinson Hyperlegible Next", 69 + type: "local", 70 + files: [ 71 + { path: "/fonts/AtkinsonHyperlegibleNext-Variable.woff2", style: "normal", weight: "200 800" }, 72 + { path: "/fonts/AtkinsonHyperlegibleNext-Italic-Variable.woff2", style: "italic", weight: "200 800" }, 73 + ], 74 + fallback: ["system-ui", "sans-serif"], 75 + }, 76 + "noto-sans": { 77 + id: "noto-sans", 78 + displayName: "Noto Sans", 79 + fontFamily: "Noto Sans", 80 + type: "local", 81 + files: [ 82 + { path: "/fonts/NotoSans-Variable.woff2", style: "normal", weight: "100 900" }, 83 + { path: "/fonts/NotoSans-Italic-Variable.woff2", style: "italic", weight: "100 900" }, 84 + ], 85 + fallback: ["Arial", "sans-serif"], 86 + }, 87 + 88 + // Google Fonts (no variable version available) 89 + "alegreya-sans": { 90 + id: "alegreya-sans", 91 + displayName: "Alegreya Sans", 92 + fontFamily: "Alegreya Sans", 93 + type: "google", 94 + googleFontsFamily: "Alegreya+Sans:ital,wght@0,400;0,700;1,400;1,700", 95 + fallback: ["system-ui", "sans-serif"], 96 + }, 97 + "space-mono": { 98 + id: "space-mono", 99 + displayName: "Space Mono", 100 + fontFamily: "Space Mono", 101 + type: "google", 102 + googleFontsFamily: "Space+Mono:ital,wght@0,400;0,700;1,400;1,700", 103 + fallback: ["monospace"], 104 + }, 105 + }; 106 + 107 + export const defaultFontId = "quattro"; 108 + 109 + export function getFontConfig(fontId: string | undefined): FontConfig { 110 + return fonts[fontId || defaultFontId] || fonts[defaultFontId]; 111 + } 112 + 113 + // Generate @font-face CSS for a local font 114 + export function generateFontFaceCSS(font: FontConfig): string { 115 + if (font.type !== "local") return ""; 116 + return font.files 117 + .map((file) => { 118 + const format = file.path.endsWith(".woff2") ? "woff2" : "truetype"; 119 + return ` 120 + @font-face { 121 + font-family: '${font.fontFamily}'; 122 + src: url('${file.path}') format('${format}'); 123 + font-style: ${file.style}; 124 + font-weight: ${file.weight || "normal"}; 125 + font-display: swap; 126 + }`.trim(); 127 + }) 128 + .join("\n\n"); 129 + } 130 + 131 + // Generate preload link attributes for a local font 132 + export function getFontPreloadLinks(font: FontConfig): { href: string; type: string }[] { 133 + if (font.type !== "local") return []; 134 + return font.files.map((file) => ({ 135 + href: file.path, 136 + type: file.path.endsWith(".woff2") ? "font/woff2" : "font/ttf", 137 + })); 138 + } 139 + 140 + // Get Google Fonts URL for a font 141 + // Using display=swap per Google's recommendation: shows fallback immediately, swaps when ready 142 + // This is better UX than blocking text rendering (display=block) 143 + export function getGoogleFontsUrl(font: FontConfig): string | null { 144 + if (font.type !== "google") return null; 145 + return `https://fonts.googleapis.com/css2?family=${font.googleFontsFamily}&display=swap`; 146 + } 147 + 148 + // Get the CSS font-family value with fallbacks 149 + export function getFontFamilyValue(font: FontConfig): string { 150 + const family = font.fontFamily.includes(" ") 151 + ? `'${font.fontFamily}'` 152 + : font.fontFamily; 153 + return [family, ...font.fallback].join(", "); 154 + }
+1 -1
src/hooks/useLongPress.ts
··· 90 90 return useMemo( 91 91 () => ({ 92 92 isLongPress: isLongPress, 93 - longPressHandlers: { 93 + handlers: { 94 94 onPointerDown, 95 95 onPointerUp: end, 96 96 onClickCapture: click,
+2 -106
src/notifications.ts
··· 21 21 | { type: "comment"; comment_uri: string; parent_uri?: string } 22 22 | { type: "subscribe"; subscription_uri: string } 23 23 | { type: "quote"; bsky_post_uri: string; document_uri: string } 24 - | { type: "bsky_post_embed"; document_uri: string; bsky_post_uri: string } 25 24 | { type: "mention"; document_uri: string; mention_type: "did" } 26 25 | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 27 26 | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } ··· 33 32 | HydratedCommentNotification 34 33 | HydratedSubscribeNotification 35 34 | HydratedQuoteNotification 36 - | HydratedBskyPostEmbedNotification 37 35 | HydratedMentionNotification 38 36 | HydratedCommentMentionNotification; 39 37 export async function hydrateNotifications( 40 38 notifications: NotificationRow[], 41 39 ): Promise<Array<HydratedNotification>> { 42 40 // Call all hydrators in parallel 43 - const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 41 + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 44 42 hydrateCommentNotifications(notifications), 45 43 hydrateSubscribeNotifications(notifications), 46 44 hydrateQuoteNotifications(notifications), 47 - hydrateBskyPostEmbedNotifications(notifications), 48 45 hydrateMentionNotifications(notifications), 49 46 hydrateCommentMentionNotifications(notifications), 50 47 ]); 51 48 52 49 // Combine all hydrated notifications 53 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications]; 50 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 54 51 55 52 // Sort by created_at to maintain order 56 53 allHydrated.sort( ··· 201 198 document_uri: notification.data.document_uri, 202 199 bskyPost, 203 200 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 201 normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 306 202 normalizedPublication: normalizePublicationRecord( 307 203 document.documents_in_publications[0]?.publications?.record,
+5 -1
src/replicache/attributes.ts
··· 187 187 } as const; 188 188 189 189 export const ThemeAttributes = { 190 - "theme/font": { 190 + "theme/heading-font": { 191 + type: "string", 192 + cardinality: "one", 193 + }, 194 + "theme/body-font": { 191 195 type: "string", 192 196 cardinality: "one", 193 197 },
-7
src/replicache/clientMutationContext.ts
··· 67 67 textData.value = base64.fromByteArray(updateBytes); 68 68 } 69 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 70 } 78 71 if (!ignoreUndo) 79 72 undoManager.add({
+10 -31
src/replicache/mutations.ts
··· 4 4 import { SupabaseClient } from "@supabase/supabase-js"; 5 5 import { Database } from "supabase/database.types"; 6 6 import { generateKeyBetween } from "fractional-indexing"; 7 - import { v7 } from "uuid"; 8 7 9 8 export type MutationContext = { 10 9 permission_token_id: string; ··· 308 307 { blockEntity: string } | { blockEntity: string }[] 309 308 > = async (args, ctx) => { 310 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; 311 315 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image"); 312 316 await ctx.runOnServer(async ({ supabase }) => { 313 317 if (image) { ··· 423 427 }, 424 428 }); 425 429 }; 426 - const moveBlockDown: Mutation<{ 427 - entityID: string; 428 - parent: string; 429 - permission_set?: string; 430 - }> = async (args, ctx) => { 430 + const moveBlockDown: Mutation<{ entityID: string; parent: string }> = async ( 431 + args, 432 + ctx, 433 + ) => { 431 434 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( 432 435 (a, b) => (a.data.position > b.data.position ? 1 : -1), 433 436 ); 434 437 let index = children.findIndex((f) => f.data.value === args.entityID); 435 438 if (index === -1) return; 436 439 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 - } 440 + if (!next) return; 462 441 await ctx.retractFact(children[index].id); 463 442 await ctx.assertFact({ 464 443 id: children[index].id,
+2 -10
src/utils/deleteBlock.ts
··· 4 4 import { scanIndex } from "src/replicache/utils"; 5 5 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 6 import { focusBlock } from "src/utils/focusBlock"; 7 - import { UndoManager } from "src/undoManager"; 8 7 9 8 export async function deleteBlock( 10 9 entities: string[], 11 10 rep: Replicache<ReplicacheMutators>, 12 - undoManager?: UndoManager, 13 11 ) { 14 12 // get what pagess we need to close as a result of deleting this block 15 13 let pagesToClose = [] as string[]; ··· 34 32 } 35 33 } 36 34 37 - // figure out what to focus 35 + // the next and previous blocks in the block list 36 + // if the focused thing is a page and not a block, return 38 37 let focusedBlock = useUIState.getState().focusedEntity; 39 38 let parent = 40 39 focusedBlock?.entityType === "page" ··· 45 44 let parentType = await rep?.query((tx) => 46 45 scanIndex(tx).eav(parent, "page/type"), 47 46 ); 48 - // if the page is a canvas, focus the page 49 47 if (parentType[0]?.data.value === "canvas") { 50 48 useUIState 51 49 .getState() 52 50 .setFocusedBlock({ entityType: "page", entityID: parent }); 53 51 useUIState.getState().setSelectedBlocks([]); 54 52 } 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 53 let siblings = 57 54 (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 58 55 ··· 108 105 } 109 106 } 110 107 111 - // close the pages 112 108 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 113 - undoManager && undoManager.startGroup(); 114 - 115 - // delete the blocks 116 109 await Promise.all( 117 110 entities.map((entity) => 118 111 rep?.mutate.removeBlock({ ··· 120 113 }), 121 114 ), 122 115 ); 123 - undoManager && undoManager.endGroup(); 124 116 }
+1 -3
src/utils/focusBlock.ts
··· 48 48 } 49 49 50 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(); 51 + el?.focus(); 54 52 requestAnimationFrame(() => { 55 53 el?.setSelectionRange(pos.offset, pos.offset); 56 54 });
-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 - };
+1 -8
src/utils/scrollIntoView.ts
··· 6 6 threshold: number = 0.9, 7 7 ) { 8 8 const element = document.getElementById(elementId); 9 - // Use double requestAnimationFrame to ensure the element is fully painted 10 - // before attempting to scroll. This fixes smooth scrolling when opening 11 - // pages from within other pages. 12 - requestAnimationFrame(() => { 13 - requestAnimationFrame(() => { 14 - scrollIntoViewIfNeeded(element, false, "smooth"); 15 - }); 16 - }); 9 + scrollIntoViewIfNeeded(element, false, "smooth"); 17 10 }
+1 -23
src/utils/timeAgo.ts
··· 1 - export function timeAgo( 2 - timestamp: string, 3 - options?: { compact?: boolean }, 4 - ): string { 5 - const { compact } = options ?? {}; 1 + export function timeAgo(timestamp: string): string { 6 2 const now = new Date(); 7 3 const date = new Date(timestamp); 8 4 const diffMs = now.getTime() - date.getTime(); ··· 13 9 const diffWeeks = Math.floor(diffDays / 7); 14 10 const diffMonths = Math.floor(diffDays / 30); 15 11 const diffYears = Math.floor(diffDays / 365); 16 - 17 - if (compact) { 18 - if (diffYears > 0) { 19 - return `${diffYears}y`; 20 - } else if (diffMonths > 0) { 21 - return `${diffMonths}mo`; 22 - } else if (diffWeeks > 0) { 23 - return `${diffWeeks}w`; 24 - } else if (diffDays > 0) { 25 - return `${diffDays}d`; 26 - } else if (diffHours > 0) { 27 - return `${diffHours}h`; 28 - } else if (diffMinutes > 0) { 29 - return `${diffMinutes}m`; 30 - } else { 31 - return "now"; 32 - } 33 - } 34 12 35 13 if (diffYears > 0) { 36 14 return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`;
+1 -1
src/utils/uriHelpers.ts
··· 44 44 ).toString(); 45 45 legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString(); 46 46 } 47 - return `name.eq."${nameOrRkey}"",uri.eq."${standard}",uri.eq."${legacy}"`; 47 + return `name.eq."${nameOrRkey}"",uri.eq."${standard}"",uri.eq."${legacy}"`; 48 48 }
-49
supabase/database.types.ts
··· 337 337 Row: { 338 338 data: Json 339 339 indexed_at: string 340 - sort_date: string 341 340 uri: string 342 341 } 343 342 Insert: { ··· 552 551 home_page: string 553 552 id: string 554 553 interface_state: Json | null 555 - metadata: Json | null 556 554 } 557 555 Insert: { 558 556 atp_did?: string | null ··· 561 559 home_page?: string 562 560 id?: string 563 561 interface_state?: Json | null 564 - metadata?: Json | null 565 562 } 566 563 Update: { 567 564 atp_did?: string | null ··· 570 567 home_page?: string 571 568 id?: string 572 569 interface_state?: Json | null 573 - metadata?: Json | null 574 570 } 575 571 Relationships: [ 576 572 { ··· 1075 1071 }, 1076 1072 ] 1077 1073 } 1078 - recommends_on_documents: { 1079 - Row: { 1080 - document: string 1081 - indexed_at: string 1082 - recommender_did: string 1083 - record: Json 1084 - uri: string 1085 - } 1086 - Insert: { 1087 - document: string 1088 - indexed_at?: string 1089 - recommender_did: string 1090 - record: Json 1091 - uri: string 1092 - } 1093 - Update: { 1094 - document?: string 1095 - indexed_at?: string 1096 - recommender_did?: string 1097 - record?: Json 1098 - uri?: string 1099 - } 1100 - Relationships: [ 1101 - { 1102 - foreignKeyName: "recommends_on_documents_document_fkey" 1103 - columns: ["document"] 1104 - isOneToOne: false 1105 - referencedRelation: "documents" 1106 - referencedColumns: ["uri"] 1107 - }, 1108 - { 1109 - foreignKeyName: "recommends_on_documents_recommender_did_fkey" 1110 - columns: ["recommender_did"] 1111 - isOneToOne: false 1112 - referencedRelation: "identities" 1113 - referencedColumns: ["atp_did"] 1114 - }, 1115 - ] 1116 - } 1117 1074 replicache_clients: { 1118 1075 Row: { 1119 1076 client_group: string ··· 1342 1299 Returns: { 1343 1300 like: unknown 1344 1301 }[] 1345 - } 1346 - parse_iso_timestamp: { 1347 - Args: { 1348 - "": string 1349 - } 1350 - Returns: string 1351 1302 } 1352 1303 pull_data: { 1353 1304 Args: {
-1
supabase/migrations/20260123000000_add_metadata_to_identities.sql
··· 1 - alter table "public"."identities" add column "metadata" jsonb;
-28
supabase/migrations/20260125000000_add_sort_date_column.sql
··· 1 - -- Add sort_date computed column to documents table 2 - -- This column stores the older of publishedAt (from JSON data) or indexed_at 3 - -- Used for sorting feeds chronologically by when content was actually published 4 - 5 - -- Create an immutable function to parse ISO 8601 timestamps from text 6 - -- This is needed because direct ::timestamp cast is not immutable (accepts 'now', 'today', etc.) 7 - -- The regex validates the format before casting to ensure immutability 8 - CREATE OR REPLACE FUNCTION parse_iso_timestamp(text) RETURNS timestamptz 9 - LANGUAGE sql IMMUTABLE STRICT AS $$ 10 - SELECT CASE 11 - -- Match ISO 8601 format: YYYY-MM-DDTHH:MM:SS with optional fractional seconds and Z/timezone 12 - WHEN $1 ~ '^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$' THEN 13 - $1::timestamptz 14 - ELSE 15 - NULL 16 - END 17 - $$; 18 - 19 - ALTER TABLE documents 20 - ADD COLUMN sort_date timestamptz GENERATED ALWAYS AS ( 21 - LEAST( 22 - COALESCE(parse_iso_timestamp(data->>'publishedAt'), indexed_at), 23 - indexed_at 24 - ) 25 - ) STORED; 26 - 27 - -- Create index on sort_date for efficient ordering 28 - CREATE INDEX documents_sort_date_idx ON documents (sort_date DESC, uri DESC);
-65
supabase/migrations/20260127000000_add_recommends_table.sql
··· 1 - create table "public"."recommends_on_documents" ( 2 - "uri" text not null, 3 - "record" jsonb not null, 4 - "document" text not null, 5 - "recommender_did" text not null, 6 - "indexed_at" timestamp with time zone not null default now() 7 - ); 8 - 9 - alter table "public"."recommends_on_documents" enable row level security; 10 - 11 - CREATE UNIQUE INDEX recommends_on_documents_pkey ON public.recommends_on_documents USING btree (uri); 12 - 13 - alter table "public"."recommends_on_documents" add constraint "recommends_on_documents_pkey" PRIMARY KEY using index "recommends_on_documents_pkey"; 14 - 15 - CREATE INDEX recommends_on_documents_document_idx ON public.recommends_on_documents USING btree (document); 16 - 17 - CREATE INDEX recommends_on_documents_recommender_did_idx ON public.recommends_on_documents USING btree (recommender_did); 18 - 19 - CREATE UNIQUE INDEX recommends_on_documents_recommender_document_idx ON public.recommends_on_documents USING btree (recommender_did, document); 20 - 21 - alter table "public"."recommends_on_documents" add constraint "recommends_on_documents_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) ON UPDATE CASCADE ON DELETE CASCADE; 22 - 23 - alter table "public"."recommends_on_documents" add constraint "recommends_on_documents_recommender_did_fkey" FOREIGN KEY (recommender_did) REFERENCES identities(atp_did) ON UPDATE CASCADE ON DELETE CASCADE; 24 - 25 - grant delete on table "public"."recommends_on_documents" to "anon"; 26 - 27 - grant insert on table "public"."recommends_on_documents" to "anon"; 28 - 29 - grant references on table "public"."recommends_on_documents" to "anon"; 30 - 31 - grant select on table "public"."recommends_on_documents" to "anon"; 32 - 33 - grant trigger on table "public"."recommends_on_documents" to "anon"; 34 - 35 - grant truncate on table "public"."recommends_on_documents" to "anon"; 36 - 37 - grant update on table "public"."recommends_on_documents" to "anon"; 38 - 39 - grant delete on table "public"."recommends_on_documents" to "authenticated"; 40 - 41 - grant insert on table "public"."recommends_on_documents" to "authenticated"; 42 - 43 - grant references on table "public"."recommends_on_documents" to "authenticated"; 44 - 45 - grant select on table "public"."recommends_on_documents" to "authenticated"; 46 - 47 - grant trigger on table "public"."recommends_on_documents" to "authenticated"; 48 - 49 - grant truncate on table "public"."recommends_on_documents" to "authenticated"; 50 - 51 - grant update on table "public"."recommends_on_documents" to "authenticated"; 52 - 53 - grant delete on table "public"."recommends_on_documents" to "service_role"; 54 - 55 - grant insert on table "public"."recommends_on_documents" to "service_role"; 56 - 57 - grant references on table "public"."recommends_on_documents" to "service_role"; 58 - 59 - grant select on table "public"."recommends_on_documents" to "service_role"; 60 - 61 - grant trigger on table "public"."recommends_on_documents" to "service_role"; 62 - 63 - grant truncate on table "public"."recommends_on_documents" to "service_role"; 64 - 65 - grant update on table "public"."recommends_on_documents" to "service_role";
+1 -1
tailwind.config.js
··· 65 65 }, 66 66 67 67 fontFamily: { 68 - sans: ["var(--font-quattro)"], 68 + sans: ["var(--theme-font, var(--font-quattro))"], 69 69 serif: ["Garamond"], 70 70 }, 71 71 },