From 44ebaf1cfe5cecae568feaa9551f2a8fbe3ad5c9 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 21 Oct 2025 11:08:16 -0400 Subject: [PATCH] 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 --- actions/publishToPublication.ts | 494 ++++++++--------- app/[leaflet_id]/Footer.tsx | 2 +- app/[leaflet_id]/Leaflet.tsx | 28 +- app/[leaflet_id]/Sidebar.tsx | 76 ++- .../Interactions/Comments/CommentBox.tsx | 2 + .../Interactions/Comments/commentAction.ts | 6 +- .../[rkey]/Interactions/Comments/index.tsx | 22 +- .../[rkey]/Interactions/InteractionDrawer.tsx | 68 ++- .../[rkey]/Interactions/Interactions.tsx | 38 +- .../[rkey]/Interactions/Quotes.tsx | 22 +- .../[did]/[publication]/[rkey]/PageLayout.tsx | 23 - .../[publication]/[rkey]/PostContent.tsx | 81 ++- .../[rkey]/PostHeader/PostHeader.tsx | 157 +++--- .../[did]/[publication]/[rkey]/PostPage.tsx | 111 ---- .../[did]/[publication]/[rkey]/PostPages.tsx | 304 +++++++++++ .../[rkey]/PublishedPageBlock.tsx | 230 ++++++++ .../[publication]/[rkey]/QuoteHandler.tsx | 48 +- .../[did]/[publication]/[rkey]/TextBlock.tsx | 10 +- app/lish/[did]/[publication]/[rkey]/page.tsx | 47 +- .../[publication]/[rkey]/quotePosition.ts | 25 +- .../[publication]/[rkey]/useHighlight.tsx | 10 +- components/Blocks/BlockCommands.tsx | 1 - components/Blocks/ImageBlock.tsx | 4 +- components/Blocks/MailboxBlock.tsx | 61 --- components/Canvas.tsx | 5 +- components/LeafletLayout.tsx | 58 ++ components/Pages/Page.tsx | 217 ++++++++ components/Pages/PageOptions.tsx | 217 ++++++++ components/Pages/PublicationMetadata.tsx | 6 +- components/Pages/index.tsx | 432 ++------------- lexicons/api/index.ts | 2 + lexicons/api/lexicons.ts | 26 +- lexicons/api/types/pub/leaflet/blocks/page.ts | 30 ++ lexicons/api/types/pub/leaflet/comment.ts | 1 + .../types/pub/leaflet/pages/linearDocument.ts | 5 +- lexicons/pub/leaflet/blocks/page.json | 17 + lexicons/pub/leaflet/comment.json | 3 + .../pub/leaflet/pages/linearDocument.json | 9 +- lexicons/pub/leaflet/publication.json | 3 +- lexicons/pub/leaflet/richtext/facet.json | 3 +- lexicons/src/blocks.ts | 15 + lexicons/src/comment.ts | 1 + lexicons/src/pages/LinearDocument.ts | 2 + lexicons/src/publication.ts | 2 +- package-lock.json | 495 +++++++++++++++--- package.json | 2 +- src/utils/scrollIntoView.ts | 62 +++ 47 files changed, 2266 insertions(+), 1217 deletions(-) delete mode 100644 app/lish/[did]/[publication]/[rkey]/PageLayout.tsx delete mode 100644 app/lish/[did]/[publication]/[rkey]/PostPage.tsx create mode 100644 app/lish/[did]/[publication]/[rkey]/PostPages.tsx create mode 100644 app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx create mode 100644 components/LeafletLayout.tsx create mode 100644 components/Pages/Page.tsx create mode 100644 components/Pages/PageOptions.tsx create mode 100644 lexicons/api/types/pub/leaflet/blocks/page.ts create mode 100644 lexicons/pub/leaflet/blocks/page.json create mode 100644 src/utils/scrollIntoView.ts diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 6dbe5a23..33fb9fff 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -20,6 +20,7 @@ import { PubLeafletBlocksBskyPost, PubLeafletBlocksBlockquote, PubLeafletBlocksIframe, + PubLeafletBlocksPage, } from "lexicons/api"; import { Block } from "components/Blocks/Block"; import { TID } from "@atproto/common"; @@ -72,44 +73,10 @@ export async function publishToPublication({ root: root_entity, }); let facts = (data as unknown as Fact[]) || []; - let scan = scanIndexLocal(facts); - let firstEntity = scan.eav(root_entity, "root/page")?.[0]; - if (!firstEntity) throw new Error("No root page"); - let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); - let images = blocks - .filter((b) => b.type === "image") - .map((b) => scan.eav(b.value, "block/image")[0]); - let links = blocks - .filter((b) => b.type == "link") - .map((b) => scan.eav(b.value, "link/preview")[0]); - let imageMap = new Map(); - for (const b of [...links, ...images]) { - if (!b) continue; - let data = await fetch(b.data.src); - if (data.status !== 200) continue; - let binary = await data.blob(); - try { - let blob = await agent.com.atproto.repo.uploadBlob(binary, { - headers: { "Content-Type": binary.type }, - }); - if (!blob.success) { - console.log(blob); - console.log("Error uploading image: " + b.data.src); - throw new Error("Failed to upload image"); - } - imageMap.set(b.data.src, blob.data.blob); - } catch (e) { - console.error(e); - console.log("Error uploading image: " + b.data.src); - throw new Error("Failed to upload image"); - } - } - - let b: PubLeafletPagesLinearDocument.Block[] = blocksToRecord( - blocks, - imageMap, - scan, + let { firstPageBlocks, pages } = await processBlocksToPages( + facts, + agent, root_entity, ); @@ -126,8 +93,13 @@ export async function publishToPublication({ pages: [ { $type: "pub.leaflet.pages.linearDocument", - blocks: b, + blocks: firstPageBlocks, }, + ...pages.map((p) => ({ + $type: "pub.leaflet.pages.linearDocument", + id: p.id, + blocks: p.blocks, + })), ], }; let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); @@ -161,248 +133,232 @@ export async function publishToPublication({ return { rkey, record: JSON.parse(JSON.stringify(record)) }; } -function blocksToRecord( - blocks: Block[], - imageMap: Map, - scan: ReturnType, - root_entity: string, -): PubLeafletPagesLinearDocument.Block[] { - let parsedBlocks = parseBlocksToList(blocks); - return parsedBlocks.flatMap((blockOrList) => { - if (blockOrList.type === "block") { - let alignmentValue = - scan.eav(blockOrList.block.value, "block/text-alignment")[0]?.data - .value || "left"; - let alignment = - alignmentValue === "center" - ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" - : alignmentValue === "right" - ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" - : undefined; - let b = blockToRecord(blockOrList.block, imageMap, scan, root_entity); - if (!b) return []; - let block: PubLeafletPagesLinearDocument.Block = { - $type: "pub.leaflet.pages.linearDocument#block", - alignment, - block: b, - }; - return [block]; - } else { - let block: PubLeafletPagesLinearDocument.Block = { - $type: "pub.leaflet.pages.linearDocument#block", - block: { - $type: "pub.leaflet.blocks.unorderedList", - children: childrenToRecord( - blockOrList.children, - imageMap, - scan, - root_entity, - ), - }, - }; - return [block]; - } - }); -} - -function childrenToRecord( - children: List[], - imageMap: Map, - scan: ReturnType, - root_entity: string, -) { - return children.flatMap((child) => { - let content = blockToRecord(child.block, imageMap, scan, root_entity); - if (!content) return []; - let record: PubLeafletBlocksUnorderedList.ListItem = { - $type: "pub.leaflet.blocks.unorderedList#listItem", - content, - children: childrenToRecord(child.children, imageMap, scan, root_entity), - }; - return record; - }); -} -function blockToRecord( - b: Block, - imageMap: Map, - scan: ReturnType, +async function processBlocksToPages( + facts: Fact[], + agent: AtpBaseClient, root_entity: string, ) { - const getBlockContent = (b: string) => { - let [content] = scan.eav(b, "block/text"); - if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; - let doc = new Y.Doc(); - const update = base64.toByteArray(content.data.value); - Y.applyUpdate(doc, update); - let nodes = doc.getXmlElement("prosemirror").toArray(); - let stringValue = YJSFragmentToString(nodes[0]); - let facets = YJSFragmentToFacets(nodes[0]); - return [stringValue, facets] as const; - }; - - if (b.type === "bluesky-post") { - let [post] = scan.eav(b.value, "block/bluesky-post"); - if (!post || !post.data.value.post) return; - let block: $Typed = { - $type: ids.PubLeafletBlocksBskyPost, - postRef: { - uri: post.data.value.post.uri, - cid: post.data.value.post.cid, - }, - }; - return block; - } - if (b.type === "horizontal-rule") { - let block: $Typed = { - $type: ids.PubLeafletBlocksHorizontalRule, - }; - return block; - } + let scan = scanIndexLocal(facts); + let pages: { id: string; blocks: PubLeafletPagesLinearDocument.Block[] }[] = + []; - if (b.type === "heading") { - let [headingLevel] = scan.eav(b.value, "block/heading-level"); + let firstEntity = scan.eav(root_entity, "root/page")?.[0]; + if (!firstEntity) throw new Error("No root page"); + let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); + let b = await blocksToRecord(blocks); + return { firstPageBlocks: b, pages }; - let [stringValue, facets] = getBlockContent(b.value); - let block: $Typed = { - $type: "pub.leaflet.blocks.header", - level: headingLevel?.data.value || 1, - plaintext: stringValue, - facets, - }; - return block; + async function uploadImage(src: string) { + let data = await fetch(src); + if (data.status !== 200) return; + let binary = await data.blob(); + let blob = await agent.com.atproto.repo.uploadBlob(binary, { + headers: { "Content-Type": binary.type }, + }); + return blob.data.blob; } - - if (b.type === "blockquote") { - let [stringValue, facets] = getBlockContent(b.value); - let block: $Typed = { - $type: ids.PubLeafletBlocksBlockquote, - plaintext: stringValue, - facets, - }; - return block; + async function blocksToRecord( + blocks: Block[], + ): Promise { + let parsedBlocks = parseBlocksToList(blocks); + return ( + await Promise.all( + parsedBlocks.map(async (blockOrList) => { + if (blockOrList.type === "block") { + let alignmentValue = + scan.eav(blockOrList.block.value, "block/text-alignment")[0]?.data + .value || "left"; + let alignment = + alignmentValue === "center" + ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" + : alignmentValue === "right" + ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" + : undefined; + let b = await blockToRecord(blockOrList.block); + if (!b) return []; + let block: PubLeafletPagesLinearDocument.Block = { + $type: "pub.leaflet.pages.linearDocument#block", + alignment, + block: b, + }; + return [block]; + } else { + let block: PubLeafletPagesLinearDocument.Block = { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.unorderedList", + children: await childrenToRecord(blockOrList.children), + }, + }; + return [block]; + } + }), + ) + ).flat(); } - if (b.type == "text") { - let [stringValue, facets] = getBlockContent(b.value); - let block: $Typed = { - $type: ids.PubLeafletBlocksText, - plaintext: stringValue, - facets, - }; - return block; - } - if (b.type === "embed") { - let [url] = scan.eav(b.value, "embed/url"); - let [height] = scan.eav(b.value, "embed/height"); - if (!url) return; - let block: $Typed = { - $type: "pub.leaflet.blocks.iframe", - url: url.data.value, - height: Math.floor(height?.data.value || 600), - }; - return block; + async function childrenToRecord(children: List[]) { + return ( + await Promise.all( + children.map(async (child) => { + let content = await blockToRecord(child.block); + if (!content) return []; + let record: PubLeafletBlocksUnorderedList.ListItem = { + $type: "pub.leaflet.blocks.unorderedList#listItem", + content, + children: await childrenToRecord(child.children), + }; + return record; + }), + ) + ).flat(); } - if (b.type == "image") { - let [image] = scan.eav(b.value, "block/image"); - if (!image) return; - let [altText] = scan.eav(b.value, "image/alt"); - let blobref = imageMap.get(image.data.src); - if (!blobref) return; - let block: $Typed = { - $type: "pub.leaflet.blocks.image", - image: blobref, - aspectRatio: { - height: image.data.height, - width: image.data.width, - }, - alt: altText ? altText.data.value : undefined, - }; - return block; - } - if (b.type === "link") { - let [previewImage] = scan.eav(b.value, "link/preview"); - let [description] = scan.eav(b.value, "link/description"); - let [src] = scan.eav(b.value, "link/url"); - if (!src) return; - let blobref = previewImage - ? imageMap.get(previewImage?.data.src) - : undefined; - let [title] = scan.eav(b.value, "link/title"); - let block: $Typed = { - $type: "pub.leaflet.blocks.website", - previewImage: blobref, - src: src.data.value, - description: description?.data.value, - title: title?.data.value, + async function blockToRecord(b: Block) { + const getBlockContent = (b: string) => { + let [content] = scan.eav(b, "block/text"); + if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; + let doc = new Y.Doc(); + const update = base64.toByteArray(content.data.value); + Y.applyUpdate(doc, update); + let nodes = doc.getXmlElement("prosemirror").toArray(); + let stringValue = YJSFragmentToString(nodes[0]); + let facets = YJSFragmentToFacets(nodes[0]); + return [stringValue, facets] as const; }; - return block; - } - if (b.type === "code") { - let [language] = scan.eav(b.value, "block/code-language"); - let [code] = scan.eav(b.value, "block/code"); - let [theme] = scan.eav(root_entity, "theme/code-theme"); - let block: $Typed = { - $type: "pub.leaflet.blocks.code", - language: language?.data.value, - plaintext: code?.data.value || "", - syntaxHighlightingTheme: theme?.data.value, - }; - return block; - } - if (b.type === "math") { - let [math] = scan.eav(b.value, "block/math"); - let block: $Typed = { - $type: "pub.leaflet.blocks.math", - tex: math?.data.value || "", - }; - return block; - } - return; -} + if (b.type === "card") { + let [page] = scan.eav(b.value, "block/card"); + if (!page) return; + let blocks = getBlocksWithTypeLocal(facts, page.data.value); + pages.push({ + id: page.data.value, + blocks: await blocksToRecord(blocks), + }); + let block: $Typed = { + $type: "pub.leaflet.blocks.page", + id: page.data.value, + }; + return block; + } -async function sendPostToEmailSubscribers( - publication_uri: string, - post: { content: string; title: string }, -) { - let { data: publication } = await supabaseServerClient - .from("publications") - .select("*, subscribers_to_publications(*)") - .eq("uri", publication_uri) - .single(); + if (b.type === "bluesky-post") { + let [post] = scan.eav(b.value, "block/bluesky-post"); + if (!post || !post.data.value.post) return; + let block: $Typed = { + $type: ids.PubLeafletBlocksBskyPost, + postRef: { + uri: post.data.value.post.uri, + cid: post.data.value.post.cid, + }, + }; + return block; + } + if (b.type === "horizontal-rule") { + let block: $Typed = { + $type: ids.PubLeafletBlocksHorizontalRule, + }; + return block; + } - let res = await fetch("https://api.postmarkapp.com/email/batch", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, - }, - body: JSON.stringify( - publication?.subscribers_to_publications.map((sub) => ({ - Headers: [ - { - Name: "List-Unsubscribe-Post", - Value: "List-Unsubscribe=One-Click", - }, - { - Name: "List-Unsubscribe", - Value: `<${"TODO"}/mail/unsubscribe?sub_id=${sub.identity}>`, - }, - ], - MessageStream: "broadcast", - From: `${publication.name} `, - Subject: post.title, - To: sub.identity, - HtmlBody: ` -

${publication.name}

-
- ${post.content} -
- This is a super alpha release! Ask Jared if you want to unsubscribe (sorry) - `, - TextBody: post.content, - })), - ), - }); + if (b.type === "heading") { + let [headingLevel] = scan.eav(b.value, "block/heading-level"); + + let [stringValue, facets] = getBlockContent(b.value); + let block: $Typed = { + $type: "pub.leaflet.blocks.header", + level: headingLevel?.data.value || 1, + plaintext: stringValue, + facets, + }; + return block; + } + + if (b.type === "blockquote") { + let [stringValue, facets] = getBlockContent(b.value); + let block: $Typed = { + $type: ids.PubLeafletBlocksBlockquote, + plaintext: stringValue, + facets, + }; + return block; + } + + if (b.type == "text") { + let [stringValue, facets] = getBlockContent(b.value); + let block: $Typed = { + $type: ids.PubLeafletBlocksText, + plaintext: stringValue, + facets, + }; + return block; + } + if (b.type === "embed") { + let [url] = scan.eav(b.value, "embed/url"); + let [height] = scan.eav(b.value, "embed/height"); + if (!url) return; + let block: $Typed = { + $type: "pub.leaflet.blocks.iframe", + url: url.data.value, + height: height?.data.value || 600, + }; + return block; + } + if (b.type == "image") { + let [image] = scan.eav(b.value, "block/image"); + if (!image) return; + let [altText] = scan.eav(b.value, "image/alt"); + let blobref = await uploadImage(image.data.src); + if (!blobref) return; + let block: $Typed = { + $type: "pub.leaflet.blocks.image", + image: blobref, + aspectRatio: { + height: image.data.height, + width: image.data.width, + }, + alt: altText ? altText.data.value : undefined, + }; + return block; + } + if (b.type === "link") { + let [previewImage] = scan.eav(b.value, "link/preview"); + let [description] = scan.eav(b.value, "link/description"); + let [src] = scan.eav(b.value, "link/url"); + if (!src) return; + let blobref = previewImage + ? await uploadImage(previewImage?.data.src) + : undefined; + let [title] = scan.eav(b.value, "link/title"); + let block: $Typed = { + $type: "pub.leaflet.blocks.website", + previewImage: blobref, + src: src.data.value, + description: description?.data.value, + title: title?.data.value, + }; + return block; + } + if (b.type === "code") { + let [language] = scan.eav(b.value, "block/code-language"); + let [code] = scan.eav(b.value, "block/code"); + let [theme] = scan.eav(root_entity, "theme/code-theme"); + let block: $Typed = { + $type: "pub.leaflet.blocks.code", + language: language?.data.value, + plaintext: code?.data.value || "", + syntaxHighlightingTheme: theme?.data.value, + }; + return block; + } + if (b.type === "math") { + let [math] = scan.eav(b.value, "block/math"); + let block: $Typed = { + $type: "pub.leaflet.blocks.math", + tex: math?.data.value || "", + }; + return block; + } + return; + } } function YJSFragmentToFacets( diff --git a/app/[leaflet_id]/Footer.tsx b/app/[leaflet_id]/Footer.tsx index 27dba913..d6eb1ff3 100644 --- a/app/[leaflet_id]/Footer.tsx +++ b/app/[leaflet_id]/Footer.tsx @@ -20,7 +20,7 @@ export function LeafletFooter(props: { entityID: string }) { let { data: pub } = useLeafletPublicationData(); return ( - + {focusedBlock && focusedBlock.entityType == "block" && entity_set.permissions.write ? ( diff --git a/app/[leaflet_id]/Leaflet.tsx b/app/[leaflet_id]/Leaflet.tsx index b4776e0a..a98ee708 100644 --- a/app/[leaflet_id]/Leaflet.tsx +++ b/app/[leaflet_id]/Leaflet.tsx @@ -12,7 +12,7 @@ import { EntitySetProvider } from "components/EntitySetProvider"; import { AddLeafletToHomepage } from "components/utils/AddLeafletToHomepage"; import { UpdateLeafletTitle } from "components/utils/UpdateLeafletTitle"; import { useUIState } from "src/useUIState"; -import { LeafletSidebar } from "./Sidebar"; +import { LeafletLayout } from "components/LeafletLayout"; export function Leaflet(props: { token: PermissionToken; @@ -36,22 +36,9 @@ export function Leaflet(props: { {/* we need the padding bottom here because if we don't have it the mobile footer will cut off... the dropshadow on the page... the padding is compensated by a negative top margin in mobile footer */} - + + + @@ -59,10 +46,3 @@ export function Leaflet(props: { ); } - -const blurPage = () => { - useUIState.setState(() => ({ - focusedEntity: null, - selectedBlocks: [], - })); -}; diff --git a/app/[leaflet_id]/Sidebar.tsx b/app/[leaflet_id]/Sidebar.tsx index 6deffc0f..edbdf086 100644 --- a/app/[leaflet_id]/Sidebar.tsx +++ b/app/[leaflet_id]/Sidebar.tsx @@ -12,53 +12,51 @@ import { Watermark } from "components/Watermark"; import { useUIState } from "src/useUIState"; import { BackToPubButton, PublishButton } from "./Actions"; import { useIdentityData } from "components/IdentityProvider"; +import { useReplicache } from "src/replicache"; -export function LeafletSidebar(props: { leaflet_id: string }) { +export function LeafletSidebar() { let entity_set = useEntitySetContext(); + let { rootEntity } = useReplicache(); let { data: pub } = useLeafletPublicationData(); let { identity } = useIdentityData(); return ( -
{ - e.currentTarget === e.target && blurPage(); - }} - > - +
- {entity_set.permissions.write && ( - - {pub?.publications && - identity?.atp_did && - pub.publications.identity_did === identity.atp_did ? ( - <> - - - - -
- - - ) : ( - <> - - - -
- - - )} -
- )} -
- +
+ {entity_set.permissions.write && ( + + {pub?.publications && + identity?.atp_did && + pub.publications.identity_did === identity.atp_did ? ( + <> + + + + +
+ + + ) : ( + <> + + + +
+ + + )} +
+ )} +
+ +
- -
+
+
); } diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx index 2ddd8456..98b9e940 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx @@ -39,6 +39,7 @@ import { betterIsUrl } from "src/utils/isURL"; export function CommentBox(props: { doc_uri: string; + pageId?: string; replyTo?: string; onSubmit?: () => void; autoFocus?: boolean; @@ -56,6 +57,7 @@ export function CommentBox(props: { let currentState = view.current.state; let [plaintext, facets] = docToFacetedText(currentState.doc); let comment = await publishComment({ + pageId: props.pageId, document: props.doc_uri, comment: { plaintext, diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts index f0b28d7c..1df1c06e 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -5,12 +5,13 @@ import { getIdentityData } from "actions/getIdentityData"; import { PubLeafletRichtextFacet } from "lexicons/api"; import { createOauthClient } from "src/atproto-oauth"; import { TID } from "@atproto/common"; -import { AtUri, Un$Typed } from "@atproto/api"; +import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; export async function publishComment(args: { document: string; + pageId?: string; comment: { plaintext: string; facets: PubLeafletRichtextFacet.Main[]; @@ -28,6 +29,7 @@ export async function publishComment(args: { ); let record: Un$Typed = { subject: args.document, + onPage: args.pageId, createdAt: new Date().toISOString(), plaintext: args.comment.plaintext, facets: args.comment.facets, @@ -66,7 +68,7 @@ export async function publishComment(args: { return { record: data?.[0].record as Json, - profile: profile.value, + profile: lexToJson(profile.value), uri: uri.toString(), }; } diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx index 89208240..f94fce28 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx @@ -23,14 +23,24 @@ export type Comment = { uri: string; bsky_profiles: { record: Json } | null; }; -export function Comments(props: { document_uri: string; comments: Comment[] }) { +export function Comments(props: { + document_uri: string; + comments: Comment[]; + pageId?: string; +}) { let { identity } = useIdentityData(); let { localComments } = useInteractionState(props.document_uri); let comments = useMemo(() => { - return [...localComments, ...props.comments]; + return [ + ...localComments.filter( + (c) => (c.record as any)?.onPage === props.pageId, + ), + ...props.comments, + ]; }, [props.comments, localComments]); let pathname = usePathname(); let redirectRoute = useMemo(() => { + if (typeof window === "undefined") return; let url = new URL(pathname, window.location.origin); url.searchParams.set("refreshAuth", ""); url.searchParams.set("interactionDrawer", "comments"); @@ -52,7 +62,7 @@ export function Comments(props: { document_uri: string; comments: Comment[] }) {
{identity?.atp_did ? ( - + ) : (
Connect a Bluesky account to comment @@ -79,6 +89,7 @@ export function Comments(props: { document_uri: string; comments: Comment[] }) { ?.record as AppBskyActorProfile.Record; return ( { return (
@@ -130,6 +142,7 @@ const Comment = (props: { /> { let { identity } = useIdentityData(); @@ -191,6 +205,7 @@ const Replies = (props: {
{replyBoxOpen && ( { return ( { - let params = useSearchParams(); - let interactionDrawerSearchParam = params.get("interactionDrawer"); - let { drawerOpen: open, drawer } = useInteractionState(); - if (open === false || (open === undefined && !interactionDrawerSearchParam)) - return null; - let currentDrawer = drawer || interactionDrawerSearchParam; + let drawer = useDrawerOpen(props.document_uri); + if (!drawer) return null; + + // Filter comments and quotes based on pageId + const filteredComments = props.comments.filter( + (c) => (c.record as any)?.onPage === props.pageId, + ); + + const filteredQuotes = props.quotes.filter((q) => { + const url = new URL(q.link); + const quoteParam = url.pathname.split("/l-quote/")[1]; + if (!quoteParam) return null; + const quotePosition = decodeQuotePosition(quoteParam); + return quotePosition?.pageId === props.pageId; + }); + return ( <> -
-
-
- {currentDrawer === "quotes" ? ( - - ) : ( - - )} -
+ +
+
+ {drawer.drawer === "quotes" ? ( + + ) : ( + + )}
); }; + +export const useDrawerOpen = (uri: string) => { + let params = useSearchParams(); + let interactionDrawerSearchParam = params.get("interactionDrawer"); + let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); + if (open === false || (open === undefined && !interactionDrawerSearchParam)) + return null; + drawer = + drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); + return { drawer, pageId }; +}; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index 0f4d4a67..130b206b 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -8,9 +8,11 @@ import type { Comment } from "./Comments"; import { QuotePosition } from "../quotePosition"; import { useContext } from "react"; import { PostPageContext } from "../PostPageContext"; +import { scrollIntoView } from "src/utils/scrollIntoView"; -type InteractionState = { +export type InteractionState = { drawerOpen: undefined | boolean; + pageId?: string; drawer: undefined | "comments" | "quotes"; localComments: Comment[]; commentBox: { quote: QuotePosition | null }; @@ -27,9 +29,9 @@ export let useInteractionStateStore = create<{ [document_uri: string]: InteractionState; }>(() => ({})); -export function useInteractionState(document_uri?: string) { +export function useInteractionState(document_uri: string) { return useInteractionStateStore((state) => { - if (!document_uri || !state[document_uri]) { + if (!state[document_uri]) { return defaultInteractionState; } return state[document_uri]; @@ -83,25 +85,12 @@ export function setInteractionState( export function openInteractionDrawer( drawer: "comments" | "quotes", document_uri: string, + pageId?: string, ) { flushSync(() => { - setInteractionState(document_uri, { drawerOpen: true, drawer }); + setInteractionState(document_uri, { drawerOpen: true, drawer, pageId }); }); - let el = document.getElementById("interaction-drawer"); - let isOffscreen = false; - if (el) { - const rect = el.getBoundingClientRect(); - const windowWidth = - window.innerWidth || document.documentElement.clientWidth; - isOffscreen = rect.right > windowWidth - 64; - } - - if (el && isOffscreen) - el.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "center", - }); + scrollIntoView("interaction-drawer"); } export const Interactions = (props: { @@ -110,23 +99,24 @@ export const Interactions = (props: { compact?: boolean; className?: string; showComments?: boolean; + pageId?: string; }) => { const data = useContext(PostPageContext); const document_uri = data?.uri; if (!document_uri) throw new Error("document_uri not available in PostPageContext"); - let { drawerOpen, drawer } = useInteractionState(document_uri); + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); return (
@@ -87,7 +92,15 @@ export const QuoteContent = (props: { const data = useContext(PostPageContext); let record = data?.data as PubLeafletDocument.Record; - let page = record.pages[0] as PubLeafletPagesLinearDocument.Main; + let page: PubLeafletPagesLinearDocument.Main | undefined = ( + props.position.pageId + ? record.pages.find( + (p) => + (p as PubLeafletPagesLinearDocument.Main).id === + props.position.pageId, + ) + : record.pages[0] + ) as PubLeafletPagesLinearDocument.Main; // Extract blocks within the quote range const content = extractQuotedBlocks(page.blocks || [], props.position, []); return ( @@ -103,6 +116,8 @@ export const QuoteContent = (props: {
{ + if (props.position.pageId) + flushSync(() => openPage(undefined, props.position.pageId!)); let scrollMargin = isMobile ? 16 : e.currentTarget.getBoundingClientRect().top; @@ -125,6 +140,7 @@ export const QuoteContent = (props: { >
{}} - className="post w-full relative overflow-x-scroll snap-x snap-mandatory no-scrollbar grow items-stretch flex h-full pwa-padding mx-auto " - id="page-carousel" - > - {/* if you adjust this padding, remember to adjust the negative margins on page - in [rkey]/page/PostPage when card borders are hidden */} -
- {props.children} -
-
- ); -} diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index a9cd3fd4..c28af348 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -1,3 +1,4 @@ +"use client"; import { PubLeafletBlocksMath, PubLeafletBlocksCode, @@ -12,18 +13,21 @@ import { PubLeafletBlocksBlockquote, PubLeafletBlocksBskyPost, PubLeafletBlocksIframe, + PubLeafletBlocksPage, } from "lexicons/api"; + import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { TextBlock } from "./TextBlock"; import { Popover } from "components/Popover"; import { theme } from "tailwind.config"; import { ImageAltSmall } from "components/Icons/ImageAlt"; -import { codeToHtml } from "shiki"; -import Katex from "katex"; import { StaticMathBlock } from "./StaticMathBlock"; import { PubCodeBlock } from "./PubCodeBlock"; import { AppBskyFeedDefs } from "@atproto/api"; import { PubBlueskyPostBlock } from "./PublishBskyPostBlock"; +import { openPage } from "./PostPages"; +import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; +import { PublishedPageLinkBlock } from "./PublishedPageBlock"; export function PostContent({ blocks, @@ -32,22 +36,28 @@ export function PostContent({ className, prerenderedCodeBlocks, bskyPostData, + pageId, + pages, }: { blocks: PubLeafletPagesLinearDocument.Block[]; + pageId?: string; did: string; preview?: boolean; className?: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + pages: PubLeafletPagesLinearDocument.Main[]; }) { return (
{blocks.map((b, index) => { return ( ; bskyPostData: AppBskyFeedDefs.PostView[]; @@ -89,8 +103,13 @@ let Block = ({ scrollMarginBottom: "4rem", wordBreak: "break-word" as React.CSSProperties["wordBreak"], }, - id: preview ? undefined : index.join("."), + id: preview + ? undefined + : pageId + ? `${pageId}~${index.join(".")}` + : index.join("."), "data-index": index.join("."), + "data-page-id": pageId, }; let alignment = b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight" @@ -114,6 +133,20 @@ let Block = ({ `; switch (true) { + case PubLeafletBlocksPage.isMain(b.block): { + let id = b.block.id; + let page = pages.find((p) => p.id === id); + if (!page) return; + return ( + + ); + } case PubLeafletBlocksBskyPost.isMain(b.block): { let uri = b.block.postRef.uri; let post = bskyPostData.find((p) => p.uri === uri); @@ -140,12 +173,14 @@ let Block = ({
    {b.block.children.map((child, i) => ( ))}
@@ -248,6 +283,7 @@ let Block = ({ plaintext={b.block.plaintext} index={index} preview={preview} + pageId={pageId} /> ); @@ -260,6 +296,7 @@ let Block = ({ plaintext={b.block.plaintext} index={index} preview={preview} + pageId={pageId} />

); @@ -267,26 +304,46 @@ let Block = ({ if (b.block.level === 1) return (

- +

); if (b.block.level === 2) return (

- +

); if (b.block.level === 3) return (

- +

); // if (b.block.level === 4) return

{b.block.plaintext}

; // if (b.block.level === 5) return
{b.block.plaintext}
; return (
- +
); } @@ -297,21 +354,25 @@ let Block = ({ function ListItem(props: { index: number[]; + pages: PubLeafletPagesLinearDocument.Main[]; item: PubLeafletBlocksUnorderedList.ListItem; did: string; className?: string; bskyPostData: AppBskyFeedDefs.PostView[]; + pageId?: string; }) { let children = props.item.children?.length ? (
    {props.item.children.map((child, index) => ( ))}
@@ -324,11 +385,13 @@ function ListItem(props: { />
{children}{" "}
diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index 1d30f509..4a09c767 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -1,5 +1,9 @@ "use client"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; +import { + PubLeafletComment, + PubLeafletDocument, + PubLeafletPublication, +} from "lexicons/api"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { Interactions } from "../Interactions/Interactions"; import { PostPageData } from "../getPostPageData"; @@ -7,6 +11,7 @@ import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/act import { useIdentityData } from "components/IdentityProvider"; import { EditTiny } from "components/Icons/EditTiny"; import { SpeedyLink } from "components/SpeedyLink"; +import { decodeQuotePosition } from "../quotePosition"; export function PostHeader(props: { data: PostPageData; @@ -24,81 +29,85 @@ export function PostHeader(props: { if (!document?.data || !document.documents_in_publications[0].publications) return; return ( - <> - {/* */} -
-
-
- - {pub?.name} - - {identity && - identity.atp_did === - document.documents_in_publications[0]?.publications - .identity_did && - document.leaflets_in_publications[0] && ( - - - - )} -
-

{record.title}

- {record.description ? ( -

{record.description}

- ) : null} +
+
+
+ + {pub?.name} + + {identity && + identity.atp_did === + document.documents_in_publications[0]?.publications + .identity_did && + document.leaflets_in_publications[0] && ( + + + + )} +
+

{record.title}

+ {record.description ? ( +

{record.description}

+ ) : null} -
- {profile ? ( - <> - - by {profile.displayName || profile.handle} - - - ) : null} - {record.publishedAt ? ( - <> - | -

- {new Date(record.publishedAt).toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "2-digit", - })} -

- - ) : null} - |{" "} - -
+
+ {profile ? ( + <> + + by {profile.displayName || profile.handle} + + + ) : null} + {record.publishedAt ? ( + <> + | +

+ {new Date(record.publishedAt).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "2-digit", + })} +

+ + ) : null} + |{" "} + { + const url = new URL(q.link); + const quoteParam = url.pathname.split("/l-quote/")[1]; + if (!quoteParam) return null; + const quotePosition = decodeQuotePosition(quoteParam); + return !quotePosition?.pageId; + }).length + } + commentsCount={ + document.comments_on_documents.filter( + (c) => !(c.record as PubLeafletComment.Record)?.onPage, + ).length + } + />
- +
); } diff --git a/app/lish/[did]/[publication]/[rkey]/PostPage.tsx b/app/lish/[did]/[publication]/[rkey]/PostPage.tsx deleted file mode 100644 index 09bff942..00000000 --- a/app/lish/[did]/[publication]/[rkey]/PostPage.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"use client"; -import { - PubLeafletPagesLinearDocument, - PubLeafletPublication, -} from "lexicons/api"; -import { PostPageData } from "./getPostPageData"; -import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; -import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import { SubscribeWithBluesky } from "app/lish/Subscribe"; -import { EditTiny } from "components/Icons/EditTiny"; -import { Interactions, useInteractionState } from "./Interactions/Interactions"; -import { PostContent } from "./PostContent"; -import { PostHeader } from "./PostHeader/PostHeader"; -import { useIdentityData } from "components/IdentityProvider"; -import { AppBskyFeedDefs } from "@atproto/api"; - -export function PostPage({ - document, - blocks, - did, - profile, - preferences, - pubRecord, - prerenderedCodeBlocks, - bskyPostData, -}: { - document: PostPageData; - blocks: PubLeafletPagesLinearDocument.Block[]; - profile: ProfileViewDetailed; - pubRecord: PubLeafletPublication.Record; - did: string; - prerenderedCodeBlocks?: Map; - bskyPostData: AppBskyFeedDefs.PostView[]; - preferences: { showComments?: boolean }; -}) { - let { identity } = useIdentityData(); - const document_uri = document?.uri; - let { drawerOpen } = useInteractionState(document_uri); - if (!document || !document.documents_in_publications[0].publications) - return null; - - let hasPageBackground = !!pubRecord.theme?.showPageBackground; - return ( - <> - {(drawerOpen || hasPageBackground) && ( -
- )} -
-
- - - -
- {identity && - identity.atp_did === - document.documents_in_publications[0]?.publications?.identity_did && - document.leaflets_in_publications[0] ? ( - - Edit Post - - ) : ( - - )} -
-
- - ); -} diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx new file mode 100644 index 00000000..e9697208 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -0,0 +1,304 @@ +"use client"; +import { + PubLeafletComment, + PubLeafletDocument, + PubLeafletPagesLinearDocument, + PubLeafletPublication, +} from "lexicons/api"; +import { PostPageData } from "./getPostPageData"; +import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { SubscribeWithBluesky } from "app/lish/Subscribe"; +import { EditTiny } from "components/Icons/EditTiny"; +import { Interactions } from "./Interactions/Interactions"; +import { PostContent } from "./PostContent"; +import { PostHeader } from "./PostHeader/PostHeader"; +import { useIdentityData } from "components/IdentityProvider"; +import { AppBskyFeedDefs } from "@atproto/api"; +import { create } from "zustand/react"; +import { + InteractionDrawer, + useDrawerOpen, +} from "./Interactions/InteractionDrawer"; +import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; +import { PageOptionButton } from "components/Pages/PageOptions"; +import { CloseTiny } from "components/Icons/CloseTiny"; +import { PageWrapper } from "components/Pages/Page"; +import { Fragment, useEffect } from "react"; +import { flushSync } from "react-dom"; +import { scrollIntoView } from "src/utils/scrollIntoView"; +import { useParams } from "next/navigation"; +import { decodeQuotePosition } from "./quotePosition"; + +const usePostPageUIState = create(() => ({ + pages: [] as string[], + initialized: false, +})); + +export const useOpenPages = () => { + const { quote } = useParams(); + const state = usePostPageUIState((s) => s); + + if (!state.initialized && quote) { + const decodedQuote = decodeQuotePosition(quote as string); + if (decodedQuote?.pageId) { + return [decodedQuote.pageId]; + } + } + + return state.pages; +}; + +export const useInitializeOpenPages = () => { + const { quote } = useParams(); + + useEffect(() => { + const state = usePostPageUIState.getState(); + if (!state.initialized) { + if (quote) { + const decodedQuote = decodeQuotePosition(quote as string); + if (decodedQuote?.pageId) { + usePostPageUIState.setState({ + pages: [decodedQuote.pageId], + initialized: true, + }); + return; + } + } + // Mark as initialized even if no pageId found + usePostPageUIState.setState({ initialized: true }); + } + }, [quote]); +}; + +export const openPage = ( + parent: string | undefined, + page: string, + options?: { scrollIntoView?: boolean }, +) => { + flushSync(() => { + usePostPageUIState.setState((state) => { + let parentPosition = state.pages.findIndex((s) => s == parent); + return { + pages: + parentPosition === -1 + ? [page] + : [...state.pages.slice(0, parentPosition + 1), page], + initialized: true, + }; + }); + }); + + if (options?.scrollIntoView !== false) { + scrollIntoView(`post-page-${page}`); + } +}; + +export const closePage = (page: string) => + usePostPageUIState.setState((state) => { + let parentPosition = state.pages.findIndex((s) => s == page); + return { + pages: state.pages.slice(0, parentPosition), + initialized: true, + }; + }); + +export function PostPages({ + document, + blocks, + did, + profile, + preferences, + pubRecord, + prerenderedCodeBlocks, + bskyPostData, + document_uri, +}: { + document_uri: string; + document: PostPageData; + blocks: PubLeafletPagesLinearDocument.Block[]; + profile: ProfileViewDetailed; + pubRecord: PubLeafletPublication.Record; + did: string; + prerenderedCodeBlocks?: Map; + bskyPostData: AppBskyFeedDefs.PostView[]; + preferences: { showComments?: boolean }; +}) { + let { identity } = useIdentityData(); + let drawer = useDrawerOpen(document_uri); + useInitializeOpenPages(); + let pages = useOpenPages(); + if (!document || !document.documents_in_publications[0].publications) + return null; + + let hasPageBackground = !!pubRecord.theme?.showPageBackground; + let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; + let record = document.data as PubLeafletDocument.Record; + return ( + <> + {!fullPageScroll && } + + + + { + const url = new URL(q.link); + const quoteParam = url.pathname.split("/l-quote/")[1]; + if (!quoteParam) return null; + const quotePosition = decodeQuotePosition(quoteParam); + return !quotePosition?.pageId; + }).length + } + commentsCount={ + document.comments_on_documents.filter( + (c) => !(c.record as PubLeafletComment.Record)?.onPage, + ).length + } + /> +
+
+ {identity && + identity.atp_did === + document.documents_in_publications[0]?.publications + ?.identity_did ? ( + + Edit Post + + ) : ( + + )} +
+
+ + {drawer && !drawer.pageId && ( + + )} + + {pages.map((p) => { + let page = record.pages.find( + (page) => (page as PubLeafletPagesLinearDocument.Main).id === p, + ) as PubLeafletPagesLinearDocument.Main | undefined; + if (!page) return null; + return ( + + + {/*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!*/} + closePage(page?.id!)} + hasPageBackground={hasPageBackground} + /> + } + > + + + q.link.includes(page.id!), + ).length + } + commentsCount={ + document.comments_on_documents.filter( + (c) => + (c.record as PubLeafletComment.Record)?.onPage === + page.id, + ).length + } + /> + + {drawer && drawer.pageId === page.id && ( + + )} + + ); + })} + {!fullPageScroll && } + + ); +} + +const PageOptions = (props: { + onClick: () => void; + hasPageBackground: boolean; +}) => { + return ( +
+ + + +
+ ); +}; diff --git a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx new file mode 100644 index 00000000..36eb55a4 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useEntity, useReplicache } from "src/replicache"; +import { useUIState } from "src/useUIState"; +import { CSSProperties, useContext, useRef } from "react"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; +import { PostContent } from "./PostContent"; +import { + PubLeafletBlocksHeader, + PubLeafletBlocksText, + PubLeafletComment, + PubLeafletPagesLinearDocument, + PubLeafletPublication, +} from "lexicons/api"; +import { AppBskyFeedDefs } from "@atproto/api"; +import { TextBlock } from "./TextBlock"; +import { PostPageContext } from "./PostPageContext"; +import { openPage, useOpenPages } from "./PostPages"; +import { + openInteractionDrawer, + setInteractionState, + useInteractionState, +} from "./Interactions/Interactions"; +import { CommentTiny } from "components/Icons/CommentTiny"; +import { QuoteTiny } from "components/Icons/QuoteTiny"; + +export function PublishedPageLinkBlock(props: { + blocks: PubLeafletPagesLinearDocument.Block[]; + parentPageId: string | undefined; + pageId: string; + did: string; + preview?: boolean; + className?: string; + prerenderedCodeBlocks?: Map; + bskyPostData: AppBskyFeedDefs.PostView[]; +}) { + //switch to use actually state + let openPages = useOpenPages(); + let isOpen = openPages.includes(props.pageId); + return ( +
{ + if (e.isDefaultPrevented()) return; + if (e.shiftKey) return; + e.preventDefault(); + e.stopPropagation(); + + openPage(props.parentPageId, props.pageId); + }} + > + +
+ ); +} +export function DocLinkBlock(props: { + blocks: PubLeafletPagesLinearDocument.Block[]; + pageId: string; + parentPageId?: string; + did: string; + preview?: boolean; + className?: string; + prerenderedCodeBlocks?: Map; + bskyPostData: AppBskyFeedDefs.PostView[]; +}) { + let [title, description] = props.blocks + .map((b) => b.block) + .filter( + (b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b), + ); + + return ( +
+ <> +
+
+
+ {title && ( +
+ +
+ )} + {description && ( +
+ +
+ )} +
+ + +
+ {!props.preview && ( + + )} +
+ +
+ ); +} + +export function PagePreview(props: { + did: string; + blocks: PubLeafletPagesLinearDocument.Block[]; +}) { + let previewRef = useRef(null); + let { rootEntity } = useReplicache(); + let data = useContext(PostPageContext); + let theme = data?.documents_in_publications[0]?.publications + ?.record as PubLeafletPublication.Record; + let pageWidth = `var(--page-width-unitless)`; + let cardBorderHidden = !theme.theme?.showPageBackground; + return ( +
+
+ {!cardBorderHidden && ( +
+ )} + +
+
+ ); +} + +const Interactions = (props: { pageId: string; parentPageId?: string }) => { + const data = useContext(PostPageContext); + const document_uri = data?.uri; + if (!document_uri) + throw new Error("document_uri not available in PostPageContext"); + let comments = data.comments_on_documents.filter( + (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, + ).length; + let quotes = data.document_mentions_in_bsky.filter((q) => + q.link.includes(props.pageId), + ).length; + + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); + + return ( +
+ {quotes > 0 && ( + + )} + {comments > 0 && ( + + )} +
+ ); +}; diff --git a/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx b/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx index b551dbd7..8a689de6 100644 --- a/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx +++ b/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx @@ -14,6 +14,8 @@ import { CommentTiny } from "components/Icons/CommentTiny"; import { setInteractionState } from "./Interactions/Interactions"; import { PostPageContext } from "./PostPageContext"; import { PubLeafletPublication } from "lexicons/api"; +import { flushSync } from "react-dom"; +import { scrollIntoView } from "src/utils/scrollIntoView"; export function QuoteHandler() { let [position, setPosition] = useState<{ @@ -24,12 +26,19 @@ export function QuoteHandler() { useEffect(() => { const handleSelectionChange = (e: Event) => { const selection = document.getSelection(); - const postContent = document.getElementById("post-content"); + + // Check if selection is within any element with postContent class const isWithinPostContent = - postContent && selection?.rangeCount && selection.rangeCount > 0 - ? postContent.contains( - selection.getRangeAt(0).commonAncestorContainer, - ) + selection?.rangeCount && selection.rangeCount > 0 + ? (() => { + const range = selection.getRangeAt(0); + const ancestor = range.commonAncestorContainer; + const element = + ancestor.nodeType === Node.ELEMENT_NODE + ? (ancestor as Element) + : ancestor.parentElement; + return element?.closest(".postContent") !== null; + })() : false; if (!selection || !isWithinPostContent || !selection?.toString()) @@ -88,6 +97,7 @@ export function QuoteHandler() { endIndex?.element, ); let position: QuotePosition = { + ...(startIndex.pageId && { pageId: startIndex.pageId }), start: { block: startIndex?.index.split(".").map((i) => parseInt(i)), offset: startOffset, @@ -114,7 +124,7 @@ export function QuoteHandler() { return (
{ // Clear existing query parameters currentUrl.search = ""; - currentUrl.hash = `#${pos?.start.block.join(".")}_${pos?.start.offset}`; + const fragmentId = pos?.pageId + ? `${pos.pageId}~${pos.start.block.join(".")}_${pos.start.offset}` + : `${pos?.start.block.join(".")}_${pos?.start.offset}`; + currentUrl.hash = `#${fragmentId}`; return [currentUrl.toString(), pos]; }, [props.position]); let pubRecord = data.documents_in_publications[0]?.publications?.record as @@ -195,11 +208,15 @@ export const QuoteOptionButtons = (props: { position: string }) => { className="flex gap-1 items-center hover:font-bold px-1" onClick={() => { if (!position) return; - setInteractionState(document_uri, { - drawer: "comments", - drawerOpen: true, - commentBox: { quote: position }, - }); + flushSync(() => + setInteractionState(document_uri, { + drawer: "comments", + drawerOpen: true, + pageId: position.pageId, + commentBox: { quote: position }, + }), + ); + scrollIntoView("interaction-drawer"); }} > Comment @@ -210,13 +227,16 @@ export const QuoteOptionButtons = (props: { position: string }) => { ); }; -function findDataIndex(node: Node): { index: string; element: Element } | null { +function findDataIndex( + node: Node, +): { index: string; element: Element; pageId?: string } | null { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; if (element.hasAttribute("data-index")) { const index = element.getAttribute("data-index"); if (index) { - return { index, element }; + const pageId = element.getAttribute("data-page-id") || undefined; + return { index, element, pageId }; } } } diff --git a/app/lish/[did]/[publication]/[rkey]/TextBlock.tsx b/app/lish/[did]/[publication]/[rkey]/TextBlock.tsx index 2f051d41..20c765a1 100644 --- a/app/lish/[did]/[publication]/[rkey]/TextBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/TextBlock.tsx @@ -11,13 +11,17 @@ export function TextBlock(props: { facets?: Facet[]; index: number[]; preview?: boolean; + pageId?: string; }) { let children = []; - let highlights = useHighlight(props.index); + let highlights = useHighlight(props.index, props.pageId); let facets = useMemo(() => { if (props.preview) return props.facets; let facets = [...(props.facets || [])]; for (let highlight of highlights) { + const fragmentId = props.pageId + ? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}` + : `${props.index.join(".")}_${highlight.startOffset || 0}`; facets = addFacet( facets, { @@ -35,7 +39,7 @@ export function TextBlock(props: { { $type: "pub.leaflet.richtext.facet#highlight" }, { $type: "pub.leaflet.richtext.facet#id", - id: `${props.index.join(".")}_${highlight.startOffset || 0}`, + id: fragmentId, }, ], }, @@ -43,7 +47,7 @@ export function TextBlock(props: { ); } return facets; - }, [props.plaintext, props.facets, highlights, props.preview]); + }, [props.plaintext, props.facets, highlights, props.preview, props.pageId]); return ; } diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index e4ba28df..2b92129d 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -17,10 +17,9 @@ import { } from "components/ThemeManager/PublicationThemeProvider"; import { getPostPageData } from "./getPostPageData"; import { PostPageContextProvider } from "./PostPageContext"; -import { PostPage } from "./PostPage"; -import { PageLayout } from "./PageLayout"; +import { PostPages } from "./PostPages"; import { extractCodeBlocks } from "./extractCodeBlocks"; -import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { LeafletLayout } from "components/LeafletLayout"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; @@ -54,13 +53,13 @@ export default async function Post(props: { let did = decodeURIComponent((await props.params).did); if (!did) return ( - -

Sorry, can't resolve handle.

+
+

Sorry, can't resolve handle.

This may be a glitch on our end. If the issue persists please{" "} send us a note.

- +
); let agent = new AtpAgent({ service: "https://public.api.bsky.app", @@ -83,13 +82,17 @@ export default async function Post(props: { ]); if (!document?.data || !document.documents_in_publications[0].publications) return ( - -

Sorry, we can't find this post!

-

- This may be a glitch on our end. If the issue persists please{" "} - send us a note. -

-
+
+
+
+

Sorry, post not found!

+

+ This may be a glitch on our end. If the issue persists please{" "} + send us a note. +

+
+
+
); let record = document.data as PubLeafletDocument.Record; let bskyPosts = record.pages.flatMap((p) => { @@ -121,7 +124,6 @@ export default async function Post(props: { let pubRecord = document.documents_in_publications[0]?.publications .record as PubLeafletPublication.Record; - let hasPageBackground = !!pubRecord.theme?.showPageBackground; let prerenderedCodeBlocks = await extractCodeBlocks(blocks); return ( @@ -152,8 +154,9 @@ export default async function Post(props: { on chrome, if you scroll backward, things stop working seems like if you use an older browser, sel direction is not a thing yet */} - - + - - + diff --git a/app/lish/[did]/[publication]/[rkey]/quotePosition.ts b/app/lish/[did]/[publication]/[rkey]/quotePosition.ts index 4f2d1154..20182c8d 100644 --- a/app/lish/[did]/[publication]/[rkey]/quotePosition.ts +++ b/app/lish/[did]/[publication]/[rkey]/quotePosition.ts @@ -1,4 +1,5 @@ export interface QuotePosition { + pageId?: string; start: { block: number[]; offset: number; @@ -14,12 +15,15 @@ export const QUOTE_PARAM = "/l-quote/"; /** * Encodes quote position into a URL-friendly string * Format: startBlock_startOffset-endBlock_endOffset + * Format with page: pageId~startBlock_startOffset-endBlock_endOffset * Block paths are joined with dots: 1.2.0_45-1.2.3_67 - * Simple blocks: 0:12-2:45 + * Simple blocks: 0_12-2_45 + * With page: page1~0_12-2_45 */ export function encodeQuotePosition(position: QuotePosition): string { - const { start, end } = position; - return `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`; + const { pageId, start, end } = position; + const positionStr = `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`; + return pageId ? `${pageId}~${positionStr}` : positionStr; } /** @@ -28,9 +32,19 @@ export function encodeQuotePosition(position: QuotePosition): string { */ export function decodeQuotePosition(encoded: string): QuotePosition | null { try { - // Match format: blockPath:number-blockPath:number + // Check for pageId prefix (format: pageId~blockPath_number-blockPath_number) + let pageId: string | undefined; + let positionStr = encoded; + + const tildeIndex = encoded.indexOf("~"); + if (tildeIndex !== -1) { + pageId = encoded.substring(0, tildeIndex); + positionStr = encoded.substring(tildeIndex + 1); + } + + // Match format: blockPath_number-blockPath_number // Block paths can be: 5, 1.2, 0.1.3, etc. - const match = encoded.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/); + const match = positionStr.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/); if (!match) { return null; @@ -39,6 +53,7 @@ export function decodeQuotePosition(encoded: string): QuotePosition | null { const [, startBlockPath, startOffset, endBlockPath, endOffset] = match; const position: QuotePosition = { + ...(pageId && { pageId }), start: { block: startBlockPath.split(".").map((i) => parseInt(i)), offset: parseInt(startOffset, 10), diff --git a/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx b/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx index f3525ecc..ee045aa6 100644 --- a/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx +++ b/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx @@ -11,7 +11,7 @@ export const useActiveHighlightState = create(() => ({ activeHighlight: null as null | QuotePosition, })); -export const useHighlight = (pos: number[]) => { +export const useHighlight = (pos: number[], pageId?: string) => { let doc = useContext(PostPageContext); let { quote } = useParams(); let activeHighlight = useActiveHighlightState( @@ -23,6 +23,14 @@ export const useHighlight = (pos: number[]) => { return highlights .map((quotePosition) => { if (!quotePosition) return null; + // Filter by pageId if provided + if (pageId && quotePosition.pageId !== pageId) { + return null; + } + // If highlight has pageId but block doesn't, skip + if (quotePosition.pageId && !pageId) { + return null; + } let maxLength = Math.max( quotePosition.start.block.length, quotePosition.end.block.length, diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx index d5de9318..de82808a 100644 --- a/components/Blocks/BlockCommands.tsx +++ b/components/Blocks/BlockCommands.tsx @@ -330,7 +330,6 @@ export const blockCommands: Command[] = [ name: "New Page", icon: , type: "page", - hiddenInPublication: true, onSelect: async (rep, props, um) => { props.entityID && clearCommandSearchText(props.entityID); let entity = await createBlockWithType(rep, props, "card"); diff --git a/components/Blocks/ImageBlock.tsx b/components/Blocks/ImageBlock.tsx index 5a12ee03..45d3a4ed 100644 --- a/components/Blocks/ImageBlock.tsx +++ b/components/Blocks/ImageBlock.tsx @@ -140,7 +140,9 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) { ) : ( {altText { - let toaster = useToaster(); - let draft = useEntity(props.mailboxEntity, "mailbox/draft"); - let { rep, permission_token } = useReplicache(); - let entity_set = useEntitySetContext(); - let pagetitle = usePageTitle(permission_token.root_entity); - let subscriber_count = useEntity( - props.mailboxEntity, - "mailbox/subscriber-count", - ); - if (!draft) return null; - - // once the send button is clicked, close the page and show a toast. - return ( -
-
- Draft -
- -
- ); -}; - const GoToArchive = (props: { entityID: string; parent: string; diff --git a/components/Canvas.tsx b/components/Canvas.tsx index f4eb90aa..8eea85d1 100644 --- a/components/Canvas.tsx +++ b/components/Canvas.tsx @@ -53,10 +53,7 @@ export function Canvas(props: { entityID: string; preview?: boolean }) { id={elementId.page(props.entityID).canvasScrollArea} className={` canvasWrapper - h-full w-fit mx-auto - max-w-[calc(100vw-12px)] - ${!narrowWidth ? "sm:max-w-[calc(100vw-128px)] lg:max-w-[calc(var(--page-width-units)*2 + 24px))]" : " sm:max-w-(--page-width-units)"} - rounded-lg + h-full w-fit overflow-y-scroll `} > diff --git a/components/LeafletLayout.tsx b/components/LeafletLayout.tsx new file mode 100644 index 00000000..ff43de2f --- /dev/null +++ b/components/LeafletLayout.tsx @@ -0,0 +1,58 @@ +export const LeafletLayout = (props: { + children: React.ReactNode; + className?: string; +}) => { + return ( + + ); +}; + +export const BookendSpacer = (props: { + onClick?: (e: React.MouseEvent) => void; + children?: React.ReactNode; +}) => { + // 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 + return ( +
{}} + > + {props.children} +
+ ); +}; + +export const SandwichSpacer = (props: { + onClick?: (e: React.MouseEvent) => void; + noWidth?: boolean; + className?: string; +}) => { + // these spacers are used between pages so that the page carousel can fit two pages side by side by snapping in between pages + return ( +
+ ); +}; diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx new file mode 100644 index 00000000..2ec2016f --- /dev/null +++ b/components/Pages/Page.tsx @@ -0,0 +1,217 @@ +"use client"; + +import React from "react"; +import { useUIState } from "src/useUIState"; + +import { elementId } from "src/utils/elementId"; + +import { useEntity, useReferenceToEntity, useReplicache } from "src/replicache"; + +import { DesktopPageFooter } from "../DesktopFooter"; +import { Canvas } from "../Canvas"; +import { Blocks } from "components/Blocks"; +import { PublicationMetadata } from "./PublicationMetadata"; +import { useCardBorderHidden } from "./useCardBorderHidden"; +import { focusPage } from "."; +import { PageOptions } from "./PageOptions"; +import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; +import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; + +export function Page(props: { + entityID: string; + first?: boolean; + fullPageScroll: boolean; +}) { + let { rep } = useReplicache(); + + let isFocused = useUIState((s) => { + let focusedElement = s.focusedEntity; + let focusedPageID = + focusedElement?.entityType === "page" + ? focusedElement.entityID + : focusedElement?.parent; + return focusedPageID === props.entityID; + }); + let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; + let canvasNarrow = + pageType === "canvas" && + useEntity(props.entityID, "canvas/narrow-width")?.data.value; + let cardBorderHidden = useCardBorderHidden(props.entityID); + let drawerOpen = useDrawerOpen(props.entityID); + return ( + + { + if (e.defaultPrevented) return; + if (rep) { + if (isFocused) return; + focusPage(props.entityID, rep); + } + }} + id={elementId.page(props.entityID).container} + drawerOpen={!!drawerOpen} + cardBorderHidden={!!cardBorderHidden} + isFocused={isFocused} + fullPageScroll={props.fullPageScroll} + pageType={pageType} + canvasNarrow={canvasNarrow} + pageOptions={ + + } + > + {props.first && ( + <> + + + )} + + + + + ); +} + +export const PageWrapper = (props: { + id: string; + children: React.ReactNode; + pageOptions?: React.ReactNode; + cardBorderHidden: boolean; + fullPageScroll: boolean; + isFocused?: boolean; + onClickAction?: (e: React.MouseEvent) => void; + pageType: "canvas" | "doc"; + canvasNarrow?: boolean | undefined; + drawerOpen: boolean | undefined; +}) => { + return ( + // this div wraps the contents AND the page options. + // it needs to be its own div because this container does NOT scroll, and therefore doesn't clip the absolutely positioned pageOptions +
+ {/* + this div is the scrolling container that wraps only the contents div. + + 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 + */} +
+
+ {props.children} +
+
+ {props.pageOptions} +
+ ); +}; +// ${narrowWidth ? " sm:max-w-(--page-width-units)" : } +const PageContent = (props: { entityID: string }) => { + let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; + if (pageType === "doc") return ; + return ; +}; + +const DocContent = (props: { entityID: string }) => { + let { rootEntity } = useReplicache(); + + let cardBorderHidden = useCardBorderHidden(props.entityID); + let rootBackgroundImage = useEntity( + rootEntity, + "theme/card-background-image", + ); + let rootBackgroundRepeat = useEntity( + rootEntity, + "theme/card-background-image-repeat", + ); + let rootBackgroundOpacity = useEntity( + rootEntity, + "theme/card-background-image-opacity", + ); + + let cardBackgroundImage = useEntity( + props.entityID, + "theme/card-background-image", + ); + + let cardBackgroundImageRepeat = useEntity( + props.entityID, + "theme/card-background-image-repeat", + ); + + let cardBackgroundImageOpacity = useEntity( + props.entityID, + "theme/card-background-image-opacity", + ); + + let backgroundImage = cardBackgroundImage || rootBackgroundImage; + let backgroundImageRepeat = cardBackgroundImage + ? cardBackgroundImageRepeat?.data?.value + : rootBackgroundRepeat?.data.value; + let backgroundImageOpacity = cardBackgroundImage + ? cardBackgroundImageOpacity?.data.value + : rootBackgroundOpacity?.data.value || 1; + + return ( + <> + {!cardBorderHidden ? ( +
+ ) : null} + + {/* we handle page bg in this sepate div so that + we can apply an opacity the background image + without affecting the opacity of the rest of the page */} + + ); +}; diff --git a/components/Pages/PageOptions.tsx b/components/Pages/PageOptions.tsx new file mode 100644 index 00000000..2903ce9d --- /dev/null +++ b/components/Pages/PageOptions.tsx @@ -0,0 +1,217 @@ +"use client"; + +import React, { JSX, useState } from "react"; +import { useUIState } from "src/useUIState"; +import { useEntitySetContext } from "../EntitySetProvider"; + +import { useReplicache } from "src/replicache"; + +import { Media } from "../Media"; +import { MenuItem, Menu } from "../Layout"; +import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; +import { PageShareMenu } from "./PageShareMenu"; +import { useUndoState } from "src/undoManager"; +import { CloseTiny } from "components/Icons/CloseTiny"; +import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; +import { PaintSmall } from "components/Icons/PaintSmall"; +import { ShareSmall } from "components/Icons/ShareSmall"; +import { useCardBorderHidden } from "./useCardBorderHidden"; +import { useLeafletPublicationData } from "components/PageSWRDataProvider"; + +export const PageOptionButton = ({ + children, + secondary, + cardBorderHidden, + className, + disabled, + ...props +}: { + children: React.ReactNode; + secondary?: boolean; + cardBorderHidden: boolean | undefined; + className?: string; + disabled?: boolean; +} & Omit) => { + return ( + + ); +}; + +export const PageOptions = (props: { + entityID: string; + first: boolean | undefined; + isFocused: boolean; +}) => { + let cardBorderHidden = useCardBorderHidden(props.entityID); + + return ( +
+ {!props.first && ( + { + useUIState.getState().closePage(props.entityID); + }} + > + + + )} + + +
+ ); +}; + +export const UndoButtons = (props: { + cardBorderHidden: boolean | undefined; +}) => { + let undoState = useUndoState(); + let { undoManager } = useReplicache(); + return ( + + {undoState.canUndo && ( +
+ undoManager.undo()} + > + + + + undoManager.undo()} + disabled={!undoState.canRedo} + > + + +
+ )} +
+ ); +}; + +export const OptionsMenu = (props: { + entityID: string; + first: boolean; + cardBorderHidden: boolean | undefined; +}) => { + let [state, setState] = useState<"normal" | "theme" | "share">("normal"); + let { permissions } = useEntitySetContext(); + if (!permissions.write) return null; + + let { data: pub, mutate } = useLeafletPublicationData(); + if (pub && props.first) return; + return ( + { + if (!open) setState("normal"); + }} + trigger={ + + + + } + > + {state === "normal" ? ( + <> + {!props.first && ( + { + e.preventDefault(); + setState("share"); + }} + > + Share Page + + )} + {!pub && ( + { + e.preventDefault(); + setState("theme"); + }} + > + Theme Page + + )} + + ) : state === "theme" ? ( + + ) : state === "share" ? ( + + ) : null} + + ); +}; + +const UndoTiny = () => { + return ( + + + + ); +}; + +const RedoTiny = () => { + return ( + + + + ); +}; diff --git a/components/Pages/PublicationMetadata.tsx b/components/Pages/PublicationMetadata.tsx index 84b4c14a..da4b3809 100644 --- a/components/Pages/PublicationMetadata.tsx +++ b/components/Pages/PublicationMetadata.tsx @@ -36,13 +36,11 @@ export const PublicationMetadata = ({ description = pub?.description || ""; } return ( -
+
{pub.publications?.name} diff --git a/components/Pages/index.tsx b/components/Pages/index.tsx index b7c1bafd..aad8c8d1 100644 --- a/components/Pages/index.tsx +++ b/components/Pages/index.tsx @@ -1,42 +1,22 @@ "use client"; -import React, { JSX, useState } from "react"; +import React from "react"; import { useUIState } from "src/useUIState"; -import { useEntitySetContext } from "../EntitySetProvider"; import { useSearchParams } from "next/navigation"; import { focusBlock } from "src/utils/focusBlock"; import { elementId } from "src/utils/elementId"; import { Replicache } from "replicache"; -import { - Fact, - ReplicacheMutators, - useEntity, - useReferenceToEntity, - useReplicache, -} from "src/replicache"; +import { Fact, ReplicacheMutators, useEntity } from "src/replicache"; -import { Media } from "../Media"; -import { DesktopPageFooter } from "../DesktopFooter"; -import { ThemePopover } from "../ThemeManager/ThemeSetter"; -import { Canvas } from "../Canvas"; -import { DraftPostOptions } from "../Blocks/MailboxBlock"; -import { Blocks } from "components/Blocks"; -import { MenuItem, Menu } from "../Layout"; import { scanIndex } from "src/replicache/utils"; -import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; -import { PageShareMenu } from "./PageShareMenu"; import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; -import { useUndoState } from "src/undoManager"; -import { CloseTiny } from "components/Icons/CloseTiny"; -import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; -import { PaintSmall } from "components/Icons/PaintSmall"; -import { ShareSmall } from "components/Icons/ShareSmall"; -import { PublicationMetadata } from "./PublicationMetadata"; import { useCardBorderHidden } from "./useCardBorderHidden"; -import { useLeafletPublicationData } from "components/PageSWRDataProvider"; +import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; +import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; +import { Page } from "./Page"; export function Pages(props: { rootPage: string }) { let rootPage = useEntity(props.rootPage, "root/page")[0]; @@ -44,358 +24,43 @@ export function Pages(props: { rootPage: string }) { let params = useSearchParams(); let queryRoot = params.get("page"); let firstPage = queryRoot || rootPage?.data.value || props.rootPage; + let cardBorderHidden = useCardBorderHidden(rootPage.id); + let firstPageIsCanvas = useEntity(firstPage, "page/type"); + let fullPageScroll = + !!cardBorderHidden && pages.length === 0 && !firstPageIsCanvas; return ( <> -
- - - -
- {pages.map((page) => ( -
- - - -
- ))} -
{ - e.currentTarget === e.target && blurPage(); - }} - /> - - ); -} - -export const LeafletOptions = (props: { entityID: string }) => { - return ( - <> - - - ); -}; - -function Page(props: { entityID: string; first?: boolean }) { - let { rep, rootEntity } = useReplicache(); - let isDraft = useReferenceToEntity("mailbox/draft", props.entityID); - - let isFocused = useUIState((s) => { - let focusedElement = s.focusedEntity; - let focusedPageID = - focusedElement?.entityType === "page" - ? focusedElement.entityID - : focusedElement?.parent; - return focusedPageID === props.entityID; - }); - let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; - let cardBorderHidden = useCardBorderHidden(props.entityID); - return ( - <> - {!props.first && ( -
+ {!fullPageScroll && ( + { e.currentTarget === e.target && blurPage(); }} /> )} -
-
{ - if (e.defaultPrevented) return; - if (rep) { - if (isFocused) return; - focusPage(props.entityID, rep); - } - }} - id={elementId.page(props.entityID).container} - style={{ - width: pageType === "doc" ? "var(--page-width-units)" : undefined, - backgroundColor: cardBorderHidden - ? "" - : "rgba(var(--bg-page), var(--bg-page-alpha))", - }} - className={` - ${pageType === "canvas" ? "!lg:max-w-[1152px]" : "max-w-(--page-width-units)"} - page - grow flex flex-col - overscroll-y-none - overflow-y-auto - ${cardBorderHidden ? "border-0 shadow-none! sm:-mt-6 sm:-mb-12 -mt-2 -mb-1 pt-3 " : "border rounded-lg"} - ${isFocused ? "shadow-md border-border" : "border-border-light"} - `} - > - - - - - {isDraft.length > 0 && ( -
- -
- )} - - -
- - {isFocused && ( - - )} - -
- - ); -} - -const PageContent = (props: { entityID: string }) => { - let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; - if (pageType === "doc") return ; - return ; -}; - -const DocContent = (props: { entityID: string }) => { - let { rootEntity } = useReplicache(); - let isFocused = useUIState((s) => { - let focusedElement = s.focusedEntity; - let focusedPageID = - focusedElement?.entityType === "page" - ? focusedElement.entityID - : focusedElement?.parent; - return focusedPageID === props.entityID; - }); - - let cardBorderHidden = useCardBorderHidden(props.entityID); - let rootBackgroundImage = useEntity( - rootEntity, - "theme/card-background-image", - ); - let rootBackgroundRepeat = useEntity( - rootEntity, - "theme/card-background-image-repeat", - ); - let rootBackgroundOpacity = useEntity( - rootEntity, - "theme/card-background-image-opacity", - ); - let cardBackgroundImage = useEntity( - props.entityID, - "theme/card-background-image", - ); - - let cardBackgroundImageRepeat = useEntity( - props.entityID, - "theme/card-background-image-repeat", - ); - - let cardBackgroundImageOpacity = useEntity( - props.entityID, - "theme/card-background-image-opacity", - ); - - let backgroundImage = cardBackgroundImage || rootBackgroundImage; - let backgroundImageRepeat = cardBackgroundImage - ? cardBackgroundImageRepeat?.data?.value - : rootBackgroundRepeat?.data.value; - let backgroundImageOpacity = cardBackgroundImage - ? cardBackgroundImageOpacity?.data.value - : rootBackgroundOpacity?.data.value || 1; - - return ( - <> - {!cardBorderHidden ? ( -
+ {pages.map((page) => ( + + { + e.currentTarget === e.target && blurPage(); + }} + /> + + + ))} + {!fullPageScroll && ( + { + e.currentTarget === e.target && blurPage(); }} /> - ) : null} - - - {/* we handle page bg in this sepate div so that - we can apply an opacity the background image - without affecting the opacity of the rest of the page */} - - ); -}; - -const PageOptionButton = ({ - children, - secondary, - cardBorderHidden, - className, - disabled, - ...props -}: { - children: React.ReactNode; - secondary?: boolean; - cardBorderHidden: boolean | undefined; - className?: string; - disabled?: boolean; -} & Omit) => { - return ( - - ); -}; - -const PageOptions = (props: { - entityID: string; - first: boolean | undefined; -}) => { - let { rootEntity } = useReplicache(); - let cardBorderHidden = useCardBorderHidden(props.entityID); - - return ( -
- {!props.first && ( - { - useUIState.getState().closePage(props.entityID); - }} - > - - - )} - - -
- ); -}; - -const UndoButtons = (props: { cardBorderHidden: boolean | undefined }) => { - let undoState = useUndoState(); - let { undoManager } = useReplicache(); - return ( - - {undoState.canUndo && ( -
- undoManager.undo()} - > - - - - undoManager.undo()} - disabled={!undoState.canRedo} - > - - -
)} -
- ); -}; - -const OptionsMenu = (props: { - entityID: string; - first: boolean; - cardBorderHidden: boolean | undefined; -}) => { - let [state, setState] = useState<"normal" | "theme" | "share">("normal"); - let { permissions } = useEntitySetContext(); - if (!permissions.write) return null; - - let { data: pub, mutate } = useLeafletPublicationData(); - if (pub && props.first) return; - return ( - { - if (!open) setState("normal"); - }} - trigger={ - - - - } - > - {state === "normal" ? ( - <> - {!props.first && ( - { - e.preventDefault(); - setState("share"); - }} - > - Share Page - - )} - {!pub && ( - { - e.preventDefault(); - setState("theme"); - }} - > - Theme Page - - )} - - ) : state === "theme" ? ( - - ) : state === "share" ? ( - - ) : null} - + ); -}; +} export async function focusPage( pageID: string, @@ -463,46 +128,9 @@ export async function focusPage( }, 50); } -const blurPage = () => { +export const blurPage = () => { useUIState.setState(() => ({ focusedEntity: null, selectedBlocks: [], })); }; -const UndoTiny = () => { - return ( - - - - ); -}; - -const RedoTiny = () => { - return ( - - - - ); -}; diff --git a/lexicons/api/index.ts b/lexicons/api/index.ts index 39556372..54f4b4c5 100644 --- a/lexicons/api/index.ts +++ b/lexicons/api/index.ts @@ -31,6 +31,7 @@ import * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/hori import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' +import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' @@ -65,6 +66,7 @@ export * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/hori export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' +export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 7b367c5a..56145091 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1185,6 +1185,21 @@ export const schemaDict = { }, }, }, + PubLeafletBlocksPage: { + lexicon: 1, + id: 'pub.leaflet.blocks.page', + defs: { + main: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string', + }, + }, + }, + }, + }, PubLeafletBlocksText: { lexicon: 1, id: 'pub.leaflet.blocks.text', @@ -1310,6 +1325,9 @@ export const schemaDict = { ref: 'lex:pub.leaflet.richtext.facet', }, }, + onPage: { + type: 'string', + }, attachment: { type: 'union', refs: ['lex:pub.leaflet.comment#linearDocumentQuote'], @@ -1422,7 +1440,11 @@ export const schemaDict = { defs: { main: { type: 'object', + required: ['blocks'], properties: { + id: { + type: 'string', + }, blocks: { type: 'array', items: { @@ -1450,6 +1472,7 @@ export const schemaDict = { 'lex:pub.leaflet.blocks.code', 'lex:pub.leaflet.blocks.horizontalRule', 'lex:pub.leaflet.blocks.bskyPost', + 'lex:pub.leaflet.blocks.page', ], }, alignment: { @@ -1521,7 +1544,6 @@ export const schemaDict = { }, base_path: { type: 'string', - format: 'uri', }, description: { type: 'string', @@ -1661,7 +1683,6 @@ export const schemaDict = { properties: { uri: { type: 'string', - format: 'uri', }, }, }, @@ -1845,6 +1866,7 @@ export const ids = { PubLeafletBlocksIframe: 'pub.leaflet.blocks.iframe', PubLeafletBlocksImage: 'pub.leaflet.blocks.image', PubLeafletBlocksMath: 'pub.leaflet.blocks.math', + PubLeafletBlocksPage: 'pub.leaflet.blocks.page', PubLeafletBlocksText: 'pub.leaflet.blocks.text', PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', diff --git a/lexicons/api/types/pub/leaflet/blocks/page.ts b/lexicons/api/types/pub/leaflet/blocks/page.ts new file mode 100644 index 00000000..fa883b13 --- /dev/null +++ b/lexicons/api/types/pub/leaflet/blocks/page.ts @@ -0,0 +1,30 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.blocks.page' + +export interface Main { + $type?: 'pub.leaflet.blocks.page' + id: string +} + +const hashMain = 'main' + +export function isMain(v: V) { + return is$typed(v, id, hashMain) +} + +export function validateMain(v: V) { + return validate
(v, id, hashMain) +} diff --git a/lexicons/api/types/pub/leaflet/comment.ts b/lexicons/api/types/pub/leaflet/comment.ts index ef7a020d..9168104a 100644 --- a/lexicons/api/types/pub/leaflet/comment.ts +++ b/lexicons/api/types/pub/leaflet/comment.ts @@ -19,6 +19,7 @@ export interface Record { reply?: ReplyRef plaintext: string facets?: PubLeafletRichtextFacet.Main[] + onPage?: string attachment?: $Typed | { $type: string } [k: string]: unknown } diff --git a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts index bfc1157b..4fe84e8e 100644 --- a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts +++ b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts @@ -20,6 +20,7 @@ import type * as PubLeafletBlocksMath from '../blocks/math' import type * as PubLeafletBlocksCode from '../blocks/code' import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' +import type * as PubLeafletBlocksPage from '../blocks/page' const is$typed = _is$typed, validate = _validate @@ -27,7 +28,8 @@ const id = 'pub.leaflet.pages.linearDocument' export interface Main { $type?: 'pub.leaflet.pages.linearDocument' - blocks?: Block[] + id?: string + blocks: Block[] } const hashMain = 'main' @@ -54,6 +56,7 @@ export interface Block { | $Typed | $Typed | $Typed + | $Typed | { $type: string } alignment?: | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft' diff --git a/lexicons/pub/leaflet/blocks/page.json b/lexicons/pub/leaflet/blocks/page.json new file mode 100644 index 00000000..ca5b430c --- /dev/null +++ b/lexicons/pub/leaflet/blocks/page.json @@ -0,0 +1,17 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.page", + "defs": { + "main": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/pub/leaflet/comment.json b/lexicons/pub/leaflet/comment.json index afca5a69..57f16a37 100644 --- a/lexicons/pub/leaflet/comment.json +++ b/lexicons/pub/leaflet/comment.json @@ -38,6 +38,9 @@ "ref": "pub.leaflet.richtext.facet" } }, + "onPage": { + "type": "string" + }, "attachment": { "type": "union", "refs": [ diff --git a/lexicons/pub/leaflet/pages/linearDocument.json b/lexicons/pub/leaflet/pages/linearDocument.json index 9bb60800..86be3080 100644 --- a/lexicons/pub/leaflet/pages/linearDocument.json +++ b/lexicons/pub/leaflet/pages/linearDocument.json @@ -4,7 +4,13 @@ "defs": { "main": { "type": "object", + "required": [ + "blocks" + ], "properties": { + "id": { + "type": "string" + }, "blocks": { "type": "array", "items": { @@ -33,7 +39,8 @@ "pub.leaflet.blocks.math", "pub.leaflet.blocks.code", "pub.leaflet.blocks.horizontalRule", - "pub.leaflet.blocks.bskyPost" + "pub.leaflet.blocks.bskyPost", + "pub.leaflet.blocks.page" ] }, "alignment": { diff --git a/lexicons/pub/leaflet/publication.json b/lexicons/pub/leaflet/publication.json index def5d283..1d3ab1e3 100644 --- a/lexicons/pub/leaflet/publication.json +++ b/lexicons/pub/leaflet/publication.json @@ -17,8 +17,7 @@ "maxLength": 2000 }, "base_path": { - "type": "string", - "format": "uri" + "type": "string" }, "description": { "type": "string", diff --git a/lexicons/pub/leaflet/richtext/facet.json b/lexicons/pub/leaflet/richtext/facet.json index f439b150..81fd90c1 100644 --- a/lexicons/pub/leaflet/richtext/facet.json +++ b/lexicons/pub/leaflet/richtext/facet.json @@ -58,8 +58,7 @@ ], "properties": { "uri": { - "type": "string", - "format": "uri" + "type": "string" } } }, diff --git a/lexicons/src/blocks.ts b/lexicons/src/blocks.ts index b416e32b..71eca297 100644 --- a/lexicons/src/blocks.ts +++ b/lexicons/src/blocks.ts @@ -19,6 +19,20 @@ export const PubLeafletBlocksText: LexiconDoc = { }, }; +export const PubLeafletBlocksPage: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.blocks.page", + defs: { + main: { + type: "object", + required: ["id"], + properties: { + id: { type: "string" }, + }, + }, + }, +}; + export const PubLeafletBlocksBskyPost: LexiconDoc = { lexicon: 1, id: "pub.leaflet.blocks.bskyPost", @@ -262,6 +276,7 @@ export const BlockLexicons = [ PubLeafletBlocksCode, PubLeafletBlocksHorizontalRule, PubLeafletBlocksBskyPost, + PubLeafletBlocksPage, ]; export const BlockUnion: LexRefUnion = { type: "union", diff --git a/lexicons/src/comment.ts b/lexicons/src/comment.ts index 18e5a73d..df95864c 100644 --- a/lexicons/src/comment.ts +++ b/lexicons/src/comment.ts @@ -23,6 +23,7 @@ export const PubLeafletComment: LexiconDoc = { type: "array", items: { type: "ref", ref: PubLeafletRichTextFacet.id }, }, + onPage: { type: "string" }, attachment: { type: "union", refs: ["#linearDocumentQuote"] }, }, }, diff --git a/lexicons/src/pages/LinearDocument.ts b/lexicons/src/pages/LinearDocument.ts index 9d699407..9c5a7625 100644 --- a/lexicons/src/pages/LinearDocument.ts +++ b/lexicons/src/pages/LinearDocument.ts @@ -7,7 +7,9 @@ export const PubLeafletPagesLinearDocument: LexiconDoc = { defs: { main: { type: "object", + required: ["blocks"], properties: { + id: { type: "string" }, blocks: { type: "array", items: { type: "ref", ref: "#block" } }, }, }, diff --git a/lexicons/src/publication.ts b/lexicons/src/publication.ts index 0f36ad35..bdca16aa 100644 --- a/lexicons/src/publication.ts +++ b/lexicons/src/publication.ts @@ -14,7 +14,7 @@ export const PubLeafletPublication: LexiconDoc = { required: ["name"], properties: { name: { type: "string", maxLength: 2000 }, - base_path: { type: "string", format: "uri" }, + base_path: { type: "string" }, description: { type: "string", maxLength: 2000 }, icon: { type: "blob", accept: ["image/*"], maxSize: 1000000 }, theme: { type: "ref", ref: "#theme" }, diff --git a/package-lock.json b/package-lock.json index 1e9349d1..c5bef979 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,7 @@ "remark-rehype": "^11.1.0", "remark-stringify": "^11.0.0", "replicache": "^15.3.0", - "sharp": "^0.34.2", + "sharp": "^0.34.4", "shiki": "^3.8.1", "swr": "^2.3.3", "thumbhash": "^0.1.1", @@ -692,6 +692,15 @@ "node": ">=10.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -1038,14 +1047,183 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", - "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1054,14 +1232,97 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", - "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", "cpu": [ "x64" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1073,7 +1334,121 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.0" + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.5.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@inngest/ai": { @@ -7643,19 +8018,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7672,16 +8034,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/colorjs.io": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", @@ -8035,10 +8387,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "license": "Apache-2.0", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "engines": { "node": ">=8" } @@ -10792,12 +11143,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT" - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -15519,14 +15864,13 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", - "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", "hasInstallScript": true, - "license": "Apache-2.0", "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.4", + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "engines": { @@ -15536,28 +15880,28 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.3", - "@img/sharp-darwin-x64": "0.34.3", - "@img/sharp-libvips-darwin-arm64": "1.2.0", - "@img/sharp-libvips-darwin-x64": "1.2.0", - "@img/sharp-libvips-linux-arm": "1.2.0", - "@img/sharp-libvips-linux-arm64": "1.2.0", - "@img/sharp-libvips-linux-ppc64": "1.2.0", - "@img/sharp-libvips-linux-s390x": "1.2.0", - "@img/sharp-libvips-linux-x64": "1.2.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", - "@img/sharp-libvips-linuxmusl-x64": "1.2.0", - "@img/sharp-linux-arm": "0.34.3", - "@img/sharp-linux-arm64": "0.34.3", - "@img/sharp-linux-ppc64": "0.34.3", - "@img/sharp-linux-s390x": "0.34.3", - "@img/sharp-linux-x64": "0.34.3", - "@img/sharp-linuxmusl-arm64": "0.34.3", - "@img/sharp-linuxmusl-x64": "0.34.3", - "@img/sharp-wasm32": "0.34.3", - "@img/sharp-win32-arm64": "0.34.3", - "@img/sharp-win32-ia32": "0.34.3", - "@img/sharp-win32-x64": "0.34.3" + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" } }, "node_modules/shebang-command": { @@ -15686,15 +16030,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", diff --git a/package.json b/package.json index f231a83b..47ae9f47 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "remark-rehype": "^11.1.0", "remark-stringify": "^11.0.0", "replicache": "^15.3.0", - "sharp": "^0.34.2", + "sharp": "^0.34.4", "shiki": "^3.8.1", "swr": "^2.3.3", "thumbhash": "^0.1.1", diff --git a/src/utils/scrollIntoView.ts b/src/utils/scrollIntoView.ts new file mode 100644 index 00000000..6ce31089 --- /dev/null +++ b/src/utils/scrollIntoView.ts @@ -0,0 +1,62 @@ +// Generated with claude code, sonnet 4.5 +/** + * Scrolls an element into view within a scrolling container using Intersection Observer + * and the scrollTo API, instead of the native scrollIntoView. + * + * @param elementId - The ID of the element to scroll into view + * @param scrollContainerId - The ID of the scrolling container (defaults to "pages") + * @param threshold - Intersection observer threshold (0-1, defaults to 0.2 for 20%) + */ +export function scrollIntoView( + elementId: string, + scrollContainerId: string = "pages", + threshold: number = 0.9, +) { + const element = document.getElementById(elementId); + const scrollContainer = document.getElementById(scrollContainerId); + + if (!element || !scrollContainer) { + console.warn(`scrollIntoView: element or container not found`, { + elementId, + scrollContainerId, + element, + scrollContainer, + }); + return; + } + + // Create an intersection observer to check if element is visible + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + + // If element is not sufficiently visible, scroll to it + if (!entry.isIntersecting || entry.intersectionRatio < threshold) { + const elementRect = element.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); + + // Calculate the target scroll position + // We want to center the element horizontally in the container + const targetScrollLeft = + scrollContainer.scrollLeft + + elementRect.left - + containerRect.left - + (containerRect.width - elementRect.width) / 2; + + scrollContainer.scrollTo({ + left: targetScrollLeft, + behavior: "smooth", + }); + } + + // Disconnect after checking once + observer.disconnect(); + }, + { + root: scrollContainer, + threshold: threshold, + }, + ); + + observer.observe(element); +} -- 2.51.2