a tool for shared writing and social publishing

Feature/page blocks (#226)

* add page block type to lexicon and basic component

* add page block generated files

* set up leaflet and post to use the same layout component and adjusted
the interaction panel to work within that

* unified page, page wrapper, and post layouts, made subpage look nice in post

* added a bit of padding under the bottom of posts, deleted unused code

* remove extra merge conflict

* add published page block preview

* always render interaction drawer

* factor out useDrawerOpen

* loosen base_path to string

* scroll pages and comments into view properly

* fixed a weird scrolling issue, also squished the interaction drawer and
page together

* make quotes have a page component

* open sub-page if quoted

* change scroll into view threshold

* scroll into view comments panel

* render quote content from subpages

* implement subpage interaction drawers and buttons

* give quote popup a z-index

* fix actually posting comments on subpages

* convert comment to json before returning

* add interactions preview for subpages

* count top level quotes and comments correctly

* ensure layout doesn't break with canvases

* prev fix broke doc pages, fixed that lol

* specify post pages are docs

* absolutely position comments on subpage preview

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by awarm.space

celine and committed by
GitHub
44ebaf1c a91991ef

+2266 -1217
+225 -269
actions/publishToPublication.ts
··· 20 20 PubLeafletBlocksBskyPost, 21 21 PubLeafletBlocksBlockquote, 22 22 PubLeafletBlocksIframe, 23 + PubLeafletBlocksPage, 23 24 } from "lexicons/api"; 24 25 import { Block } from "components/Blocks/Block"; 25 26 import { TID } from "@atproto/common"; ··· 72 73 root: root_entity, 73 74 }); 74 75 let facts = (data as unknown as Fact<Attribute>[]) || []; 75 - let scan = scanIndexLocal(facts); 76 - let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 77 - if (!firstEntity) throw new Error("No root page"); 78 - let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 79 76 80 - let images = blocks 81 - .filter((b) => b.type === "image") 82 - .map((b) => scan.eav(b.value, "block/image")[0]); 83 - let links = blocks 84 - .filter((b) => b.type == "link") 85 - .map((b) => scan.eav(b.value, "link/preview")[0]); 86 - let imageMap = new Map<string, BlobRef>(); 87 - for (const b of [...links, ...images]) { 88 - if (!b) continue; 89 - let data = await fetch(b.data.src); 90 - if (data.status !== 200) continue; 91 - let binary = await data.blob(); 92 - try { 93 - let blob = await agent.com.atproto.repo.uploadBlob(binary, { 94 - headers: { "Content-Type": binary.type }, 95 - }); 96 - if (!blob.success) { 97 - console.log(blob); 98 - console.log("Error uploading image: " + b.data.src); 99 - throw new Error("Failed to upload image"); 100 - } 101 - imageMap.set(b.data.src, blob.data.blob); 102 - } catch (e) { 103 - console.error(e); 104 - console.log("Error uploading image: " + b.data.src); 105 - throw new Error("Failed to upload image"); 106 - } 107 - } 108 - 109 - let b: PubLeafletPagesLinearDocument.Block[] = blocksToRecord( 110 - blocks, 111 - imageMap, 112 - scan, 77 + let { firstPageBlocks, pages } = await processBlocksToPages( 78 + facts, 79 + agent, 113 80 root_entity, 114 81 ); 115 82 ··· 126 93 pages: [ 127 94 { 128 95 $type: "pub.leaflet.pages.linearDocument", 129 - blocks: b, 96 + blocks: firstPageBlocks, 130 97 }, 98 + ...pages.map((p) => ({ 99 + $type: "pub.leaflet.pages.linearDocument", 100 + id: p.id, 101 + blocks: p.blocks, 102 + })), 131 103 ], 132 104 }; 133 105 let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); ··· 161 133 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 162 134 } 163 135 164 - function blocksToRecord( 165 - blocks: Block[], 166 - imageMap: Map<string, BlobRef>, 167 - scan: ReturnType<typeof scanIndexLocal>, 168 - root_entity: string, 169 - ): PubLeafletPagesLinearDocument.Block[] { 170 - let parsedBlocks = parseBlocksToList(blocks); 171 - return parsedBlocks.flatMap((blockOrList) => { 172 - if (blockOrList.type === "block") { 173 - let alignmentValue = 174 - scan.eav(blockOrList.block.value, "block/text-alignment")[0]?.data 175 - .value || "left"; 176 - let alignment = 177 - alignmentValue === "center" 178 - ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 179 - : alignmentValue === "right" 180 - ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 181 - : undefined; 182 - let b = blockToRecord(blockOrList.block, imageMap, scan, root_entity); 183 - if (!b) return []; 184 - let block: PubLeafletPagesLinearDocument.Block = { 185 - $type: "pub.leaflet.pages.linearDocument#block", 186 - alignment, 187 - block: b, 188 - }; 189 - return [block]; 190 - } else { 191 - let block: PubLeafletPagesLinearDocument.Block = { 192 - $type: "pub.leaflet.pages.linearDocument#block", 193 - block: { 194 - $type: "pub.leaflet.blocks.unorderedList", 195 - children: childrenToRecord( 196 - blockOrList.children, 197 - imageMap, 198 - scan, 199 - root_entity, 200 - ), 201 - }, 202 - }; 203 - return [block]; 204 - } 205 - }); 206 - } 207 - 208 - function childrenToRecord( 209 - children: List[], 210 - imageMap: Map<string, BlobRef>, 211 - scan: ReturnType<typeof scanIndexLocal>, 136 + async function processBlocksToPages( 137 + facts: Fact<any>[], 138 + agent: AtpBaseClient, 212 139 root_entity: string, 213 140 ) { 214 - return children.flatMap((child) => { 215 - let content = blockToRecord(child.block, imageMap, scan, root_entity); 216 - if (!content) return []; 217 - let record: PubLeafletBlocksUnorderedList.ListItem = { 218 - $type: "pub.leaflet.blocks.unorderedList#listItem", 219 - content, 220 - children: childrenToRecord(child.children, imageMap, scan, root_entity), 221 - }; 222 - return record; 223 - }); 224 - } 225 - function blockToRecord( 226 - b: Block, 227 - imageMap: Map<string, BlobRef>, 228 - scan: ReturnType<typeof scanIndexLocal>, 229 - root_entity: string, 230 - ) { 231 - const getBlockContent = (b: string) => { 232 - let [content] = scan.eav(b, "block/text"); 233 - if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; 234 - let doc = new Y.Doc(); 235 - const update = base64.toByteArray(content.data.value); 236 - Y.applyUpdate(doc, update); 237 - let nodes = doc.getXmlElement("prosemirror").toArray(); 238 - let stringValue = YJSFragmentToString(nodes[0]); 239 - let facets = YJSFragmentToFacets(nodes[0]); 240 - return [stringValue, facets] as const; 241 - }; 141 + let scan = scanIndexLocal(facts); 142 + let pages: { id: string; blocks: PubLeafletPagesLinearDocument.Block[] }[] = 143 + []; 242 144 243 - if (b.type === "bluesky-post") { 244 - let [post] = scan.eav(b.value, "block/bluesky-post"); 245 - if (!post || !post.data.value.post) return; 246 - let block: $Typed<PubLeafletBlocksBskyPost.Main> = { 247 - $type: ids.PubLeafletBlocksBskyPost, 248 - postRef: { 249 - uri: post.data.value.post.uri, 250 - cid: post.data.value.post.cid, 251 - }, 252 - }; 253 - return block; 254 - } 255 - if (b.type === "horizontal-rule") { 256 - let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { 257 - $type: ids.PubLeafletBlocksHorizontalRule, 258 - }; 259 - return block; 260 - } 261 - 262 - if (b.type === "heading") { 263 - let [headingLevel] = scan.eav(b.value, "block/heading-level"); 145 + let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 146 + if (!firstEntity) throw new Error("No root page"); 147 + let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 148 + let b = await blocksToRecord(blocks); 149 + return { firstPageBlocks: b, pages }; 264 150 265 - let [stringValue, facets] = getBlockContent(b.value); 266 - let block: $Typed<PubLeafletBlocksHeader.Main> = { 267 - $type: "pub.leaflet.blocks.header", 268 - level: headingLevel?.data.value || 1, 269 - plaintext: stringValue, 270 - facets, 271 - }; 272 - return block; 151 + async function uploadImage(src: string) { 152 + let data = await fetch(src); 153 + if (data.status !== 200) return; 154 + let binary = await data.blob(); 155 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 156 + headers: { "Content-Type": binary.type }, 157 + }); 158 + return blob.data.blob; 273 159 } 274 - 275 - if (b.type === "blockquote") { 276 - let [stringValue, facets] = getBlockContent(b.value); 277 - let block: $Typed<PubLeafletBlocksBlockquote.Main> = { 278 - $type: ids.PubLeafletBlocksBlockquote, 279 - plaintext: stringValue, 280 - facets, 281 - }; 282 - return block; 160 + async function blocksToRecord( 161 + blocks: Block[], 162 + ): Promise<PubLeafletPagesLinearDocument.Block[]> { 163 + let parsedBlocks = parseBlocksToList(blocks); 164 + return ( 165 + await Promise.all( 166 + parsedBlocks.map(async (blockOrList) => { 167 + if (blockOrList.type === "block") { 168 + let alignmentValue = 169 + scan.eav(blockOrList.block.value, "block/text-alignment")[0]?.data 170 + .value || "left"; 171 + let alignment = 172 + alignmentValue === "center" 173 + ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 174 + : alignmentValue === "right" 175 + ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 176 + : undefined; 177 + let b = await blockToRecord(blockOrList.block); 178 + if (!b) return []; 179 + let block: PubLeafletPagesLinearDocument.Block = { 180 + $type: "pub.leaflet.pages.linearDocument#block", 181 + alignment, 182 + block: b, 183 + }; 184 + return [block]; 185 + } else { 186 + let block: PubLeafletPagesLinearDocument.Block = { 187 + $type: "pub.leaflet.pages.linearDocument#block", 188 + block: { 189 + $type: "pub.leaflet.blocks.unorderedList", 190 + children: await childrenToRecord(blockOrList.children), 191 + }, 192 + }; 193 + return [block]; 194 + } 195 + }), 196 + ) 197 + ).flat(); 283 198 } 284 199 285 - if (b.type == "text") { 286 - let [stringValue, facets] = getBlockContent(b.value); 287 - let block: $Typed<PubLeafletBlocksText.Main> = { 288 - $type: ids.PubLeafletBlocksText, 289 - plaintext: stringValue, 290 - facets, 291 - }; 292 - return block; 293 - } 294 - if (b.type === "embed") { 295 - let [url] = scan.eav(b.value, "embed/url"); 296 - let [height] = scan.eav(b.value, "embed/height"); 297 - if (!url) return; 298 - let block: $Typed<PubLeafletBlocksIframe.Main> = { 299 - $type: "pub.leaflet.blocks.iframe", 300 - url: url.data.value, 301 - height: Math.floor(height?.data.value || 600), 302 - }; 303 - return block; 304 - } 305 - if (b.type == "image") { 306 - let [image] = scan.eav(b.value, "block/image"); 307 - if (!image) return; 308 - let [altText] = scan.eav(b.value, "image/alt"); 309 - let blobref = imageMap.get(image.data.src); 310 - if (!blobref) return; 311 - let block: $Typed<PubLeafletBlocksImage.Main> = { 312 - $type: "pub.leaflet.blocks.image", 313 - image: blobref, 314 - aspectRatio: { 315 - height: image.data.height, 316 - width: image.data.width, 317 - }, 318 - alt: altText ? altText.data.value : undefined, 319 - }; 320 - return block; 321 - } 322 - if (b.type === "link") { 323 - let [previewImage] = scan.eav(b.value, "link/preview"); 324 - let [description] = scan.eav(b.value, "link/description"); 325 - let [src] = scan.eav(b.value, "link/url"); 326 - if (!src) return; 327 - let blobref = previewImage 328 - ? imageMap.get(previewImage?.data.src) 329 - : undefined; 330 - let [title] = scan.eav(b.value, "link/title"); 331 - let block: $Typed<PubLeafletBlocksWebsite.Main> = { 332 - $type: "pub.leaflet.blocks.website", 333 - previewImage: blobref, 334 - src: src.data.value, 335 - description: description?.data.value, 336 - title: title?.data.value, 337 - }; 338 - return block; 339 - } 340 - if (b.type === "code") { 341 - let [language] = scan.eav(b.value, "block/code-language"); 342 - let [code] = scan.eav(b.value, "block/code"); 343 - let [theme] = scan.eav(root_entity, "theme/code-theme"); 344 - let block: $Typed<PubLeafletBlocksCode.Main> = { 345 - $type: "pub.leaflet.blocks.code", 346 - language: language?.data.value, 347 - plaintext: code?.data.value || "", 348 - syntaxHighlightingTheme: theme?.data.value, 349 - }; 350 - return block; 200 + async function childrenToRecord(children: List[]) { 201 + return ( 202 + await Promise.all( 203 + children.map(async (child) => { 204 + let content = await blockToRecord(child.block); 205 + if (!content) return []; 206 + let record: PubLeafletBlocksUnorderedList.ListItem = { 207 + $type: "pub.leaflet.blocks.unorderedList#listItem", 208 + content, 209 + children: await childrenToRecord(child.children), 210 + }; 211 + return record; 212 + }), 213 + ) 214 + ).flat(); 351 215 } 352 - if (b.type === "math") { 353 - let [math] = scan.eav(b.value, "block/math"); 354 - let block: $Typed<PubLeafletBlocksMath.Main> = { 355 - $type: "pub.leaflet.blocks.math", 356 - tex: math?.data.value || "", 216 + async function blockToRecord(b: Block) { 217 + const getBlockContent = (b: string) => { 218 + let [content] = scan.eav(b, "block/text"); 219 + if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; 220 + let doc = new Y.Doc(); 221 + const update = base64.toByteArray(content.data.value); 222 + Y.applyUpdate(doc, update); 223 + let nodes = doc.getXmlElement("prosemirror").toArray(); 224 + let stringValue = YJSFragmentToString(nodes[0]); 225 + let facets = YJSFragmentToFacets(nodes[0]); 226 + return [stringValue, facets] as const; 357 227 }; 358 - return block; 359 - } 360 - return; 361 - } 228 + if (b.type === "card") { 229 + let [page] = scan.eav(b.value, "block/card"); 230 + if (!page) return; 231 + let blocks = getBlocksWithTypeLocal(facts, page.data.value); 232 + pages.push({ 233 + id: page.data.value, 234 + blocks: await blocksToRecord(blocks), 235 + }); 236 + let block: $Typed<PubLeafletBlocksPage.Main> = { 237 + $type: "pub.leaflet.blocks.page", 238 + id: page.data.value, 239 + }; 240 + return block; 241 + } 362 242 363 - async function sendPostToEmailSubscribers( 364 - publication_uri: string, 365 - post: { content: string; title: string }, 366 - ) { 367 - let { data: publication } = await supabaseServerClient 368 - .from("publications") 369 - .select("*, subscribers_to_publications(*)") 370 - .eq("uri", publication_uri) 371 - .single(); 243 + if (b.type === "bluesky-post") { 244 + let [post] = scan.eav(b.value, "block/bluesky-post"); 245 + if (!post || !post.data.value.post) return; 246 + let block: $Typed<PubLeafletBlocksBskyPost.Main> = { 247 + $type: ids.PubLeafletBlocksBskyPost, 248 + postRef: { 249 + uri: post.data.value.post.uri, 250 + cid: post.data.value.post.cid, 251 + }, 252 + }; 253 + return block; 254 + } 255 + if (b.type === "horizontal-rule") { 256 + let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { 257 + $type: ids.PubLeafletBlocksHorizontalRule, 258 + }; 259 + return block; 260 + } 261 + 262 + if (b.type === "heading") { 263 + let [headingLevel] = scan.eav(b.value, "block/heading-level"); 264 + 265 + let [stringValue, facets] = getBlockContent(b.value); 266 + let block: $Typed<PubLeafletBlocksHeader.Main> = { 267 + $type: "pub.leaflet.blocks.header", 268 + level: headingLevel?.data.value || 1, 269 + plaintext: stringValue, 270 + facets, 271 + }; 272 + return block; 273 + } 274 + 275 + if (b.type === "blockquote") { 276 + let [stringValue, facets] = getBlockContent(b.value); 277 + let block: $Typed<PubLeafletBlocksBlockquote.Main> = { 278 + $type: ids.PubLeafletBlocksBlockquote, 279 + plaintext: stringValue, 280 + facets, 281 + }; 282 + return block; 283 + } 372 284 373 - let res = await fetch("https://api.postmarkapp.com/email/batch", { 374 - method: "POST", 375 - headers: { 376 - "Content-Type": "application/json", 377 - "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, 378 - }, 379 - body: JSON.stringify( 380 - publication?.subscribers_to_publications.map((sub) => ({ 381 - Headers: [ 382 - { 383 - Name: "List-Unsubscribe-Post", 384 - Value: "List-Unsubscribe=One-Click", 385 - }, 386 - { 387 - Name: "List-Unsubscribe", 388 - Value: `<${"TODO"}/mail/unsubscribe?sub_id=${sub.identity}>`, 389 - }, 390 - ], 391 - MessageStream: "broadcast", 392 - From: `${publication.name} <mailbox@leaflet.pub>`, 393 - Subject: post.title, 394 - To: sub.identity, 395 - HtmlBody: ` 396 - <h1>${publication.name}</h1> 397 - <hr style="margin-top: 1em; margin-bottom: 1em;"> 398 - ${post.content} 399 - <hr style="margin-top: 1em; margin-bottom: 1em;"> 400 - This is a super alpha release! Ask Jared if you want to unsubscribe (sorry) 401 - `, 402 - TextBody: post.content, 403 - })), 404 - ), 405 - }); 285 + if (b.type == "text") { 286 + let [stringValue, facets] = getBlockContent(b.value); 287 + let block: $Typed<PubLeafletBlocksText.Main> = { 288 + $type: ids.PubLeafletBlocksText, 289 + plaintext: stringValue, 290 + facets, 291 + }; 292 + return block; 293 + } 294 + if (b.type === "embed") { 295 + let [url] = scan.eav(b.value, "embed/url"); 296 + let [height] = scan.eav(b.value, "embed/height"); 297 + if (!url) return; 298 + let block: $Typed<PubLeafletBlocksIframe.Main> = { 299 + $type: "pub.leaflet.blocks.iframe", 300 + url: url.data.value, 301 + height: height?.data.value || 600, 302 + }; 303 + return block; 304 + } 305 + if (b.type == "image") { 306 + let [image] = scan.eav(b.value, "block/image"); 307 + if (!image) return; 308 + let [altText] = scan.eav(b.value, "image/alt"); 309 + let blobref = await uploadImage(image.data.src); 310 + if (!blobref) return; 311 + let block: $Typed<PubLeafletBlocksImage.Main> = { 312 + $type: "pub.leaflet.blocks.image", 313 + image: blobref, 314 + aspectRatio: { 315 + height: image.data.height, 316 + width: image.data.width, 317 + }, 318 + alt: altText ? altText.data.value : undefined, 319 + }; 320 + return block; 321 + } 322 + if (b.type === "link") { 323 + let [previewImage] = scan.eav(b.value, "link/preview"); 324 + let [description] = scan.eav(b.value, "link/description"); 325 + let [src] = scan.eav(b.value, "link/url"); 326 + if (!src) return; 327 + let blobref = previewImage 328 + ? await uploadImage(previewImage?.data.src) 329 + : undefined; 330 + let [title] = scan.eav(b.value, "link/title"); 331 + let block: $Typed<PubLeafletBlocksWebsite.Main> = { 332 + $type: "pub.leaflet.blocks.website", 333 + previewImage: blobref, 334 + src: src.data.value, 335 + description: description?.data.value, 336 + title: title?.data.value, 337 + }; 338 + return block; 339 + } 340 + if (b.type === "code") { 341 + let [language] = scan.eav(b.value, "block/code-language"); 342 + let [code] = scan.eav(b.value, "block/code"); 343 + let [theme] = scan.eav(root_entity, "theme/code-theme"); 344 + let block: $Typed<PubLeafletBlocksCode.Main> = { 345 + $type: "pub.leaflet.blocks.code", 346 + language: language?.data.value, 347 + plaintext: code?.data.value || "", 348 + syntaxHighlightingTheme: theme?.data.value, 349 + }; 350 + return block; 351 + } 352 + if (b.type === "math") { 353 + let [math] = scan.eav(b.value, "block/math"); 354 + let block: $Typed<PubLeafletBlocksMath.Main> = { 355 + $type: "pub.leaflet.blocks.math", 356 + tex: math?.data.value || "", 357 + }; 358 + return block; 359 + } 360 + return; 361 + } 406 362 } 407 363 408 364 function YJSFragmentToFacets(
+1 -1
app/[leaflet_id]/Footer.tsx
··· 20 20 let { data: pub } = useLeafletPublicationData(); 21 21 22 22 return ( 23 - <Media mobile className="mobileFooter w-full z-10 touch-none -mt-4 "> 23 + <Media mobile className="mobileFooter w-full z-10 touch-none -mt-[54px] "> 24 24 {focusedBlock && 25 25 focusedBlock.entityType == "block" && 26 26 entity_set.permissions.write ? (
+4 -24
app/[leaflet_id]/Leaflet.tsx
··· 12 12 import { AddLeafletToHomepage } from "components/utils/AddLeafletToHomepage"; 13 13 import { UpdateLeafletTitle } from "components/utils/UpdateLeafletTitle"; 14 14 import { useUIState } from "src/useUIState"; 15 - import { LeafletSidebar } from "./Sidebar"; 15 + import { LeafletLayout } from "components/LeafletLayout"; 16 16 17 17 export function Leaflet(props: { 18 18 token: PermissionToken; ··· 36 36 <SelectionManager /> 37 37 {/* we need the padding bottom here because if we don't have it the mobile footer will cut off... 38 38 the dropshadow on the page... the padding is compensated by a negative top margin in mobile footer */} 39 - <div 40 - className="leafletContentWrapper w-full relative overflow-x-scroll snap-x snap-mandatory no-scrollbar grow items-stretch flex h-full pb-4 pwa-padding" 41 - id="page-carousel" 42 - > 43 - {/* if you adjust this padding, remember to adjust the negative margins on page in Pages/index when card borders are hidden (also applies for the pb in the parent div)*/} 44 - <div 45 - id="pages" 46 - className="pages flex pt-2 pb-1 sm:pb-8 sm:pt-6" 47 - onClick={(e) => { 48 - e.currentTarget === e.target && blurPage(); 49 - }} 50 - > 51 - <LeafletSidebar leaflet_id={props.leaflet_id} /> 52 - <Pages rootPage={props.leaflet_id} /> 53 - </div> 54 - </div> 39 + <LeafletLayout className="!pb-[64px] sm:!pb-6"> 40 + <Pages rootPage={props.leaflet_id} /> 41 + </LeafletLayout> 55 42 <LeafletFooter entityID={props.leaflet_id} /> 56 43 </ThemeBackgroundProvider> 57 44 </ThemeProvider> ··· 59 46 </ReplicacheProvider> 60 47 ); 61 48 } 62 - 63 - const blurPage = () => { 64 - useUIState.setState(() => ({ 65 - focusedEntity: null, 66 - selectedBlocks: [], 67 - })); 68 - };
+37 -39
app/[leaflet_id]/Sidebar.tsx
··· 12 12 import { useUIState } from "src/useUIState"; 13 13 import { BackToPubButton, PublishButton } from "./Actions"; 14 14 import { useIdentityData } from "components/IdentityProvider"; 15 + import { useReplicache } from "src/replicache"; 15 16 16 - export function LeafletSidebar(props: { leaflet_id: string }) { 17 + export function LeafletSidebar() { 17 18 let entity_set = useEntitySetContext(); 19 + let { rootEntity } = useReplicache(); 18 20 let { data: pub } = useLeafletPublicationData(); 19 21 let { identity } = useIdentityData(); 20 22 21 23 return ( 22 - <div 23 - className="spacer flex justify-end items-start" 24 - style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }} 25 - onClick={(e) => { 26 - e.currentTarget === e.target && blurPage(); 27 - }} 28 - > 29 - <Media 30 - mobile={false} 31 - className="sidebarContainer relative flex flex-col justify-end h-full w-16" 24 + <Media mobile={false} className="w-0 h-full relative"> 25 + <div 26 + className="absolute top-0 left-0 h-full flex justify-end " 27 + style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }} 32 28 > 33 - {entity_set.permissions.write && ( 34 - <Sidebar> 35 - {pub?.publications && 36 - identity?.atp_did && 37 - pub.publications.identity_did === identity.atp_did ? ( 38 - <> 39 - <PublishButton /> 40 - <ShareOptions /> 41 - <ThemePopover entityID={props.leaflet_id} /> 42 - <HelpPopover /> 43 - <hr className="text-border" /> 44 - <BackToPubButton publication={pub.publications} /> 45 - </> 46 - ) : ( 47 - <> 48 - <ShareOptions /> 49 - <ThemePopover entityID={props.leaflet_id} /> 50 - <HelpPopover /> 51 - <hr className="text-border" /> 52 - <HomeButton /> 53 - </> 54 - )} 55 - </Sidebar> 56 - )} 57 - <div className="h-full flex items-end"> 58 - <Watermark /> 29 + <div className="sidebarContainer flex flex-col justify-end h-full w-16 relative"> 30 + {entity_set.permissions.write && ( 31 + <Sidebar> 32 + {pub?.publications && 33 + identity?.atp_did && 34 + pub.publications.identity_did === identity.atp_did ? ( 35 + <> 36 + <PublishButton /> 37 + <ShareOptions /> 38 + <ThemePopover entityID={rootEntity} /> 39 + <HelpPopover /> 40 + <hr className="text-border" /> 41 + <BackToPubButton publication={pub.publications} /> 42 + </> 43 + ) : ( 44 + <> 45 + <ShareOptions /> 46 + <ThemePopover entityID={rootEntity} /> 47 + <HelpPopover /> 48 + <hr className="text-border" /> 49 + <HomeButton /> 50 + </> 51 + )} 52 + </Sidebar> 53 + )} 54 + <div className="h-full flex items-end"> 55 + <Watermark /> 56 + </div> 59 57 </div> 60 - </Media> 61 - </div> 58 + </div> 59 + </Media> 62 60 ); 63 61 } 64 62
+2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 39 39 40 40 export function CommentBox(props: { 41 41 doc_uri: string; 42 + pageId?: string; 42 43 replyTo?: string; 43 44 onSubmit?: () => void; 44 45 autoFocus?: boolean; ··· 56 57 let currentState = view.current.state; 57 58 let [plaintext, facets] = docToFacetedText(currentState.doc); 58 59 let comment = await publishComment({ 60 + pageId: props.pageId, 59 61 document: props.doc_uri, 60 62 comment: { 61 63 plaintext,
+4 -2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 5 5 import { PubLeafletRichtextFacet } from "lexicons/api"; 6 6 import { createOauthClient } from "src/atproto-oauth"; 7 7 import { TID } from "@atproto/common"; 8 - import { AtUri, Un$Typed } from "@atproto/api"; 8 + import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; 9 9 import { supabaseServerClient } from "supabase/serverClient"; 10 10 import { Json } from "supabase/database.types"; 11 11 12 12 export async function publishComment(args: { 13 13 document: string; 14 + pageId?: string; 14 15 comment: { 15 16 plaintext: string; 16 17 facets: PubLeafletRichtextFacet.Main[]; ··· 28 29 ); 29 30 let record: Un$Typed<PubLeafletComment.Record> = { 30 31 subject: args.document, 32 + onPage: args.pageId, 31 33 createdAt: new Date().toISOString(), 32 34 plaintext: args.comment.plaintext, 33 35 facets: args.comment.facets, ··· 66 68 67 69 return { 68 70 record: data?.[0].record as Json, 69 - profile: profile.value, 71 + profile: lexToJson(profile.value), 70 72 uri: uri.toString(), 71 73 }; 72 74 }
+19 -3
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 23 23 uri: string; 24 24 bsky_profiles: { record: Json } | null; 25 25 }; 26 - export function Comments(props: { document_uri: string; comments: Comment[] }) { 26 + export function Comments(props: { 27 + document_uri: string; 28 + comments: Comment[]; 29 + pageId?: string; 30 + }) { 27 31 let { identity } = useIdentityData(); 28 32 let { localComments } = useInteractionState(props.document_uri); 29 33 let comments = useMemo(() => { 30 - return [...localComments, ...props.comments]; 34 + return [ 35 + ...localComments.filter( 36 + (c) => (c.record as any)?.onPage === props.pageId, 37 + ), 38 + ...props.comments, 39 + ]; 31 40 }, [props.comments, localComments]); 32 41 let pathname = usePathname(); 33 42 let redirectRoute = useMemo(() => { 43 + if (typeof window === "undefined") return; 34 44 let url = new URL(pathname, window.location.origin); 35 45 url.searchParams.set("refreshAuth", ""); 36 46 url.searchParams.set("interactionDrawer", "comments"); ··· 52 62 </button> 53 63 </div> 54 64 {identity?.atp_did ? ( 55 - <CommentBox doc_uri={props.document_uri} /> 65 + <CommentBox doc_uri={props.document_uri} pageId={props.pageId} /> 56 66 ) : ( 57 67 <div className="w-full accent-container text-tertiary text-center italic p-3 flex flex-col gap-2"> 58 68 Connect a Bluesky account to comment ··· 79 89 ?.record as AppBskyActorProfile.Record; 80 90 return ( 81 91 <Comment 92 + pageId={props.pageId} 82 93 profile={profile} 83 94 document={props.document_uri} 84 95 comment={comment} ··· 99 110 comments: Comment[]; 100 111 profile?: AppBskyActorProfile.Record; 101 112 record: PubLeafletComment.Record; 113 + pageId?: string; 102 114 }) => { 103 115 return ( 104 116 <div className="comment"> ··· 130 142 /> 131 143 </pre> 132 144 <Replies 145 + pageId={props.pageId} 133 146 comment_uri={props.comment.uri} 134 147 comments={props.comments} 135 148 document={props.document} ··· 142 155 comment_uri: string; 143 156 comments: Comment[]; 144 157 document: string; 158 + pageId?: string; 145 159 }) => { 146 160 let { identity } = useIdentityData(); 147 161 ··· 191 205 <div className="flex flex-col gap-2"> 192 206 {replyBoxOpen && ( 193 207 <CommentBox 208 + pageId={props.pageId} 194 209 doc_uri={props.document} 195 210 replyTo={props.comment_uri} 196 211 autoFocus={true} ··· 214 229 {replies.map((reply) => { 215 230 return ( 216 231 <Comment 232 + pageId={props.pageId} 217 233 document={props.document} 218 234 key={reply.uri} 219 235 comment={reply}
+46 -22
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 1 "use client"; 2 2 import { Media } from "components/Media"; 3 3 import { Quotes } from "./Quotes"; 4 - import { useInteractionState } from "./Interactions"; 4 + import { InteractionState, useInteractionState } from "./Interactions"; 5 5 import { Json } from "supabase/database.types"; 6 6 import { Comment, Comments } from "./Comments"; 7 7 import { useSearchParams } from "next/navigation"; 8 + import { SandwichSpacer } from "components/LeafletLayout"; 9 + import { decodeQuotePosition } from "../quotePosition"; 8 10 9 11 export const InteractionDrawer = (props: { 10 12 document_uri: string; 11 13 quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 12 14 comments: Comment[]; 13 15 did: string; 16 + pageId?: string; 14 17 }) => { 15 - let params = useSearchParams(); 16 - let interactionDrawerSearchParam = params.get("interactionDrawer"); 17 - let { drawerOpen: open, drawer } = useInteractionState(); 18 - if (open === false || (open === undefined && !interactionDrawerSearchParam)) 19 - return null; 20 - let currentDrawer = drawer || interactionDrawerSearchParam; 18 + let drawer = useDrawerOpen(props.document_uri); 19 + if (!drawer) return null; 20 + 21 + // Filter comments and quotes based on pageId 22 + const filteredComments = props.comments.filter( 23 + (c) => (c.record as any)?.onPage === props.pageId, 24 + ); 25 + 26 + const filteredQuotes = props.quotes.filter((q) => { 27 + const url = new URL(q.link); 28 + const quoteParam = url.pathname.split("/l-quote/")[1]; 29 + if (!quoteParam) return null; 30 + const quotePosition = decodeQuotePosition(quoteParam); 31 + return quotePosition?.pageId === props.pageId; 32 + }); 33 + 21 34 return ( 22 35 <> 23 - <div className="sm:pr-4 pr-[6px] snap-center"> 24 - <div className="shrink-0 w-[calc(var(--page-width-units)-12px)] sm:w-(--page-width-units) h-full flex z-10"> 25 - <div 26 - id="interaction-drawer" 27 - className="opaque-container rounded-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll " 28 - > 29 - {currentDrawer === "quotes" ? ( 30 - <Quotes {...props} /> 31 - ) : ( 32 - <Comments 33 - document_uri={props.document_uri} 34 - comments={props.comments} 35 - /> 36 - )} 37 - </div> 36 + <SandwichSpacer noWidth /> 37 + <div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]"> 38 + <div 39 + id="interaction-drawer" 40 + className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] " 41 + > 42 + {drawer.drawer === "quotes" ? ( 43 + <Quotes {...props} quotes={filteredQuotes} /> 44 + ) : ( 45 + <Comments 46 + document_uri={props.document_uri} 47 + comments={filteredComments} 48 + pageId={props.pageId} 49 + /> 50 + )} 38 51 </div> 39 52 </div> 40 53 </> 41 54 ); 42 55 }; 56 + 57 + export const useDrawerOpen = (uri: string) => { 58 + let params = useSearchParams(); 59 + let interactionDrawerSearchParam = params.get("interactionDrawer"); 60 + let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 61 + if (open === false || (open === undefined && !interactionDrawerSearchParam)) 62 + return null; 63 + drawer = 64 + drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 65 + return { drawer, pageId }; 66 + };
+14 -24
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 8 8 import { QuotePosition } from "../quotePosition"; 9 9 import { useContext } from "react"; 10 10 import { PostPageContext } from "../PostPageContext"; 11 + import { scrollIntoView } from "src/utils/scrollIntoView"; 11 12 12 - type InteractionState = { 13 + export type InteractionState = { 13 14 drawerOpen: undefined | boolean; 15 + pageId?: string; 14 16 drawer: undefined | "comments" | "quotes"; 15 17 localComments: Comment[]; 16 18 commentBox: { quote: QuotePosition | null }; ··· 27 29 [document_uri: string]: InteractionState; 28 30 }>(() => ({})); 29 31 30 - export function useInteractionState(document_uri?: string) { 32 + export function useInteractionState(document_uri: string) { 31 33 return useInteractionStateStore((state) => { 32 - if (!document_uri || !state[document_uri]) { 34 + if (!state[document_uri]) { 33 35 return defaultInteractionState; 34 36 } 35 37 return state[document_uri]; ··· 83 85 export function openInteractionDrawer( 84 86 drawer: "comments" | "quotes", 85 87 document_uri: string, 88 + pageId?: string, 86 89 ) { 87 90 flushSync(() => { 88 - setInteractionState(document_uri, { drawerOpen: true, drawer }); 91 + setInteractionState(document_uri, { drawerOpen: true, drawer, pageId }); 89 92 }); 90 - let el = document.getElementById("interaction-drawer"); 91 - let isOffscreen = false; 92 - if (el) { 93 - const rect = el.getBoundingClientRect(); 94 - const windowWidth = 95 - window.innerWidth || document.documentElement.clientWidth; 96 - isOffscreen = rect.right > windowWidth - 64; 97 - } 98 - 99 - if (el && isOffscreen) 100 - el.scrollIntoView({ 101 - behavior: "smooth", 102 - block: "center", 103 - inline: "center", 104 - }); 93 + scrollIntoView("interaction-drawer"); 105 94 } 106 95 107 96 export const Interactions = (props: { ··· 110 99 compact?: boolean; 111 100 className?: string; 112 101 showComments?: boolean; 102 + pageId?: string; 113 103 }) => { 114 104 const data = useContext(PostPageContext); 115 105 const document_uri = data?.uri; 116 106 if (!document_uri) 117 107 throw new Error("document_uri not available in PostPageContext"); 118 108 119 - let { drawerOpen, drawer } = useInteractionState(document_uri); 109 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 120 110 121 111 return ( 122 112 <div 123 - className={`flex gap-2 text-tertiary ${props.compact ? "text-sm" : ""} ${props.className}`} 113 + className={`flex gap-2 text-tertiary ${props.compact ? "text-sm" : "px-3 sm:px-4"} ${props.className}`} 124 114 > 125 115 <button 126 116 className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 127 117 onClick={() => { 128 118 if (!drawerOpen || drawer !== "quotes") 129 - openInteractionDrawer("quotes", document_uri); 119 + openInteractionDrawer("quotes", document_uri, props.pageId); 130 120 else setInteractionState(document_uri, { drawerOpen: false }); 131 121 }} 132 122 > ··· 142 132 <button 143 133 className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 144 134 onClick={() => { 145 - if (!drawerOpen || drawer !== "comments") 146 - openInteractionDrawer("comments", document_uri); 135 + if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 136 + openInteractionDrawer("comments", document_uri, props.pageId); 147 137 else setInteractionState(document_uri, { drawerOpen: false }); 148 138 }} 149 139 >
+19 -3
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 19 19 import { useActiveHighlightState } from "../useHighlight"; 20 20 import { PostContent } from "../PostContent"; 21 21 import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 22 + import { flushSync } from "react-dom"; 23 + import { openPage } from "../PostPages"; 22 24 23 25 export const Quotes = (props: { 24 26 quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; ··· 26 28 }) => { 27 29 let data = useContext(PostPageContext); 28 30 const document_uri = data?.uri; 29 - if (!document_uri) throw new Error('document_uri not available in PostPageContext'); 31 + if (!document_uri) 32 + throw new Error("document_uri not available in PostPageContext"); 30 33 31 34 return ( 32 35 <div className="flex flex-col gap-2"> ··· 34 37 Quotes 35 38 <button 36 39 className="text-tertiary" 37 - onClick={() => setInteractionState(document_uri, { drawerOpen: false })} 40 + onClick={() => 41 + setInteractionState(document_uri, { drawerOpen: false }) 42 + } 38 43 > 39 44 <CloseTiny /> 40 45 </button> ··· 87 92 const data = useContext(PostPageContext); 88 93 89 94 let record = data?.data as PubLeafletDocument.Record; 90 - let page = record.pages[0] as PubLeafletPagesLinearDocument.Main; 95 + let page: PubLeafletPagesLinearDocument.Main | undefined = ( 96 + props.position.pageId 97 + ? record.pages.find( 98 + (p) => 99 + (p as PubLeafletPagesLinearDocument.Main).id === 100 + props.position.pageId, 101 + ) 102 + : record.pages[0] 103 + ) as PubLeafletPagesLinearDocument.Main; 91 104 // Extract blocks within the quote range 92 105 const content = extractQuotedBlocks(page.blocks || [], props.position, []); 93 106 return ( ··· 103 116 <div 104 117 className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" 105 118 onClick={(e) => { 119 + if (props.position.pageId) 120 + flushSync(() => openPage(undefined, props.position.pageId!)); 106 121 let scrollMargin = isMobile 107 122 ? 16 108 123 : e.currentTarget.getBoundingClientRect().top; ··· 125 140 > 126 141 <div className="italic border border-border-light rounded-md px-2 pt-1"> 127 142 <PostContent 143 + pages={[]} 128 144 bskyPostData={[]} 129 145 blocks={content} 130 146 did={props.did}
-23
app/lish/[did]/[publication]/[rkey]/PageLayout.tsx
··· 1 - "use client"; 2 - 3 - import { useInteractionState } from "./Interactions/Interactions"; 4 - 5 - export function PageLayout(props: { children: React.ReactNode }) { 6 - let { drawerOpen } = useInteractionState(); 7 - return ( 8 - <div 9 - onScroll={(e) => {}} 10 - className="post w-full relative overflow-x-scroll snap-x snap-mandatory no-scrollbar grow items-stretch flex h-full pwa-padding mx-auto " 11 - id="page-carousel" 12 - > 13 - {/* if you adjust this padding, remember to adjust the negative margins on page 14 - in [rkey]/page/PostPage when card borders are hidden */} 15 - <div 16 - id="pages" 17 - className="postWrapper flex h-full gap-0 sm:gap-3 py-2 sm:py-6 w-full" 18 - > 19 - {props.children} 20 - </div> 21 - </div> 22 - ); 23 - }
+72 -9
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 1 + "use client"; 1 2 import { 2 3 PubLeafletBlocksMath, 3 4 PubLeafletBlocksCode, ··· 12 13 PubLeafletBlocksBlockquote, 13 14 PubLeafletBlocksBskyPost, 14 15 PubLeafletBlocksIframe, 16 + PubLeafletBlocksPage, 15 17 } from "lexicons/api"; 18 + 16 19 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 17 20 import { TextBlock } from "./TextBlock"; 18 21 import { Popover } from "components/Popover"; 19 22 import { theme } from "tailwind.config"; 20 23 import { ImageAltSmall } from "components/Icons/ImageAlt"; 21 - import { codeToHtml } from "shiki"; 22 - import Katex from "katex"; 23 24 import { StaticMathBlock } from "./StaticMathBlock"; 24 25 import { PubCodeBlock } from "./PubCodeBlock"; 25 26 import { AppBskyFeedDefs } from "@atproto/api"; 26 27 import { PubBlueskyPostBlock } from "./PublishBskyPostBlock"; 28 + import { openPage } from "./PostPages"; 29 + import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; 30 + import { PublishedPageLinkBlock } from "./PublishedPageBlock"; 27 31 28 32 export function PostContent({ 29 33 blocks, ··· 32 36 className, 33 37 prerenderedCodeBlocks, 34 38 bskyPostData, 39 + pageId, 40 + pages, 35 41 }: { 36 42 blocks: PubLeafletPagesLinearDocument.Block[]; 43 + pageId?: string; 37 44 did: string; 38 45 preview?: boolean; 39 46 className?: string; 40 47 prerenderedCodeBlocks?: Map<string, string>; 41 48 bskyPostData: AppBskyFeedDefs.PostView[]; 49 + pages: PubLeafletPagesLinearDocument.Main[]; 42 50 }) { 43 51 return ( 44 52 <div 45 - id="post-content" 46 - className={`postContent flex flex-col pb-1 sm:pb-2 pt-1 sm:pt-2 ${className}`} 53 + //The postContent class is important for QuoteHandler 54 + className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-2 ${className}`} 47 55 > 48 56 {blocks.map((b, index) => { 49 57 return ( 50 58 <Block 59 + pageId={pageId} 60 + pages={pages} 51 61 bskyPostData={bskyPostData} 52 62 block={b} 53 63 did={did} ··· 72 82 previousBlock, 73 83 prerenderedCodeBlocks, 74 84 bskyPostData, 85 + pageId, 86 + pages, 75 87 }: { 88 + pageId?: string; 76 89 preview?: boolean; 77 90 index: number[]; 78 91 block: PubLeafletPagesLinearDocument.Block; 79 92 did: string; 80 93 isList?: boolean; 94 + pages: PubLeafletPagesLinearDocument.Main[]; 81 95 previousBlock?: PubLeafletPagesLinearDocument.Block; 82 96 prerenderedCodeBlocks?: Map<string, string>; 83 97 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 89 103 scrollMarginBottom: "4rem", 90 104 wordBreak: "break-word" as React.CSSProperties["wordBreak"], 91 105 }, 92 - id: preview ? undefined : index.join("."), 106 + id: preview 107 + ? undefined 108 + : pageId 109 + ? `${pageId}~${index.join(".")}` 110 + : index.join("."), 93 111 "data-index": index.join("."), 112 + "data-page-id": pageId, 94 113 }; 95 114 let alignment = 96 115 b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight" ··· 114 133 `; 115 134 116 135 switch (true) { 136 + case PubLeafletBlocksPage.isMain(b.block): { 137 + let id = b.block.id; 138 + let page = pages.find((p) => p.id === id); 139 + if (!page) return; 140 + return ( 141 + <PublishedPageLinkBlock 142 + blocks={page.blocks} 143 + pageId={id} 144 + parentPageId={pageId} 145 + did={did} 146 + bskyPostData={bskyPostData} 147 + /> 148 + ); 149 + } 117 150 case PubLeafletBlocksBskyPost.isMain(b.block): { 118 151 let uri = b.block.postRef.uri; 119 152 let post = bskyPostData.find((p) => p.uri === uri); ··· 140 173 <ul className="-ml-px sm:ml-[9px] pb-2"> 141 174 {b.block.children.map((child, i) => ( 142 175 <ListItem 176 + pages={pages} 143 177 bskyPostData={bskyPostData} 144 178 index={[...index, i]} 145 179 item={child} 146 180 did={did} 147 181 key={i} 148 182 className={className} 183 + pageId={pageId} 149 184 /> 150 185 ))} 151 186 </ul> ··· 248 283 plaintext={b.block.plaintext} 249 284 index={index} 250 285 preview={preview} 286 + pageId={pageId} 251 287 /> 252 288 </blockquote> 253 289 ); ··· 260 296 plaintext={b.block.plaintext} 261 297 index={index} 262 298 preview={preview} 299 + pageId={pageId} 263 300 /> 264 301 </p> 265 302 ); ··· 267 304 if (b.block.level === 1) 268 305 return ( 269 306 <h2 className={`${className}`} {...blockProps}> 270 - <TextBlock {...b.block} index={index} preview={preview} /> 307 + <TextBlock 308 + {...b.block} 309 + index={index} 310 + preview={preview} 311 + pageId={pageId} 312 + /> 271 313 </h2> 272 314 ); 273 315 if (b.block.level === 2) 274 316 return ( 275 317 <h3 className={`${className}`} {...blockProps}> 276 - <TextBlock {...b.block} index={index} preview={preview} /> 318 + <TextBlock 319 + {...b.block} 320 + index={index} 321 + preview={preview} 322 + pageId={pageId} 323 + /> 277 324 </h3> 278 325 ); 279 326 if (b.block.level === 3) 280 327 return ( 281 328 <h4 className={`${className}`} {...blockProps}> 282 - <TextBlock {...b.block} index={index} preview={preview} /> 329 + <TextBlock 330 + {...b.block} 331 + index={index} 332 + preview={preview} 333 + pageId={pageId} 334 + /> 283 335 </h4> 284 336 ); 285 337 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 286 338 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 287 339 return ( 288 340 <h6 className={`${className}`} {...blockProps}> 289 - <TextBlock {...b.block} index={index} preview={preview} /> 341 + <TextBlock 342 + {...b.block} 343 + index={index} 344 + preview={preview} 345 + pageId={pageId} 346 + /> 290 347 </h6> 291 348 ); 292 349 } ··· 297 354 298 355 function ListItem(props: { 299 356 index: number[]; 357 + pages: PubLeafletPagesLinearDocument.Main[]; 300 358 item: PubLeafletBlocksUnorderedList.ListItem; 301 359 did: string; 302 360 className?: string; 303 361 bskyPostData: AppBskyFeedDefs.PostView[]; 362 + pageId?: string; 304 363 }) { 305 364 let children = props.item.children?.length ? ( 306 365 <ul className="-ml-[7px] sm:ml-[7px]"> 307 366 {props.item.children.map((child, index) => ( 308 367 <ListItem 368 + pages={props.pages} 309 369 bskyPostData={props.bskyPostData} 310 370 index={[...props.index, index]} 311 371 item={child} 312 372 did={props.did} 313 373 key={index} 314 374 className={props.className} 375 + pageId={props.pageId} 315 376 /> 316 377 ))} 317 378 </ul> ··· 324 385 /> 325 386 <div className="flex flex-col w-full"> 326 387 <Block 388 + pages={props.pages} 327 389 bskyPostData={props.bskyPostData} 328 390 block={{ block: props.item.content }} 329 391 did={props.did} 330 392 isList 331 393 index={props.index} 394 + pageId={props.pageId} 332 395 /> 333 396 {children}{" "} 334 397 </div>
+83 -74
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 1 1 "use client"; 2 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 2 + import { 3 + PubLeafletComment, 4 + PubLeafletDocument, 5 + PubLeafletPublication, 6 + } from "lexicons/api"; 3 7 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 8 import { Interactions } from "../Interactions/Interactions"; 5 9 import { PostPageData } from "../getPostPageData"; ··· 7 11 import { useIdentityData } from "components/IdentityProvider"; 8 12 import { EditTiny } from "components/Icons/EditTiny"; 9 13 import { SpeedyLink } from "components/SpeedyLink"; 14 + import { decodeQuotePosition } from "../quotePosition"; 10 15 11 16 export function PostHeader(props: { 12 17 data: PostPageData; ··· 24 29 if (!document?.data || !document.documents_in_publications[0].publications) 25 30 return; 26 31 return ( 27 - <> 28 - {/* <CollapsedPostHeader 29 - pubIcon={ 30 - pubRecord?.icon && pub 31 - ? blobRefToSrc(pubRecord.icon.ref, new AtUri(pub.uri).host) 32 - : undefined 33 - } 34 - title={record.title} 35 - quotes={document.document_mentions_in_bsky} 36 - /> */} 37 - <div className="max-w-prose w-full mx-auto" id="post-header"> 38 - <div className="pubHeader flex flex-col pb-5"> 39 - <div className="flex justify-between w-full"> 40 - <SpeedyLink 41 - className="font-bold hover:no-underline text-accent-contrast" 42 - href={ 43 - document && 44 - getPublicationURL( 45 - document.documents_in_publications[0].publications, 46 - ) 47 - } 48 - > 49 - {pub?.name} 50 - </SpeedyLink> 51 - {identity && 52 - identity.atp_did === 53 - document.documents_in_publications[0]?.publications 54 - .identity_did && 55 - document.leaflets_in_publications[0] && ( 56 - <a 57 - className=" rounded-full flex place-items-center" 58 - href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 59 - > 60 - <EditTiny className="shrink-0" /> 61 - </a> 62 - )} 63 - </div> 64 - <h2 className="">{record.title}</h2> 65 - {record.description ? ( 66 - <p className="italic text-secondary">{record.description}</p> 32 + <div 33 + className="max-w-prose w-full mx-auto px-3 sm:px-4 sm:pt-3 pt-2" 34 + id="post-header" 35 + > 36 + <div className="pubHeader flex flex-col pb-5"> 37 + <div className="flex justify-between w-full"> 38 + <SpeedyLink 39 + className="font-bold hover:no-underline text-accent-contrast" 40 + href={ 41 + document && 42 + getPublicationURL( 43 + document.documents_in_publications[0].publications, 44 + ) 45 + } 46 + > 47 + {pub?.name} 48 + </SpeedyLink> 49 + {identity && 50 + identity.atp_did === 51 + document.documents_in_publications[0]?.publications 52 + .identity_did && 53 + document.leaflets_in_publications[0] && ( 54 + <a 55 + className=" rounded-full flex place-items-center" 56 + href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 57 + > 58 + <EditTiny className="shrink-0" /> 59 + </a> 60 + )} 61 + </div> 62 + <h2 className="">{record.title}</h2> 63 + {record.description ? ( 64 + <p className="italic text-secondary">{record.description}</p> 65 + ) : null} 66 + 67 + <div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap"> 68 + {profile ? ( 69 + <> 70 + <a 71 + className="text-tertiary" 72 + href={`https://bsky.app/profile/${profile.handle}`} 73 + > 74 + by {profile.displayName || profile.handle} 75 + </a> 76 + </> 77 + ) : null} 78 + {record.publishedAt ? ( 79 + <> 80 + | 81 + <p> 82 + {new Date(record.publishedAt).toLocaleDateString(undefined, { 83 + year: "numeric", 84 + month: "long", 85 + day: "2-digit", 86 + })} 87 + </p> 88 + </> 67 89 ) : null} 68 - 69 - <div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap"> 70 - {profile ? ( 71 - <> 72 - <a 73 - className="text-tertiary" 74 - href={`https://bsky.app/profile/${profile.handle}`} 75 - > 76 - by {profile.displayName || profile.handle} 77 - </a> 78 - </> 79 - ) : null} 80 - {record.publishedAt ? ( 81 - <> 82 - | 83 - <p suppressHydrationWarning> 84 - {new Date(record.publishedAt).toLocaleDateString(undefined, { 85 - year: "numeric", 86 - month: "long", 87 - day: "2-digit", 88 - })} 89 - </p> 90 - </> 91 - ) : null} 92 - |{" "} 93 - <Interactions 94 - showComments={props.preferences.showComments} 95 - compact 96 - quotesCount={document.document_mentions_in_bsky.length} 97 - commentsCount={document.comments_on_documents.length} 98 - /> 99 - </div> 90 + |{" "} 91 + <Interactions 92 + showComments={props.preferences.showComments} 93 + compact 94 + quotesCount={ 95 + document.document_mentions_in_bsky.filter((q) => { 96 + const url = new URL(q.link); 97 + const quoteParam = url.pathname.split("/l-quote/")[1]; 98 + if (!quoteParam) return null; 99 + const quotePosition = decodeQuotePosition(quoteParam); 100 + return !quotePosition?.pageId; 101 + }).length 102 + } 103 + commentsCount={ 104 + document.comments_on_documents.filter( 105 + (c) => !(c.record as PubLeafletComment.Record)?.onPage, 106 + ).length 107 + } 108 + /> 100 109 </div> 101 110 </div> 102 - </> 111 + </div> 103 112 ); 104 113 }
-111
app/lish/[did]/[publication]/[rkey]/PostPage.tsx
··· 1 - "use client"; 2 - import { 3 - PubLeafletPagesLinearDocument, 4 - PubLeafletPublication, 5 - } from "lexicons/api"; 6 - import { PostPageData } from "./getPostPageData"; 7 - import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 8 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 9 - import { SubscribeWithBluesky } from "app/lish/Subscribe"; 10 - import { EditTiny } from "components/Icons/EditTiny"; 11 - import { Interactions, useInteractionState } from "./Interactions/Interactions"; 12 - import { PostContent } from "./PostContent"; 13 - import { PostHeader } from "./PostHeader/PostHeader"; 14 - import { useIdentityData } from "components/IdentityProvider"; 15 - import { AppBskyFeedDefs } from "@atproto/api"; 16 - 17 - export function PostPage({ 18 - document, 19 - blocks, 20 - did, 21 - profile, 22 - preferences, 23 - pubRecord, 24 - prerenderedCodeBlocks, 25 - bskyPostData, 26 - }: { 27 - document: PostPageData; 28 - blocks: PubLeafletPagesLinearDocument.Block[]; 29 - profile: ProfileViewDetailed; 30 - pubRecord: PubLeafletPublication.Record; 31 - did: string; 32 - prerenderedCodeBlocks?: Map<string, string>; 33 - bskyPostData: AppBskyFeedDefs.PostView[]; 34 - preferences: { showComments?: boolean }; 35 - }) { 36 - let { identity } = useIdentityData(); 37 - const document_uri = document?.uri; 38 - let { drawerOpen } = useInteractionState(document_uri); 39 - if (!document || !document.documents_in_publications[0].publications) 40 - return null; 41 - 42 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 43 - return ( 44 - <> 45 - {(drawerOpen || hasPageBackground) && ( 46 - <div 47 - className="spacer sm:block hidden" 48 - style={{ 49 - width: `calc(50vw - 12px - ((var(--page-width-units)/2))`, 50 - }} 51 - /> 52 - )} 53 - <div 54 - id="post-page" 55 - className={`postPageWrapper relative overflow-y-auto sm:mx-0 mx-[6px] w-full 56 - ${drawerOpen || hasPageBackground ? "max-w-(--page-width-units) shrink-0 snap-center " : "w-full"} 57 - ${ 58 - hasPageBackground 59 - ? "h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] rounded-lg border border-border " 60 - : "sm:h-[calc(100%+48px)] h-[calc(100%+24px)] sm:-my-6 -my-3 " 61 - }`} 62 - > 63 - <div 64 - className={`postPageContent sm:max-w-prose mx-auto h-fit w-full px-3 sm:px-4 ${hasPageBackground ? " pt-2 pb-3 sm:pb-6" : "py-6 sm:py-9"}`} 65 - > 66 - <PostHeader 67 - data={document} 68 - profile={profile} 69 - preferences={preferences} 70 - /> 71 - <PostContent 72 - bskyPostData={bskyPostData} 73 - blocks={blocks} 74 - did={did} 75 - prerenderedCodeBlocks={prerenderedCodeBlocks} 76 - /> 77 - <Interactions 78 - showComments={preferences.showComments} 79 - quotesCount={document.document_mentions_in_bsky.length} 80 - commentsCount={document.comments_on_documents.length} 81 - /> 82 - <hr className="border-border-light mb-4 mt-4" /> 83 - {identity && 84 - identity.atp_did === 85 - document.documents_in_publications[0]?.publications?.identity_did && 86 - document.leaflets_in_publications[0] ? ( 87 - <a 88 - href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 89 - 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" 90 - > 91 - <EditTiny /> Edit Post 92 - </a> 93 - ) : ( 94 - <SubscribeWithBluesky 95 - isPost 96 - base_url={getPublicationURL( 97 - document.documents_in_publications[0].publications, 98 - )} 99 - pub_uri={document.documents_in_publications[0].publications.uri} 100 - subscribers={ 101 - document.documents_in_publications[0].publications 102 - .publication_subscriptions 103 - } 104 - pubName={pubRecord.name} 105 - /> 106 - )} 107 - </div> 108 - </div> 109 - </> 110 - ); 111 - }
+304
app/lish/[did]/[publication]/[rkey]/PostPages.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 { Interactions } from "./Interactions/Interactions"; 14 + import { PostContent } from "./PostContent"; 15 + import { PostHeader } from "./PostHeader/PostHeader"; 16 + import { useIdentityData } from "components/IdentityProvider"; 17 + import { AppBskyFeedDefs } from "@atproto/api"; 18 + import { create } from "zustand/react"; 19 + import { 20 + InteractionDrawer, 21 + useDrawerOpen, 22 + } from "./Interactions/InteractionDrawer"; 23 + import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 24 + import { PageOptionButton } from "components/Pages/PageOptions"; 25 + import { CloseTiny } from "components/Icons/CloseTiny"; 26 + import { PageWrapper } from "components/Pages/Page"; 27 + import { Fragment, useEffect } from "react"; 28 + import { flushSync } from "react-dom"; 29 + import { scrollIntoView } from "src/utils/scrollIntoView"; 30 + import { useParams } from "next/navigation"; 31 + import { decodeQuotePosition } from "./quotePosition"; 32 + 33 + const usePostPageUIState = create(() => ({ 34 + pages: [] as string[], 35 + initialized: false, 36 + })); 37 + 38 + export const useOpenPages = () => { 39 + const { quote } = useParams(); 40 + const state = usePostPageUIState((s) => s); 41 + 42 + if (!state.initialized && quote) { 43 + const decodedQuote = decodeQuotePosition(quote as string); 44 + if (decodedQuote?.pageId) { 45 + return [decodedQuote.pageId]; 46 + } 47 + } 48 + 49 + return state.pages; 50 + }; 51 + 52 + export const useInitializeOpenPages = () => { 53 + const { quote } = useParams(); 54 + 55 + useEffect(() => { 56 + const state = usePostPageUIState.getState(); 57 + if (!state.initialized) { 58 + if (quote) { 59 + const decodedQuote = decodeQuotePosition(quote as string); 60 + if (decodedQuote?.pageId) { 61 + usePostPageUIState.setState({ 62 + pages: [decodedQuote.pageId], 63 + initialized: true, 64 + }); 65 + return; 66 + } 67 + } 68 + // Mark as initialized even if no pageId found 69 + usePostPageUIState.setState({ initialized: true }); 70 + } 71 + }, [quote]); 72 + }; 73 + 74 + export const openPage = ( 75 + parent: string | undefined, 76 + page: string, 77 + options?: { scrollIntoView?: boolean }, 78 + ) => { 79 + flushSync(() => { 80 + usePostPageUIState.setState((state) => { 81 + let parentPosition = state.pages.findIndex((s) => s == parent); 82 + return { 83 + pages: 84 + parentPosition === -1 85 + ? [page] 86 + : [...state.pages.slice(0, parentPosition + 1), page], 87 + initialized: true, 88 + }; 89 + }); 90 + }); 91 + 92 + if (options?.scrollIntoView !== false) { 93 + scrollIntoView(`post-page-${page}`); 94 + } 95 + }; 96 + 97 + export const closePage = (page: string) => 98 + usePostPageUIState.setState((state) => { 99 + let parentPosition = state.pages.findIndex((s) => s == page); 100 + return { 101 + pages: state.pages.slice(0, parentPosition), 102 + initialized: true, 103 + }; 104 + }); 105 + 106 + export function PostPages({ 107 + document, 108 + blocks, 109 + did, 110 + profile, 111 + preferences, 112 + pubRecord, 113 + prerenderedCodeBlocks, 114 + bskyPostData, 115 + document_uri, 116 + }: { 117 + document_uri: string; 118 + document: PostPageData; 119 + blocks: PubLeafletPagesLinearDocument.Block[]; 120 + profile: ProfileViewDetailed; 121 + pubRecord: PubLeafletPublication.Record; 122 + did: string; 123 + prerenderedCodeBlocks?: Map<string, string>; 124 + bskyPostData: AppBskyFeedDefs.PostView[]; 125 + preferences: { showComments?: boolean }; 126 + }) { 127 + let { identity } = useIdentityData(); 128 + let drawer = useDrawerOpen(document_uri); 129 + useInitializeOpenPages(); 130 + let pages = useOpenPages(); 131 + if (!document || !document.documents_in_publications[0].publications) 132 + return null; 133 + 134 + let hasPageBackground = !!pubRecord.theme?.showPageBackground; 135 + let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; 136 + let record = document.data as PubLeafletDocument.Record; 137 + return ( 138 + <> 139 + {!fullPageScroll && <BookendSpacer />} 140 + <PageWrapper 141 + pageType="doc" 142 + fullPageScroll={fullPageScroll} 143 + cardBorderHidden={!hasPageBackground} 144 + id={"post-page"} 145 + drawerOpen={!!drawer && !drawer.pageId} 146 + > 147 + <PostHeader 148 + data={document} 149 + profile={profile} 150 + preferences={preferences} 151 + /> 152 + <PostContent 153 + pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 154 + bskyPostData={bskyPostData} 155 + blocks={blocks} 156 + did={did} 157 + prerenderedCodeBlocks={prerenderedCodeBlocks} 158 + /> 159 + <Interactions 160 + showComments={preferences.showComments} 161 + quotesCount={ 162 + document.document_mentions_in_bsky.filter((q) => { 163 + const url = new URL(q.link); 164 + const quoteParam = url.pathname.split("/l-quote/")[1]; 165 + if (!quoteParam) return null; 166 + const quotePosition = decodeQuotePosition(quoteParam); 167 + return !quotePosition?.pageId; 168 + }).length 169 + } 170 + commentsCount={ 171 + document.comments_on_documents.filter( 172 + (c) => !(c.record as PubLeafletComment.Record)?.onPage, 173 + ).length 174 + } 175 + /> 176 + <hr className="border-border-light mb-4 mt-4 sm:mx-4 mx-3" /> 177 + <div className="pb-6 sm:px-4 px-3"> 178 + {identity && 179 + identity.atp_did === 180 + document.documents_in_publications[0]?.publications 181 + ?.identity_did ? ( 182 + <a 183 + href={`https://leaflet.pub/${document.leaflets_in_publications[0]?.leaflet}`} 184 + 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" 185 + > 186 + <EditTiny /> Edit Post 187 + </a> 188 + ) : ( 189 + <SubscribeWithBluesky 190 + isPost 191 + base_url={getPublicationURL( 192 + document.documents_in_publications[0].publications, 193 + )} 194 + pub_uri={document.documents_in_publications[0].publications.uri} 195 + subscribers={ 196 + document.documents_in_publications[0].publications 197 + .publication_subscriptions 198 + } 199 + pubName={document.documents_in_publications[0].publications.name} 200 + /> 201 + )} 202 + </div> 203 + </PageWrapper> 204 + 205 + {drawer && !drawer.pageId && ( 206 + <InteractionDrawer 207 + document_uri={document.uri} 208 + comments={ 209 + pubRecord.preferences?.showComments === false 210 + ? [] 211 + : document.comments_on_documents 212 + } 213 + quotes={document.document_mentions_in_bsky} 214 + did={did} 215 + /> 216 + )} 217 + 218 + {pages.map((p) => { 219 + let page = record.pages.find( 220 + (page) => (page as PubLeafletPagesLinearDocument.Main).id === p, 221 + ) as PubLeafletPagesLinearDocument.Main | undefined; 222 + if (!page) return null; 223 + return ( 224 + <Fragment key={p}> 225 + <SandwichSpacer /> 226 + {/*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!*/} 227 + <PageWrapper 228 + pageType="doc" 229 + cardBorderHidden={!hasPageBackground} 230 + id={`post-page-${p}`} 231 + fullPageScroll={false} 232 + drawerOpen={!!drawer && drawer.pageId === page.id} 233 + pageOptions={ 234 + <PageOptions 235 + onClick={() => closePage(page?.id!)} 236 + hasPageBackground={hasPageBackground} 237 + /> 238 + } 239 + > 240 + <PostContent 241 + pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 242 + pageId={page.id} 243 + bskyPostData={bskyPostData} 244 + blocks={page.blocks} 245 + did={did} 246 + prerenderedCodeBlocks={prerenderedCodeBlocks} 247 + /> 248 + <Interactions 249 + pageId={page.id} 250 + showComments={preferences.showComments} 251 + quotesCount={ 252 + document.document_mentions_in_bsky.filter((q) => 253 + q.link.includes(page.id!), 254 + ).length 255 + } 256 + commentsCount={ 257 + document.comments_on_documents.filter( 258 + (c) => 259 + (c.record as PubLeafletComment.Record)?.onPage === 260 + page.id, 261 + ).length 262 + } 263 + /> 264 + </PageWrapper> 265 + {drawer && drawer.pageId === page.id && ( 266 + <InteractionDrawer 267 + pageId={page.id} 268 + document_uri={document.uri} 269 + comments={ 270 + pubRecord.preferences?.showComments === false 271 + ? [] 272 + : document.comments_on_documents 273 + } 274 + quotes={document.document_mentions_in_bsky} 275 + did={did} 276 + /> 277 + )} 278 + </Fragment> 279 + ); 280 + })} 281 + {!fullPageScroll && <BookendSpacer />} 282 + </> 283 + ); 284 + } 285 + 286 + const PageOptions = (props: { 287 + onClick: () => void; 288 + hasPageBackground: boolean; 289 + }) => { 290 + return ( 291 + <div 292 + className={`pageOptions w-fit z-10 293 + absolute sm:-right-[20px] right-3 sm:top-3 top-0 294 + flex sm:flex-col flex-row-reverse gap-1 items-start`} 295 + > 296 + <PageOptionButton 297 + cardBorderHidden={!props.hasPageBackground} 298 + onClick={props.onClick} 299 + > 300 + <CloseTiny /> 301 + </PageOptionButton> 302 + </div> 303 + ); 304 + };
+230
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 1 + "use client"; 2 + 3 + import { useEntity, useReplicache } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { CSSProperties, useContext, useRef } from "react"; 6 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 + import { PostContent } from "./PostContent"; 8 + import { 9 + PubLeafletBlocksHeader, 10 + PubLeafletBlocksText, 11 + PubLeafletComment, 12 + PubLeafletPagesLinearDocument, 13 + PubLeafletPublication, 14 + } from "lexicons/api"; 15 + import { AppBskyFeedDefs } from "@atproto/api"; 16 + import { TextBlock } from "./TextBlock"; 17 + import { PostPageContext } from "./PostPageContext"; 18 + import { openPage, useOpenPages } from "./PostPages"; 19 + import { 20 + openInteractionDrawer, 21 + setInteractionState, 22 + useInteractionState, 23 + } from "./Interactions/Interactions"; 24 + import { CommentTiny } from "components/Icons/CommentTiny"; 25 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 26 + 27 + export function PublishedPageLinkBlock(props: { 28 + blocks: PubLeafletPagesLinearDocument.Block[]; 29 + parentPageId: string | undefined; 30 + pageId: string; 31 + did: string; 32 + preview?: boolean; 33 + className?: string; 34 + prerenderedCodeBlocks?: Map<string, string>; 35 + bskyPostData: AppBskyFeedDefs.PostView[]; 36 + }) { 37 + //switch to use actually state 38 + let openPages = useOpenPages(); 39 + let isOpen = openPages.includes(props.pageId); 40 + return ( 41 + <div 42 + className={`w-full cursor-pointer 43 + pageLinkBlockWrapper relative group/pageLinkBlock 44 + bg-bg-page shadow-sm 45 + flex overflow-clip 46 + block-border 47 + ${isOpen && "!border-tertiary"} 48 + `} 49 + onClick={(e) => { 50 + if (e.isDefaultPrevented()) return; 51 + if (e.shiftKey) return; 52 + e.preventDefault(); 53 + e.stopPropagation(); 54 + 55 + openPage(props.parentPageId, props.pageId); 56 + }} 57 + > 58 + <DocLinkBlock {...props} /> 59 + </div> 60 + ); 61 + } 62 + export function DocLinkBlock(props: { 63 + blocks: PubLeafletPagesLinearDocument.Block[]; 64 + pageId: string; 65 + parentPageId?: string; 66 + did: string; 67 + preview?: boolean; 68 + className?: string; 69 + prerenderedCodeBlocks?: Map<string, string>; 70 + bskyPostData: AppBskyFeedDefs.PostView[]; 71 + }) { 72 + let [title, description] = props.blocks 73 + .map((b) => b.block) 74 + .filter( 75 + (b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b), 76 + ); 77 + 78 + return ( 79 + <div 80 + style={{ "--list-marker-width": "20px" } as CSSProperties} 81 + className={` 82 + w-full h-[104px] 83 + `} 84 + > 85 + <> 86 + <div className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full"> 87 + <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip flex flex-col "> 88 + <div className="grow"> 89 + {title && ( 90 + <div 91 + className={`pageBlockOne outline-none resize-none align-top flex gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 92 + > 93 + <TextBlock 94 + facets={title.facets} 95 + plaintext={title.plaintext} 96 + index={[]} 97 + preview 98 + /> 99 + </div> 100 + )} 101 + {description && ( 102 + <div 103 + className={`pageBlockLineTwo outline-none resize-none align-top flex gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 104 + > 105 + <TextBlock 106 + facets={description.facets} 107 + plaintext={description.plaintext} 108 + index={[]} 109 + preview 110 + /> 111 + </div> 112 + )} 113 + </div> 114 + 115 + <Interactions 116 + pageId={props.pageId} 117 + parentPageId={props.parentPageId} 118 + /> 119 + </div> 120 + {!props.preview && ( 121 + <PagePreview blocks={props.blocks} did={props.did} /> 122 + )} 123 + </div> 124 + </> 125 + </div> 126 + ); 127 + } 128 + 129 + export function PagePreview(props: { 130 + did: string; 131 + blocks: PubLeafletPagesLinearDocument.Block[]; 132 + }) { 133 + let previewRef = useRef<HTMLDivElement | null>(null); 134 + let { rootEntity } = useReplicache(); 135 + let data = useContext(PostPageContext); 136 + let theme = data?.documents_in_publications[0]?.publications 137 + ?.record as PubLeafletPublication.Record; 138 + let pageWidth = `var(--page-width-unitless)`; 139 + let cardBorderHidden = !theme.theme?.showPageBackground; 140 + return ( 141 + <div 142 + ref={previewRef} 143 + className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`} 144 + > 145 + <div 146 + className="absolute top-0 left-0 origin-top-left pointer-events-none " 147 + style={{ 148 + width: `calc(1px * ${pageWidth})`, 149 + height: `calc(100vh - 64px)`, 150 + transform: `scale(calc((120 / ${pageWidth} )))`, 151 + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 152 + }} 153 + > 154 + {!cardBorderHidden && ( 155 + <div 156 + className={`pageLinkBlockBackground 157 + absolute top-0 left-0 right-0 bottom-0 158 + pointer-events-none 159 + `} 160 + /> 161 + )} 162 + <PostContent 163 + pages={[]} 164 + did={props.did} 165 + blocks={props.blocks} 166 + preview 167 + bskyPostData={[]} 168 + /> 169 + </div> 170 + </div> 171 + ); 172 + } 173 + 174 + const Interactions = (props: { pageId: string; parentPageId?: string }) => { 175 + const data = useContext(PostPageContext); 176 + const document_uri = data?.uri; 177 + if (!document_uri) 178 + throw new Error("document_uri not available in PostPageContext"); 179 + let comments = data.comments_on_documents.filter( 180 + (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 181 + ).length; 182 + let quotes = data.document_mentions_in_bsky.filter((q) => 183 + q.link.includes(props.pageId), 184 + ).length; 185 + 186 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 187 + 188 + return ( 189 + <div 190 + className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`} 191 + > 192 + {quotes > 0 && ( 193 + <button 194 + className={`flex gap-1 items-center`} 195 + onClick={(e) => { 196 + e.preventDefault(); 197 + e.stopPropagation(); 198 + openPage(props.parentPageId, props.pageId, { 199 + scrollIntoView: false, 200 + }); 201 + if (!drawerOpen || drawer !== "quotes") 202 + openInteractionDrawer("quotes", document_uri, props.pageId); 203 + else setInteractionState(document_uri, { drawerOpen: false }); 204 + }} 205 + > 206 + <span className="sr-only">Page quotes</span> 207 + <QuoteTiny aria-hidden /> {quotes}{" "} 208 + </button> 209 + )} 210 + {comments > 0 && ( 211 + <button 212 + className={`flex gap-1 items-center`} 213 + onClick={(e) => { 214 + e.preventDefault(); 215 + e.stopPropagation(); 216 + openPage(props.parentPageId, props.pageId, { 217 + scrollIntoView: false, 218 + }); 219 + if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 220 + openInteractionDrawer("comments", document_uri, props.pageId); 221 + else setInteractionState(document_uri, { drawerOpen: false }); 222 + }} 223 + > 224 + <span className="sr-only">Page comments</span> 225 + <CommentTiny aria-hidden /> {comments}{" "} 226 + </button> 227 + )} 228 + </div> 229 + ); 230 + };
+34 -14
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 14 14 import { setInteractionState } from "./Interactions/Interactions"; 15 15 import { PostPageContext } from "./PostPageContext"; 16 16 import { PubLeafletPublication } from "lexicons/api"; 17 + import { flushSync } from "react-dom"; 18 + import { scrollIntoView } from "src/utils/scrollIntoView"; 17 19 18 20 export function QuoteHandler() { 19 21 let [position, setPosition] = useState<{ ··· 24 26 useEffect(() => { 25 27 const handleSelectionChange = (e: Event) => { 26 28 const selection = document.getSelection(); 27 - const postContent = document.getElementById("post-content"); 29 + 30 + // Check if selection is within any element with postContent class 28 31 const isWithinPostContent = 29 - postContent && selection?.rangeCount && selection.rangeCount > 0 30 - ? postContent.contains( 31 - selection.getRangeAt(0).commonAncestorContainer, 32 - ) 32 + selection?.rangeCount && selection.rangeCount > 0 33 + ? (() => { 34 + const range = selection.getRangeAt(0); 35 + const ancestor = range.commonAncestorContainer; 36 + const element = 37 + ancestor.nodeType === Node.ELEMENT_NODE 38 + ? (ancestor as Element) 39 + : ancestor.parentElement; 40 + return element?.closest(".postContent") !== null; 41 + })() 33 42 : false; 34 43 35 44 if (!selection || !isWithinPostContent || !selection?.toString()) ··· 88 97 endIndex?.element, 89 98 ); 90 99 let position: QuotePosition = { 100 + ...(startIndex.pageId && { pageId: startIndex.pageId }), 91 101 start: { 92 102 block: startIndex?.index.split(".").map((i) => parseInt(i)), 93 103 offset: startOffset, ··· 114 124 return ( 115 125 <div 116 126 id="quote-trigger" 117 - className={`accent-container border border-border-light text-accent-contrast px-1 flex gap-1 text-sm justify-center text-center items-center`} 127 + className={`z-20 accent-container border border-border-light text-accent-contrast px-1 flex gap-1 text-sm justify-center text-center items-center`} 118 128 style={{ 119 129 position: "absolute", 120 130 top: position.top, ··· 145 155 // Clear existing query parameters 146 156 currentUrl.search = ""; 147 157 148 - currentUrl.hash = `#${pos?.start.block.join(".")}_${pos?.start.offset}`; 158 + const fragmentId = pos?.pageId 159 + ? `${pos.pageId}~${pos.start.block.join(".")}_${pos.start.offset}` 160 + : `${pos?.start.block.join(".")}_${pos?.start.offset}`; 161 + currentUrl.hash = `#${fragmentId}`; 149 162 return [currentUrl.toString(), pos]; 150 163 }, [props.position]); 151 164 let pubRecord = data.documents_in_publications[0]?.publications?.record as ··· 195 208 className="flex gap-1 items-center hover:font-bold px-1" 196 209 onClick={() => { 197 210 if (!position) return; 198 - setInteractionState(document_uri, { 199 - drawer: "comments", 200 - drawerOpen: true, 201 - commentBox: { quote: position }, 202 - }); 211 + flushSync(() => 212 + setInteractionState(document_uri, { 213 + drawer: "comments", 214 + drawerOpen: true, 215 + pageId: position.pageId, 216 + commentBox: { quote: position }, 217 + }), 218 + ); 219 + scrollIntoView("interaction-drawer"); 203 220 }} 204 221 > 205 222 <CommentTiny /> Comment ··· 210 227 ); 211 228 }; 212 229 213 - function findDataIndex(node: Node): { index: string; element: Element } | null { 230 + function findDataIndex( 231 + node: Node, 232 + ): { index: string; element: Element; pageId?: string } | null { 214 233 if (node.nodeType === Node.ELEMENT_NODE) { 215 234 const element = node as Element; 216 235 if (element.hasAttribute("data-index")) { 217 236 const index = element.getAttribute("data-index"); 218 237 if (index) { 219 - return { index, element }; 238 + const pageId = element.getAttribute("data-page-id") || undefined; 239 + return { index, element, pageId }; 220 240 } 221 241 } 222 242 }
+7 -3
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
··· 11 11 facets?: Facet[]; 12 12 index: number[]; 13 13 preview?: boolean; 14 + pageId?: string; 14 15 }) { 15 16 let children = []; 16 - let highlights = useHighlight(props.index); 17 + let highlights = useHighlight(props.index, props.pageId); 17 18 let facets = useMemo(() => { 18 19 if (props.preview) return props.facets; 19 20 let facets = [...(props.facets || [])]; 20 21 for (let highlight of highlights) { 22 + const fragmentId = props.pageId 23 + ? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}` 24 + : `${props.index.join(".")}_${highlight.startOffset || 0}`; 21 25 facets = addFacet( 22 26 facets, 23 27 { ··· 35 39 { $type: "pub.leaflet.richtext.facet#highlight" }, 36 40 { 37 41 $type: "pub.leaflet.richtext.facet#id", 38 - id: `${props.index.join(".")}_${highlight.startOffset || 0}`, 42 + id: fragmentId, 39 43 }, 40 44 ], 41 45 }, ··· 43 47 ); 44 48 } 45 49 return facets; 46 - }, [props.plaintext, props.facets, highlights, props.preview]); 50 + }, [props.plaintext, props.facets, highlights, props.preview, props.pageId]); 47 51 return <BaseTextBlock {...props} facets={facets} />; 48 52 } 49 53
+20 -27
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 17 17 } from "components/ThemeManager/PublicationThemeProvider"; 18 18 import { getPostPageData } from "./getPostPageData"; 19 19 import { PostPageContextProvider } from "./PostPageContext"; 20 - import { PostPage } from "./PostPage"; 21 - import { PageLayout } from "./PageLayout"; 20 + import { PostPages } from "./PostPages"; 22 21 import { extractCodeBlocks } from "./extractCodeBlocks"; 23 - import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 22 + import { LeafletLayout } from "components/LeafletLayout"; 24 23 25 24 export async function generateMetadata(props: { 26 25 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 54 53 let did = decodeURIComponent((await props.params).did); 55 54 if (!did) 56 55 return ( 57 - <NotFoundLayout> 58 - <p className="font-bold">Sorry, can&apos;t resolve handle.</p> 56 + <div className="p-4 text-lg text-center flex flex-col gap-4"> 57 + <p>Sorry, can&apos;t resolve handle.</p> 59 58 <p> 60 59 This may be a glitch on our end. If the issue persists please{" "} 61 60 <a href="mailto:contact@leaflet.pub">send us a note</a>. 62 61 </p> 63 - </NotFoundLayout> 62 + </div> 64 63 ); 65 64 let agent = new AtpAgent({ 66 65 service: "https://public.api.bsky.app", ··· 83 82 ]); 84 83 if (!document?.data || !document.documents_in_publications[0].publications) 85 84 return ( 86 - <NotFoundLayout> 87 - <p className="font-bold">Sorry, we can't find this post!</p> 88 - <p> 89 - This may be a glitch on our end. If the issue persists please{" "} 90 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 91 - </p> 92 - </NotFoundLayout> 85 + <div className="bg-bg-leaflet h-full p-3 text-center relative"> 86 + <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full"> 87 + <div className=" px-3 py-4 opaque-container flex flex-col gap-1 mx-2 "> 88 + <h3>Sorry, post not found!</h3> 89 + <p> 90 + This may be a glitch on our end. If the issue persists please{" "} 91 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 92 + </p> 93 + </div> 94 + </div> 95 + </div> 93 96 ); 94 97 let record = document.data as PubLeafletDocument.Record; 95 98 let bskyPosts = record.pages.flatMap((p) => { ··· 121 124 let pubRecord = document.documents_in_publications[0]?.publications 122 125 .record as PubLeafletPublication.Record; 123 126 124 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 125 127 let prerenderedCodeBlocks = await extractCodeBlocks(blocks); 126 128 127 129 return ( ··· 152 154 on chrome, if you scroll backward, things stop working 153 155 seems like if you use an older browser, sel direction is not a thing yet 154 156 */} 155 - <PageLayout> 156 - <PostPage 157 + <LeafletLayout> 158 + <PostPages 159 + document_uri={document.uri} 157 160 preferences={pubRecord.preferences || {}} 158 161 pubRecord={pubRecord} 159 162 profile={JSON.parse(JSON.stringify(profile.data))} ··· 163 166 blocks={blocks} 164 167 prerenderedCodeBlocks={prerenderedCodeBlocks} 165 168 /> 166 - <InteractionDrawer 167 - document_uri={document.uri} 168 - comments={ 169 - pubRecord.preferences?.showComments === false 170 - ? [] 171 - : document.comments_on_documents 172 - } 173 - quotes={document.document_mentions_in_bsky} 174 - did={did} 175 - /> 176 - </PageLayout> 169 + </LeafletLayout> 177 170 178 171 <QuoteHandler /> 179 172 </PublicationBackgroundProvider>
+20 -5
app/lish/[did]/[publication]/[rkey]/quotePosition.ts
··· 1 1 export interface QuotePosition { 2 + pageId?: string; 2 3 start: { 3 4 block: number[]; 4 5 offset: number; ··· 14 15 /** 15 16 * Encodes quote position into a URL-friendly string 16 17 * Format: startBlock_startOffset-endBlock_endOffset 18 + * Format with page: pageId~startBlock_startOffset-endBlock_endOffset 17 19 * Block paths are joined with dots: 1.2.0_45-1.2.3_67 18 - * Simple blocks: 0:12-2:45 20 + * Simple blocks: 0_12-2_45 21 + * With page: page1~0_12-2_45 19 22 */ 20 23 export function encodeQuotePosition(position: QuotePosition): string { 21 - const { start, end } = position; 22 - return `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`; 24 + const { pageId, start, end } = position; 25 + const positionStr = `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`; 26 + return pageId ? `${pageId}~${positionStr}` : positionStr; 23 27 } 24 28 25 29 /** ··· 28 32 */ 29 33 export function decodeQuotePosition(encoded: string): QuotePosition | null { 30 34 try { 31 - // Match format: blockPath:number-blockPath:number 35 + // Check for pageId prefix (format: pageId~blockPath_number-blockPath_number) 36 + let pageId: string | undefined; 37 + let positionStr = encoded; 38 + 39 + const tildeIndex = encoded.indexOf("~"); 40 + if (tildeIndex !== -1) { 41 + pageId = encoded.substring(0, tildeIndex); 42 + positionStr = encoded.substring(tildeIndex + 1); 43 + } 44 + 45 + // Match format: blockPath_number-blockPath_number 32 46 // Block paths can be: 5, 1.2, 0.1.3, etc. 33 - const match = encoded.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/); 47 + const match = positionStr.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/); 34 48 35 49 if (!match) { 36 50 return null; ··· 39 53 const [, startBlockPath, startOffset, endBlockPath, endOffset] = match; 40 54 41 55 const position: QuotePosition = { 56 + ...(pageId && { pageId }), 42 57 start: { 43 58 block: startBlockPath.split(".").map((i) => parseInt(i)), 44 59 offset: parseInt(startOffset, 10),
+9 -1
app/lish/[did]/[publication]/[rkey]/useHighlight.tsx
··· 11 11 activeHighlight: null as null | QuotePosition, 12 12 })); 13 13 14 - export const useHighlight = (pos: number[]) => { 14 + export const useHighlight = (pos: number[], pageId?: string) => { 15 15 let doc = useContext(PostPageContext); 16 16 let { quote } = useParams(); 17 17 let activeHighlight = useActiveHighlightState( ··· 23 23 return highlights 24 24 .map((quotePosition) => { 25 25 if (!quotePosition) return null; 26 + // Filter by pageId if provided 27 + if (pageId && quotePosition.pageId !== pageId) { 28 + return null; 29 + } 30 + // If highlight has pageId but block doesn't, skip 31 + if (quotePosition.pageId && !pageId) { 32 + return null; 33 + } 26 34 let maxLength = Math.max( 27 35 quotePosition.start.block.length, 28 36 quotePosition.end.block.length,
-1
components/Blocks/BlockCommands.tsx
··· 330 330 name: "New Page", 331 331 icon: <BlockDocPageSmall />, 332 332 type: "page", 333 - hiddenInPublication: true, 334 333 onSelect: async (rep, props, um) => { 335 334 props.entityID && clearCommandSearchText(props.entityID); 336 335 let entity = await createBlockWithType(rep, props, "card");
+3 -1
components/Blocks/ImageBlock.tsx
··· 140 140 ) : ( 141 141 <Image 142 142 alt={altText || ""} 143 - src={new URL(image.data.src).pathname.split("/").slice(5).join("/")} 143 + src={ 144 + "/" + new URL(image.data.src).pathname.split("/").slice(5).join("/") 145 + } 144 146 height={image?.data.height} 145 147 width={image?.data.width} 146 148 className={className}
-61
components/Blocks/MailboxBlock.tsx
··· 372 372 ); 373 373 }; 374 374 375 - export const DraftPostOptions = (props: { mailboxEntity: string }) => { 376 - let toaster = useToaster(); 377 - let draft = useEntity(props.mailboxEntity, "mailbox/draft"); 378 - let { rep, permission_token } = useReplicache(); 379 - let entity_set = useEntitySetContext(); 380 - let pagetitle = usePageTitle(permission_token.root_entity); 381 - let subscriber_count = useEntity( 382 - props.mailboxEntity, 383 - "mailbox/subscriber-count", 384 - ); 385 - if (!draft) return null; 386 - 387 - // once the send button is clicked, close the page and show a toast. 388 - return ( 389 - <div className="flex justify-between items-center text-sm"> 390 - <div className="flex gap-2"> 391 - <em>Draft</em> 392 - </div> 393 - <button 394 - className="font-bold text-accent-2 bg-accent-1 border hover:bg-accent-2 hover:text-accent-1 rounded-md px-2" 395 - onClick={async () => { 396 - if (!rep) return; 397 - let blocks = 398 - (await rep?.query((tx) => 399 - getBlocksWithType(tx, draft.data.value), 400 - )) || []; 401 - let html = (await getBlocksAsHTML(rep, blocks))?.join("\n"); 402 - await sendPostToSubscribers({ 403 - title: pagetitle, 404 - permission_token, 405 - mailboxEntity: props.mailboxEntity, 406 - messageEntity: draft.data.value, 407 - contents: { 408 - html, 409 - markdown: htmlToMarkdown(html), 410 - }, 411 - }); 412 - 413 - rep?.mutate.archiveDraft({ 414 - entity_set: entity_set.set, 415 - mailboxEntity: props.mailboxEntity, 416 - newBlockEntity: v7(), 417 - archiveEntity: v7(), 418 - }); 419 - 420 - toaster({ 421 - content: <div className="font-bold">Sent Post to Readers!</div>, 422 - type: "success", 423 - }); 424 - }} 425 - > 426 - Send 427 - {!subscriber_count || 428 - (subscriber_count.data.value !== 0 && 429 - ` to ${subscriber_count.data.value} Reader${subscriber_count.data.value === 1 ? "" : "s"}`)} 430 - ! 431 - </button> 432 - </div> 433 - ); 434 - }; 435 - 436 375 const GoToArchive = (props: { 437 376 entityID: string; 438 377 parent: string;
+1 -4
components/Canvas.tsx
··· 53 53 id={elementId.page(props.entityID).canvasScrollArea} 54 54 className={` 55 55 canvasWrapper 56 - h-full w-fit mx-auto 57 - max-w-[calc(100vw-12px)] 58 - ${!narrowWidth ? "sm:max-w-[calc(100vw-128px)] lg:max-w-[calc(var(--page-width-units)*2 + 24px))]" : " sm:max-w-(--page-width-units)"} 59 - rounded-lg 56 + h-full w-fit 60 57 overflow-y-scroll 61 58 `} 62 59 >
+58
components/LeafletLayout.tsx
··· 1 + export const LeafletLayout = (props: { 2 + children: React.ReactNode; 3 + className?: string; 4 + }) => { 5 + return ( 6 + <div 7 + className={` 8 + leafetLayout 9 + w-full h-full relative 10 + mx-auto pwa-padding 11 + flex items-stretch grow`} 12 + id="page-carousel" 13 + > 14 + {/* if you adjust this padding, remember to adjust the negative margins on page in components/Pages/Page.tsx in pageScrollWrapper when card borders are hidden */} 15 + <div 16 + id="pages" 17 + className={`pagesWrapper 18 + w-full h-full 19 + flex gap-0 20 + py-2 sm:py-6 21 + overflow-y-hidden overflow-x-scroll snap-x snap-mandatory no-scrollbar 22 + ${props.className}`} 23 + > 24 + {props.children} 25 + </div> 26 + </div> 27 + ); 28 + }; 29 + 30 + export const BookendSpacer = (props: { 31 + onClick?: (e: React.MouseEvent) => void; 32 + children?: React.ReactNode; 33 + }) => { 34 + // these spacers go at the end of the first and last pages so that those pages can be scrolled to the center of the screen 35 + return ( 36 + <div 37 + className="spacer shrink-0 flex justify-end items-start" 38 + style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }} 39 + onClick={props.onClick ? props.onClick : () => {}} 40 + > 41 + {props.children} 42 + </div> 43 + ); 44 + }; 45 + 46 + export const SandwichSpacer = (props: { 47 + onClick?: (e: React.MouseEvent) => void; 48 + noWidth?: boolean; 49 + className?: string; 50 + }) => { 51 + // these spacers are used between pages so that the page carousel can fit two pages side by side by snapping in between pages 52 + return ( 53 + <div 54 + onClick={props.onClick} 55 + className={`spacer shrink-0 lg:snap-center ${props.noWidth ? "w-0" : "w-6"} ${props.className}`} 56 + /> 57 + ); 58 + };
+217
components/Pages/Page.tsx
··· 1 + "use client"; 2 + 3 + import React from "react"; 4 + import { useUIState } from "src/useUIState"; 5 + 6 + import { elementId } from "src/utils/elementId"; 7 + 8 + import { useEntity, useReferenceToEntity, useReplicache } from "src/replicache"; 9 + 10 + import { DesktopPageFooter } from "../DesktopFooter"; 11 + import { Canvas } from "../Canvas"; 12 + import { Blocks } from "components/Blocks"; 13 + import { PublicationMetadata } from "./PublicationMetadata"; 14 + import { useCardBorderHidden } from "./useCardBorderHidden"; 15 + import { focusPage } from "."; 16 + import { PageOptions } from "./PageOptions"; 17 + import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 18 + import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; 19 + 20 + export function Page(props: { 21 + entityID: string; 22 + first?: boolean; 23 + fullPageScroll: boolean; 24 + }) { 25 + let { rep } = useReplicache(); 26 + 27 + let isFocused = useUIState((s) => { 28 + let focusedElement = s.focusedEntity; 29 + let focusedPageID = 30 + focusedElement?.entityType === "page" 31 + ? focusedElement.entityID 32 + : focusedElement?.parent; 33 + return focusedPageID === props.entityID; 34 + }); 35 + let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 36 + let canvasNarrow = 37 + pageType === "canvas" && 38 + useEntity(props.entityID, "canvas/narrow-width")?.data.value; 39 + let cardBorderHidden = useCardBorderHidden(props.entityID); 40 + let drawerOpen = useDrawerOpen(props.entityID); 41 + return ( 42 + <CardThemeProvider entityID={props.entityID}> 43 + <PageWrapper 44 + onClickAction={(e) => { 45 + if (e.defaultPrevented) return; 46 + if (rep) { 47 + if (isFocused) return; 48 + focusPage(props.entityID, rep); 49 + } 50 + }} 51 + id={elementId.page(props.entityID).container} 52 + drawerOpen={!!drawerOpen} 53 + cardBorderHidden={!!cardBorderHidden} 54 + isFocused={isFocused} 55 + fullPageScroll={props.fullPageScroll} 56 + pageType={pageType} 57 + canvasNarrow={canvasNarrow} 58 + pageOptions={ 59 + <PageOptions 60 + entityID={props.entityID} 61 + first={props.first} 62 + isFocused={isFocused} 63 + /> 64 + } 65 + > 66 + {props.first && ( 67 + <> 68 + <PublicationMetadata cardBorderHidden={!!cardBorderHidden} /> 69 + </> 70 + )} 71 + <PageContent entityID={props.entityID} /> 72 + </PageWrapper> 73 + <DesktopPageFooter pageID={props.entityID} /> 74 + </CardThemeProvider> 75 + ); 76 + } 77 + 78 + export const PageWrapper = (props: { 79 + id: string; 80 + children: React.ReactNode; 81 + pageOptions?: React.ReactNode; 82 + cardBorderHidden: boolean; 83 + fullPageScroll: boolean; 84 + isFocused?: boolean; 85 + onClickAction?: (e: React.MouseEvent) => void; 86 + pageType: "canvas" | "doc"; 87 + canvasNarrow?: boolean | undefined; 88 + drawerOpen: boolean | undefined; 89 + }) => { 90 + return ( 91 + // this div wraps the contents AND the page options. 92 + // it needs to be its own div because this container does NOT scroll, and therefore doesn't clip the absolutely positioned pageOptions 93 + <div 94 + className={`pageWrapper relative shrink-0 ${props.fullPageScroll ? "w-full" : "w-max"}`} 95 + > 96 + {/* 97 + this div is the scrolling container that wraps only the contents div. 98 + 99 + it needs to be a separate div so that the user can scroll from anywhere on the page if there isn't a card border 100 + */} 101 + <div 102 + onClick={props.onClickAction} 103 + id={props.id} 104 + className={` 105 + pageScrollWrapper 106 + grow 107 + 108 + shrink-0 snap-center 109 + overflow-y-scroll 110 + ${ 111 + !props.cardBorderHidden && 112 + `h-full border 113 + bg-[rgba(var(--bg-page),var(--bg-page-alpha))] 114 + ${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"} 115 + ${props.isFocused ? "shadow-md border-border" : "border-border-light"}` 116 + } 117 + ${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 118 + ${props.fullPageScroll && "max-w-full "} 119 + ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 120 + ${ 121 + props.pageType === "canvas" && 122 + !props.fullPageScroll && 123 + (props.canvasNarrow 124 + ? "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]" 125 + : "sm:max-w-[calc(100vw-128px)] lg:max-w-fit lg:w-[calc(var(--page-width-units)*2 + 24px))]") 126 + } 127 + 128 + `} 129 + > 130 + <div 131 + className={`postPageContent 132 + ${props.fullPageScroll ? "sm:max-w-[var(--page-width-units)] mx-auto" : "w-full h-full"} 133 + `} 134 + > 135 + {props.children} 136 + </div> 137 + </div> 138 + {props.pageOptions} 139 + </div> 140 + ); 141 + }; 142 + // ${narrowWidth ? " sm:max-w-(--page-width-units)" : } 143 + const PageContent = (props: { entityID: string }) => { 144 + let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 145 + if (pageType === "doc") return <DocContent entityID={props.entityID} />; 146 + return <Canvas entityID={props.entityID} />; 147 + }; 148 + 149 + const DocContent = (props: { entityID: string }) => { 150 + let { rootEntity } = useReplicache(); 151 + 152 + let cardBorderHidden = useCardBorderHidden(props.entityID); 153 + let rootBackgroundImage = useEntity( 154 + rootEntity, 155 + "theme/card-background-image", 156 + ); 157 + let rootBackgroundRepeat = useEntity( 158 + rootEntity, 159 + "theme/card-background-image-repeat", 160 + ); 161 + let rootBackgroundOpacity = useEntity( 162 + rootEntity, 163 + "theme/card-background-image-opacity", 164 + ); 165 + 166 + let cardBackgroundImage = useEntity( 167 + props.entityID, 168 + "theme/card-background-image", 169 + ); 170 + 171 + let cardBackgroundImageRepeat = useEntity( 172 + props.entityID, 173 + "theme/card-background-image-repeat", 174 + ); 175 + 176 + let cardBackgroundImageOpacity = useEntity( 177 + props.entityID, 178 + "theme/card-background-image-opacity", 179 + ); 180 + 181 + let backgroundImage = cardBackgroundImage || rootBackgroundImage; 182 + let backgroundImageRepeat = cardBackgroundImage 183 + ? cardBackgroundImageRepeat?.data?.value 184 + : rootBackgroundRepeat?.data.value; 185 + let backgroundImageOpacity = cardBackgroundImage 186 + ? cardBackgroundImageOpacity?.data.value 187 + : rootBackgroundOpacity?.data.value || 1; 188 + 189 + return ( 190 + <> 191 + {!cardBorderHidden ? ( 192 + <div 193 + className={`pageBackground 194 + absolute top-0 left-0 right-0 bottom-0 195 + pointer-events-none 196 + rounded-lg 197 + `} 198 + style={{ 199 + backgroundImage: backgroundImage 200 + ? `url(${backgroundImage.data.src}), url(${backgroundImage.data.fallback})` 201 + : undefined, 202 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 203 + backgroundPosition: "center", 204 + backgroundSize: !backgroundImageRepeat 205 + ? "cover" 206 + : backgroundImageRepeat, 207 + opacity: backgroundImage?.data.src ? backgroundImageOpacity : 1, 208 + }} 209 + /> 210 + ) : null} 211 + <Blocks entityID={props.entityID} /> 212 + {/* we handle page bg in this sepate div so that 213 + we can apply an opacity the background image 214 + without affecting the opacity of the rest of the page */} 215 + </> 216 + ); 217 + };
+217
components/Pages/PageOptions.tsx
··· 1 + "use client"; 2 + 3 + import React, { JSX, useState } from "react"; 4 + import { useUIState } from "src/useUIState"; 5 + import { useEntitySetContext } from "../EntitySetProvider"; 6 + 7 + import { useReplicache } from "src/replicache"; 8 + 9 + import { Media } from "../Media"; 10 + import { MenuItem, Menu } from "../Layout"; 11 + import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 12 + import { PageShareMenu } from "./PageShareMenu"; 13 + import { useUndoState } from "src/undoManager"; 14 + import { CloseTiny } from "components/Icons/CloseTiny"; 15 + import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 16 + import { PaintSmall } from "components/Icons/PaintSmall"; 17 + import { ShareSmall } from "components/Icons/ShareSmall"; 18 + import { useCardBorderHidden } from "./useCardBorderHidden"; 19 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 20 + 21 + export const PageOptionButton = ({ 22 + children, 23 + secondary, 24 + cardBorderHidden, 25 + className, 26 + disabled, 27 + ...props 28 + }: { 29 + children: React.ReactNode; 30 + secondary?: boolean; 31 + cardBorderHidden: boolean | undefined; 32 + className?: string; 33 + disabled?: boolean; 34 + } & Omit<JSX.IntrinsicElements["button"], "content">) => { 35 + return ( 36 + <button 37 + className={` 38 + pageOptionsTrigger 39 + shrink-0 40 + pt-[2px] h-5 w-5 p-0.5 mx-auto 41 + border border-border 42 + ${secondary ? "bg-border text-bg-page" : "bg-bg-page text-border"} 43 + ${disabled && "opacity-50"} 44 + ${cardBorderHidden ? "rounded-md" : `rounded-b-md sm:rounded-l-none sm:rounded-r-md`} 45 + flex items-center justify-center 46 + ${className} 47 + 48 + `} 49 + {...props} 50 + > 51 + {children} 52 + </button> 53 + ); 54 + }; 55 + 56 + export const PageOptions = (props: { 57 + entityID: string; 58 + first: boolean | undefined; 59 + isFocused: boolean; 60 + }) => { 61 + let cardBorderHidden = useCardBorderHidden(props.entityID); 62 + 63 + return ( 64 + <div 65 + className={`pageOptions w-fit z-10 66 + ${props.isFocused ? "block" : "sm:hidden block"} 67 + absolute sm:-right-[20px] right-3 sm:top-3 top-0 68 + flex sm:flex-col flex-row-reverse gap-1 items-start`} 69 + > 70 + {!props.first && ( 71 + <PageOptionButton 72 + cardBorderHidden={cardBorderHidden} 73 + secondary 74 + onClick={() => { 75 + useUIState.getState().closePage(props.entityID); 76 + }} 77 + > 78 + <CloseTiny /> 79 + </PageOptionButton> 80 + )} 81 + <OptionsMenu 82 + entityID={props.entityID} 83 + first={!!props.first} 84 + cardBorderHidden={cardBorderHidden} 85 + /> 86 + <UndoButtons cardBorderHidden={cardBorderHidden} /> 87 + </div> 88 + ); 89 + }; 90 + 91 + export const UndoButtons = (props: { 92 + cardBorderHidden: boolean | undefined; 93 + }) => { 94 + let undoState = useUndoState(); 95 + let { undoManager } = useReplicache(); 96 + return ( 97 + <Media mobile> 98 + {undoState.canUndo && ( 99 + <div className="gap-1 flex sm:flex-col"> 100 + <PageOptionButton 101 + secondary 102 + cardBorderHidden={props.cardBorderHidden} 103 + onClick={() => undoManager.undo()} 104 + > 105 + <UndoTiny /> 106 + </PageOptionButton> 107 + 108 + <PageOptionButton 109 + secondary 110 + cardBorderHidden={props.cardBorderHidden} 111 + onClick={() => undoManager.undo()} 112 + disabled={!undoState.canRedo} 113 + > 114 + <RedoTiny /> 115 + </PageOptionButton> 116 + </div> 117 + )} 118 + </Media> 119 + ); 120 + }; 121 + 122 + export const OptionsMenu = (props: { 123 + entityID: string; 124 + first: boolean; 125 + cardBorderHidden: boolean | undefined; 126 + }) => { 127 + let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 128 + let { permissions } = useEntitySetContext(); 129 + if (!permissions.write) return null; 130 + 131 + let { data: pub, mutate } = useLeafletPublicationData(); 132 + if (pub && props.first) return; 133 + return ( 134 + <Menu 135 + align="end" 136 + asChild 137 + onOpenChange={(open) => { 138 + if (!open) setState("normal"); 139 + }} 140 + trigger={ 141 + <PageOptionButton 142 + cardBorderHidden={props.cardBorderHidden} 143 + className="!w-8 !h-5 sm:!w-5 sm:!h-8" 144 + > 145 + <MoreOptionsTiny className="sm:rotate-90" /> 146 + </PageOptionButton> 147 + } 148 + > 149 + {state === "normal" ? ( 150 + <> 151 + {!props.first && ( 152 + <MenuItem 153 + onSelect={(e) => { 154 + e.preventDefault(); 155 + setState("share"); 156 + }} 157 + > 158 + <ShareSmall /> Share Page 159 + </MenuItem> 160 + )} 161 + {!pub && ( 162 + <MenuItem 163 + onSelect={(e) => { 164 + e.preventDefault(); 165 + setState("theme"); 166 + }} 167 + > 168 + <PaintSmall /> Theme Page 169 + </MenuItem> 170 + )} 171 + </> 172 + ) : state === "theme" ? ( 173 + <PageThemeSetter entityID={props.entityID} /> 174 + ) : state === "share" ? ( 175 + <PageShareMenu entityID={props.entityID} /> 176 + ) : null} 177 + </Menu> 178 + ); 179 + }; 180 + 181 + const UndoTiny = () => { 182 + return ( 183 + <svg 184 + width="16" 185 + height="16" 186 + viewBox="0 0 16 16" 187 + fill="none" 188 + xmlns="http://www.w3.org/2000/svg" 189 + > 190 + <path 191 + fillRule="evenodd" 192 + clipRule="evenodd" 193 + d="M5.98775 3.14543C6.37828 2.75491 6.37828 2.12174 5.98775 1.73122C5.59723 1.34069 4.96407 1.34069 4.57354 1.73122L1.20732 5.09744C0.816798 5.48796 0.816798 6.12113 1.20732 6.51165L4.57354 9.87787C4.96407 10.2684 5.59723 10.2684 5.98775 9.87787C6.37828 9.48735 6.37828 8.85418 5.98775 8.46366L4.32865 6.80456H9.6299C12.1732 6.80456 13.0856 8.27148 13.0856 9.21676C13.0856 9.84525 12.8932 10.5028 12.5318 10.9786C12.1942 11.4232 11.6948 11.7367 10.9386 11.7367H9.43173C8.87944 11.7367 8.43173 12.1844 8.43173 12.7367C8.43173 13.2889 8.87944 13.7367 9.43173 13.7367H10.9386C12.3587 13.7367 13.4328 13.0991 14.1246 12.1883C14.7926 11.3086 15.0856 10.2062 15.0856 9.21676C15.0856 6.92612 13.0205 4.80456 9.6299 4.80456L4.32863 4.80456L5.98775 3.14543Z" 194 + fill="currentColor" 195 + /> 196 + </svg> 197 + ); 198 + }; 199 + 200 + const RedoTiny = () => { 201 + return ( 202 + <svg 203 + width="16" 204 + height="16" 205 + viewBox="0 0 16 16" 206 + fill="none" 207 + xmlns="http://www.w3.org/2000/svg" 208 + > 209 + <path 210 + fillRule="evenodd" 211 + clipRule="evenodd" 212 + d="M10.0122 3.14543C9.62172 2.75491 9.62172 2.12174 10.0122 1.73122C10.4028 1.34069 11.0359 1.34069 11.4265 1.73122L14.7927 5.09744C15.1832 5.48796 15.1832 6.12113 14.7927 6.51165L11.4265 9.87787C11.0359 10.2684 10.4028 10.2684 10.0122 9.87787C9.62172 9.48735 9.62172 8.85418 10.0122 8.46366L11.6713 6.80456H6.3701C3.82678 6.80456 2.91443 8.27148 2.91443 9.21676C2.91443 9.84525 3.10681 10.5028 3.46817 10.9786C3.8058 11.4232 4.30523 11.7367 5.06143 11.7367H6.56827C7.12056 11.7367 7.56827 12.1844 7.56827 12.7367C7.56827 13.2889 7.12056 13.7367 6.56827 13.7367H5.06143C3.6413 13.7367 2.56723 13.0991 1.87544 12.1883C1.20738 11.3086 0.914429 10.2062 0.914429 9.21676C0.914429 6.92612 2.97946 4.80456 6.3701 4.80456L11.6714 4.80456L10.0122 3.14543Z" 213 + fill="currentColor" 214 + /> 215 + </svg> 216 + ); 217 + };
+2 -4
components/Pages/PublicationMetadata.tsx
··· 36 36 description = pub?.description || ""; 37 37 } 38 38 return ( 39 - <div 40 - className={`flex flex-col px-3 sm:px-4 pb-5 ${cardBorderHidden ? "sm:pt-6 pt-0" : "sm:pt-3 pt-2"}`} 41 - > 39 + <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 42 40 <div className="flex gap-2"> 43 41 <Link 44 42 href={`${getBasePublicationURL(pub.publications)}/dashboard`} 45 - className="text-accent-contrast font-bold hover:no-underline" 43 + className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 46 44 > 47 45 {pub.publications?.name} 48 46 </Link>
+30 -402
components/Pages/index.tsx
··· 1 1 "use client"; 2 2 3 - import React, { JSX, useState } from "react"; 3 + import React from "react"; 4 4 import { useUIState } from "src/useUIState"; 5 - import { useEntitySetContext } from "../EntitySetProvider"; 6 5 import { useSearchParams } from "next/navigation"; 7 6 8 7 import { focusBlock } from "src/utils/focusBlock"; 9 8 import { elementId } from "src/utils/elementId"; 10 9 11 10 import { Replicache } from "replicache"; 12 - import { 13 - Fact, 14 - ReplicacheMutators, 15 - useEntity, 16 - useReferenceToEntity, 17 - useReplicache, 18 - } from "src/replicache"; 11 + import { Fact, ReplicacheMutators, useEntity } from "src/replicache"; 19 12 20 - import { Media } from "../Media"; 21 - import { DesktopPageFooter } from "../DesktopFooter"; 22 - import { ThemePopover } from "../ThemeManager/ThemeSetter"; 23 - import { Canvas } from "../Canvas"; 24 - import { DraftPostOptions } from "../Blocks/MailboxBlock"; 25 - import { Blocks } from "components/Blocks"; 26 - import { MenuItem, Menu } from "../Layout"; 27 13 import { scanIndex } from "src/replicache/utils"; 28 - import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 29 14 import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; 30 - import { PageShareMenu } from "./PageShareMenu"; 31 15 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 32 - import { useUndoState } from "src/undoManager"; 33 - import { CloseTiny } from "components/Icons/CloseTiny"; 34 - import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 35 - import { PaintSmall } from "components/Icons/PaintSmall"; 36 - import { ShareSmall } from "components/Icons/ShareSmall"; 37 - import { PublicationMetadata } from "./PublicationMetadata"; 38 16 import { useCardBorderHidden } from "./useCardBorderHidden"; 39 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 17 + import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 18 + import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; 19 + import { Page } from "./Page"; 40 20 41 21 export function Pages(props: { rootPage: string }) { 42 22 let rootPage = useEntity(props.rootPage, "root/page")[0]; ··· 44 24 let params = useSearchParams(); 45 25 let queryRoot = params.get("page"); 46 26 let firstPage = queryRoot || rootPage?.data.value || props.rootPage; 47 - 48 - return ( 49 - <> 50 - <div className="flex items-stretch"> 51 - <CardThemeProvider entityID={firstPage}> 52 - <Page entityID={firstPage} first /> 53 - </CardThemeProvider> 54 - </div> 55 - {pages.map((page) => ( 56 - <div className="flex items-stretch" key={page}> 57 - <CardThemeProvider entityID={page}> 58 - <Page entityID={page} /> 59 - </CardThemeProvider> 60 - </div> 61 - ))} 62 - <div 63 - className="spacer" 64 - style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }} 65 - onClick={(e) => { 66 - e.currentTarget === e.target && blurPage(); 67 - }} 68 - /> 69 - </> 70 - ); 71 - } 72 - 73 - export const LeafletOptions = (props: { entityID: string }) => { 74 - return ( 75 - <> 76 - <ThemePopover entityID={props.entityID} /> 77 - </> 78 - ); 79 - }; 27 + let cardBorderHidden = useCardBorderHidden(rootPage.id); 28 + let firstPageIsCanvas = useEntity(firstPage, "page/type"); 29 + let fullPageScroll = 30 + !!cardBorderHidden && pages.length === 0 && !firstPageIsCanvas; 80 31 81 - function Page(props: { entityID: string; first?: boolean }) { 82 - let { rep, rootEntity } = useReplicache(); 83 - let isDraft = useReferenceToEntity("mailbox/draft", props.entityID); 84 - 85 - let isFocused = useUIState((s) => { 86 - let focusedElement = s.focusedEntity; 87 - let focusedPageID = 88 - focusedElement?.entityType === "page" 89 - ? focusedElement.entityID 90 - : focusedElement?.parent; 91 - return focusedPageID === props.entityID; 92 - }); 93 - let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 94 - let cardBorderHidden = useCardBorderHidden(props.entityID); 95 32 return ( 96 33 <> 97 - {!props.first && ( 98 - <div 99 - className="w-6 lg:snap-center" 34 + <LeafletSidebar /> 35 + {!fullPageScroll && ( 36 + <BookendSpacer 100 37 onClick={(e) => { 101 38 e.currentTarget === e.target && blurPage(); 102 39 }} 103 40 /> 104 41 )} 105 - <div className="pageWrapper w-fit flex relative snap-center"> 106 - <div 107 - onClick={(e) => { 108 - if (e.defaultPrevented) return; 109 - if (rep) { 110 - if (isFocused) return; 111 - focusPage(props.entityID, rep); 112 - } 113 - }} 114 - id={elementId.page(props.entityID).container} 115 - style={{ 116 - width: pageType === "doc" ? "var(--page-width-units)" : undefined, 117 - backgroundColor: cardBorderHidden 118 - ? "" 119 - : "rgba(var(--bg-page), var(--bg-page-alpha))", 120 - }} 121 - className={` 122 - ${pageType === "canvas" ? "!lg:max-w-[1152px]" : "max-w-(--page-width-units)"} 123 - page 124 - grow flex flex-col 125 - overscroll-y-none 126 - overflow-y-auto 127 - ${cardBorderHidden ? "border-0 shadow-none! sm:-mt-6 sm:-mb-12 -mt-2 -mb-1 pt-3 " : "border rounded-lg"} 128 - ${isFocused ? "shadow-md border-border" : "border-border-light"} 129 - `} 130 - > 131 - <Media mobile={true}> 132 - <PageOptions entityID={props.entityID} first={props.first} /> 133 - </Media> 134 - <DesktopPageFooter pageID={props.entityID} /> 135 - {isDraft.length > 0 && ( 136 - <div 137 - className={`pageStatus pt-[6px] pb-1 ${!props.first ? "pr-10 pl-3 sm:px-4" : "px-3 sm:px-4"} border-b border-border text-tertiary`} 138 - style={{ 139 - backgroundColor: 140 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 141 - }} 142 - > 143 - <DraftPostOptions mailboxEntity={isDraft[0].entity} /> 144 - </div> 145 - )} 146 42 147 - <PageContent entityID={props.entityID} /> 148 - </div> 149 - <Media mobile={false}> 150 - {isFocused && ( 151 - <PageOptions entityID={props.entityID} first={props.first} /> 152 - )} 153 - </Media> 154 - </div> 155 - </> 156 - ); 157 - } 158 - 159 - const PageContent = (props: { entityID: string }) => { 160 - let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 161 - if (pageType === "doc") return <DocContent entityID={props.entityID} />; 162 - return <Canvas entityID={props.entityID} />; 163 - }; 164 - 165 - const DocContent = (props: { entityID: string }) => { 166 - let { rootEntity } = useReplicache(); 167 - let isFocused = useUIState((s) => { 168 - let focusedElement = s.focusedEntity; 169 - let focusedPageID = 170 - focusedElement?.entityType === "page" 171 - ? focusedElement.entityID 172 - : focusedElement?.parent; 173 - return focusedPageID === props.entityID; 174 - }); 175 - 176 - let cardBorderHidden = useCardBorderHidden(props.entityID); 177 - let rootBackgroundImage = useEntity( 178 - rootEntity, 179 - "theme/card-background-image", 180 - ); 181 - let rootBackgroundRepeat = useEntity( 182 - rootEntity, 183 - "theme/card-background-image-repeat", 184 - ); 185 - let rootBackgroundOpacity = useEntity( 186 - rootEntity, 187 - "theme/card-background-image-opacity", 188 - ); 189 - 190 - let cardBackgroundImage = useEntity( 191 - props.entityID, 192 - "theme/card-background-image", 193 - ); 194 - 195 - let cardBackgroundImageRepeat = useEntity( 196 - props.entityID, 197 - "theme/card-background-image-repeat", 198 - ); 199 - 200 - let cardBackgroundImageOpacity = useEntity( 201 - props.entityID, 202 - "theme/card-background-image-opacity", 203 - ); 204 - 205 - let backgroundImage = cardBackgroundImage || rootBackgroundImage; 206 - let backgroundImageRepeat = cardBackgroundImage 207 - ? cardBackgroundImageRepeat?.data?.value 208 - : rootBackgroundRepeat?.data.value; 209 - let backgroundImageOpacity = cardBackgroundImage 210 - ? cardBackgroundImageOpacity?.data.value 211 - : rootBackgroundOpacity?.data.value || 1; 212 - 213 - return ( 214 - <> 215 - {!cardBorderHidden ? ( 216 - <div 217 - className={`pageBackground 218 - absolute top-0 left-0 right-0 bottom-0 219 - pointer-events-none 220 - rounded-lg border 221 - ${isFocused ? " border-border" : "border-border-light"} 222 - `} 223 - style={{ 224 - backgroundImage: backgroundImage 225 - ? `url(${backgroundImage.data.src}), url(${backgroundImage.data.fallback})` 226 - : undefined, 227 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 228 - backgroundPosition: "center", 229 - backgroundSize: !backgroundImageRepeat 230 - ? "cover" 231 - : backgroundImageRepeat, 232 - opacity: backgroundImage?.data.src ? backgroundImageOpacity : 1, 43 + <Page entityID={firstPage} first fullPageScroll={fullPageScroll} /> 44 + {pages.map((page) => ( 45 + <React.Fragment key={page}> 46 + <SandwichSpacer 47 + onClick={(e) => { 48 + e.currentTarget === e.target && blurPage(); 49 + }} 50 + /> 51 + <Page entityID={page} fullPageScroll={false} /> 52 + </React.Fragment> 53 + ))} 54 + {!fullPageScroll && ( 55 + <BookendSpacer 56 + onClick={(e) => { 57 + e.currentTarget === e.target && blurPage(); 233 58 }} 234 59 /> 235 - ) : null} 236 - <PublicationMetadata cardBorderHidden={!!cardBorderHidden} /> 237 - <Blocks entityID={props.entityID} /> 238 - {/* we handle page bg in this sepate div so that 239 - we can apply an opacity the background image 240 - without affecting the opacity of the rest of the page */} 241 - </> 242 - ); 243 - }; 244 - 245 - const PageOptionButton = ({ 246 - children, 247 - secondary, 248 - cardBorderHidden, 249 - className, 250 - disabled, 251 - ...props 252 - }: { 253 - children: React.ReactNode; 254 - secondary?: boolean; 255 - cardBorderHidden: boolean | undefined; 256 - className?: string; 257 - disabled?: boolean; 258 - } & Omit<JSX.IntrinsicElements["button"], "content">) => { 259 - return ( 260 - <button 261 - className={` 262 - pageOptionsTrigger 263 - shrink-0 264 - pt-[2px] h-5 w-5 p-0.5 mx-auto 265 - border border-border 266 - ${secondary ? "bg-border text-bg-page" : "bg-bg-page text-border"} 267 - ${disabled && "opacity-50"} 268 - ${cardBorderHidden ? "rounded-md" : `rounded-b-md sm:rounded-l-none sm:rounded-r-md`} 269 - flex items-center justify-center 270 - ${className} 271 - 272 - `} 273 - {...props} 274 - > 275 - {children} 276 - </button> 277 - ); 278 - }; 279 - 280 - const PageOptions = (props: { 281 - entityID: string; 282 - first: boolean | undefined; 283 - }) => { 284 - let { rootEntity } = useReplicache(); 285 - let cardBorderHidden = useCardBorderHidden(props.entityID); 286 - 287 - return ( 288 - <div 289 - className={`z-10 w-fit absolute ${cardBorderHidden ? "top-1" : "sm:top-3"} sm:-right-[19px] top-0 right-3 flex sm:flex-col flex-row-reverse gap-1 items-start`} 290 - > 291 - {!props.first && ( 292 - <PageOptionButton 293 - cardBorderHidden={cardBorderHidden} 294 - secondary 295 - onClick={() => { 296 - useUIState.getState().closePage(props.entityID); 297 - }} 298 - > 299 - <CloseTiny /> 300 - </PageOptionButton> 301 60 )} 302 - <OptionsMenu 303 - entityID={props.entityID} 304 - first={!!props.first} 305 - cardBorderHidden={cardBorderHidden} 306 - /> 307 - <UndoButtons cardBorderHidden={cardBorderHidden} /> 308 - </div> 309 - ); 310 - }; 311 - 312 - const UndoButtons = (props: { cardBorderHidden: boolean | undefined }) => { 313 - let undoState = useUndoState(); 314 - let { undoManager } = useReplicache(); 315 - return ( 316 - <Media mobile> 317 - {undoState.canUndo && ( 318 - <div className="gap-1 flex sm:flex-col"> 319 - <PageOptionButton 320 - secondary 321 - cardBorderHidden={props.cardBorderHidden} 322 - onClick={() => undoManager.undo()} 323 - > 324 - <UndoTiny /> 325 - </PageOptionButton> 326 - 327 - <PageOptionButton 328 - secondary 329 - cardBorderHidden={props.cardBorderHidden} 330 - onClick={() => undoManager.undo()} 331 - disabled={!undoState.canRedo} 332 - > 333 - <RedoTiny /> 334 - </PageOptionButton> 335 - </div> 336 - )} 337 - </Media> 338 - ); 339 - }; 340 - 341 - const OptionsMenu = (props: { 342 - entityID: string; 343 - first: boolean; 344 - cardBorderHidden: boolean | undefined; 345 - }) => { 346 - let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 347 - let { permissions } = useEntitySetContext(); 348 - if (!permissions.write) return null; 349 - 350 - let { data: pub, mutate } = useLeafletPublicationData(); 351 - if (pub && props.first) return; 352 - return ( 353 - <Menu 354 - align="end" 355 - asChild 356 - onOpenChange={(open) => { 357 - if (!open) setState("normal"); 358 - }} 359 - trigger={ 360 - <PageOptionButton 361 - cardBorderHidden={props.cardBorderHidden} 362 - className="w-8! h-5! sm:w-5! sm:h-8!" 363 - > 364 - <MoreOptionsTiny className="sm:rotate-90" /> 365 - </PageOptionButton> 366 - } 367 - > 368 - {state === "normal" ? ( 369 - <> 370 - {!props.first && ( 371 - <MenuItem 372 - onSelect={(e) => { 373 - e.preventDefault(); 374 - setState("share"); 375 - }} 376 - > 377 - <ShareSmall /> Share Page 378 - </MenuItem> 379 - )} 380 - {!pub && ( 381 - <MenuItem 382 - onSelect={(e) => { 383 - e.preventDefault(); 384 - setState("theme"); 385 - }} 386 - > 387 - <PaintSmall /> Theme Page 388 - </MenuItem> 389 - )} 390 - </> 391 - ) : state === "theme" ? ( 392 - <PageThemeSetter entityID={props.entityID} /> 393 - ) : state === "share" ? ( 394 - <PageShareMenu entityID={props.entityID} /> 395 - ) : null} 396 - </Menu> 61 + </> 397 62 ); 398 - }; 63 + } 399 64 400 65 export async function focusPage( 401 66 pageID: string, ··· 463 128 }, 50); 464 129 } 465 130 466 - const blurPage = () => { 131 + export const blurPage = () => { 467 132 useUIState.setState(() => ({ 468 133 focusedEntity: null, 469 134 selectedBlocks: [], 470 135 })); 471 136 }; 472 - const UndoTiny = () => { 473 - return ( 474 - <svg 475 - width="16" 476 - height="16" 477 - viewBox="0 0 16 16" 478 - fill="none" 479 - xmlns="http://www.w3.org/2000/svg" 480 - > 481 - <path 482 - fillRule="evenodd" 483 - clipRule="evenodd" 484 - d="M5.98775 3.14543C6.37828 2.75491 6.37828 2.12174 5.98775 1.73122C5.59723 1.34069 4.96407 1.34069 4.57354 1.73122L1.20732 5.09744C0.816798 5.48796 0.816798 6.12113 1.20732 6.51165L4.57354 9.87787C4.96407 10.2684 5.59723 10.2684 5.98775 9.87787C6.37828 9.48735 6.37828 8.85418 5.98775 8.46366L4.32865 6.80456H9.6299C12.1732 6.80456 13.0856 8.27148 13.0856 9.21676C13.0856 9.84525 12.8932 10.5028 12.5318 10.9786C12.1942 11.4232 11.6948 11.7367 10.9386 11.7367H9.43173C8.87944 11.7367 8.43173 12.1844 8.43173 12.7367C8.43173 13.2889 8.87944 13.7367 9.43173 13.7367H10.9386C12.3587 13.7367 13.4328 13.0991 14.1246 12.1883C14.7926 11.3086 15.0856 10.2062 15.0856 9.21676C15.0856 6.92612 13.0205 4.80456 9.6299 4.80456L4.32863 4.80456L5.98775 3.14543Z" 485 - fill="currentColor" 486 - /> 487 - </svg> 488 - ); 489 - }; 490 - 491 - const RedoTiny = () => { 492 - return ( 493 - <svg 494 - width="16" 495 - height="16" 496 - viewBox="0 0 16 16" 497 - fill="none" 498 - xmlns="http://www.w3.org/2000/svg" 499 - > 500 - <path 501 - fillRule="evenodd" 502 - clipRule="evenodd" 503 - d="M10.0122 3.14543C9.62172 2.75491 9.62172 2.12174 10.0122 1.73122C10.4028 1.34069 11.0359 1.34069 11.4265 1.73122L14.7927 5.09744C15.1832 5.48796 15.1832 6.12113 14.7927 6.51165L11.4265 9.87787C11.0359 10.2684 10.4028 10.2684 10.0122 9.87787C9.62172 9.48735 9.62172 8.85418 10.0122 8.46366L11.6713 6.80456H6.3701C3.82678 6.80456 2.91443 8.27148 2.91443 9.21676C2.91443 9.84525 3.10681 10.5028 3.46817 10.9786C3.8058 11.4232 4.30523 11.7367 5.06143 11.7367H6.56827C7.12056 11.7367 7.56827 12.1844 7.56827 12.7367C7.56827 13.2889 7.12056 13.7367 6.56827 13.7367H5.06143C3.6413 13.7367 2.56723 13.0991 1.87544 12.1883C1.20738 11.3086 0.914429 10.2062 0.914429 9.21676C0.914429 6.92612 2.97946 4.80456 6.3701 4.80456L11.6714 4.80456L10.0122 3.14543Z" 504 - fill="currentColor" 505 - /> 506 - </svg> 507 - ); 508 - };
+2
lexicons/api/index.ts
··· 31 31 import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 32 32 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 33 33 import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 34 + import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 34 35 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 35 36 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 36 37 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' ··· 65 66 export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 66 67 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 67 68 export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 69 + export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 68 70 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 69 71 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 70 72 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website'
+24 -2
lexicons/api/lexicons.ts
··· 1185 1185 }, 1186 1186 }, 1187 1187 }, 1188 + PubLeafletBlocksPage: { 1189 + lexicon: 1, 1190 + id: 'pub.leaflet.blocks.page', 1191 + defs: { 1192 + main: { 1193 + type: 'object', 1194 + required: ['id'], 1195 + properties: { 1196 + id: { 1197 + type: 'string', 1198 + }, 1199 + }, 1200 + }, 1201 + }, 1202 + }, 1188 1203 PubLeafletBlocksText: { 1189 1204 lexicon: 1, 1190 1205 id: 'pub.leaflet.blocks.text', ··· 1310 1325 ref: 'lex:pub.leaflet.richtext.facet', 1311 1326 }, 1312 1327 }, 1328 + onPage: { 1329 + type: 'string', 1330 + }, 1313 1331 attachment: { 1314 1332 type: 'union', 1315 1333 refs: ['lex:pub.leaflet.comment#linearDocumentQuote'], ··· 1422 1440 defs: { 1423 1441 main: { 1424 1442 type: 'object', 1443 + required: ['blocks'], 1425 1444 properties: { 1445 + id: { 1446 + type: 'string', 1447 + }, 1426 1448 blocks: { 1427 1449 type: 'array', 1428 1450 items: { ··· 1450 1472 'lex:pub.leaflet.blocks.code', 1451 1473 'lex:pub.leaflet.blocks.horizontalRule', 1452 1474 'lex:pub.leaflet.blocks.bskyPost', 1475 + 'lex:pub.leaflet.blocks.page', 1453 1476 ], 1454 1477 }, 1455 1478 alignment: { ··· 1521 1544 }, 1522 1545 base_path: { 1523 1546 type: 'string', 1524 - format: 'uri', 1525 1547 }, 1526 1548 description: { 1527 1549 type: 'string', ··· 1661 1683 properties: { 1662 1684 uri: { 1663 1685 type: 'string', 1664 - format: 'uri', 1665 1686 }, 1666 1687 }, 1667 1688 }, ··· 1845 1866 PubLeafletBlocksIframe: 'pub.leaflet.blocks.iframe', 1846 1867 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 1847 1868 PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 1869 + PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 1848 1870 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 1849 1871 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 1850 1872 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website',
+30
lexicons/api/types/pub/leaflet/blocks/page.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.blocks.page' 16 + 17 + export interface Main { 18 + $type?: 'pub.leaflet.blocks.page' 19 + id: string 20 + } 21 + 22 + const hashMain = 'main' 23 + 24 + export function isMain<V>(v: V) { 25 + return is$typed(v, id, hashMain) 26 + } 27 + 28 + export function validateMain<V>(v: V) { 29 + return validate<Main & V>(v, id, hashMain) 30 + }
+1
lexicons/api/types/pub/leaflet/comment.ts
··· 19 19 reply?: ReplyRef 20 20 plaintext: string 21 21 facets?: PubLeafletRichtextFacet.Main[] 22 + onPage?: string 22 23 attachment?: $Typed<LinearDocumentQuote> | { $type: string } 23 24 [k: string]: unknown 24 25 }
+4 -1
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 20 20 import type * as PubLeafletBlocksCode from '../blocks/code' 21 21 import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' 22 22 import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' 23 + import type * as PubLeafletBlocksPage from '../blocks/page' 23 24 24 25 const is$typed = _is$typed, 25 26 validate = _validate ··· 27 28 28 29 export interface Main { 29 30 $type?: 'pub.leaflet.pages.linearDocument' 30 - blocks?: Block[] 31 + id?: string 32 + blocks: Block[] 31 33 } 32 34 33 35 const hashMain = 'main' ··· 54 56 | $Typed<PubLeafletBlocksCode.Main> 55 57 | $Typed<PubLeafletBlocksHorizontalRule.Main> 56 58 | $Typed<PubLeafletBlocksBskyPost.Main> 59 + | $Typed<PubLeafletBlocksPage.Main> 57 60 | { $type: string } 58 61 alignment?: 59 62 | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft'
+17
lexicons/pub/leaflet/blocks/page.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.page", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "id" 9 + ], 10 + "properties": { 11 + "id": { 12 + "type": "string" 13 + } 14 + } 15 + } 16 + } 17 + }
+3
lexicons/pub/leaflet/comment.json
··· 38 38 "ref": "pub.leaflet.richtext.facet" 39 39 } 40 40 }, 41 + "onPage": { 42 + "type": "string" 43 + }, 41 44 "attachment": { 42 45 "type": "union", 43 46 "refs": [
+8 -1
lexicons/pub/leaflet/pages/linearDocument.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "object", 7 + "required": [ 8 + "blocks" 9 + ], 7 10 "properties": { 11 + "id": { 12 + "type": "string" 13 + }, 8 14 "blocks": { 9 15 "type": "array", 10 16 "items": { ··· 33 39 "pub.leaflet.blocks.math", 34 40 "pub.leaflet.blocks.code", 35 41 "pub.leaflet.blocks.horizontalRule", 36 - "pub.leaflet.blocks.bskyPost" 42 + "pub.leaflet.blocks.bskyPost", 43 + "pub.leaflet.blocks.page" 37 44 ] 38 45 }, 39 46 "alignment": {
+1 -2
lexicons/pub/leaflet/publication.json
··· 17 17 "maxLength": 2000 18 18 }, 19 19 "base_path": { 20 - "type": "string", 21 - "format": "uri" 20 + "type": "string" 22 21 }, 23 22 "description": { 24 23 "type": "string",
+1 -2
lexicons/pub/leaflet/richtext/facet.json
··· 58 58 ], 59 59 "properties": { 60 60 "uri": { 61 - "type": "string", 62 - "format": "uri" 61 + "type": "string" 63 62 } 64 63 } 65 64 },
+15
lexicons/src/blocks.ts
··· 19 19 }, 20 20 }; 21 21 22 + export const PubLeafletBlocksPage: LexiconDoc = { 23 + lexicon: 1, 24 + id: "pub.leaflet.blocks.page", 25 + defs: { 26 + main: { 27 + type: "object", 28 + required: ["id"], 29 + properties: { 30 + id: { type: "string" }, 31 + }, 32 + }, 33 + }, 34 + }; 35 + 22 36 export const PubLeafletBlocksBskyPost: LexiconDoc = { 23 37 lexicon: 1, 24 38 id: "pub.leaflet.blocks.bskyPost", ··· 262 276 PubLeafletBlocksCode, 263 277 PubLeafletBlocksHorizontalRule, 264 278 PubLeafletBlocksBskyPost, 279 + PubLeafletBlocksPage, 265 280 ]; 266 281 export const BlockUnion: LexRefUnion = { 267 282 type: "union",
+1
lexicons/src/comment.ts
··· 23 23 type: "array", 24 24 items: { type: "ref", ref: PubLeafletRichTextFacet.id }, 25 25 }, 26 + onPage: { type: "string" }, 26 27 attachment: { type: "union", refs: ["#linearDocumentQuote"] }, 27 28 }, 28 29 },
+2
lexicons/src/pages/LinearDocument.ts
··· 7 7 defs: { 8 8 main: { 9 9 type: "object", 10 + required: ["blocks"], 10 11 properties: { 12 + id: { type: "string" }, 11 13 blocks: { type: "array", items: { type: "ref", ref: "#block" } }, 12 14 }, 13 15 },
+1 -1
lexicons/src/publication.ts
··· 14 14 required: ["name"], 15 15 properties: { 16 16 name: { type: "string", maxLength: 2000 }, 17 - base_path: { type: "string", format: "uri" }, 17 + base_path: { type: "string" }, 18 18 description: { type: "string", maxLength: 2000 }, 19 19 icon: { type: "blob", accept: ["image/*"], maxSize: 1000000 }, 20 20 theme: { type: "ref", ref: "#theme" },
+415 -80
package-lock.json
··· 71 71 "remark-rehype": "^11.1.0", 72 72 "remark-stringify": "^11.0.0", 73 73 "replicache": "^15.3.0", 74 - "sharp": "^0.34.2", 74 + "sharp": "^0.34.4", 75 75 "shiki": "^3.8.1", 76 76 "swr": "^2.3.3", 77 77 "thumbhash": "^0.1.1", ··· 692 692 "node": ">=10.0.0" 693 693 } 694 694 }, 695 + "node_modules/@emnapi/runtime": { 696 + "version": "1.5.0", 697 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", 698 + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", 699 + "optional": true, 700 + "dependencies": { 701 + "tslib": "^2.4.0" 702 + } 703 + }, 695 704 "node_modules/@esbuild-kit/core-utils": { 696 705 "version": "3.3.2", 697 706 "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", ··· 1038 1047 "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", 1039 1048 "dev": true 1040 1049 }, 1050 + "node_modules/@img/colour": { 1051 + "version": "1.0.0", 1052 + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", 1053 + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", 1054 + "engines": { 1055 + "node": ">=18" 1056 + } 1057 + }, 1058 + "node_modules/@img/sharp-darwin-arm64": { 1059 + "version": "0.34.4", 1060 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", 1061 + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", 1062 + "cpu": [ 1063 + "arm64" 1064 + ], 1065 + "optional": true, 1066 + "os": [ 1067 + "darwin" 1068 + ], 1069 + "engines": { 1070 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1071 + }, 1072 + "funding": { 1073 + "url": "https://opencollective.com/libvips" 1074 + }, 1075 + "optionalDependencies": { 1076 + "@img/sharp-libvips-darwin-arm64": "1.2.3" 1077 + } 1078 + }, 1079 + "node_modules/@img/sharp-darwin-x64": { 1080 + "version": "0.34.4", 1081 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", 1082 + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", 1083 + "cpu": [ 1084 + "x64" 1085 + ], 1086 + "optional": true, 1087 + "os": [ 1088 + "darwin" 1089 + ], 1090 + "engines": { 1091 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1092 + }, 1093 + "funding": { 1094 + "url": "https://opencollective.com/libvips" 1095 + }, 1096 + "optionalDependencies": { 1097 + "@img/sharp-libvips-darwin-x64": "1.2.3" 1098 + } 1099 + }, 1100 + "node_modules/@img/sharp-libvips-darwin-arm64": { 1101 + "version": "1.2.3", 1102 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", 1103 + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", 1104 + "cpu": [ 1105 + "arm64" 1106 + ], 1107 + "optional": true, 1108 + "os": [ 1109 + "darwin" 1110 + ], 1111 + "funding": { 1112 + "url": "https://opencollective.com/libvips" 1113 + } 1114 + }, 1115 + "node_modules/@img/sharp-libvips-darwin-x64": { 1116 + "version": "1.2.3", 1117 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", 1118 + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", 1119 + "cpu": [ 1120 + "x64" 1121 + ], 1122 + "optional": true, 1123 + "os": [ 1124 + "darwin" 1125 + ], 1126 + "funding": { 1127 + "url": "https://opencollective.com/libvips" 1128 + } 1129 + }, 1130 + "node_modules/@img/sharp-libvips-linux-arm": { 1131 + "version": "1.2.3", 1132 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", 1133 + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", 1134 + "cpu": [ 1135 + "arm" 1136 + ], 1137 + "optional": true, 1138 + "os": [ 1139 + "linux" 1140 + ], 1141 + "funding": { 1142 + "url": "https://opencollective.com/libvips" 1143 + } 1144 + }, 1145 + "node_modules/@img/sharp-libvips-linux-arm64": { 1146 + "version": "1.2.3", 1147 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", 1148 + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", 1149 + "cpu": [ 1150 + "arm64" 1151 + ], 1152 + "optional": true, 1153 + "os": [ 1154 + "linux" 1155 + ], 1156 + "funding": { 1157 + "url": "https://opencollective.com/libvips" 1158 + } 1159 + }, 1160 + "node_modules/@img/sharp-libvips-linux-ppc64": { 1161 + "version": "1.2.3", 1162 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", 1163 + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", 1164 + "cpu": [ 1165 + "ppc64" 1166 + ], 1167 + "optional": true, 1168 + "os": [ 1169 + "linux" 1170 + ], 1171 + "funding": { 1172 + "url": "https://opencollective.com/libvips" 1173 + } 1174 + }, 1175 + "node_modules/@img/sharp-libvips-linux-s390x": { 1176 + "version": "1.2.3", 1177 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", 1178 + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", 1179 + "cpu": [ 1180 + "s390x" 1181 + ], 1182 + "optional": true, 1183 + "os": [ 1184 + "linux" 1185 + ], 1186 + "funding": { 1187 + "url": "https://opencollective.com/libvips" 1188 + } 1189 + }, 1041 1190 "node_modules/@img/sharp-libvips-linux-x64": { 1042 - "version": "1.2.0", 1043 - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", 1044 - "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", 1191 + "version": "1.2.3", 1192 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", 1193 + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", 1194 + "cpu": [ 1195 + "x64" 1196 + ], 1197 + "optional": true, 1198 + "os": [ 1199 + "linux" 1200 + ], 1201 + "funding": { 1202 + "url": "https://opencollective.com/libvips" 1203 + } 1204 + }, 1205 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { 1206 + "version": "1.2.3", 1207 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", 1208 + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", 1209 + "cpu": [ 1210 + "arm64" 1211 + ], 1212 + "optional": true, 1213 + "os": [ 1214 + "linux" 1215 + ], 1216 + "funding": { 1217 + "url": "https://opencollective.com/libvips" 1218 + } 1219 + }, 1220 + "node_modules/@img/sharp-libvips-linuxmusl-x64": { 1221 + "version": "1.2.3", 1222 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", 1223 + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", 1045 1224 "cpu": [ 1046 1225 "x64" 1047 1226 ], 1048 - "license": "LGPL-3.0-or-later", 1227 + "optional": true, 1228 + "os": [ 1229 + "linux" 1230 + ], 1231 + "funding": { 1232 + "url": "https://opencollective.com/libvips" 1233 + } 1234 + }, 1235 + "node_modules/@img/sharp-linux-arm": { 1236 + "version": "0.34.4", 1237 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", 1238 + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", 1239 + "cpu": [ 1240 + "arm" 1241 + ], 1242 + "optional": true, 1243 + "os": [ 1244 + "linux" 1245 + ], 1246 + "engines": { 1247 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1248 + }, 1249 + "funding": { 1250 + "url": "https://opencollective.com/libvips" 1251 + }, 1252 + "optionalDependencies": { 1253 + "@img/sharp-libvips-linux-arm": "1.2.3" 1254 + } 1255 + }, 1256 + "node_modules/@img/sharp-linux-arm64": { 1257 + "version": "0.34.4", 1258 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", 1259 + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", 1260 + "cpu": [ 1261 + "arm64" 1262 + ], 1263 + "optional": true, 1264 + "os": [ 1265 + "linux" 1266 + ], 1267 + "engines": { 1268 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1269 + }, 1270 + "funding": { 1271 + "url": "https://opencollective.com/libvips" 1272 + }, 1273 + "optionalDependencies": { 1274 + "@img/sharp-libvips-linux-arm64": "1.2.3" 1275 + } 1276 + }, 1277 + "node_modules/@img/sharp-linux-ppc64": { 1278 + "version": "0.34.4", 1279 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", 1280 + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", 1281 + "cpu": [ 1282 + "ppc64" 1283 + ], 1284 + "optional": true, 1285 + "os": [ 1286 + "linux" 1287 + ], 1288 + "engines": { 1289 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1290 + }, 1291 + "funding": { 1292 + "url": "https://opencollective.com/libvips" 1293 + }, 1294 + "optionalDependencies": { 1295 + "@img/sharp-libvips-linux-ppc64": "1.2.3" 1296 + } 1297 + }, 1298 + "node_modules/@img/sharp-linux-s390x": { 1299 + "version": "0.34.4", 1300 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", 1301 + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", 1302 + "cpu": [ 1303 + "s390x" 1304 + ], 1049 1305 "optional": true, 1050 1306 "os": [ 1051 1307 "linux" 1052 1308 ], 1309 + "engines": { 1310 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1311 + }, 1053 1312 "funding": { 1054 1313 "url": "https://opencollective.com/libvips" 1314 + }, 1315 + "optionalDependencies": { 1316 + "@img/sharp-libvips-linux-s390x": "1.2.3" 1055 1317 } 1056 1318 }, 1057 1319 "node_modules/@img/sharp-linux-x64": { 1058 - "version": "0.34.3", 1059 - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", 1060 - "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", 1320 + "version": "0.34.4", 1321 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", 1322 + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", 1061 1323 "cpu": [ 1062 1324 "x64" 1063 1325 ], 1064 - "license": "Apache-2.0", 1065 1326 "optional": true, 1066 1327 "os": [ 1067 1328 "linux" ··· 1073 1334 "url": "https://opencollective.com/libvips" 1074 1335 }, 1075 1336 "optionalDependencies": { 1076 - "@img/sharp-libvips-linux-x64": "1.2.0" 1337 + "@img/sharp-libvips-linux-x64": "1.2.3" 1338 + } 1339 + }, 1340 + "node_modules/@img/sharp-linuxmusl-arm64": { 1341 + "version": "0.34.4", 1342 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", 1343 + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", 1344 + "cpu": [ 1345 + "arm64" 1346 + ], 1347 + "optional": true, 1348 + "os": [ 1349 + "linux" 1350 + ], 1351 + "engines": { 1352 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1353 + }, 1354 + "funding": { 1355 + "url": "https://opencollective.com/libvips" 1356 + }, 1357 + "optionalDependencies": { 1358 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" 1359 + } 1360 + }, 1361 + "node_modules/@img/sharp-linuxmusl-x64": { 1362 + "version": "0.34.4", 1363 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", 1364 + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", 1365 + "cpu": [ 1366 + "x64" 1367 + ], 1368 + "optional": true, 1369 + "os": [ 1370 + "linux" 1371 + ], 1372 + "engines": { 1373 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1374 + }, 1375 + "funding": { 1376 + "url": "https://opencollective.com/libvips" 1377 + }, 1378 + "optionalDependencies": { 1379 + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" 1380 + } 1381 + }, 1382 + "node_modules/@img/sharp-wasm32": { 1383 + "version": "0.34.4", 1384 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", 1385 + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", 1386 + "cpu": [ 1387 + "wasm32" 1388 + ], 1389 + "optional": true, 1390 + "dependencies": { 1391 + "@emnapi/runtime": "^1.5.0" 1392 + }, 1393 + "engines": { 1394 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1395 + }, 1396 + "funding": { 1397 + "url": "https://opencollective.com/libvips" 1398 + } 1399 + }, 1400 + "node_modules/@img/sharp-win32-arm64": { 1401 + "version": "0.34.4", 1402 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", 1403 + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", 1404 + "cpu": [ 1405 + "arm64" 1406 + ], 1407 + "optional": true, 1408 + "os": [ 1409 + "win32" 1410 + ], 1411 + "engines": { 1412 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1413 + }, 1414 + "funding": { 1415 + "url": "https://opencollective.com/libvips" 1416 + } 1417 + }, 1418 + "node_modules/@img/sharp-win32-ia32": { 1419 + "version": "0.34.4", 1420 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", 1421 + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", 1422 + "cpu": [ 1423 + "ia32" 1424 + ], 1425 + "optional": true, 1426 + "os": [ 1427 + "win32" 1428 + ], 1429 + "engines": { 1430 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1431 + }, 1432 + "funding": { 1433 + "url": "https://opencollective.com/libvips" 1434 + } 1435 + }, 1436 + "node_modules/@img/sharp-win32-x64": { 1437 + "version": "0.34.4", 1438 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", 1439 + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", 1440 + "cpu": [ 1441 + "x64" 1442 + ], 1443 + "optional": true, 1444 + "os": [ 1445 + "win32" 1446 + ], 1447 + "engines": { 1448 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1449 + }, 1450 + "funding": { 1451 + "url": "https://opencollective.com/libvips" 1077 1452 } 1078 1453 }, 1079 1454 "node_modules/@inngest/ai": { ··· 7643 8018 "url": "https://github.com/sponsors/wooorm" 7644 8019 } 7645 8020 }, 7646 - "node_modules/color": { 7647 - "version": "4.2.3", 7648 - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", 7649 - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", 7650 - "license": "MIT", 7651 - "dependencies": { 7652 - "color-convert": "^2.0.1", 7653 - "color-string": "^1.9.0" 7654 - }, 7655 - "engines": { 7656 - "node": ">=12.5.0" 7657 - } 7658 - }, 7659 8021 "node_modules/color-convert": { 7660 8022 "version": "2.0.1", 7661 8023 "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", ··· 7671 8033 "version": "1.1.4", 7672 8034 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 7673 8035 "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 7674 - }, 7675 - "node_modules/color-string": { 7676 - "version": "1.9.1", 7677 - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", 7678 - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", 7679 - "license": "MIT", 7680 - "dependencies": { 7681 - "color-name": "^1.0.0", 7682 - "simple-swizzle": "^0.2.2" 7683 - } 7684 8036 }, 7685 8037 "node_modules/colorjs.io": { 7686 8038 "version": "0.5.2", ··· 8035 8387 } 8036 8388 }, 8037 8389 "node_modules/detect-libc": { 8038 - "version": "2.0.4", 8039 - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", 8040 - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", 8041 - "license": "Apache-2.0", 8390 + "version": "2.1.2", 8391 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 8392 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", 8042 8393 "engines": { 8043 8394 "node": ">=8" 8044 8395 } ··· 10791 11142 "funding": { 10792 11143 "url": "https://github.com/sponsors/ljharb" 10793 11144 } 10794 - }, 10795 - "node_modules/is-arrayish": { 10796 - "version": "0.3.2", 10797 - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", 10798 - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", 10799 - "license": "MIT" 10800 11145 }, 10801 11146 "node_modules/is-async-function": { 10802 11147 "version": "2.1.1", ··· 15519 15864 "license": "ISC" 15520 15865 }, 15521 15866 "node_modules/sharp": { 15522 - "version": "0.34.3", 15523 - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", 15524 - "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", 15867 + "version": "0.34.4", 15868 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", 15869 + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", 15525 15870 "hasInstallScript": true, 15526 - "license": "Apache-2.0", 15527 15871 "dependencies": { 15528 - "color": "^4.2.3", 15529 - "detect-libc": "^2.0.4", 15872 + "@img/colour": "^1.0.0", 15873 + "detect-libc": "^2.1.0", 15530 15874 "semver": "^7.7.2" 15531 15875 }, 15532 15876 "engines": { ··· 15536 15880 "url": "https://opencollective.com/libvips" 15537 15881 }, 15538 15882 "optionalDependencies": { 15539 - "@img/sharp-darwin-arm64": "0.34.3", 15540 - "@img/sharp-darwin-x64": "0.34.3", 15541 - "@img/sharp-libvips-darwin-arm64": "1.2.0", 15542 - "@img/sharp-libvips-darwin-x64": "1.2.0", 15543 - "@img/sharp-libvips-linux-arm": "1.2.0", 15544 - "@img/sharp-libvips-linux-arm64": "1.2.0", 15545 - "@img/sharp-libvips-linux-ppc64": "1.2.0", 15546 - "@img/sharp-libvips-linux-s390x": "1.2.0", 15547 - "@img/sharp-libvips-linux-x64": "1.2.0", 15548 - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", 15549 - "@img/sharp-libvips-linuxmusl-x64": "1.2.0", 15550 - "@img/sharp-linux-arm": "0.34.3", 15551 - "@img/sharp-linux-arm64": "0.34.3", 15552 - "@img/sharp-linux-ppc64": "0.34.3", 15553 - "@img/sharp-linux-s390x": "0.34.3", 15554 - "@img/sharp-linux-x64": "0.34.3", 15555 - "@img/sharp-linuxmusl-arm64": "0.34.3", 15556 - "@img/sharp-linuxmusl-x64": "0.34.3", 15557 - "@img/sharp-wasm32": "0.34.3", 15558 - "@img/sharp-win32-arm64": "0.34.3", 15559 - "@img/sharp-win32-ia32": "0.34.3", 15560 - "@img/sharp-win32-x64": "0.34.3" 15883 + "@img/sharp-darwin-arm64": "0.34.4", 15884 + "@img/sharp-darwin-x64": "0.34.4", 15885 + "@img/sharp-libvips-darwin-arm64": "1.2.3", 15886 + "@img/sharp-libvips-darwin-x64": "1.2.3", 15887 + "@img/sharp-libvips-linux-arm": "1.2.3", 15888 + "@img/sharp-libvips-linux-arm64": "1.2.3", 15889 + "@img/sharp-libvips-linux-ppc64": "1.2.3", 15890 + "@img/sharp-libvips-linux-s390x": "1.2.3", 15891 + "@img/sharp-libvips-linux-x64": "1.2.3", 15892 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", 15893 + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", 15894 + "@img/sharp-linux-arm": "0.34.4", 15895 + "@img/sharp-linux-arm64": "0.34.4", 15896 + "@img/sharp-linux-ppc64": "0.34.4", 15897 + "@img/sharp-linux-s390x": "0.34.4", 15898 + "@img/sharp-linux-x64": "0.34.4", 15899 + "@img/sharp-linuxmusl-arm64": "0.34.4", 15900 + "@img/sharp-linuxmusl-x64": "0.34.4", 15901 + "@img/sharp-wasm32": "0.34.4", 15902 + "@img/sharp-win32-arm64": "0.34.4", 15903 + "@img/sharp-win32-ia32": "0.34.4", 15904 + "@img/sharp-win32-x64": "0.34.4" 15561 15905 } 15562 15906 }, 15563 15907 "node_modules/shebang-command": { ··· 15684 16028 }, 15685 16029 "funding": { 15686 16030 "url": "https://github.com/sponsors/isaacs" 15687 - } 15688 - }, 15689 - "node_modules/simple-swizzle": { 15690 - "version": "0.2.2", 15691 - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", 15692 - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", 15693 - "license": "MIT", 15694 - "dependencies": { 15695 - "is-arrayish": "^0.3.1" 15696 16031 } 15697 16032 }, 15698 16033 "node_modules/sirv": {
+1 -1
package.json
··· 81 81 "remark-rehype": "^11.1.0", 82 82 "remark-stringify": "^11.0.0", 83 83 "replicache": "^15.3.0", 84 - "sharp": "^0.34.2", 84 + "sharp": "^0.34.4", 85 85 "shiki": "^3.8.1", 86 86 "swr": "^2.3.3", 87 87 "thumbhash": "^0.1.1",
+62
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 + */ 10 + export function scrollIntoView( 11 + elementId: string, 12 + scrollContainerId: string = "pages", 13 + threshold: number = 0.9, 14 + ) { 15 + 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); 62 + }