a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'main' into feature/atp-polls

+1689 -271
+88 -12
actions/publishToPublication.ts
··· 12 12 PubLeafletBlocksUnorderedList, 13 13 PubLeafletDocument, 14 14 PubLeafletPagesLinearDocument, 15 + PubLeafletPagesCanvas, 15 16 PubLeafletRichtextFacet, 16 17 PubLeafletBlocksWebsite, 17 18 PubLeafletBlocksCode, ··· 98 99 $type: "pub.leaflet.pages.linearDocument", 99 100 blocks: firstPageBlocks, 100 101 }, 101 - ...pages.map((p) => ({ 102 - $type: "pub.leaflet.pages.linearDocument", 103 - id: p.id, 104 - blocks: p.blocks, 105 - })), 102 + ...pages.map((p) => { 103 + if (p.type === "canvas") { 104 + return { 105 + $type: "pub.leaflet.pages.canvas" as const, 106 + id: p.id, 107 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 108 + }; 109 + } else { 110 + return { 111 + $type: "pub.leaflet.pages.linearDocument" as const, 112 + id: p.id, 113 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 114 + }; 115 + } 116 + }), 106 117 ], 107 118 }; 108 119 let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); ··· 143 154 did: string, 144 155 ) { 145 156 let scan = scanIndexLocal(facts); 146 - let pages: { id: string; blocks: PubLeafletPagesLinearDocument.Block[] }[] = 147 - []; 157 + let pages: { 158 + id: string; 159 + blocks: 160 + | PubLeafletPagesLinearDocument.Block[] 161 + | PubLeafletPagesCanvas.Block[]; 162 + type: "doc" | "canvas"; 163 + }[] = []; 148 164 149 165 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 150 166 if (!firstEntity) throw new Error("No root page"); ··· 233 249 if (b.type === "card") { 234 250 let [page] = scan.eav(b.value, "block/card"); 235 251 if (!page) return; 236 - let blocks = getBlocksWithTypeLocal(facts, page.data.value); 237 - pages.push({ 238 - id: page.data.value, 239 - blocks: await blocksToRecord(blocks, did), 240 - }); 252 + let [pageType] = scan.eav(page.data.value, "page/type"); 253 + 254 + if (pageType?.data.value === "canvas") { 255 + let canvasBlocks = await canvasBlocksToRecord(page.data.value, did); 256 + pages.push({ 257 + id: page.data.value, 258 + blocks: canvasBlocks, 259 + type: "canvas", 260 + }); 261 + } else { 262 + let blocks = getBlocksWithTypeLocal(facts, page.data.value); 263 + pages.push({ 264 + id: page.data.value, 265 + blocks: await blocksToRecord(blocks, did), 266 + type: "doc", 267 + }); 268 + } 269 + 241 270 let block: $Typed<PubLeafletBlocksPage.Main> = { 242 271 $type: "pub.leaflet.blocks.page", 243 272 id: page.data.value, ··· 412 441 return block; 413 442 } 414 443 return; 444 + } 445 + 446 + async function canvasBlocksToRecord( 447 + pageID: string, 448 + did: string, 449 + ): Promise<PubLeafletPagesCanvas.Block[]> { 450 + let canvasBlocks = scan.eav(pageID, "canvas/block"); 451 + return ( 452 + await Promise.all( 453 + canvasBlocks.map(async (canvasBlock) => { 454 + let blockEntity = canvasBlock.data.value; 455 + let position = canvasBlock.data.position; 456 + 457 + // Get the block content 458 + let blockType = scan.eav(blockEntity, "block/type")?.[0]; 459 + if (!blockType) return null; 460 + 461 + let block: Block = { 462 + type: blockType.data.value, 463 + value: blockEntity, 464 + parent: pageID, 465 + position: "", 466 + factID: canvasBlock.id, 467 + }; 468 + 469 + let content = await blockToRecord(block, did); 470 + if (!content) return null; 471 + 472 + // Get canvas-specific properties 473 + let width = 474 + scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; 475 + let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] 476 + ?.data.value; 477 + 478 + let canvasBlockRecord: PubLeafletPagesCanvas.Block = { 479 + $type: "pub.leaflet.pages.canvas#block", 480 + block: content, 481 + x: position.x, 482 + y: position.y, 483 + width, 484 + ...(rotation !== undefined && { rotation }), 485 + }; 486 + 487 + return canvasBlockRecord; 488 + }), 489 + ) 490 + ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); 415 491 } 416 492 } 417 493
+19 -3
app/lish/Subscribe.tsx
··· 23 23 import { useSearchParams } from "next/navigation"; 24 24 import LoginForm from "app/login/LoginForm"; 25 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 + import { SpeedyLink } from "components/SpeedyLink"; 26 27 27 28 type State = 28 29 | { state: "email" } ··· 217 218 pub_uri={props.pub_uri} 218 219 setSuccessModalOpen={setSuccessModalOpen} 219 220 /> 220 - <a href={`${props.base_url}/rss`} className="flex" target="_blank" aria-label="Subscribe to RSS"> 221 + <a 222 + href={`${props.base_url}/rss`} 223 + className="flex" 224 + target="_blank" 225 + aria-label="Subscribe to RSS" 226 + > 221 227 <RSSSmall className="self-center" aria-hidden /> 222 228 </a> 223 229 </div> ··· 246 252 className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 247 253 > 248 254 <div className="font-bold text-tertiary text-sm"> 249 - You&apos;re Subscribed{props.isPost ? ` to ${props.pubName}` : "!"} 255 + You&apos;re Subscribed{props.isPost ? ` to ` : "!"} 256 + {props.isPost && ( 257 + <SpeedyLink href={props.base_url} className="text-accent-contrast"> 258 + {props.pubName} 259 + </SpeedyLink> 260 + )} 250 261 </div> 251 262 <Popover 252 263 trigger={<div className="text-accent-contrast text-sm">Manage</div>} ··· 266 277 </a> 267 278 )} 268 279 269 - <a href={`${props.base_url}/rss`} className="flex" target="_blank" aria-label="Subscribe to RSS"> 280 + <a 281 + href={`${props.base_url}/rss`} 282 + className="flex" 283 + target="_blank" 284 + aria-label="Subscribe to RSS" 285 + > 270 286 <ButtonPrimary fullWidth compact> 271 287 Get RSS 272 288 </ButtonPrimary>
+250
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 1 + "use client"; 2 + import { 3 + PubLeafletPagesCanvas, 4 + PubLeafletPagesLinearDocument, 5 + PubLeafletPublication, 6 + } from "lexicons/api"; 7 + import { PostPageData } from "./getPostPageData"; 8 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 9 + import { AppBskyFeedDefs } from "@atproto/api"; 10 + import { PageWrapper } from "components/Pages/Page"; 11 + import { Block } from "./PostContent"; 12 + import { CanvasBackgroundPattern } from "components/Canvas"; 13 + import { 14 + getCommentCount, 15 + getQuoteCount, 16 + Interactions, 17 + } from "./Interactions/Interactions"; 18 + import { Separator } from "components/Layout"; 19 + import { Popover } from "components/Popover"; 20 + import { InfoSmall } from "components/Icons/InfoSmall"; 21 + import { PostHeader } from "./PostHeader/PostHeader"; 22 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 + import { PollData } from "./fetchPollData"; 24 + 25 + export function CanvasPage({ 26 + document, 27 + blocks, 28 + did, 29 + profile, 30 + preferences, 31 + pubRecord, 32 + prerenderedCodeBlocks, 33 + bskyPostData, 34 + pollData, 35 + document_uri, 36 + pageId, 37 + pageOptions, 38 + fullPageScroll, 39 + pages, 40 + }: { 41 + document_uri: string; 42 + document: PostPageData; 43 + blocks: PubLeafletPagesCanvas.Block[]; 44 + profile: ProfileViewDetailed; 45 + pubRecord: PubLeafletPublication.Record; 46 + did: string; 47 + prerenderedCodeBlocks?: Map<string, string>; 48 + bskyPostData: AppBskyFeedDefs.PostView[]; 49 + pollData: PollData[]; 50 + preferences: { showComments?: boolean }; 51 + pageId?: string; 52 + pageOptions?: React.ReactNode; 53 + fullPageScroll: boolean; 54 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 55 + }) { 56 + let hasPageBackground = !!pubRecord.theme?.showPageBackground; 57 + let isSubpage = !!pageId; 58 + let drawer = useDrawerOpen(document_uri); 59 + 60 + return ( 61 + <PageWrapper 62 + pageType="canvas" 63 + fullPageScroll={fullPageScroll} 64 + cardBorderHidden={!hasPageBackground} 65 + id={pageId ? `post-page-${pageId}` : "post-page"} 66 + drawerOpen={ 67 + !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 68 + } 69 + pageOptions={pageOptions} 70 + > 71 + <CanvasMetadata 72 + pageId={pageId} 73 + isSubpage={isSubpage} 74 + data={document} 75 + profile={profile} 76 + preferences={preferences} 77 + commentsCount={getCommentCount(document, pageId)} 78 + quotesCount={getQuoteCount(document, pageId)} 79 + /> 80 + <CanvasContent 81 + blocks={blocks} 82 + did={did} 83 + prerenderedCodeBlocks={prerenderedCodeBlocks} 84 + bskyPostData={bskyPostData} 85 + pollData={pollData} 86 + pageId={pageId} 87 + pages={pages} 88 + /> 89 + </PageWrapper> 90 + ); 91 + } 92 + 93 + function CanvasContent({ 94 + blocks, 95 + did, 96 + prerenderedCodeBlocks, 97 + bskyPostData, 98 + pageId, 99 + pollData, 100 + pages, 101 + }: { 102 + blocks: PubLeafletPagesCanvas.Block[]; 103 + did: string; 104 + prerenderedCodeBlocks?: Map<string, string>; 105 + pollData: PollData[]; 106 + bskyPostData: AppBskyFeedDefs.PostView[]; 107 + pageId?: string; 108 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 109 + }) { 110 + let height = blocks.length > 0 ? Math.max(...blocks.map((b) => b.y), 0) : 0; 111 + 112 + return ( 113 + <div className="canvasWrapper h-full w-fit overflow-y-scroll postContent"> 114 + <div 115 + style={{ 116 + minHeight: height + 512, 117 + contain: "size layout paint", 118 + }} 119 + className="relative h-full w-[1272px]" 120 + > 121 + <CanvasBackground /> 122 + 123 + {blocks 124 + .sort((a, b) => { 125 + if (a.y === b.y) { 126 + return a.x - b.x; 127 + } 128 + return a.y - b.y; 129 + }) 130 + .map((canvasBlock, index) => { 131 + return ( 132 + <CanvasBlock 133 + key={index} 134 + canvasBlock={canvasBlock} 135 + did={did} 136 + pollData={pollData} 137 + prerenderedCodeBlocks={prerenderedCodeBlocks} 138 + bskyPostData={bskyPostData} 139 + pageId={pageId} 140 + pages={pages} 141 + index={index} 142 + /> 143 + ); 144 + })} 145 + </div> 146 + </div> 147 + ); 148 + } 149 + 150 + function CanvasBlock({ 151 + canvasBlock, 152 + did, 153 + prerenderedCodeBlocks, 154 + bskyPostData, 155 + pollData, 156 + pageId, 157 + pages, 158 + index, 159 + }: { 160 + canvasBlock: PubLeafletPagesCanvas.Block; 161 + did: string; 162 + prerenderedCodeBlocks?: Map<string, string>; 163 + bskyPostData: AppBskyFeedDefs.PostView[]; 164 + pollData: PollData[]; 165 + pageId?: string; 166 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 167 + index: number; 168 + }) { 169 + let { x, y, width, rotation } = canvasBlock; 170 + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 171 + 172 + // Wrap the block in a LinearDocument.Block structure for compatibility 173 + let linearBlock: PubLeafletPagesLinearDocument.Block = { 174 + $type: "pub.leaflet.pages.linearDocument#block", 175 + block: canvasBlock.block, 176 + }; 177 + 178 + return ( 179 + <div 180 + className="absolute rounded-lg flex items-stretch origin-center p-3" 181 + style={{ 182 + top: 0, 183 + left: 0, 184 + width, 185 + transform, 186 + }} 187 + > 188 + <div className="contents"> 189 + <Block 190 + pollData={pollData} 191 + pageId={pageId} 192 + pages={pages} 193 + bskyPostData={bskyPostData} 194 + block={linearBlock} 195 + did={did} 196 + index={[index]} 197 + preview={false} 198 + prerenderedCodeBlocks={prerenderedCodeBlocks} 199 + /> 200 + </div> 201 + </div> 202 + ); 203 + } 204 + 205 + const CanvasMetadata = (props: { 206 + pageId: string | undefined; 207 + isSubpage: boolean | undefined; 208 + data: PostPageData; 209 + profile: ProfileViewDetailed; 210 + preferences: { showComments?: boolean }; 211 + quotesCount: number | undefined; 212 + commentsCount: number | undefined; 213 + }) => { 214 + return ( 215 + <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 216 + <Interactions 217 + quotesCount={props.quotesCount || 0} 218 + commentsCount={props.commentsCount || 0} 219 + compact 220 + showComments={props.preferences.showComments} 221 + pageId={props.pageId} 222 + /> 223 + {!props.isSubpage && ( 224 + <> 225 + <Separator classname="h-5" /> 226 + <Popover 227 + side="left" 228 + align="start" 229 + className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 230 + trigger={<InfoSmall />} 231 + > 232 + <PostHeader 233 + data={props.data} 234 + profile={props.profile} 235 + preferences={props.preferences} 236 + /> 237 + </Popover> 238 + </> 239 + )} 240 + </div> 241 + ); 242 + }; 243 + 244 + const CanvasBackground = () => { 245 + return ( 246 + <div className="w-full h-full pointer-events-none"> 247 + <CanvasBackgroundPattern pattern="grid" /> 248 + </div> 249 + ); 250 + };
+32 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 5 5 import type { Json } from "supabase/database.types"; 6 6 import { create } from "zustand"; 7 7 import type { Comment } from "./Comments"; 8 - import { QuotePosition } from "../quotePosition"; 8 + import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 9 9 import { useContext } from "react"; 10 10 import { PostPageContext } from "../PostPageContext"; 11 11 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 + import { PostPageData } from "../getPostPageData"; 13 + import { PubLeafletComment } from "lexicons/api"; 12 14 13 15 export type InteractionState = { 14 16 drawerOpen: undefined | boolean; ··· 149 151 </div> 150 152 ); 151 153 }; 154 + 155 + export function getCommentCount(document: PostPageData, pageId?: string) { 156 + if (!document) return; 157 + 158 + if (pageId) 159 + return document.document_mentions_in_bsky.filter((q) => 160 + q.link.includes(pageId), 161 + ).length; 162 + else 163 + return document.document_mentions_in_bsky.filter((q) => { 164 + const url = new URL(q.link); 165 + const quoteParam = url.pathname.split("/l-quote/")[1]; 166 + if (!quoteParam) return null; 167 + const quotePosition = decodeQuotePosition(quoteParam); 168 + return !quotePosition?.pageId; 169 + }).length; 170 + } 171 + 172 + export function getQuoteCount(document: PostPageData, pageId?: string) { 173 + if (!document) return; 174 + if (pageId) 175 + return document.comments_on_documents.filter( 176 + (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 177 + ).length; 178 + else 179 + return document.comments_on_documents.filter( 180 + (c) => !(c.record as PubLeafletComment.Record)?.onPage, 181 + ).length; 182 + }
+139
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 1 + "use client"; 2 + import { 3 + PubLeafletComment, 4 + PubLeafletDocument, 5 + PubLeafletPagesLinearDocument, 6 + PubLeafletPublication, 7 + } from "lexicons/api"; 8 + import { PostPageData } from "./getPostPageData"; 9 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 + import { EditTiny } from "components/Icons/EditTiny"; 13 + import { 14 + getCommentCount, 15 + getQuoteCount, 16 + Interactions, 17 + } from "./Interactions/Interactions"; 18 + import { PostContent } from "./PostContent"; 19 + import { PostHeader } from "./PostHeader/PostHeader"; 20 + import { useIdentityData } from "components/IdentityProvider"; 21 + import { AppBskyFeedDefs } from "@atproto/api"; 22 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 + import { PageWrapper } from "components/Pages/Page"; 24 + import { decodeQuotePosition } from "./quotePosition"; 25 + import { PollData } from "./fetchPollData"; 26 + 27 + export function LinearDocumentPage({ 28 + document, 29 + blocks, 30 + did, 31 + profile, 32 + preferences, 33 + pubRecord, 34 + prerenderedCodeBlocks, 35 + bskyPostData, 36 + document_uri, 37 + pageId, 38 + pageOptions, 39 + pollData, 40 + fullPageScroll, 41 + }: { 42 + document_uri: string; 43 + document: PostPageData; 44 + blocks: PubLeafletPagesLinearDocument.Block[]; 45 + profile?: ProfileViewDetailed; 46 + pubRecord: PubLeafletPublication.Record; 47 + did: string; 48 + prerenderedCodeBlocks?: Map<string, string>; 49 + bskyPostData: AppBskyFeedDefs.PostView[]; 50 + pollData: PollData[]; 51 + preferences: { showComments?: boolean }; 52 + pageId?: string; 53 + pageOptions?: React.ReactNode; 54 + fullPageScroll: boolean; 55 + }) { 56 + let { identity } = useIdentityData(); 57 + let drawer = useDrawerOpen(document_uri); 58 + 59 + if (!document || !document.documents_in_publications[0].publications) 60 + return null; 61 + 62 + let hasPageBackground = !!pubRecord.theme?.showPageBackground; 63 + let record = document.data as PubLeafletDocument.Record; 64 + 65 + const isSubpage = !!pageId; 66 + 67 + return ( 68 + <> 69 + <PageWrapper 70 + pageType="doc" 71 + fullPageScroll={fullPageScroll} 72 + cardBorderHidden={!hasPageBackground} 73 + id={pageId ? `post-page-${pageId}` : "post-page"} 74 + drawerOpen={ 75 + !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 76 + } 77 + pageOptions={pageOptions} 78 + > 79 + {!isSubpage && profile && ( 80 + <PostHeader 81 + data={document} 82 + profile={profile} 83 + preferences={preferences} 84 + /> 85 + )} 86 + <PostContent 87 + pollData={pollData} 88 + pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 89 + pageId={pageId} 90 + bskyPostData={bskyPostData} 91 + blocks={blocks} 92 + did={did} 93 + prerenderedCodeBlocks={prerenderedCodeBlocks} 94 + /> 95 + <Interactions 96 + pageId={pageId} 97 + showComments={preferences.showComments} 98 + commentsCount={getCommentCount(document, pageId) || 0} 99 + quotesCount={getQuoteCount(document, pageId) || 0} 100 + /> 101 + {!isSubpage && ( 102 + <> 103 + <hr className="border-border-light mb-4 mt-4 sm:mx-4 mx-3" /> 104 + <div className="sm:px-4 px-3"> 105 + {identity && 106 + identity.atp_did === 107 + document.documents_in_publications[0]?.publications 108 + ?.identity_did ? ( 109 + <a 110 + href={`https://leaflet.pub/${document.leaflets_in_publications[0]?.leaflet}`} 111 + className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1 mx-auto" 112 + > 113 + <EditTiny /> Edit Post 114 + </a> 115 + ) : ( 116 + <SubscribeWithBluesky 117 + isPost 118 + base_url={getPublicationURL( 119 + document.documents_in_publications[0].publications, 120 + )} 121 + pub_uri={ 122 + document.documents_in_publications[0].publications.uri 123 + } 124 + subscribers={ 125 + document.documents_in_publications[0].publications 126 + .publication_subscriptions 127 + } 128 + pubName={ 129 + document.documents_in_publications[0].publications.name 130 + } 131 + /> 132 + )} 133 + </div> 134 + </> 135 + )} 136 + </PageWrapper> 137 + </> 138 + ); 139 + }
+10 -4
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 9 9 PubLeafletBlocksWebsite, 10 10 PubLeafletDocument, 11 11 PubLeafletPagesLinearDocument, 12 + PubLeafletPagesCanvas, 12 13 PubLeafletBlocksHorizontalRule, 13 14 PubLeafletBlocksBlockquote, 14 15 PubLeafletBlocksBskyPost, ··· 50 51 className?: string; 51 52 prerenderedCodeBlocks?: Map<string, string>; 52 53 bskyPostData: AppBskyFeedDefs.PostView[]; 53 - pages: PubLeafletPagesLinearDocument.Main[]; 54 54 pollData: PollData[]; 55 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 55 56 }) { 56 57 return ( 57 58 <div ··· 79 80 ); 80 81 } 81 82 82 - let Block = ({ 83 + export let Block = ({ 83 84 block, 84 85 did, 85 86 isList, ··· 98 99 block: PubLeafletPagesLinearDocument.Block; 99 100 did: string; 100 101 isList?: boolean; 101 - pages: PubLeafletPagesLinearDocument.Main[]; 102 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 102 103 previousBlock?: PubLeafletPagesLinearDocument.Block; 103 104 prerenderedCodeBlocks?: Map<string, string>; 104 105 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 144 145 let id = b.block.id; 145 146 let page = pages.find((p) => p.id === id); 146 147 if (!page) return; 148 + 149 + const isCanvas = PubLeafletPagesCanvas.isMain(page); 150 + 147 151 return ( 148 152 <PublishedPageLinkBlock 149 153 blocks={page.blocks} ··· 151 155 parentPageId={pageId} 152 156 did={did} 153 157 bskyPostData={bskyPostData} 158 + isCanvas={isCanvas} 159 + pages={pages} 154 160 className={className} 155 161 /> 156 162 ); ··· 375 381 376 382 function ListItem(props: { 377 383 index: number[]; 378 - pages: PubLeafletPagesLinearDocument.Main[]; 384 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 379 385 item: PubLeafletBlocksUnorderedList.ListItem; 380 386 did: string; 381 387 className?: string;
+66 -110
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 1 1 "use client"; 2 2 import { 3 - PubLeafletComment, 4 3 PubLeafletDocument, 5 4 PubLeafletPagesLinearDocument, 5 + PubLeafletPagesCanvas, 6 6 PubLeafletPublication, 7 7 } from "lexicons/api"; 8 8 import { PostPageData } from "./getPostPageData"; 9 9 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 - import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 - import { EditTiny } from "components/Icons/EditTiny"; 13 - import { Interactions } from "./Interactions/Interactions"; 14 - import { PostContent } from "./PostContent"; 15 - import { PostHeader } from "./PostHeader/PostHeader"; 16 - import { useIdentityData } from "components/IdentityProvider"; 17 10 import { AppBskyFeedDefs } from "@atproto/api"; 18 11 import { create } from "zustand/react"; 19 12 import { ··· 23 16 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 24 17 import { PageOptionButton } from "components/Pages/PageOptions"; 25 18 import { CloseTiny } from "components/Icons/CloseTiny"; 26 - import { PageWrapper } from "components/Pages/Page"; 27 19 import { Fragment, useEffect } from "react"; 28 20 import { flushSync } from "react-dom"; 29 21 import { scrollIntoView } from "src/utils/scrollIntoView"; 30 22 import { useParams } from "next/navigation"; 31 23 import { decodeQuotePosition } from "./quotePosition"; 32 24 import { PollData } from "./fetchPollData"; 25 + import { LinearDocumentPage } from "./LinearDocumentPage"; 26 + import { CanvasPage } from "./CanvasPage"; 33 27 34 28 const usePostPageUIState = create(() => ({ 35 29 pages: [] as string[], ··· 127 121 preferences: { showComments?: boolean }; 128 122 pollData: PollData[]; 129 123 }) { 130 - let { identity } = useIdentityData(); 131 124 let drawer = useDrawerOpen(document_uri); 132 125 useInitializeOpenPages(); 133 126 let pages = useOpenPages(); ··· 135 128 return null; 136 129 137 130 let hasPageBackground = !!pubRecord.theme?.showPageBackground; 131 + let record = document.data as PubLeafletDocument.Record; 132 + 138 133 let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; 139 - let record = document.data as PubLeafletDocument.Record; 140 134 return ( 141 135 <> 142 136 {!fullPageScroll && <BookendSpacer />} 143 - <PageWrapper 144 - pageType="doc" 137 + <LinearDocumentPage 138 + document={document} 139 + blocks={blocks} 140 + did={did} 141 + profile={profile} 145 142 fullPageScroll={fullPageScroll} 146 - cardBorderHidden={!hasPageBackground} 147 - id={"post-page"} 148 - drawerOpen={!!drawer && !drawer.pageId} 149 - > 150 - <PostHeader 151 - data={document} 152 - profile={profile} 153 - preferences={preferences} 154 - /> 155 - <PostContent 156 - pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 157 - bskyPostData={bskyPostData} 158 - blocks={blocks} 159 - did={did} 160 - prerenderedCodeBlocks={prerenderedCodeBlocks} 161 - pollData={pollData} 162 - /> 163 - <Interactions 164 - showComments={preferences.showComments} 165 - quotesCount={ 166 - document.document_mentions_in_bsky.filter((q) => { 167 - const url = new URL(q.link); 168 - const quoteParam = url.pathname.split("/l-quote/")[1]; 169 - if (!quoteParam) return null; 170 - const quotePosition = decodeQuotePosition(quoteParam); 171 - return !quotePosition?.pageId; 172 - }).length 173 - } 174 - commentsCount={ 175 - document.comments_on_documents.filter( 176 - (c) => !(c.record as PubLeafletComment.Record)?.onPage, 177 - ).length 178 - } 179 - /> 180 - <hr className="border-border-light mb-4 mt-4 sm:mx-4 mx-3" /> 181 - <div className="sm:px-4 px-3"> 182 - {identity && 183 - identity.atp_did === 184 - document.documents_in_publications[0]?.publications 185 - ?.identity_did ? ( 186 - <a 187 - href={`https://leaflet.pub/${document.leaflets_in_publications[0]?.leaflet}`} 188 - className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1 mx-auto" 189 - > 190 - <EditTiny /> Edit Post 191 - </a> 192 - ) : ( 193 - <SubscribeWithBluesky 194 - isPost 195 - base_url={getPublicationURL( 196 - document.documents_in_publications[0].publications, 197 - )} 198 - pub_uri={document.documents_in_publications[0].publications.uri} 199 - subscribers={ 200 - document.documents_in_publications[0].publications 201 - .publication_subscriptions 202 - } 203 - pubName={document.documents_in_publications[0].publications.name} 204 - /> 205 - )} 206 - </div> 207 - </PageWrapper> 143 + pollData={pollData} 144 + preferences={preferences} 145 + pubRecord={pubRecord} 146 + prerenderedCodeBlocks={prerenderedCodeBlocks} 147 + bskyPostData={bskyPostData} 148 + document_uri={document_uri} 149 + /> 208 150 209 151 {drawer && !drawer.pageId && ( 210 152 <InteractionDrawer ··· 221 163 222 164 {pages.map((p) => { 223 165 let page = record.pages.find( 224 - (page) => (page as PubLeafletPagesLinearDocument.Main).id === p, 225 - ) as PubLeafletPagesLinearDocument.Main | undefined; 166 + (page) => 167 + ( 168 + page as 169 + | PubLeafletPagesLinearDocument.Main 170 + | PubLeafletPagesCanvas.Main 171 + ).id === p, 172 + ) as 173 + | PubLeafletPagesLinearDocument.Main 174 + | PubLeafletPagesCanvas.Main 175 + | undefined; 226 176 if (!page) return null; 177 + 178 + const isCanvas = PubLeafletPagesCanvas.isMain(page); 179 + 227 180 return ( 228 181 <Fragment key={p}> 229 182 <SandwichSpacer /> 230 - {/*JARED TODO : drawerOpen here is checking whether the drawer is open on the first page, rather than if it's open on this page. Please rewire this when you add drawers per page!*/} 231 - <PageWrapper 232 - pageType="doc" 233 - cardBorderHidden={!hasPageBackground} 234 - id={`post-page-${p}`} 235 - fullPageScroll={false} 236 - drawerOpen={!!drawer && drawer.pageId === page.id} 237 - pageOptions={ 238 - <PageOptions 239 - onClick={() => closePage(page?.id!)} 240 - hasPageBackground={hasPageBackground} 241 - /> 242 - } 243 - > 244 - <PostContent 245 - pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 246 - pageId={page.id} 247 - bskyPostData={bskyPostData} 248 - blocks={page.blocks} 183 + {isCanvas ? ( 184 + <CanvasPage 185 + fullPageScroll={false} 186 + document={document} 187 + blocks={(page as PubLeafletPagesCanvas.Main).blocks} 249 188 did={did} 189 + preferences={preferences} 190 + profile={profile} 191 + pubRecord={pubRecord} 250 192 prerenderedCodeBlocks={prerenderedCodeBlocks} 251 193 pollData={pollData} 252 - /> 253 - <Interactions 194 + bskyPostData={bskyPostData} 195 + document_uri={document_uri} 254 196 pageId={page.id} 255 - showComments={preferences.showComments} 256 - quotesCount={ 257 - document.document_mentions_in_bsky.filter((q) => 258 - q.link.includes(page.id!), 259 - ).length 197 + pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 198 + pageOptions={ 199 + <PageOptions 200 + onClick={() => closePage(page?.id!)} 201 + hasPageBackground={hasPageBackground} 202 + /> 260 203 } 261 - commentsCount={ 262 - document.comments_on_documents.filter( 263 - (c) => 264 - (c.record as PubLeafletComment.Record)?.onPage === 265 - page.id, 266 - ).length 204 + /> 205 + ) : ( 206 + <LinearDocumentPage 207 + fullPageScroll={false} 208 + document={document} 209 + blocks={(page as PubLeafletPagesLinearDocument.Main).blocks} 210 + did={did} 211 + preferences={preferences} 212 + pubRecord={pubRecord} 213 + pollData={pollData} 214 + prerenderedCodeBlocks={prerenderedCodeBlocks} 215 + bskyPostData={bskyPostData} 216 + document_uri={document_uri} 217 + pageId={page.id} 218 + pageOptions={ 219 + <PageOptions 220 + onClick={() => closePage(page?.id!)} 221 + hasPageBackground={hasPageBackground} 222 + /> 267 223 } 268 224 /> 269 - </PageWrapper> 225 + )} 270 226 {drawer && drawer.pageId === page.id && ( 271 227 <InteractionDrawer 272 228 pageId={page.id}
+105 -5
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 4 4 import { useUIState } from "src/useUIState"; 5 5 import { CSSProperties, useContext, useRef } from "react"; 6 6 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 - import { PostContent } from "./PostContent"; 7 + import { PostContent, Block } from "./PostContent"; 8 8 import { 9 9 PubLeafletBlocksHeader, 10 10 PubLeafletBlocksText, 11 11 PubLeafletComment, 12 12 PubLeafletPagesLinearDocument, 13 + PubLeafletPagesCanvas, 13 14 PubLeafletPublication, 14 15 } from "lexicons/api"; 15 16 import { AppBskyFeedDefs } from "@atproto/api"; ··· 23 24 } from "./Interactions/Interactions"; 24 25 import { CommentTiny } from "components/Icons/CommentTiny"; 25 26 import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 + import { CanvasBackgroundPattern } from "components/Canvas"; 26 28 27 29 export function PublishedPageLinkBlock(props: { 28 - blocks: PubLeafletPagesLinearDocument.Block[]; 30 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; 29 31 parentPageId: string | undefined; 30 32 pageId: string; 31 33 did: string; ··· 33 35 className?: string; 34 36 prerenderedCodeBlocks?: Map<string, string>; 35 37 bskyPostData: AppBskyFeedDefs.PostView[]; 38 + isCanvas?: boolean; 39 + pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 36 40 }) { 37 41 //switch to use actually state 38 42 let openPages = useOpenPages(); ··· 56 60 openPage(props.parentPageId, props.pageId); 57 61 }} 58 62 > 59 - <DocLinkBlock {...props} /> 63 + {props.isCanvas ? ( 64 + <CanvasLinkBlock 65 + blocks={props.blocks as PubLeafletPagesCanvas.Block[]} 66 + did={props.did} 67 + pageId={props.pageId} 68 + bskyPostData={props.bskyPostData} 69 + pages={props.pages || []} 70 + /> 71 + ) : ( 72 + <DocLinkBlock 73 + {...props} 74 + blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]} 75 + /> 76 + )} 60 77 </div> 61 78 ); 62 79 } ··· 204 221 openInteractionDrawer("quotes", document_uri, props.pageId); 205 222 else setInteractionState(document_uri, { drawerOpen: false }); 206 223 }} 207 - aria-label="Page quotes" 208 224 > 225 + <span className="sr-only">Page quotes</span> 209 226 <QuoteTiny aria-hidden /> {quotes}{" "} 210 227 </button> 211 228 )} ··· 222 239 openInteractionDrawer("comments", document_uri, props.pageId); 223 240 else setInteractionState(document_uri, { drawerOpen: false }); 224 241 }} 225 - aria-label="Page comments" 226 242 > 243 + <span className="sr-only">Page comments</span> 227 244 <CommentTiny aria-hidden /> {comments}{" "} 228 245 </button> 229 246 )} 230 247 </div> 231 248 ); 232 249 }; 250 + 251 + const CanvasLinkBlock = (props: { 252 + blocks: PubLeafletPagesCanvas.Block[]; 253 + did: string; 254 + pageId: string; 255 + bskyPostData: AppBskyFeedDefs.PostView[]; 256 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 257 + }) => { 258 + let pageWidth = `var(--page-width-unitless)`; 259 + let height = 260 + props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0; 261 + 262 + return ( 263 + <div 264 + style={{ contain: "size layout paint" }} 265 + className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`} 266 + > 267 + <div 268 + className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`} 269 + style={{ 270 + width: `calc(1px * ${pageWidth})`, 271 + height: "calc(1150px * 2)", 272 + transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`, 273 + }} 274 + > 275 + <div 276 + style={{ 277 + minHeight: height + 512, 278 + contain: "size layout paint", 279 + }} 280 + className="relative h-full w-[1272px]" 281 + > 282 + <div className="w-full h-full pointer-events-none"> 283 + <CanvasBackgroundPattern pattern="grid" /> 284 + </div> 285 + {props.blocks 286 + .sort((a, b) => { 287 + if (a.y === b.y) { 288 + return a.x - b.x; 289 + } 290 + return a.y - b.y; 291 + }) 292 + .map((canvasBlock, index) => { 293 + let { x, y, width, rotation } = canvasBlock; 294 + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 295 + 296 + // Wrap the block in a LinearDocument.Block structure for compatibility 297 + let linearBlock: PubLeafletPagesLinearDocument.Block = { 298 + $type: "pub.leaflet.pages.linearDocument#block", 299 + block: canvasBlock.block, 300 + }; 301 + 302 + return ( 303 + <div 304 + key={index} 305 + className="absolute rounded-lg flex items-stretch origin-center p-3" 306 + style={{ 307 + top: 0, 308 + left: 0, 309 + width, 310 + transform, 311 + }} 312 + > 313 + <div className="contents"> 314 + <Block 315 + pollData={[]} 316 + pageId={props.pageId} 317 + pages={props.pages} 318 + bskyPostData={props.bskyPostData} 319 + block={linearBlock} 320 + did={props.did} 321 + index={[index]} 322 + preview={true} 323 + /> 324 + </div> 325 + </div> 326 + ); 327 + })} 328 + </div> 329 + </div> 330 + </div> 331 + ); 332 + };
+19 -1
components/Blocks/Block.tsx
··· 7 7 import { useBlockKeyboardHandlers } from "./useBlockKeyboardHandlers"; 8 8 import { useLongPress } from "src/hooks/useLongPress"; 9 9 import { focusBlock } from "src/utils/focusBlock"; 10 + import { useHandleDrop } from "./useHandleDrop"; 11 + import { useEntitySetContext } from "components/EntitySetProvider"; 10 12 11 13 import { TextBlock } from "components/Blocks/TextBlock"; 12 14 import { ImageBlock } from "./ImageBlock"; ··· 15 17 import { EmbedBlock } from "./EmbedBlock"; 16 18 import { MailboxBlock } from "./MailboxBlock"; 17 19 import { AreYouSure } from "./DeleteBlock"; 18 - import { useEntitySetContext } from "components/EntitySetProvider"; 19 20 import { useIsMobile } from "src/hooks/isMobile"; 20 21 import { DateTimeBlock } from "./DateTimeBlock"; 21 22 import { RSVPBlock } from "./RSVPBlock"; ··· 63 64 // and shared styling like padding and flex for list layouting 64 65 65 66 let mouseHandlers = useBlockMouseHandlers(props); 67 + let handleDrop = useHandleDrop({ 68 + parent: props.parent, 69 + position: props.position, 70 + nextPosition: props.nextPosition, 71 + }); 72 + let entity_set = useEntitySetContext(); 66 73 67 74 let { isLongPress, handlers } = useLongPress(() => { 68 75 if (isTextBlock[props.type]) return; ··· 93 100 {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})} 94 101 id={ 95 102 !props.preview ? elementId.block(props.entityID).container : undefined 103 + } 104 + onDragOver={ 105 + !props.preview && entity_set.permissions.write 106 + ? (e) => { 107 + e.preventDefault(); 108 + e.stopPropagation(); 109 + } 110 + : undefined 111 + } 112 + onDrop={ 113 + !props.preview && entity_set.permissions.write ? handleDrop : undefined 96 114 } 97 115 className={` 98 116 blockWrapper relative
-1
components/Blocks/BlockCommands.tsx
··· 368 368 name: "New Canvas", 369 369 icon: <BlockCanvasPageSmall />, 370 370 type: "page", 371 - hiddenInPublication: true, 372 371 onSelect: async (rep, props, um) => { 373 372 props.entityID && clearCommandSearchText(props.entityID); 374 373 let entity = await createBlockWithType(rep, props, "card");
+46 -25
components/Blocks/ImageBlock.tsx
··· 51 51 } 52 52 }, [isSelected, props.preview, props.entityID]); 53 53 54 + const handleImageUpload = async (file: File) => { 55 + if (!rep) return; 56 + let entity = props.entityID; 57 + if (!entity) { 58 + entity = v7(); 59 + await rep?.mutate.addBlock({ 60 + parent: props.parent, 61 + factID: v7(), 62 + permission_set: entity_set.set, 63 + type: "text", 64 + position: generateKeyBetween( 65 + props.position, 66 + props.nextPosition, 67 + ), 68 + newEntityID: entity, 69 + }); 70 + } 71 + await rep.mutate.assertFact({ 72 + entity, 73 + attribute: "block/type", 74 + data: { type: "block-type-union", value: "image" }, 75 + }); 76 + await addImage(file, rep, { 77 + entityID: entity, 78 + attribute: "block/image", 79 + }); 80 + }; 81 + 54 82 if (!image) { 55 83 if (!entity_set.permissions.write) return null; 56 84 return ( ··· 65 93 ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 66 94 ${props.pageType === "canvas" && "bg-bg-page"}`} 67 95 onMouseDown={(e) => e.preventDefault()} 96 + onDragOver={(e) => { 97 + e.preventDefault(); 98 + e.stopPropagation(); 99 + }} 100 + onDrop={async (e) => { 101 + e.preventDefault(); 102 + e.stopPropagation(); 103 + if (isLocked) return; 104 + const files = e.dataTransfer.files; 105 + if (files && files.length > 0) { 106 + const file = files[0]; 107 + if (file.type.startsWith('image/')) { 108 + await handleImageUpload(file); 109 + } 110 + } 111 + }} 68 112 > 69 113 <div className="flex gap-2"> 70 114 <BlockImageSmall ··· 79 123 accept="image/*" 80 124 onChange={async (e) => { 81 125 let file = e.currentTarget.files?.[0]; 82 - if (!file || !rep) return; 83 - let entity = props.entityID; 84 - if (!entity) { 85 - entity = v7(); 86 - await rep?.mutate.addBlock({ 87 - parent: props.parent, 88 - factID: v7(), 89 - permission_set: entity_set.set, 90 - type: "text", 91 - position: generateKeyBetween( 92 - props.position, 93 - props.nextPosition, 94 - ), 95 - newEntityID: entity, 96 - }); 97 - } 98 - await rep.mutate.assertFact({ 99 - entity, 100 - attribute: "block/type", 101 - data: { type: "block-type-union", value: "image" }, 102 - }); 103 - await addImage(file, rep, { 104 - entityID: entity, 105 - attribute: "block/image", 106 - }); 126 + if (!file) return; 127 + await handleImageUpload(file); 107 128 }} 108 129 /> 109 130 </label>
+11
components/Blocks/index.tsx
··· 17 17 import { useEffect } from "react"; 18 18 import { addShortcut } from "src/shortcuts"; 19 19 import { QuoteEmbedBlock } from "./QuoteEmbedBlock"; 20 + import { useHandleDrop } from "./useHandleDrop"; 20 21 21 22 export function Blocks(props: { entityID: string }) { 22 23 let rep = useReplicache(); ··· 231 232 }) => { 232 233 let { rep } = useReplicache(); 233 234 let entity_set = useEntitySetContext(); 235 + let handleDrop = useHandleDrop({ 236 + parent: props.entityID, 237 + position: props.lastRootBlock?.position || null, 238 + nextPosition: null, 239 + }); 234 240 235 241 if (!entity_set.permissions.write) return; 236 242 return ( ··· 267 273 }, 10); 268 274 } 269 275 }} 276 + onDragOver={(e) => { 277 + e.preventDefault(); 278 + e.stopPropagation(); 279 + }} 280 + onDrop={handleDrop} 270 281 /> 271 282 ); 272 283 };
+233
components/Blocks/useHandleCanvasDrop.ts
··· 1 + import { useCallback } from "react"; 2 + import { useReplicache, useEntity } from "src/replicache"; 3 + import { useEntitySetContext } from "components/EntitySetProvider"; 4 + import { v7 } from "uuid"; 5 + import { supabaseBrowserClient } from "supabase/browserClient"; 6 + import { localImages } from "src/utils/addImage"; 7 + import { rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; 8 + 9 + // Helper function to load image dimensions and thumbhash 10 + const processImage = async ( 11 + file: File, 12 + ): Promise<{ 13 + width: number; 14 + height: number; 15 + thumbhash: string; 16 + }> => { 17 + // Load image to get dimensions 18 + const img = new Image(); 19 + const url = URL.createObjectURL(file); 20 + 21 + const dimensions = await new Promise<{ width: number; height: number }>( 22 + (resolve, reject) => { 23 + img.onload = () => { 24 + resolve({ width: img.width, height: img.height }); 25 + }; 26 + img.onerror = reject; 27 + img.src = url; 28 + }, 29 + ); 30 + 31 + // Generate thumbhash 32 + const arrayBuffer = await file.arrayBuffer(); 33 + const blob = new Blob([arrayBuffer], { type: file.type }); 34 + const imageBitmap = await createImageBitmap(blob); 35 + 36 + const canvas = document.createElement("canvas"); 37 + const context = canvas.getContext("2d") as CanvasRenderingContext2D; 38 + const maxDimension = 100; 39 + let width = imageBitmap.width; 40 + let height = imageBitmap.height; 41 + 42 + if (width > height) { 43 + if (width > maxDimension) { 44 + height *= maxDimension / width; 45 + width = maxDimension; 46 + } 47 + } else { 48 + if (height > maxDimension) { 49 + width *= maxDimension / height; 50 + height = maxDimension; 51 + } 52 + } 53 + 54 + canvas.width = width; 55 + canvas.height = height; 56 + context.drawImage(imageBitmap, 0, 0, width, height); 57 + 58 + const imageData = context.getImageData(0, 0, width, height); 59 + const thumbhash = thumbHashToDataURL( 60 + rgbaToThumbHash(imageData.width, imageData.height, imageData.data), 61 + ); 62 + 63 + URL.revokeObjectURL(url); 64 + 65 + return { 66 + width: dimensions.width, 67 + height: dimensions.height, 68 + thumbhash, 69 + }; 70 + }; 71 + 72 + export const useHandleCanvasDrop = (entityID: string) => { 73 + let { rep } = useReplicache(); 74 + let entity_set = useEntitySetContext(); 75 + let blocks = useEntity(entityID, "canvas/block"); 76 + 77 + return useCallback( 78 + async (e: React.DragEvent) => { 79 + e.preventDefault(); 80 + e.stopPropagation(); 81 + 82 + if (!rep) return; 83 + 84 + const files = e.dataTransfer.files; 85 + if (!files || files.length === 0) return; 86 + 87 + // Filter for image files only 88 + const imageFiles = Array.from(files).filter((file) => 89 + file.type.startsWith("image/"), 90 + ); 91 + 92 + if (imageFiles.length === 0) return; 93 + 94 + const parentRect = e.currentTarget.getBoundingClientRect(); 95 + const dropX = Math.max(e.clientX - parentRect.left, 0); 96 + const dropY = Math.max(e.clientY - parentRect.top, 0); 97 + 98 + const SPACING = 0; 99 + const DEFAULT_WIDTH = 360; 100 + 101 + // Process all images to get dimensions and thumbhashes 102 + const processedImages = await Promise.all( 103 + imageFiles.map((file) => processImage(file)), 104 + ); 105 + 106 + // Calculate grid dimensions based on image count 107 + const COLUMNS = Math.ceil(Math.sqrt(imageFiles.length)); 108 + 109 + // Calculate the width and height for each column and row 110 + const colWidths: number[] = []; 111 + const rowHeights: number[] = []; 112 + 113 + for (let i = 0; i < imageFiles.length; i++) { 114 + const col = i % COLUMNS; 115 + const row = Math.floor(i / COLUMNS); 116 + const dims = processedImages[i]; 117 + 118 + // Scale image to fit within DEFAULT_WIDTH while maintaining aspect ratio 119 + const scale = DEFAULT_WIDTH / dims.width; 120 + const scaledWidth = DEFAULT_WIDTH; 121 + const scaledHeight = dims.height * scale; 122 + 123 + // Track max width for each column and max height for each row 124 + colWidths[col] = Math.max(colWidths[col] || 0, scaledWidth); 125 + rowHeights[row] = Math.max(rowHeights[row] || 0, scaledHeight); 126 + } 127 + 128 + const client = supabaseBrowserClient(); 129 + const cache = await caches.open("minilink-user-assets"); 130 + 131 + // Calculate positions and prepare data for all images 132 + const imageBlocks = imageFiles.map((file, index) => { 133 + const entity = v7(); 134 + const fileID = v7(); 135 + const row = Math.floor(index / COLUMNS); 136 + const col = index % COLUMNS; 137 + 138 + // Calculate x position by summing all previous column widths 139 + let x = dropX; 140 + for (let c = 0; c < col; c++) { 141 + x += colWidths[c] + SPACING; 142 + } 143 + 144 + // Calculate y position by summing all previous row heights 145 + let y = dropY; 146 + for (let r = 0; r < row; r++) { 147 + y += rowHeights[r] + SPACING; 148 + } 149 + 150 + const url = client.storage 151 + .from("minilink-user-assets") 152 + .getPublicUrl(fileID).data.publicUrl; 153 + 154 + return { 155 + file, 156 + entity, 157 + fileID, 158 + url, 159 + position: { x, y }, 160 + dimensions: processedImages[index], 161 + }; 162 + }); 163 + 164 + // Create all blocks with image facts 165 + for (const block of imageBlocks) { 166 + // Add to cache for immediate display 167 + await cache.put( 168 + new URL(block.url + "?local"), 169 + new Response(block.file, { 170 + headers: { 171 + "Content-Type": block.file.type, 172 + "Content-Length": block.file.size.toString(), 173 + }, 174 + }), 175 + ); 176 + localImages.set(block.url, true); 177 + 178 + // Create canvas block 179 + await rep.mutate.addCanvasBlock({ 180 + newEntityID: block.entity, 181 + parent: entityID, 182 + position: block.position, 183 + factID: v7(), 184 + type: "image", 185 + permission_set: entity_set.set, 186 + }); 187 + 188 + // Add image fact with local version for immediate display 189 + if (navigator.serviceWorker) { 190 + await rep.mutate.assertFact({ 191 + entity: block.entity, 192 + attribute: "block/image", 193 + data: { 194 + fallback: block.dimensions.thumbhash, 195 + type: "image", 196 + local: rep.clientID, 197 + src: block.url, 198 + height: block.dimensions.height, 199 + width: block.dimensions.width, 200 + }, 201 + }); 202 + } 203 + } 204 + 205 + // Upload all files to storage in parallel 206 + await Promise.all( 207 + imageBlocks.map(async (block) => { 208 + await client.storage 209 + .from("minilink-user-assets") 210 + .upload(block.fileID, block.file, { 211 + cacheControl: "public, max-age=31560000, immutable", 212 + }); 213 + 214 + // Update fact with final version 215 + await rep.mutate.assertFact({ 216 + entity: block.entity, 217 + attribute: "block/image", 218 + data: { 219 + fallback: block.dimensions.thumbhash, 220 + type: "image", 221 + src: block.url, 222 + height: block.dimensions.height, 223 + width: block.dimensions.width, 224 + }, 225 + }); 226 + }), 227 + ); 228 + 229 + return true; 230 + }, 231 + [rep, entityID, entity_set.set, blocks], 232 + ); 233 + };
+74
components/Blocks/useHandleDrop.ts
··· 1 + import { useCallback } from "react"; 2 + import { useReplicache } from "src/replicache"; 3 + import { generateKeyBetween } from "fractional-indexing"; 4 + import { addImage } from "src/utils/addImage"; 5 + import { useEntitySetContext } from "components/EntitySetProvider"; 6 + import { v7 } from "uuid"; 7 + 8 + export const useHandleDrop = (params: { 9 + parent: string; 10 + position: string | null; 11 + nextPosition: string | null; 12 + }) => { 13 + let { rep } = useReplicache(); 14 + let entity_set = useEntitySetContext(); 15 + 16 + return useCallback( 17 + async (e: React.DragEvent) => { 18 + e.preventDefault(); 19 + e.stopPropagation(); 20 + 21 + if (!rep) return; 22 + 23 + const files = e.dataTransfer.files; 24 + if (!files || files.length === 0) return; 25 + 26 + // Filter for image files only 27 + const imageFiles = Array.from(files).filter((file) => 28 + file.type.startsWith("image/"), 29 + ); 30 + 31 + if (imageFiles.length === 0) return; 32 + 33 + let currentPosition = params.position; 34 + 35 + // Calculate positions for all images first 36 + const imageBlocks = imageFiles.map((file) => { 37 + const entity = v7(); 38 + const position = generateKeyBetween( 39 + currentPosition, 40 + params.nextPosition, 41 + ); 42 + currentPosition = position; 43 + return { file, entity, position }; 44 + }); 45 + 46 + // Create all blocks in parallel 47 + await Promise.all( 48 + imageBlocks.map((block) => 49 + rep.mutate.addBlock({ 50 + parent: params.parent, 51 + factID: v7(), 52 + permission_set: entity_set.set, 53 + type: "image", 54 + position: block.position, 55 + newEntityID: block.entity, 56 + }), 57 + ), 58 + ); 59 + 60 + // Upload all images in parallel 61 + await Promise.all( 62 + imageBlocks.map((block) => 63 + addImage(block.file, rep, { 64 + entityID: block.entity, 65 + attribute: "block/image", 66 + }), 67 + ), 68 + ); 69 + 70 + return true; 71 + }, 72 + [rep, params.position, params.nextPosition, params.parent, entity_set.set], 73 + ); 74 + };
+66 -30
components/Canvas.tsx
··· 14 14 import { TooltipButton } from "./Buttons"; 15 15 import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers"; 16 16 import { AddSmall } from "./Icons/AddSmall"; 17 + import { InfoSmall } from "./Icons/InfoSmall"; 18 + import { Popover } from "./Popover"; 19 + import { Separator } from "./Layout"; 20 + import { CommentTiny } from "./Icons/CommentTiny"; 21 + import { QuoteTiny } from "./Icons/QuoteTiny"; 22 + import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 + import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 + import { 25 + PubLeafletPublication, 26 + PubLeafletPublicationRecord, 27 + } from "lexicons/api"; 28 + import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 17 29 18 - export function Canvas(props: { entityID: string; preview?: boolean }) { 30 + export function Canvas(props: { 31 + entityID: string; 32 + preview?: boolean; 33 + first?: boolean; 34 + }) { 19 35 let entity_set = useEntitySetContext(); 20 36 let ref = useRef<HTMLDivElement>(null); 21 37 useEffect(() => { ··· 44 60 return () => abort.abort(); 45 61 }); 46 62 47 - let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data 48 - .value; 49 - 50 63 return ( 51 64 <div 52 65 ref={ref} ··· 58 71 `} 59 72 > 60 73 <AddCanvasBlockButton entityID={props.entityID} entity_set={entity_set} /> 74 + 75 + <CanvasMetadata isSubpage={!props.first} /> 76 + 61 77 <CanvasContent {...props} /> 62 - <CanvasWidthHandle entityID={props.entityID} /> 63 78 </div> 64 79 ); 65 80 } ··· 69 84 let { rep } = useReplicache(); 70 85 let entity_set = useEntitySetContext(); 71 86 let height = Math.max(...blocks.map((f) => f.data.position.y), 0); 87 + let handleDrop = useHandleCanvasDrop(props.entityID); 88 + 72 89 return ( 73 90 <div 74 91 onClick={async (e) => { ··· 106 123 ); 107 124 } 108 125 }} 126 + onDragOver={ 127 + !props.preview && entity_set.permissions.write 128 + ? (e) => { 129 + e.preventDefault(); 130 + e.stopPropagation(); 131 + } 132 + : undefined 133 + } 134 + onDrop={ 135 + !props.preview && entity_set.permissions.write ? handleDrop : undefined 136 + } 109 137 style={{ 110 138 minHeight: height + 512, 111 139 contain: "size layout paint", ··· 136 164 ); 137 165 } 138 166 139 - function CanvasWidthHandle(props: { entityID: string }) { 140 - let canvasFocused = useUIState((s) => s.focusedEntity?.entityType === "page"); 141 - let { rep } = useReplicache(); 142 - let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data 143 - .value; 167 + const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 168 + let { data: pub } = useLeafletPublicationData(); 169 + if (!pub || !pub.publications) return null; 170 + 171 + let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 + let showComments = pubRecord.preferences?.showComments; 173 + 144 174 return ( 145 - <button 146 - onClick={() => { 147 - rep?.mutate.assertFact({ 148 - entity: props.entityID, 149 - attribute: "canvas/narrow-width", 150 - data: { 151 - type: "boolean", 152 - value: !narrowWidth, 153 - }, 154 - }); 155 - }} 156 - className={`resizeHandle 157 - ${narrowWidth ? "cursor-e-resize" : "cursor-w-resize"} shrink-0 z-10 158 - ${canvasFocused ? "sm:block hidden" : "hidden"} 159 - w-[8px] h-12 160 - absolute top-1/2 right-0 -translate-y-1/2 translate-x-[3px] 161 - rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 162 - /> 175 + <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 176 + {showComments && ( 177 + <div className="flex gap-1 text-tertiary items-center"> 178 + <CommentTiny className="text-border" /> — 179 + </div> 180 + )} 181 + <div className="flex gap-1 text-tertiary items-center"> 182 + <QuoteTiny className="text-border" /> — 183 + </div> 184 + 185 + {!props.isSubpage && ( 186 + <> 187 + <Separator classname="h-5" /> 188 + <Popover 189 + side="left" 190 + align="start" 191 + className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 192 + trigger={<InfoSmall />} 193 + > 194 + <PublicationMetadata /> 195 + </Popover> 196 + </> 197 + )} 198 + </div> 163 199 ); 164 - } 200 + }; 165 201 166 202 const AddCanvasBlockButton = (props: { 167 203 entityID: string; ··· 173 209 174 210 if (!permissions.write) return null; 175 211 return ( 176 - <div className="absolute right-2 sm:top-4 sm:right-4 bottom-2 sm:bottom-auto z-10 flex flex-col gap-1 justify-center"> 212 + <div className="absolute right-2 sm:bottom-4 sm:right-4 bottom-2 sm:top-auto z-10 flex flex-col gap-1 justify-center"> 177 213 <TooltipButton 178 214 side="left" 179 215 open={blocks.length === 0 ? true : undefined}
+1 -1
components/Input.tsx
··· 100 100 JSX.IntrinsicElements["textarea"], 101 101 ) => { 102 102 let { label, textarea, ...inputProps } = props; 103 - let style = `appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 ${props.className} outline-hidden resize-none`; 103 + let style = `appearance-none w-full font-normal not-italic bg-transparent text-base text-primary focus:outline-0 ${props.className} outline-hidden resize-none`; 104 104 return ( 105 105 <label className=" input-with-border flex flex-col gap-px text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]!"> 106 106 {props.label}
+9 -14
components/Pages/Page.tsx
··· 33 33 return focusedPageID === props.entityID; 34 34 }); 35 35 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 36 - let canvasNarrow = useEntity(props.entityID, "canvas/narrow-width")?.data 37 - .value; 38 36 let cardBorderHidden = useCardBorderHidden(props.entityID); 37 + 39 38 let drawerOpen = useDrawerOpen(props.entityID); 40 39 return ( 41 40 <CardThemeProvider entityID={props.entityID}> ··· 53 52 isFocused={isFocused} 54 53 fullPageScroll={props.fullPageScroll} 55 54 pageType={pageType} 56 - canvasNarrow={canvasNarrow} 57 55 pageOptions={ 58 56 <PageOptions 59 57 entityID={props.entityID} ··· 64 62 > 65 63 {props.first && ( 66 64 <> 67 - <PublicationMetadata cardBorderHidden={!!cardBorderHidden} /> 65 + <PublicationMetadata /> 68 66 </> 69 67 )} 70 - <PageContent entityID={props.entityID} /> 68 + <PageContent entityID={props.entityID} first={props.first} /> 71 69 </PageWrapper> 72 70 <DesktopPageFooter pageID={props.entityID} /> 73 71 </CardThemeProvider> ··· 83 81 isFocused?: boolean; 84 82 onClickAction?: (e: React.MouseEvent) => void; 85 83 pageType: "canvas" | "doc"; 86 - canvasNarrow?: boolean | undefined; 87 84 drawerOpen: boolean | undefined; 88 85 }) => { 89 86 return ( ··· 103 100 className={` 104 101 pageScrollWrapper 105 102 grow 106 - 107 103 shrink-0 snap-center 108 104 overflow-y-scroll 109 105 ${ ··· 119 115 ${ 120 116 props.pageType === "canvas" && 121 117 !props.fullPageScroll && 122 - (props.canvasNarrow 123 - ? "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]" 124 - : "sm:max-w-[calc(100vw-128px)] lg:max-w-fit lg:w-[calc(var(--page-width-units)*2 + 24px))]") 118 + "max-w-[var(--page-width-units)] sm:max-w-[calc(100vw-128px)] lg:max-w-fit lg:w-[calc(var(--page-width-units)*2 + 24px))]" 125 119 } 126 120 127 121 `} ··· 132 126 `} 133 127 > 134 128 {props.children} 135 - <div className="h-4 sm:h-6 w-full" /> 129 + {props.pageType === "doc" && <div className="h-4 sm:h-6 w-full" />} 136 130 </div> 137 131 </div> 138 132 {props.pageOptions} 139 133 </div> 140 134 ); 141 135 }; 142 - // ${narrowWidth ? " sm:max-w-(--page-width-units)" : } 143 - const PageContent = (props: { entityID: string }) => { 136 + 137 + const PageContent = (props: { entityID: string; first?: boolean }) => { 144 138 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 145 139 if (pageType === "doc") return <DocContent entityID={props.entityID} />; 146 - return <Canvas entityID={props.entityID} />; 140 + return <Canvas entityID={props.entityID} first={props.first} />; 147 141 }; 148 142 149 143 const DocContent = (props: { entityID: string }) => { ··· 209 203 /> 210 204 ) : null} 211 205 <Blocks entityID={props.entityID} /> 206 + <div className="h-4 sm:h-6 w-full" /> 212 207 {/* we handle page bg in this sepate div so that 213 208 we can apply an opacity the background image 214 209 without affecting the opacity of the rest of the page */}
+1 -5
components/Pages/PublicationMetadata.tsx
··· 13 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 15 import { timeAgo } from "src/utils/timeAgo"; 16 - export const PublicationMetadata = ({ 17 - cardBorderHidden, 18 - }: { 19 - cardBorderHidden: boolean; 20 - }) => { 16 + export const PublicationMetadata = () => { 21 17 let { rep } = useReplicache(); 22 18 let { data: pub } = useLeafletPublicationData(); 23 19 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title"));
+5
lexicons/api/index.ts
··· 39 39 import * as PubLeafletComment from './types/pub/leaflet/comment' 40 40 import * as PubLeafletDocument from './types/pub/leaflet/document' 41 41 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 42 + import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 42 43 import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 43 44 import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 44 45 import * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' ··· 77 78 export * as PubLeafletComment from './types/pub/leaflet/comment' 78 79 export * as PubLeafletDocument from './types/pub/leaflet/document' 79 80 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 81 + export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 80 82 export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 81 83 export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 82 84 export * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' ··· 86 88 export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 87 89 88 90 export const PUB_LEAFLET_PAGES = { 91 + CanvasTextAlignLeft: 'pub.leaflet.pages.canvas#textAlignLeft', 92 + CanvasTextAlignCenter: 'pub.leaflet.pages.canvas#textAlignCenter', 93 + CanvasTextAlignRight: 'pub.leaflet.pages.canvas#textAlignRight', 89 94 LinearDocumentTextAlignLeft: 'pub.leaflet.pages.linearDocument#textAlignLeft', 90 95 LinearDocumentTextAlignCenter: 91 96 'pub.leaflet.pages.linearDocument#textAlignCenter',
+103 -1
lexicons/api/lexicons.ts
··· 1421 1421 type: 'array', 1422 1422 items: { 1423 1423 type: 'union', 1424 - refs: ['lex:pub.leaflet.pages.linearDocument'], 1424 + refs: [ 1425 + 'lex:pub.leaflet.pages.linearDocument', 1426 + 'lex:pub.leaflet.pages.canvas', 1427 + ], 1425 1428 }, 1426 1429 }, 1427 1430 }, ··· 1445 1448 type: 'string', 1446 1449 format: 'at-uri', 1447 1450 }, 1451 + }, 1452 + }, 1453 + }, 1454 + }, 1455 + }, 1456 + PubLeafletPagesCanvas: { 1457 + lexicon: 1, 1458 + id: 'pub.leaflet.pages.canvas', 1459 + defs: { 1460 + main: { 1461 + type: 'object', 1462 + required: ['blocks'], 1463 + properties: { 1464 + id: { 1465 + type: 'string', 1466 + }, 1467 + blocks: { 1468 + type: 'array', 1469 + items: { 1470 + type: 'ref', 1471 + ref: 'lex:pub.leaflet.pages.canvas#block', 1472 + }, 1473 + }, 1474 + }, 1475 + }, 1476 + block: { 1477 + type: 'object', 1478 + required: ['block', 'x', 'y', 'width'], 1479 + properties: { 1480 + block: { 1481 + type: 'union', 1482 + refs: [ 1483 + 'lex:pub.leaflet.blocks.iframe', 1484 + 'lex:pub.leaflet.blocks.text', 1485 + 'lex:pub.leaflet.blocks.blockquote', 1486 + 'lex:pub.leaflet.blocks.header', 1487 + 'lex:pub.leaflet.blocks.image', 1488 + 'lex:pub.leaflet.blocks.unorderedList', 1489 + 'lex:pub.leaflet.blocks.website', 1490 + 'lex:pub.leaflet.blocks.math', 1491 + 'lex:pub.leaflet.blocks.code', 1492 + 'lex:pub.leaflet.blocks.horizontalRule', 1493 + 'lex:pub.leaflet.blocks.bskyPost', 1494 + 'lex:pub.leaflet.blocks.page', 1495 + ], 1496 + }, 1497 + x: { 1498 + type: 'integer', 1499 + }, 1500 + y: { 1501 + type: 'integer', 1502 + }, 1503 + width: { 1504 + type: 'integer', 1505 + }, 1506 + height: { 1507 + type: 'integer', 1508 + }, 1509 + rotation: { 1510 + type: 'integer', 1511 + }, 1512 + }, 1513 + }, 1514 + textAlignLeft: { 1515 + type: 'token', 1516 + }, 1517 + textAlignCenter: { 1518 + type: 'token', 1519 + }, 1520 + textAlignRight: { 1521 + type: 'token', 1522 + }, 1523 + quote: { 1524 + type: 'object', 1525 + required: ['start', 'end'], 1526 + properties: { 1527 + start: { 1528 + type: 'ref', 1529 + ref: 'lex:pub.leaflet.pages.canvas#position', 1530 + }, 1531 + end: { 1532 + type: 'ref', 1533 + ref: 'lex:pub.leaflet.pages.canvas#position', 1534 + }, 1535 + }, 1536 + }, 1537 + position: { 1538 + type: 'object', 1539 + required: ['block', 'offset'], 1540 + properties: { 1541 + block: { 1542 + type: 'array', 1543 + items: { 1544 + type: 'integer', 1545 + }, 1546 + }, 1547 + offset: { 1548 + type: 'integer', 1448 1549 }, 1449 1550 }, 1450 1551 }, ··· 1961 2062 PubLeafletComment: 'pub.leaflet.comment', 1962 2063 PubLeafletDocument: 'pub.leaflet.document', 1963 2064 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 2065 + PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', 1964 2066 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 1965 2067 PubLeafletPollDefinition: 'pub.leaflet.poll.definition', 1966 2068 PubLeafletPollVote: 'pub.leaflet.poll.vote',
+6 -1
lexicons/api/types/pub/leaflet/document.ts
··· 7 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 8 import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 9 import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 10 + import type * as PubLeafletPagesCanvas from './pages/canvas' 10 11 11 12 const is$typed = _is$typed, 12 13 validate = _validate ··· 20 21 publishedAt?: string 21 22 publication: string 22 23 author: string 23 - pages: ($Typed<PubLeafletPagesLinearDocument.Main> | { $type: string })[] 24 + pages: ( 25 + | $Typed<PubLeafletPagesLinearDocument.Main> 26 + | $Typed<PubLeafletPagesCanvas.Main> 27 + | { $type: string } 28 + )[] 24 29 [k: string]: unknown 25 30 } 26 31
+112
lexicons/api/types/pub/leaflet/pages/canvas.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 + import type * as PubLeafletBlocksIframe from '../blocks/iframe' 13 + import type * as PubLeafletBlocksText from '../blocks/text' 14 + import type * as PubLeafletBlocksBlockquote from '../blocks/blockquote' 15 + import type * as PubLeafletBlocksHeader from '../blocks/header' 16 + import type * as PubLeafletBlocksImage from '../blocks/image' 17 + import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 18 + import type * as PubLeafletBlocksWebsite from '../blocks/website' 19 + import type * as PubLeafletBlocksMath from '../blocks/math' 20 + import type * as PubLeafletBlocksCode from '../blocks/code' 21 + import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' 22 + import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' 23 + import type * as PubLeafletBlocksPage from '../blocks/page' 24 + 25 + const is$typed = _is$typed, 26 + validate = _validate 27 + const id = 'pub.leaflet.pages.canvas' 28 + 29 + export interface Main { 30 + $type?: 'pub.leaflet.pages.canvas' 31 + id?: string 32 + blocks: Block[] 33 + } 34 + 35 + const hashMain = 'main' 36 + 37 + export function isMain<V>(v: V) { 38 + return is$typed(v, id, hashMain) 39 + } 40 + 41 + export function validateMain<V>(v: V) { 42 + return validate<Main & V>(v, id, hashMain) 43 + } 44 + 45 + export interface Block { 46 + $type?: 'pub.leaflet.pages.canvas#block' 47 + block: 48 + | $Typed<PubLeafletBlocksIframe.Main> 49 + | $Typed<PubLeafletBlocksText.Main> 50 + | $Typed<PubLeafletBlocksBlockquote.Main> 51 + | $Typed<PubLeafletBlocksHeader.Main> 52 + | $Typed<PubLeafletBlocksImage.Main> 53 + | $Typed<PubLeafletBlocksUnorderedList.Main> 54 + | $Typed<PubLeafletBlocksWebsite.Main> 55 + | $Typed<PubLeafletBlocksMath.Main> 56 + | $Typed<PubLeafletBlocksCode.Main> 57 + | $Typed<PubLeafletBlocksHorizontalRule.Main> 58 + | $Typed<PubLeafletBlocksBskyPost.Main> 59 + | $Typed<PubLeafletBlocksPage.Main> 60 + | { $type: string } 61 + x: number 62 + y: number 63 + width: number 64 + height?: number 65 + rotation?: number 66 + } 67 + 68 + const hashBlock = 'block' 69 + 70 + export function isBlock<V>(v: V) { 71 + return is$typed(v, id, hashBlock) 72 + } 73 + 74 + export function validateBlock<V>(v: V) { 75 + return validate<Block & V>(v, id, hashBlock) 76 + } 77 + 78 + export const TEXTALIGNLEFT = `${id}#textAlignLeft` 79 + export const TEXTALIGNCENTER = `${id}#textAlignCenter` 80 + export const TEXTALIGNRIGHT = `${id}#textAlignRight` 81 + 82 + export interface Quote { 83 + $type?: 'pub.leaflet.pages.canvas#quote' 84 + start: Position 85 + end: Position 86 + } 87 + 88 + const hashQuote = 'quote' 89 + 90 + export function isQuote<V>(v: V) { 91 + return is$typed(v, id, hashQuote) 92 + } 93 + 94 + export function validateQuote<V>(v: V) { 95 + return validate<Quote & V>(v, id, hashQuote) 96 + } 97 + 98 + export interface Position { 99 + $type?: 'pub.leaflet.pages.canvas#position' 100 + block: number[] 101 + offset: number 102 + } 103 + 104 + const hashPosition = 'position' 105 + 106 + export function isPosition<V>(v: V) { 107 + return is$typed(v, id, hashPosition) 108 + } 109 + 110 + export function validatePosition<V>(v: V) { 111 + return validate<Position & V>(v, id, hashPosition) 112 + }
+1
lexicons/build.ts
··· 22 22 PubLeafletComment, 23 23 PubLeafletRichTextFacet, 24 24 PageLexicons.PubLeafletPagesLinearDocument, 25 + PageLexicons.PubLeafletPagesCanvasDocument, 25 26 ...ThemeLexicons, 26 27 ...BlockLexicons, 27 28 ...Object.values(PublicationLexicons),
+2 -1
lexicons/pub/leaflet/document.json
··· 48 48 "items": { 49 49 "type": "union", 50 50 "refs": [ 51 - "pub.leaflet.pages.linearDocument" 51 + "pub.leaflet.pages.linearDocument", 52 + "pub.leaflet.pages.canvas" 52 53 ] 53 54 } 54 55 }
+111
lexicons/pub/leaflet/pages/canvas.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.pages.canvas", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "blocks" 9 + ], 10 + "properties": { 11 + "id": { 12 + "type": "string" 13 + }, 14 + "blocks": { 15 + "type": "array", 16 + "items": { 17 + "type": "ref", 18 + "ref": "#block" 19 + } 20 + } 21 + } 22 + }, 23 + "block": { 24 + "type": "object", 25 + "required": [ 26 + "block", 27 + "x", 28 + "y", 29 + "width" 30 + ], 31 + "properties": { 32 + "block": { 33 + "type": "union", 34 + "refs": [ 35 + "pub.leaflet.blocks.iframe", 36 + "pub.leaflet.blocks.text", 37 + "pub.leaflet.blocks.blockquote", 38 + "pub.leaflet.blocks.header", 39 + "pub.leaflet.blocks.image", 40 + "pub.leaflet.blocks.unorderedList", 41 + "pub.leaflet.blocks.website", 42 + "pub.leaflet.blocks.math", 43 + "pub.leaflet.blocks.code", 44 + "pub.leaflet.blocks.horizontalRule", 45 + "pub.leaflet.blocks.bskyPost", 46 + "pub.leaflet.blocks.page" 47 + ] 48 + }, 49 + "x": { 50 + "type": "integer" 51 + }, 52 + "y": { 53 + "type": "integer" 54 + }, 55 + "width": { 56 + "type": "integer" 57 + }, 58 + "height": { 59 + "type": "integer" 60 + }, 61 + "rotation": { 62 + "type": "integer" 63 + } 64 + } 65 + }, 66 + "textAlignLeft": { 67 + "type": "token" 68 + }, 69 + "textAlignCenter": { 70 + "type": "token" 71 + }, 72 + "textAlignRight": { 73 + "type": "token" 74 + }, 75 + "quote": { 76 + "type": "object", 77 + "required": [ 78 + "start", 79 + "end" 80 + ], 81 + "properties": { 82 + "start": { 83 + "type": "ref", 84 + "ref": "#position" 85 + }, 86 + "end": { 87 + "type": "ref", 88 + "ref": "#position" 89 + } 90 + } 91 + }, 92 + "position": { 93 + "type": "object", 94 + "required": [ 95 + "block", 96 + "offset" 97 + ], 98 + "properties": { 99 + "block": { 100 + "type": "array", 101 + "items": { 102 + "type": "integer" 103 + } 104 + }, 105 + "offset": { 106 + "type": "integer" 107 + } 108 + } 109 + } 110 + } 111 + }
+5 -1
lexicons/src/document.ts
··· 1 1 import { LexiconDoc } from "@atproto/lexicon"; 2 2 import { PubLeafletPagesLinearDocument } from "./pages/LinearDocument"; 3 + import { PubLeafletPagesCanvasDocument } from "./pages"; 3 4 4 5 export const PubLeafletDocument: LexiconDoc = { 5 6 lexicon: 1, ··· 25 26 type: "array", 26 27 items: { 27 28 type: "union", 28 - refs: [PubLeafletPagesLinearDocument.id], 29 + refs: [ 30 + PubLeafletPagesLinearDocument.id, 31 + PubLeafletPagesCanvasDocument.id, 32 + ], 29 33 }, 30 34 }, 31 35 },
+48
lexicons/src/pages/Canvas.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + import { BlockUnion } from "../blocks"; 3 + 4 + export const PubLeafletPagesCanvasDocument: LexiconDoc = { 5 + lexicon: 1, 6 + id: "pub.leaflet.pages.canvas", 7 + defs: { 8 + main: { 9 + type: "object", 10 + required: ["blocks"], 11 + properties: { 12 + id: { type: "string" }, 13 + blocks: { type: "array", items: { type: "ref", ref: "#block" } }, 14 + }, 15 + }, 16 + block: { 17 + type: "object", 18 + required: ["block", "x", "y", "width"], 19 + properties: { 20 + block: BlockUnion, 21 + x: { type: "integer" }, 22 + y: { type: "integer" }, 23 + width: { type: "integer" }, 24 + height: { type: "integer" }, 25 + rotation: { type: "integer" }, 26 + }, 27 + }, 28 + textAlignLeft: { type: "token" }, 29 + textAlignCenter: { type: "token" }, 30 + textAlignRight: { type: "token" }, 31 + quote: { 32 + type: "object", 33 + required: ["start", "end"], 34 + properties: { 35 + start: { type: "ref", ref: "#position" }, 36 + end: { type: "ref", ref: "#position" }, 37 + }, 38 + }, 39 + position: { 40 + type: "object", 41 + required: ["block", "offset"], 42 + properties: { 43 + block: { type: "array", items: { type: "integer" } }, 44 + offset: { type: "integer" }, 45 + }, 46 + }, 47 + }, 48 + };
+1
lexicons/src/pages/index.ts
··· 1 1 export { PubLeafletPagesLinearDocument } from "./LinearDocument"; 2 + export { PubLeafletPagesCanvasDocument } from "./Canvas";
+3 -55
src/utils/scrollIntoView.ts
··· 1 - // Generated with claude code, sonnet 4.5 2 - /** 3 - * Scrolls an element into view within a scrolling container using Intersection Observer 4 - * and the scrollTo API, instead of the native scrollIntoView. 5 - * 6 - * @param elementId - The ID of the element to scroll into view 7 - * @param scrollContainerId - The ID of the scrolling container (defaults to "pages") 8 - * @param threshold - Intersection observer threshold (0-1, defaults to 0.2 for 20%) 9 - */ 1 + import { scrollIntoViewIfNeeded } from "./scrollIntoViewIfNeeded"; 2 + 10 3 export function scrollIntoView( 11 4 elementId: string, 12 5 scrollContainerId: string = "pages", 13 6 threshold: number = 0.9, 14 7 ) { 15 8 const element = document.getElementById(elementId); 16 - const scrollContainer = document.getElementById(scrollContainerId); 17 - 18 - if (!element || !scrollContainer) { 19 - console.warn(`scrollIntoView: element or container not found`, { 20 - elementId, 21 - scrollContainerId, 22 - element, 23 - scrollContainer, 24 - }); 25 - return; 26 - } 27 - 28 - // Create an intersection observer to check if element is visible 29 - const observer = new IntersectionObserver( 30 - (entries) => { 31 - const entry = entries[0]; 32 - 33 - // If element is not sufficiently visible, scroll to it 34 - if (!entry.isIntersecting || entry.intersectionRatio < threshold) { 35 - const elementRect = element.getBoundingClientRect(); 36 - const containerRect = scrollContainer.getBoundingClientRect(); 37 - 38 - // Calculate the target scroll position 39 - // We want to center the element horizontally in the container 40 - const targetScrollLeft = 41 - scrollContainer.scrollLeft + 42 - elementRect.left - 43 - containerRect.left - 44 - (containerRect.width - elementRect.width) / 2; 45 - 46 - scrollContainer.scrollTo({ 47 - left: targetScrollLeft, 48 - behavior: "smooth", 49 - }); 50 - } 51 - 52 - // Disconnect after checking once 53 - observer.disconnect(); 54 - }, 55 - { 56 - root: scrollContainer, 57 - threshold: threshold, 58 - }, 59 - ); 60 - 61 - observer.observe(element); 9 + scrollIntoViewIfNeeded(element, false, "smooth"); 62 10 }
+123
supabase/migrations/20251023200453_atp_poll_votes.sql
··· 1 + create table "public"."atp_poll_votes" ( 2 + "uri" text not null, 3 + "record" jsonb not null, 4 + "voter_did" text not null, 5 + "poll_uri" text not null, 6 + "poll_cid" text not null, 7 + "option" text not null, 8 + "indexed_at" timestamp with time zone not null default now() 9 + ); 10 + 11 + alter table "public"."atp_poll_votes" enable row level security; 12 + 13 + CREATE UNIQUE INDEX atp_poll_votes_pkey ON public.atp_poll_votes USING btree (uri); 14 + 15 + alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_pkey" PRIMARY KEY using index "atp_poll_votes_pkey"; 16 + 17 + CREATE INDEX atp_poll_votes_poll_uri_idx ON public.atp_poll_votes USING btree (poll_uri); 18 + 19 + CREATE INDEX atp_poll_votes_voter_did_idx ON public.atp_poll_votes USING btree (voter_did); 20 + 21 + grant delete on table "public"."atp_poll_votes" to "anon"; 22 + 23 + grant insert on table "public"."atp_poll_votes" to "anon"; 24 + 25 + grant references on table "public"."atp_poll_votes" to "anon"; 26 + 27 + grant select on table "public"."atp_poll_votes" to "anon"; 28 + 29 + grant trigger on table "public"."atp_poll_votes" to "anon"; 30 + 31 + grant truncate on table "public"."atp_poll_votes" to "anon"; 32 + 33 + grant update on table "public"."atp_poll_votes" to "anon"; 34 + 35 + grant delete on table "public"."atp_poll_votes" to "authenticated"; 36 + 37 + grant insert on table "public"."atp_poll_votes" to "authenticated"; 38 + 39 + grant references on table "public"."atp_poll_votes" to "authenticated"; 40 + 41 + grant select on table "public"."atp_poll_votes" to "authenticated"; 42 + 43 + grant trigger on table "public"."atp_poll_votes" to "authenticated"; 44 + 45 + grant truncate on table "public"."atp_poll_votes" to "authenticated"; 46 + 47 + grant update on table "public"."atp_poll_votes" to "authenticated"; 48 + 49 + grant delete on table "public"."atp_poll_votes" to "service_role"; 50 + 51 + grant insert on table "public"."atp_poll_votes" to "service_role"; 52 + 53 + grant references on table "public"."atp_poll_votes" to "service_role"; 54 + 55 + grant select on table "public"."atp_poll_votes" to "service_role"; 56 + 57 + grant trigger on table "public"."atp_poll_votes" to "service_role"; 58 + 59 + grant truncate on table "public"."atp_poll_votes" to "service_role"; 60 + 61 + grant update on table "public"."atp_poll_votes" to "service_role"; 62 + 63 + create table "public"."atp_poll_records" ( 64 + "uri" text not null, 65 + "cid" text not null, 66 + "record" jsonb not null, 67 + "created_at" timestamp with time zone not null default now() 68 + ); 69 + 70 + 71 + alter table "public"."atp_poll_records" enable row level security; 72 + 73 + alter table "public"."bsky_follows" alter column "identity" set default ''::text; 74 + 75 + CREATE UNIQUE INDEX atp_poll_records_pkey ON public.atp_poll_records USING btree (uri); 76 + 77 + alter table "public"."atp_poll_records" add constraint "atp_poll_records_pkey" PRIMARY KEY using index "atp_poll_records_pkey"; 78 + 79 + alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_poll_uri_fkey" FOREIGN KEY (poll_uri) REFERENCES atp_poll_records(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid; 80 + 81 + alter table "public"."atp_poll_votes" validate constraint "atp_poll_votes_poll_uri_fkey"; 82 + 83 + grant delete on table "public"."atp_poll_records" to "anon"; 84 + 85 + grant insert on table "public"."atp_poll_records" to "anon"; 86 + 87 + grant references on table "public"."atp_poll_records" to "anon"; 88 + 89 + grant select on table "public"."atp_poll_records" to "anon"; 90 + 91 + grant trigger on table "public"."atp_poll_records" to "anon"; 92 + 93 + grant truncate on table "public"."atp_poll_records" to "anon"; 94 + 95 + grant update on table "public"."atp_poll_records" to "anon"; 96 + 97 + grant delete on table "public"."atp_poll_records" to "authenticated"; 98 + 99 + grant insert on table "public"."atp_poll_records" to "authenticated"; 100 + 101 + grant references on table "public"."atp_poll_records" to "authenticated"; 102 + 103 + grant select on table "public"."atp_poll_records" to "authenticated"; 104 + 105 + grant trigger on table "public"."atp_poll_records" to "authenticated"; 106 + 107 + grant truncate on table "public"."atp_poll_records" to "authenticated"; 108 + 109 + grant update on table "public"."atp_poll_records" to "authenticated"; 110 + 111 + grant delete on table "public"."atp_poll_records" to "service_role"; 112 + 113 + grant insert on table "public"."atp_poll_records" to "service_role"; 114 + 115 + grant references on table "public"."atp_poll_records" to "service_role"; 116 + 117 + grant select on table "public"."atp_poll_records" to "service_role"; 118 + 119 + grant trigger on table "public"."atp_poll_records" to "service_role"; 120 + 121 + grant truncate on table "public"."atp_poll_records" to "service_role"; 122 + 123 + grant update on table "public"."atp_poll_records" to "service_role";