a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

Changed files
+1800 -1688
actions
app
components
lexicons
api
types
pub
leaflet
pub
leaflet
src
src
+1 -1
.prettierrc
··· 1 - {}
··· 1 + {}
+18 -8
actions/publishToPublication.ts
··· 2 3 import * as Y from "yjs"; 4 import * as base64 from "base64-js"; 5 - import { 6 - restoreOAuthSession, 7 - OAuthSessionError, 8 - } from "src/atproto-oauth"; 9 import { getIdentityData } from "actions/getIdentityData"; 10 import { 11 AtpBaseClient, ··· 50 ColorToRGBA, 51 } from "components/ThemeManager/colorToLexicons"; 52 import { parseColor } from "@react-stately/color"; 53 - import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 54 import { v7 } from "uuid"; 55 56 type PublishResult = ··· 253 254 // Create notifications for mentions (only on first publish) 255 if (!existingDocUri) { 256 - await createMentionNotifications(result.uri, record, credentialSession.did!); 257 } 258 259 return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; ··· 463 464 if (b.type == "text") { 465 let [stringValue, facets] = getBlockContent(b.value); 466 let block: $Typed<PubLeafletBlocksText.Main> = { 467 $type: ids.PubLeafletBlocksText, 468 plaintext: stringValue, 469 facets, 470 }; 471 return block; 472 } ··· 865 .single(); 866 867 if (publication && publication.identity_did !== authorDid) { 868 - mentionedPublications.set(publication.identity_did, feature.atURI); 869 } 870 } else if (uri.collection === "pub.leaflet.document") { 871 // Get the document owner's DID ··· 876 .single(); 877 878 if (document) { 879 - const docRecord = document.data as PubLeafletDocument.Record; 880 if (docRecord.author !== authorDid) { 881 mentionedDocuments.set(docRecord.author, feature.atURI); 882 }
··· 2 3 import * as Y from "yjs"; 4 import * as base64 from "base64-js"; 5 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 6 import { getIdentityData } from "actions/getIdentityData"; 7 import { 8 AtpBaseClient, ··· 47 ColorToRGBA, 48 } from "components/ThemeManager/colorToLexicons"; 49 import { parseColor } from "@react-stately/color"; 50 + import { 51 + Notification, 52 + pingIdentityToUpdateNotification, 53 + } from "src/notifications"; 54 import { v7 } from "uuid"; 55 56 type PublishResult = ··· 253 254 // Create notifications for mentions (only on first publish) 255 if (!existingDocUri) { 256 + await createMentionNotifications( 257 + result.uri, 258 + record, 259 + credentialSession.did!, 260 + ); 261 } 262 263 return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; ··· 467 468 if (b.type == "text") { 469 let [stringValue, facets] = getBlockContent(b.value); 470 + let [textSize] = scan.eav(b.value, "block/text-size"); 471 let block: $Typed<PubLeafletBlocksText.Main> = { 472 $type: ids.PubLeafletBlocksText, 473 plaintext: stringValue, 474 facets, 475 + ...(textSize && { textSize: textSize.data.value }), 476 }; 477 return block; 478 } ··· 871 .single(); 872 873 if (publication && publication.identity_did !== authorDid) { 874 + mentionedPublications.set( 875 + publication.identity_did, 876 + feature.atURI, 877 + ); 878 } 879 } else if (uri.collection === "pub.leaflet.document") { 880 // Get the document owner's DID ··· 885 .single(); 886 887 if (document) { 888 + const docRecord = 889 + document.data as PubLeafletDocument.Record; 890 if (docRecord.author !== authorDid) { 891 mentionedDocuments.set(docRecord.author, feature.atURI); 892 }
+1 -1
app/(home-pages)/notifications/CommentNotication.tsx
··· 1 - import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 2 import { 3 AppBskyActorProfile, 4 PubLeafletComment,
··· 1 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 2 import { 3 AppBskyActorProfile, 4 PubLeafletComment,
+1 -1
app/(home-pages)/notifications/Notification.tsx
··· 1 "use client"; 2 import { Avatar } from "components/Avatar"; 3 - import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 4 import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; 5 import { timeAgo } from "src/utils/timeAgo"; 6 import { useReplicache, useEntity } from "src/replicache";
··· 1 "use client"; 2 import { Avatar } from "components/Avatar"; 3 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 4 import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; 5 import { timeAgo } from "src/utils/timeAgo"; 6 import { useReplicache, useEntity } from "src/replicache";
+1 -1
app/(home-pages)/notifications/ReplyNotification.tsx
··· 1 import { Avatar } from "components/Avatar"; 2 - import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 3 import { ReplyTiny } from "components/Icons/ReplyTiny"; 4 import { 5 CommentInNotification,
··· 1 import { Avatar } from "components/Avatar"; 2 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 3 import { ReplyTiny } from "components/Icons/ReplyTiny"; 4 import { 5 CommentInNotification,
+1 -1
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 6 import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 7 import { ReplyTiny } from "components/Icons/ReplyTiny"; 8 import { Avatar } from "components/Avatar"; 9 - import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 import { 12 getProfileComments,
··· 6 import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 7 import { ReplyTiny } from "components/Icons/ReplyTiny"; 8 import { Avatar } from "components/Avatar"; 9 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 import { 12 getProfileComments,
+24
app/[leaflet_id]/actions/HelpButton.tsx
··· 58 keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]} 59 /> 60 <KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} /> 61 62 <Label>Block Shortcuts</Label> 63 {/* shift + up/down arrows (or click + drag): select multiple blocks */}
··· 58 keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]} 59 /> 60 <KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} /> 61 + <KeyboardShortcut 62 + name="Make Title" 63 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "1"]} 64 + /> 65 + <KeyboardShortcut 66 + name="Make Heading" 67 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "2"]} 68 + /> 69 + <KeyboardShortcut 70 + name="Make Subheading" 71 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "3"]} 72 + /> 73 + <KeyboardShortcut 74 + name="Regular Text" 75 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "0"]} 76 + /> 77 + <KeyboardShortcut 78 + name="Large Text" 79 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "+"]} 80 + /> 81 + <KeyboardShortcut 82 + name="Small Text" 83 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "-"]} 84 + /> 85 86 <Label>Block Shortcuts</Label> 87 {/* shift + up/down arrows (or click + drag): select multiple blocks */}
+2 -2
app/[leaflet_id]/actions/PublishButton.tsx
··· 136 content: ( 137 <div> 138 {pub.doc ? "Updated! " : "Published! "} 139 - <SpeedyLink className="underline" href={docUrl}> 140 - See Published Post 141 </SpeedyLink> 142 </div> 143 ),
··· 136 content: ( 137 <div> 138 {pub.doc ? "Updated! " : "Published! "} 139 + <SpeedyLink className="underline font-bold" href={docUrl}> 140 + See Post 141 </SpeedyLink> 142 </div> 143 ),
+5 -1
app/[leaflet_id]/publish/PublishPost.tsx
··· 199 className="place-self-end h-[30px]" 200 disabled={charCount > 300} 201 > 202 - {isLoading ? <DotLoader /> : "Publish this Post!"} 203 </ButtonPrimary> 204 </div> 205 {oauthError && (
··· 199 className="place-self-end h-[30px]" 200 disabled={charCount > 300} 201 > 202 + {isLoading ? ( 203 + <DotLoader className="h-[23px]" /> 204 + ) : ( 205 + "Publish this Post!" 206 + )} 207 </ButtonPrimary> 208 </div> 209 {oauthError && (
-26
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 - import { ProfilePopover } from "components/ProfilePopover"; 2 - import { TextBlockCore, TextBlockCoreProps, RichText } from "./TextBlockCore"; 3 - import { ReactNode } from "react"; 4 - 5 - // Re-export RichText for backwards compatibility 6 - export { RichText }; 7 - 8 - function DidMentionWithPopover(props: { did: string; children: ReactNode }) { 9 - return ( 10 - <ProfilePopover 11 - didOrHandle={props.did} 12 - trigger={props.children} 13 - /> 14 - ); 15 - } 16 - 17 - export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) { 18 - return ( 19 - <TextBlockCore 20 - {...props} 21 - renderers={{ 22 - DidMention: DidMentionWithPopover, 23 - }} 24 - /> 25 - ); 26 - }
···
+25
app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx
···
··· 1 + import { ProfilePopover } from "components/ProfilePopover"; 2 + import { 3 + TextBlockCore, 4 + TextBlockCoreProps, 5 + RichText, 6 + } from "../Blocks/TextBlockCore"; 7 + import { ReactNode } from "react"; 8 + 9 + // Re-export RichText for backwards compatibility 10 + export { RichText }; 11 + 12 + function DidMentionWithPopover(props: { did: string; children: ReactNode }) { 13 + return <ProfilePopover didOrHandle={props.did} trigger={props.children} />; 14 + } 15 + 16 + export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) { 17 + return ( 18 + <TextBlockCore 19 + {...props} 20 + renderers={{ 21 + DidMention: DidMentionWithPopover, 22 + }} 23 + /> 24 + ); 25 + }
+28
app/lish/[did]/[publication]/[rkey]/Blocks/PubCodeBlock.tsx
···
··· 1 + "use client"; 2 + 3 + import { PubLeafletBlocksCode } from "lexicons/api"; 4 + import { useLayoutEffect, useState } from "react"; 5 + import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 6 + 7 + export function PubCodeBlock({ 8 + block, 9 + prerenderedCode, 10 + }: { 11 + block: PubLeafletBlocksCode.Main; 12 + prerenderedCode?: string; 13 + }) { 14 + const [html, setHTML] = useState<string | null>(prerenderedCode || null); 15 + 16 + useLayoutEffect(() => { 17 + const lang = bundledLanguagesInfo.find((l) => l.id === block.language)?.id || "plaintext"; 18 + const theme = bundledThemesInfo.find((t) => t.id === block.syntaxHighlightingTheme)?.id || "github-light"; 19 + 20 + codeToHtml(block.plaintext, { lang, theme }).then(setHTML); 21 + }, [block]); 22 + return ( 23 + <div 24 + className="w-full min-h-[42px] my-2 rounded-md border-border-light outline-border-light selected-outline" 25 + dangerouslySetInnerHTML={{ __html: html || "" }} 26 + /> 27 + ); 28 + }
+174
app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
···
··· 1 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 2 + import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 + import { Separator } from "components/Layout"; 4 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 5 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 + import { CommentTiny } from "components/Icons/CommentTiny"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 + import { ThreadLink, QuotesLink } from "../PostLinks"; 9 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 + import { 11 + BlueskyEmbed, 12 + PostNotAvailable, 13 + } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 14 + import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 15 + import { openPage } from "../PostPages"; 16 + 17 + export const PubBlueskyPostBlock = (props: { 18 + post: PostView; 19 + className: string; 20 + pageId?: string; 21 + }) => { 22 + let post = props.post; 23 + 24 + const handleOpenThread = () => { 25 + openPage(props.pageId ? { type: "doc", id: props.pageId } : undefined, { 26 + type: "thread", 27 + uri: post.uri, 28 + }); 29 + }; 30 + 31 + switch (true) { 32 + case AppBskyFeedDefs.isBlockedPost(post) || 33 + AppBskyFeedDefs.isBlockedAuthor(post) || 34 + AppBskyFeedDefs.isNotFoundPost(post): 35 + return ( 36 + <div className={`w-full`}> 37 + <PostNotAvailable /> 38 + </div> 39 + ); 40 + 41 + case AppBskyFeedDefs.validatePostView(post).success: 42 + let record = post.record as AppBskyFeedDefs.PostView["record"]; 43 + 44 + // silliness to get the text and timestamp from the record with proper types 45 + let timestamp: string | undefined = undefined; 46 + if (AppBskyFeedPost.isRecord(record)) { 47 + timestamp = (record as AppBskyFeedPost.Record).createdAt; 48 + } 49 + 50 + //getting the url to the post 51 + let postId = post.uri.split("/")[4]; 52 + let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 53 + 54 + const parent = props.pageId 55 + ? { type: "doc" as const, id: props.pageId } 56 + : undefined; 57 + 58 + return ( 59 + <div 60 + onClick={handleOpenThread} 61 + className={` 62 + ${props.className} 63 + block-border 64 + mb-2 65 + flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 66 + cursor-pointer hover:border-accent-contrast 67 + `} 68 + > 69 + {post.author && record && ( 70 + <> 71 + <div className="bskyAuthor w-full flex items-center gap-2"> 72 + {post.author.avatar && ( 73 + <img 74 + src={post.author?.avatar} 75 + alt={`${post.author?.displayName}'s avatar`} 76 + className="shink-0 w-8 h-8 rounded-full border border-border-light" 77 + /> 78 + )} 79 + <div className="grow flex flex-col gap-0.5 leading-tight"> 80 + <div className=" font-bold text-secondary"> 81 + {post.author?.displayName} 82 + </div> 83 + <a 84 + className="text-xs text-tertiary hover:underline" 85 + target="_blank" 86 + href={`https://bsky.app/profile/${post.author?.handle}`} 87 + onClick={(e) => e.stopPropagation()} 88 + > 89 + @{post.author?.handle} 90 + </a> 91 + </div> 92 + </div> 93 + 94 + <div className="flex flex-col gap-2 "> 95 + <div> 96 + <pre className="whitespace-pre-wrap"> 97 + {BlueskyRichText({ 98 + record: record as AppBskyFeedPost.Record | null, 99 + })} 100 + </pre> 101 + </div> 102 + {post.embed && ( 103 + <div onClick={(e) => e.stopPropagation()}> 104 + <BlueskyEmbed embed={post.embed} postUrl={url} /> 105 + </div> 106 + )} 107 + </div> 108 + </> 109 + )} 110 + <div className="w-full flex gap-2 items-center justify-between"> 111 + <ClientDate date={timestamp} /> 112 + <div className="flex gap-2 items-center"> 113 + {post.replyCount != null && post.replyCount > 0 && ( 114 + <> 115 + <ThreadLink 116 + threadUri={post.uri} 117 + parent={parent} 118 + className="flex items-center gap-1 hover:text-accent-contrast" 119 + onClick={(e) => e.stopPropagation()} 120 + > 121 + {post.replyCount} 122 + <CommentTiny /> 123 + </ThreadLink> 124 + <Separator classname="h-4" /> 125 + </> 126 + )} 127 + {post.quoteCount != null && post.quoteCount > 0 && ( 128 + <> 129 + <QuotesLink 130 + postUri={post.uri} 131 + parent={parent} 132 + className="flex items-center gap-1 hover:text-accent-contrast" 133 + onClick={(e) => e.stopPropagation()} 134 + > 135 + {post.quoteCount} 136 + <QuoteTiny /> 137 + </QuotesLink> 138 + <Separator classname="h-4" /> 139 + </> 140 + )} 141 + 142 + <a 143 + className="" 144 + target="_blank" 145 + href={url} 146 + onClick={(e) => e.stopPropagation()} 147 + > 148 + <BlueskyTiny /> 149 + </a> 150 + </div> 151 + </div> 152 + </div> 153 + ); 154 + } 155 + }; 156 + 157 + const ClientDate = (props: { date?: string }) => { 158 + let pageLoaded = useHasPageLoaded(); 159 + const formattedDate = useLocalizedDate( 160 + props.date || new Date().toISOString(), 161 + { 162 + month: "short", 163 + day: "numeric", 164 + year: "numeric", 165 + hour: "numeric", 166 + minute: "numeric", 167 + hour12: true, 168 + }, 169 + ); 170 + 171 + if (!pageLoaded) return null; 172 + 173 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 174 + };
+344
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
···
··· 1 + "use client"; 2 + 3 + import { useEntity, useReplicache } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { CSSProperties, useContext, useRef } from "react"; 6 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 + import { PostContent, Block } from "../PostContent"; 8 + import { 9 + PubLeafletBlocksHeader, 10 + PubLeafletBlocksText, 11 + PubLeafletComment, 12 + PubLeafletPagesLinearDocument, 13 + PubLeafletPagesCanvas, 14 + PubLeafletPublication, 15 + } from "lexicons/api"; 16 + import { AppBskyFeedDefs } from "@atproto/api"; 17 + import { TextBlock } from "./TextBlock"; 18 + import { PostPageContext } from "../PostPageContext"; 19 + import { openPage, useOpenPages } from "../PostPages"; 20 + import { 21 + openInteractionDrawer, 22 + setInteractionState, 23 + useInteractionState, 24 + } from "../Interactions/Interactions"; 25 + import { CommentTiny } from "components/Icons/CommentTiny"; 26 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 + import { CanvasBackgroundPattern } from "components/Canvas"; 28 + 29 + export function PublishedPageLinkBlock(props: { 30 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; 31 + parentPageId: string | undefined; 32 + pageId: string; 33 + did: string; 34 + preview?: boolean; 35 + className?: string; 36 + prerenderedCodeBlocks?: Map<string, string>; 37 + bskyPostData: AppBskyFeedDefs.PostView[]; 38 + isCanvas?: boolean; 39 + pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 40 + }) { 41 + //switch to use actually state 42 + let openPages = useOpenPages(); 43 + let isOpen = openPages.some((p) => p.type === "doc" && p.id === props.pageId); 44 + return ( 45 + <div 46 + className={`w-full cursor-pointer 47 + pageLinkBlockWrapper relative group/pageLinkBlock 48 + bg-bg-page shadow-sm 49 + flex overflow-clip 50 + block-border 51 + ${isOpen && "!border-tertiary"} 52 + ${props.className} 53 + `} 54 + onClick={(e) => { 55 + if (e.isDefaultPrevented()) return; 56 + if (e.shiftKey) return; 57 + e.preventDefault(); 58 + e.stopPropagation(); 59 + 60 + openPage( 61 + props.parentPageId 62 + ? { type: "doc", id: props.parentPageId } 63 + : undefined, 64 + { type: "doc", id: props.pageId }, 65 + ); 66 + }} 67 + > 68 + {props.isCanvas ? ( 69 + <CanvasLinkBlock 70 + blocks={props.blocks as PubLeafletPagesCanvas.Block[]} 71 + did={props.did} 72 + pageId={props.pageId} 73 + bskyPostData={props.bskyPostData} 74 + pages={props.pages || []} 75 + /> 76 + ) : ( 77 + <DocLinkBlock 78 + {...props} 79 + blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]} 80 + /> 81 + )} 82 + </div> 83 + ); 84 + } 85 + export function DocLinkBlock(props: { 86 + blocks: PubLeafletPagesLinearDocument.Block[]; 87 + pageId: string; 88 + parentPageId?: string; 89 + did: string; 90 + preview?: boolean; 91 + className?: string; 92 + prerenderedCodeBlocks?: Map<string, string>; 93 + bskyPostData: AppBskyFeedDefs.PostView[]; 94 + }) { 95 + let [title, description] = props.blocks 96 + .map((b) => b.block) 97 + .filter( 98 + (b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b), 99 + ); 100 + 101 + return ( 102 + <div 103 + style={{ "--list-marker-width": "20px" } as CSSProperties} 104 + className={` 105 + w-full h-[104px] 106 + `} 107 + > 108 + <> 109 + <div className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full"> 110 + <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip flex flex-col "> 111 + <div className="grow"> 112 + {title && ( 113 + <div 114 + className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 115 + > 116 + <TextBlock 117 + facets={title.facets} 118 + plaintext={title.plaintext} 119 + index={[]} 120 + preview 121 + /> 122 + </div> 123 + )} 124 + {description && ( 125 + <div 126 + className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 127 + > 128 + <TextBlock 129 + facets={description.facets} 130 + plaintext={description.plaintext} 131 + index={[]} 132 + preview 133 + /> 134 + </div> 135 + )} 136 + </div> 137 + 138 + <Interactions 139 + pageId={props.pageId} 140 + parentPageId={props.parentPageId} 141 + /> 142 + </div> 143 + {!props.preview && ( 144 + <PagePreview blocks={props.blocks} did={props.did} /> 145 + )} 146 + </div> 147 + </> 148 + </div> 149 + ); 150 + } 151 + 152 + export function PagePreview(props: { 153 + did: string; 154 + blocks: PubLeafletPagesLinearDocument.Block[]; 155 + }) { 156 + let previewRef = useRef<HTMLDivElement | null>(null); 157 + let { rootEntity } = useReplicache(); 158 + let data = useContext(PostPageContext); 159 + let theme = data?.theme; 160 + let pageWidth = `var(--page-width-unitless)`; 161 + let cardBorderHidden = !theme?.showPageBackground; 162 + return ( 163 + <div 164 + ref={previewRef} 165 + className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`} 166 + > 167 + <div 168 + className="absolute top-0 left-0 origin-top-left pointer-events-none " 169 + style={{ 170 + width: `calc(1px * ${pageWidth})`, 171 + height: `calc(100vh - 64px)`, 172 + transform: `scale(calc((120 / ${pageWidth} )))`, 173 + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 174 + }} 175 + > 176 + {!cardBorderHidden && ( 177 + <div 178 + className={`pageLinkBlockBackground 179 + absolute top-0 left-0 right-0 bottom-0 180 + pointer-events-none 181 + `} 182 + /> 183 + )} 184 + <PostContent 185 + pollData={[]} 186 + pages={[]} 187 + did={props.did} 188 + blocks={props.blocks} 189 + preview 190 + bskyPostData={[]} 191 + /> 192 + </div> 193 + </div> 194 + ); 195 + } 196 + 197 + const Interactions = (props: { pageId: string; parentPageId?: string }) => { 198 + const data = useContext(PostPageContext); 199 + const document_uri = data?.uri; 200 + if (!document_uri) 201 + throw new Error("document_uri not available in PostPageContext"); 202 + let comments = data.comments_on_documents.filter( 203 + (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 204 + ).length; 205 + let quotes = data.document_mentions_in_bsky.filter((q) => 206 + q.link.includes(props.pageId), 207 + ).length; 208 + 209 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 210 + 211 + return ( 212 + <div 213 + className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`} 214 + > 215 + {quotes > 0 && ( 216 + <button 217 + className={`flex gap-1 items-center`} 218 + onClick={(e) => { 219 + e.preventDefault(); 220 + e.stopPropagation(); 221 + openPage( 222 + props.parentPageId 223 + ? { type: "doc", id: props.parentPageId } 224 + : undefined, 225 + { type: "doc", id: props.pageId }, 226 + { scrollIntoView: false }, 227 + ); 228 + if (!drawerOpen || drawer !== "quotes") 229 + openInteractionDrawer("quotes", document_uri, props.pageId); 230 + else setInteractionState(document_uri, { drawerOpen: false }); 231 + }} 232 + > 233 + <span className="sr-only">Page quotes</span> 234 + <QuoteTiny aria-hidden /> {quotes}{" "} 235 + </button> 236 + )} 237 + {comments > 0 && ( 238 + <button 239 + className={`flex gap-1 items-center`} 240 + onClick={(e) => { 241 + e.preventDefault(); 242 + e.stopPropagation(); 243 + openPage( 244 + props.parentPageId 245 + ? { type: "doc", id: props.parentPageId } 246 + : undefined, 247 + { type: "doc", id: props.pageId }, 248 + { scrollIntoView: false }, 249 + ); 250 + if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 251 + openInteractionDrawer("comments", document_uri, props.pageId); 252 + else setInteractionState(document_uri, { drawerOpen: false }); 253 + }} 254 + > 255 + <span className="sr-only">Page comments</span> 256 + <CommentTiny aria-hidden /> {comments}{" "} 257 + </button> 258 + )} 259 + </div> 260 + ); 261 + }; 262 + 263 + const CanvasLinkBlock = (props: { 264 + blocks: PubLeafletPagesCanvas.Block[]; 265 + did: string; 266 + pageId: string; 267 + bskyPostData: AppBskyFeedDefs.PostView[]; 268 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 269 + }) => { 270 + let pageWidth = `var(--page-width-unitless)`; 271 + let height = 272 + props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0; 273 + 274 + return ( 275 + <div 276 + style={{ contain: "size layout paint" }} 277 + className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`} 278 + > 279 + <div 280 + className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`} 281 + style={{ 282 + width: `calc(1px * ${pageWidth})`, 283 + height: "calc(1150px * 2)", 284 + transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`, 285 + }} 286 + > 287 + <div 288 + style={{ 289 + minHeight: height + 512, 290 + contain: "size layout paint", 291 + }} 292 + className="relative h-full w-[1272px]" 293 + > 294 + <div className="w-full h-full pointer-events-none"> 295 + <CanvasBackgroundPattern pattern="grid" /> 296 + </div> 297 + {props.blocks 298 + .sort((a, b) => { 299 + if (a.y === b.y) { 300 + return a.x - b.x; 301 + } 302 + return a.y - b.y; 303 + }) 304 + .map((canvasBlock, index) => { 305 + let { x, y, width, rotation } = canvasBlock; 306 + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 307 + 308 + // Wrap the block in a LinearDocument.Block structure for compatibility 309 + let linearBlock: PubLeafletPagesLinearDocument.Block = { 310 + $type: "pub.leaflet.pages.linearDocument#block", 311 + block: canvasBlock.block, 312 + }; 313 + 314 + return ( 315 + <div 316 + key={index} 317 + className="absolute rounded-lg flex items-stretch origin-center p-3" 318 + style={{ 319 + top: 0, 320 + left: 0, 321 + width, 322 + transform, 323 + }} 324 + > 325 + <div className="contents"> 326 + <Block 327 + pollData={[]} 328 + pageId={props.pageId} 329 + pages={props.pages} 330 + bskyPostData={props.bskyPostData} 331 + block={linearBlock} 332 + did={props.did} 333 + index={[index]} 334 + preview={true} 335 + /> 336 + </div> 337 + </div> 338 + ); 339 + })} 340 + </div> 341 + </div> 342 + </div> 343 + ); 344 + };
+346
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx
···
··· 1 + "use client"; 2 + 3 + import { 4 + PubLeafletBlocksPoll, 5 + PubLeafletPollDefinition, 6 + PubLeafletPollVote, 7 + } from "lexicons/api"; 8 + import { useState, useEffect } from "react"; 9 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 10 + import { useIdentityData } from "components/IdentityProvider"; 11 + import { AtpAgent } from "@atproto/api"; 12 + import { voteOnPublishedPoll } from "../voteOnPublishedPoll"; 13 + import { PollData } from "../fetchPollData"; 14 + import { Popover } from "components/Popover"; 15 + import LoginForm from "app/login/LoginForm"; 16 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 17 + import { getVoterIdentities, VoterIdentity } from "../getVoterIdentities"; 18 + import { Json } from "supabase/database.types"; 19 + import { InfoSmall } from "components/Icons/InfoSmall"; 20 + 21 + // Helper function to extract the first option from a vote record 22 + const getVoteOption = (voteRecord: any): string | null => { 23 + try { 24 + const record = voteRecord as PubLeafletPollVote.Record; 25 + return record.option && record.option.length > 0 ? record.option[0] : null; 26 + } catch { 27 + return null; 28 + } 29 + }; 30 + 31 + export const PublishedPollBlock = (props: { 32 + block: PubLeafletBlocksPoll.Main; 33 + pollData: PollData; 34 + className?: string; 35 + }) => { 36 + const { identity } = useIdentityData(); 37 + const [selectedOption, setSelectedOption] = useState<string | null>(null); 38 + const [isVoting, setIsVoting] = useState(false); 39 + const [showResults, setShowResults] = useState(false); 40 + const [optimisticVote, setOptimisticVote] = useState<{ 41 + option: string; 42 + voter_did: string; 43 + } | null>(null); 44 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 45 + let [isClient, setIsClient] = useState(false); 46 + useEffect(() => { 47 + setIsClient(true); 48 + }, []); 49 + 50 + const handleVote = async () => { 51 + if (!selectedOption || !identity?.atp_did) return; 52 + 53 + setIsVoting(true); 54 + 55 + // Optimistically add the vote 56 + setOptimisticVote({ 57 + option: selectedOption, 58 + voter_did: identity.atp_did, 59 + }); 60 + setShowResults(true); 61 + 62 + try { 63 + const result = await voteOnPublishedPoll( 64 + props.block.pollRef.uri, 65 + props.block.pollRef.cid, 66 + selectedOption, 67 + ); 68 + 69 + if (!result.success) { 70 + console.error("Failed to vote:", result.error); 71 + // Revert optimistic update on failure 72 + setOptimisticVote(null); 73 + setShowResults(false); 74 + } 75 + } catch (error) { 76 + console.error("Failed to vote:", error); 77 + // Revert optimistic update on failure 78 + setOptimisticVote(null); 79 + setShowResults(false); 80 + } finally { 81 + setIsVoting(false); 82 + } 83 + }; 84 + 85 + const hasVoted = 86 + !!identity?.atp_did && 87 + (!!props.pollData?.atp_poll_votes.find( 88 + (v) => v.voter_did === identity?.atp_did, 89 + ) || 90 + !!optimisticVote); 91 + let isCreator = 92 + identity?.atp_did && props.pollData.uri.includes(identity?.atp_did); 93 + const displayResults = showResults || hasVoted; 94 + 95 + return ( 96 + <div 97 + className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`} 98 + style={{ 99 + backgroundColor: 100 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 101 + }} 102 + > 103 + {displayResults ? ( 104 + <> 105 + <PollResults 106 + pollData={props.pollData} 107 + hasVoted={hasVoted} 108 + setShowResults={setShowResults} 109 + optimisticVote={optimisticVote} 110 + /> 111 + {!hasVoted && ( 112 + <div className="flex justify-start"> 113 + <button 114 + className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 115 + onClick={() => setShowResults(false)} 116 + > 117 + Back to Voting 118 + </button> 119 + </div> 120 + )} 121 + </> 122 + ) : ( 123 + <> 124 + {pollRecord.options.map((option, index) => ( 125 + <PollOptionButton 126 + key={index} 127 + option={option} 128 + optionIndex={index.toString()} 129 + selected={selectedOption === index.toString()} 130 + onSelect={() => setSelectedOption(index.toString())} 131 + disabled={!identity?.atp_did} 132 + /> 133 + ))} 134 + <div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-2 items-center pt-2"> 135 + <div className="text-sm text-tertiary">All votes are public</div> 136 + <div className="flex sm:gap-3 sm:flex-row flex-col-reverse sm:justify-end justify-center gap-1 items-center"> 137 + <button 138 + className="w-fit font-bold text-accent-contrast" 139 + onClick={() => setShowResults(!showResults)} 140 + > 141 + See Results 142 + </button> 143 + {identity?.atp_did ? ( 144 + <ButtonPrimary 145 + className="place-self-end" 146 + onClick={handleVote} 147 + disabled={!selectedOption || isVoting} 148 + > 149 + {isVoting ? "Voting..." : "Vote!"} 150 + </ButtonPrimary> 151 + ) : ( 152 + <Popover 153 + asChild 154 + trigger={ 155 + <ButtonPrimary className="place-self-center"> 156 + <BlueskyTiny /> Login to vote 157 + </ButtonPrimary> 158 + } 159 + > 160 + {isClient && ( 161 + <LoginForm 162 + text="Log in to vote on this poll!" 163 + noEmail 164 + redirectRoute={window?.location.href + "?refreshAuth"} 165 + /> 166 + )} 167 + </Popover> 168 + )} 169 + </div> 170 + </div> 171 + </> 172 + )} 173 + </div> 174 + ); 175 + }; 176 + 177 + const PollOptionButton = (props: { 178 + option: PubLeafletPollDefinition.Option; 179 + optionIndex: string; 180 + selected: boolean; 181 + onSelect: () => void; 182 + disabled?: boolean; 183 + }) => { 184 + const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary; 185 + 186 + return ( 187 + <div className="flex gap-2 items-center"> 188 + <ButtonComponent 189 + className="pollOption grow max-w-full flex" 190 + onClick={props.onSelect} 191 + disabled={props.disabled} 192 + > 193 + {props.option.text} 194 + </ButtonComponent> 195 + </div> 196 + ); 197 + }; 198 + 199 + const PollResults = (props: { 200 + pollData: PollData; 201 + hasVoted: boolean; 202 + setShowResults: (show: boolean) => void; 203 + optimisticVote: { option: string; voter_did: string } | null; 204 + }) => { 205 + // Merge optimistic vote with actual votes 206 + const allVotes = props.optimisticVote 207 + ? [ 208 + ...props.pollData.atp_poll_votes, 209 + { 210 + voter_did: props.optimisticVote.voter_did, 211 + record: { 212 + $type: "pub.leaflet.poll.vote", 213 + option: [props.optimisticVote.option], 214 + }, 215 + }, 216 + ] 217 + : props.pollData.atp_poll_votes; 218 + 219 + const totalVotes = allVotes.length || 0; 220 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 221 + let optionsWithCount = pollRecord.options.map((o, index) => ({ 222 + ...o, 223 + votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()), 224 + })); 225 + 226 + const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length)); 227 + return ( 228 + <> 229 + {pollRecord.options.map((option, index) => { 230 + const voteRecords = allVotes.filter( 231 + (v) => getVoteOption(v.record) === index.toString(), 232 + ); 233 + const isWinner = totalVotes > 0 && voteRecords.length === highestVotes; 234 + 235 + return ( 236 + <PollResult 237 + key={index} 238 + option={option} 239 + votes={voteRecords.length} 240 + voteRecords={voteRecords} 241 + totalVotes={totalVotes} 242 + winner={isWinner} 243 + /> 244 + ); 245 + })} 246 + </> 247 + ); 248 + }; 249 + 250 + const VoterListPopover = (props: { 251 + votes: number; 252 + voteRecords: { voter_did: string; record: Json }[]; 253 + }) => { 254 + const [voterIdentities, setVoterIdentities] = useState<VoterIdentity[]>([]); 255 + const [isLoading, setIsLoading] = useState(false); 256 + const [hasFetched, setHasFetched] = useState(false); 257 + 258 + const handleOpenChange = async () => { 259 + if (!hasFetched && props.voteRecords.length > 0) { 260 + setIsLoading(true); 261 + setHasFetched(true); 262 + try { 263 + const dids = props.voteRecords.map((v) => v.voter_did); 264 + const identities = await getVoterIdentities(dids); 265 + setVoterIdentities(identities); 266 + } catch (error) { 267 + console.error("Failed to fetch voter identities:", error); 268 + } finally { 269 + setIsLoading(false); 270 + } 271 + } 272 + }; 273 + 274 + return ( 275 + <Popover 276 + trigger={ 277 + <button 278 + className="hover:underline cursor-pointer" 279 + disabled={props.votes === 0} 280 + > 281 + {props.votes} 282 + </button> 283 + } 284 + onOpenChange={handleOpenChange} 285 + className="w-64 max-h-80" 286 + > 287 + {isLoading ? ( 288 + <div className="flex justify-center py-4"> 289 + <div className="text-sm text-secondary">Loading...</div> 290 + </div> 291 + ) : ( 292 + <div className="flex flex-col gap-1 text-sm py-0.5"> 293 + {voterIdentities.map((voter) => ( 294 + <a 295 + key={voter.did} 296 + href={`https://bsky.app/profile/${voter.handle || voter.did}`} 297 + target="_blank" 298 + rel="noopener noreferrer" 299 + className="" 300 + > 301 + @{voter.handle || voter.did} 302 + </a> 303 + ))} 304 + </div> 305 + )} 306 + </Popover> 307 + ); 308 + }; 309 + 310 + const PollResult = (props: { 311 + option: PubLeafletPollDefinition.Option; 312 + votes: number; 313 + voteRecords: { voter_did: string; record: Json }[]; 314 + totalVotes: number; 315 + winner: boolean; 316 + }) => { 317 + return ( 318 + <div 319 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 320 + > 321 + <div 322 + style={{ 323 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`, 324 + paintOrder: "stroke fill", 325 + }} 326 + className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10" 327 + > 328 + <div className="grow max-w-full truncate">{props.option.text}</div> 329 + <VoterListPopover votes={props.votes} voteRecords={props.voteRecords} /> 330 + </div> 331 + <div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0"> 332 + <div 333 + className="bg-accent-contrast rounded-[2px] m-0.5" 334 + style={{ 335 + maskImage: "var(--hatchSVG)", 336 + maskRepeat: "repeat repeat", 337 + ...(props.votes === 0 338 + ? { width: "4px" } 339 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 340 + }} 341 + /> 342 + <div /> 343 + </div> 344 + </div> 345 + ); 346 + };
+20
app/lish/[did]/[publication]/[rkey]/Blocks/StaticMathBlock.tsx
···
··· 1 + import { PubLeafletBlocksMath } from "lexicons/api"; 2 + import Katex from "katex"; 3 + import "katex/dist/katex.min.css"; 4 + 5 + export const StaticMathBlock = ({ 6 + block, 7 + }: { 8 + block: PubLeafletBlocksMath.Main; 9 + }) => { 10 + const html = Katex.renderToString(block.tex, { 11 + displayMode: true, 12 + output: "html", 13 + throwOnError: false, 14 + }); 15 + return ( 16 + <div className="math-block my-2"> 17 + <div dangerouslySetInnerHTML={{ __html: html }} /> 18 + </div> 19 + ); 20 + };
+95
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx
···
··· 1 + "use client"; 2 + import { UnicodeString } from "@atproto/api"; 3 + import { PubLeafletRichtextFacet } from "lexicons/api"; 4 + import { useMemo } from "react"; 5 + import { useHighlight } from "../useHighlight"; 6 + import { BaseTextBlock } from "./BaseTextBlock"; 7 + 8 + type Facet = PubLeafletRichtextFacet.Main; 9 + export function TextBlock(props: { 10 + plaintext: string; 11 + facets?: Facet[]; 12 + index: number[]; 13 + preview?: boolean; 14 + pageId?: string; 15 + }) { 16 + let children = []; 17 + let highlights = useHighlight(props.index, props.pageId); 18 + let facets = useMemo(() => { 19 + if (props.preview) return props.facets; 20 + let facets = [...(props.facets || [])]; 21 + for (let highlight of highlights) { 22 + const fragmentId = props.pageId 23 + ? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}` 24 + : `${props.index.join(".")}_${highlight.startOffset || 0}`; 25 + facets = addFacet( 26 + facets, 27 + { 28 + index: { 29 + byteStart: highlight.startOffset 30 + ? new UnicodeString( 31 + props.plaintext.slice(0, highlight.startOffset), 32 + ).length 33 + : 0, 34 + byteEnd: new UnicodeString( 35 + props.plaintext.slice(0, highlight.endOffset ?? undefined), 36 + ).length, 37 + }, 38 + features: [ 39 + { $type: "pub.leaflet.richtext.facet#highlight" }, 40 + { 41 + $type: "pub.leaflet.richtext.facet#id", 42 + id: fragmentId, 43 + }, 44 + ], 45 + }, 46 + new UnicodeString(props.plaintext).length, 47 + ); 48 + } 49 + return facets; 50 + }, [props.plaintext, props.facets, highlights, props.preview, props.pageId]); 51 + return <BaseTextBlock {...props} facets={facets} />; 52 + } 53 + 54 + function addFacet(facets: Facet[], newFacet: Facet, length: number) { 55 + if (facets.length === 0) { 56 + return [newFacet]; 57 + } 58 + 59 + const allFacets = [...facets, newFacet]; 60 + 61 + // Collect all boundary positions 62 + const boundaries = new Set<number>(); 63 + boundaries.add(0); 64 + boundaries.add(length); 65 + 66 + for (const facet of allFacets) { 67 + boundaries.add(facet.index.byteStart); 68 + boundaries.add(facet.index.byteEnd); 69 + } 70 + 71 + const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); 72 + const result: Facet[] = []; 73 + 74 + // Process segments between consecutive boundaries 75 + for (let i = 0; i < sortedBoundaries.length - 1; i++) { 76 + const start = sortedBoundaries[i]; 77 + const end = sortedBoundaries[i + 1]; 78 + 79 + // Find facets that are active at the start position 80 + const activeFacets = allFacets.filter( 81 + (facet) => facet.index.byteStart <= start && facet.index.byteEnd > start, 82 + ); 83 + 84 + // Only create facet if there are active facets (features present) 85 + if (activeFacets.length > 0) { 86 + const features = activeFacets.flatMap((f) => f.features); 87 + result.push({ 88 + index: { byteStart: start, byteEnd: end }, 89 + features, 90 + }); 91 + } 92 + } 93 + 94 + return result; 95 + }
+181
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx
···
··· 1 + import { UnicodeString } from "@atproto/api"; 2 + import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { AtMentionLink } from "components/AtMentionLink"; 4 + import { ReactNode } from "react"; 5 + 6 + type Facet = PubLeafletRichtextFacet.Main; 7 + 8 + export type FacetRenderers = { 9 + DidMention?: (props: { did: string; children: ReactNode }) => ReactNode; 10 + }; 11 + 12 + export type TextBlockCoreProps = { 13 + plaintext: string; 14 + facets?: Facet[]; 15 + index: number[]; 16 + preview?: boolean; 17 + renderers?: FacetRenderers; 18 + }; 19 + 20 + export function TextBlockCore(props: TextBlockCoreProps) { 21 + let children = []; 22 + let richText = new RichText({ 23 + text: props.plaintext, 24 + facets: props.facets || [], 25 + }); 26 + let counter = 0; 27 + for (const segment of richText.segments()) { 28 + let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 29 + let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 30 + let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 31 + let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 32 + let isStrikethrough = segment.facet?.find( 33 + PubLeafletRichtextFacet.isStrikethrough, 34 + ); 35 + let isDidMention = segment.facet?.find( 36 + PubLeafletRichtextFacet.isDidMention, 37 + ); 38 + let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); 39 + let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 40 + let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 41 + let isHighlighted = segment.facet?.find( 42 + PubLeafletRichtextFacet.isHighlight, 43 + ); 44 + let className = ` 45 + ${isCode ? "inline-code" : ""} 46 + ${id ? "scroll-mt-12 scroll-mb-10" : ""} 47 + ${isBold ? "font-bold" : ""} 48 + ${isItalic ? "italic" : ""} 49 + ${isUnderline ? "underline" : ""} 50 + ${isStrikethrough ? "line-through decoration-tertiary" : ""} 51 + ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 52 + 53 + // Split text by newlines and insert <br> tags 54 + const textParts = segment.text.split("\n"); 55 + const renderedText = textParts.flatMap((part, i) => 56 + i < textParts.length - 1 57 + ? [part, <br key={`br-${counter}-${i}`} />] 58 + : [part], 59 + ); 60 + 61 + if (isCode) { 62 + children.push( 63 + <code key={counter} className={className} id={id?.id}> 64 + {renderedText} 65 + </code>, 66 + ); 67 + } else if (isDidMention) { 68 + const DidMentionRenderer = props.renderers?.DidMention; 69 + if (DidMentionRenderer) { 70 + children.push( 71 + <DidMentionRenderer key={counter} did={isDidMention.did}> 72 + <span className="mention">{renderedText}</span> 73 + </DidMentionRenderer>, 74 + ); 75 + } else { 76 + // Default: render as a simple link 77 + children.push( 78 + <a 79 + key={counter} 80 + href={`https://leaflet.pub/p/${isDidMention.did}`} 81 + target="_blank" 82 + className="no-underline" 83 + > 84 + <span className="mention">{renderedText}</span> 85 + </a>, 86 + ); 87 + } 88 + } else if (isAtMention) { 89 + children.push( 90 + <AtMentionLink 91 + key={counter} 92 + atURI={isAtMention.atURI} 93 + className={className} 94 + > 95 + {renderedText} 96 + </AtMentionLink>, 97 + ); 98 + } else if (link) { 99 + children.push( 100 + <a 101 + key={counter} 102 + href={link.uri.trim()} 103 + className={`text-accent-contrast hover:underline ${className}`} 104 + target="_blank" 105 + > 106 + {renderedText} 107 + </a>, 108 + ); 109 + } else { 110 + children.push( 111 + <span key={counter} className={className} id={id?.id}> 112 + {renderedText} 113 + </span>, 114 + ); 115 + } 116 + 117 + counter++; 118 + } 119 + return <>{children}</>; 120 + } 121 + 122 + type RichTextSegment = { 123 + text: string; 124 + facet?: Exclude<Facet["features"], { $type: string }>; 125 + }; 126 + 127 + export class RichText { 128 + unicodeText: UnicodeString; 129 + facets?: Facet[]; 130 + 131 + constructor(props: { text: string; facets: Facet[] }) { 132 + this.unicodeText = new UnicodeString(props.text); 133 + this.facets = props.facets; 134 + if (this.facets) { 135 + this.facets = this.facets 136 + .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 137 + .sort((a, b) => a.index.byteStart - b.index.byteStart); 138 + } 139 + } 140 + 141 + *segments(): Generator<RichTextSegment, void, void> { 142 + const facets = this.facets || []; 143 + if (!facets.length) { 144 + yield { text: this.unicodeText.utf16 }; 145 + return; 146 + } 147 + 148 + let textCursor = 0; 149 + let facetCursor = 0; 150 + do { 151 + const currFacet = facets[facetCursor]; 152 + if (textCursor < currFacet.index.byteStart) { 153 + yield { 154 + text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 155 + }; 156 + } else if (textCursor > currFacet.index.byteStart) { 157 + facetCursor++; 158 + continue; 159 + } 160 + if (currFacet.index.byteStart < currFacet.index.byteEnd) { 161 + const subtext = this.unicodeText.slice( 162 + currFacet.index.byteStart, 163 + currFacet.index.byteEnd, 164 + ); 165 + if (!subtext.trim()) { 166 + // dont empty string entities 167 + yield { text: subtext }; 168 + } else { 169 + yield { text: subtext, facet: currFacet.features }; 170 + } 171 + } 172 + textCursor = currFacet.index.byteEnd; 173 + facetCursor++; 174 + } while (facetCursor < facets.length); 175 + if (textCursor < this.unicodeText.length) { 176 + yield { 177 + text: this.unicodeText.slice(textCursor, this.unicodeText.length), 178 + }; 179 + } 180 + } 181 + }
+1 -6
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 202 isSubpage: boolean | undefined; 203 data: PostPageData; 204 profile: ProfileViewDetailed; 205 - preferences: { 206 - showComments?: boolean; 207 - showMentions?: boolean; 208 - showPrevNext?: boolean; 209 - }; 210 quotesCount: number | undefined; 211 commentsCount: number | undefined; 212 }) => { ··· 217 quotesCount={props.quotesCount || 0} 218 commentsCount={props.commentsCount || 0} 219 showComments={props.preferences.showComments} 220 - showMentions={props.preferences.showMentions} 221 pageId={props.pageId} 222 /> 223 {!props.isSubpage && (
··· 202 isSubpage: boolean | undefined; 203 data: PostPageData; 204 profile: ProfileViewDetailed; 205 + preferences: { showComments?: boolean }; 206 quotesCount: number | undefined; 207 commentsCount: number | undefined; 208 }) => { ··· 213 quotesCount={props.quotesCount || 0} 214 commentsCount={props.commentsCount || 0} 215 showComments={props.preferences.showComments} 216 pageId={props.pageId} 217 /> 218 {!props.isSubpage && (
+2 -5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 5 import { CommentBox } from "./CommentBox"; 6 import { Json } from "supabase/database.types"; 7 import { PubLeafletComment } from "lexicons/api"; 8 - import { BaseTextBlock } from "../../BaseTextBlock"; 9 import { useMemo, useState } from "react"; 10 import { CommentTiny } from "components/Icons/CommentTiny"; 11 import { Separator } from "components/Layout"; ··· 51 }, []); 52 53 return ( 54 - <div 55 - id={"commentsDrawer"} 56 - className="flex flex-col gap-2 relative text-sm text-secondary" 57 - > 58 <div className="w-full flex justify-between text-secondary font-bold"> 59 Comments 60 <button
··· 5 import { CommentBox } from "./CommentBox"; 6 import { Json } from "supabase/database.types"; 7 import { PubLeafletComment } from "lexicons/api"; 8 + import { BaseTextBlock } from "../../Blocks/BaseTextBlock"; 9 import { useMemo, useState } from "react"; 10 import { CommentTiny } from "components/Icons/CommentTiny"; 11 import { Separator } from "components/Layout"; ··· 51 }, []); 52 53 return ( 54 + <div id={"commentsDrawer"} className="flex flex-col gap-2 relative"> 55 <div className="w-full flex justify-between text-secondary font-bold"> 56 Comments 57 <button
+1 -2
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 9 import { decodeQuotePosition } from "../quotePosition"; 10 11 export const InteractionDrawer = (props: { 12 - showPageBackground: boolean | undefined; 13 document_uri: string; 14 quotesAndMentions: { uri: string; link?: string }[]; 15 comments: Comment[]; ··· 39 <div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]"> 40 <div 41 id="interaction-drawer" 42 - className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] ${props.showPageBackground ? "rounded-l-none! rounded-r-lg!" : "rounded-lg! sm:mx-2"}`} 43 > 44 {drawer.drawer === "quotes" ? ( 45 <Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
··· 9 import { decodeQuotePosition } from "../quotePosition"; 10 11 export const InteractionDrawer = (props: { 12 document_uri: string; 13 quotesAndMentions: { uri: string; link?: string }[]; 14 comments: Comment[]; ··· 38 <div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]"> 39 <div 40 id="interaction-drawer" 41 + className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] " 42 > 43 {drawer.drawer === "quotes" ? ( 44 <Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
+44 -68
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 108 commentsCount: number; 109 className?: string; 110 showComments?: boolean; 111 - showMentions?: boolean; 112 pageId?: string; 113 }) => { 114 const data = useContext(PostPageContext); ··· 132 <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 133 {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 134 135 - {props.quotesCount === 0 || props.showMentions === false ? null : ( 136 <button 137 className="flex w-fit gap-2 items-center" 138 onClick={() => { ··· 169 commentsCount: number; 170 className?: string; 171 showComments?: boolean; 172 - showMentions?: boolean; 173 pageId?: string; 174 }) => { 175 const data = useContext(PostPageContext); ··· 191 const tags = (data?.data as any)?.tags as string[] | undefined; 192 const tagCount = tags?.length || 0; 193 194 - let noInteractions = !props.showComments && !props.showMentions; 195 - 196 let subscribed = 197 identity?.atp_did && 198 publication?.publication_subscriptions && ··· 233 <TagList tags={tags} className="mb-3" /> 234 </> 235 )} 236 - 237 <hr className="border-border-light mb-3 " /> 238 - 239 <div className="flex gap-2 justify-between"> 240 - {noInteractions ? ( 241 - <div /> 242 - ) : ( 243 - <> 244 - <div className="flex gap-2"> 245 - {props.quotesCount === 0 || 246 - props.showMentions === false ? null : ( 247 - <button 248 - className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 249 - onClick={() => { 250 - if (!drawerOpen || drawer !== "quotes") 251 - openInteractionDrawer( 252 - "quotes", 253 - document_uri, 254 - props.pageId, 255 - ); 256 - else 257 - setInteractionState(document_uri, { drawerOpen: false }); 258 - }} 259 - onMouseEnter={handleQuotePrefetch} 260 - onTouchStart={handleQuotePrefetch} 261 - aria-label="Post quotes" 262 - > 263 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 264 - <span 265 - aria-hidden 266 - >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 267 - </button> 268 - )} 269 - {props.showComments === false ? null : ( 270 - <button 271 - className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 272 - onClick={() => { 273 - if ( 274 - !drawerOpen || 275 - drawer !== "comments" || 276 - pageId !== props.pageId 277 - ) 278 - openInteractionDrawer( 279 - "comments", 280 - document_uri, 281 - props.pageId, 282 - ); 283 - else 284 - setInteractionState(document_uri, { drawerOpen: false }); 285 - }} 286 - aria-label="Post comments" 287 - > 288 - <CommentTiny aria-hidden />{" "} 289 - {props.commentsCount > 0 ? ( 290 - <span aria-hidden> 291 - {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 292 - </span> 293 - ) : ( 294 - "Comment" 295 - )} 296 - </button> 297 )} 298 - </div> 299 - </> 300 - )} 301 - 302 <EditButton document={data} /> 303 {subscribed && publication && ( 304 <ManageSubscription
··· 108 commentsCount: number; 109 className?: string; 110 showComments?: boolean; 111 pageId?: string; 112 }) => { 113 const data = useContext(PostPageContext); ··· 131 <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 132 {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 133 134 + {props.quotesCount > 0 && ( 135 <button 136 className="flex w-fit gap-2 items-center" 137 onClick={() => { ··· 168 commentsCount: number; 169 className?: string; 170 showComments?: boolean; 171 pageId?: string; 172 }) => { 173 const data = useContext(PostPageContext); ··· 189 const tags = (data?.data as any)?.tags as string[] | undefined; 190 const tagCount = tags?.length || 0; 191 192 let subscribed = 193 identity?.atp_did && 194 publication?.publication_subscriptions && ··· 229 <TagList tags={tags} className="mb-3" /> 230 </> 231 )} 232 <hr className="border-border-light mb-3 " /> 233 <div className="flex gap-2 justify-between"> 234 + <div className="flex gap-2"> 235 + {props.quotesCount > 0 && ( 236 + <button 237 + className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 238 + onClick={() => { 239 + if (!drawerOpen || drawer !== "quotes") 240 + openInteractionDrawer("quotes", document_uri, props.pageId); 241 + else setInteractionState(document_uri, { drawerOpen: false }); 242 + }} 243 + onMouseEnter={handleQuotePrefetch} 244 + onTouchStart={handleQuotePrefetch} 245 + aria-label="Post quotes" 246 + > 247 + <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 248 + <span 249 + aria-hidden 250 + >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 251 + </button> 252 + )} 253 + {props.showComments === false ? null : ( 254 + <button 255 + className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 256 + onClick={() => { 257 + if ( 258 + !drawerOpen || 259 + drawer !== "comments" || 260 + pageId !== props.pageId 261 + ) 262 + openInteractionDrawer("comments", document_uri, props.pageId); 263 + else setInteractionState(document_uri, { drawerOpen: false }); 264 + }} 265 + aria-label="Post comments" 266 + > 267 + <CommentTiny aria-hidden />{" "} 268 + {props.commentsCount > 0 ? ( 269 + <span aria-hidden> 270 + {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 271 + </span> 272 + ) : ( 273 + "Comment" 274 )} 275 + </button> 276 + )} 277 + </div> 278 <EditButton document={data} /> 279 {subscribed && publication && ( 280 <ManageSubscription
+2 -7
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 14 ExpandedInteractions, 15 getCommentCount, 16 getQuoteCount, 17 } from "./Interactions/Interactions"; 18 import { PostContent } from "./PostContent"; 19 import { PostHeader } from "./PostHeader/PostHeader"; ··· 24 import { decodeQuotePosition } from "./quotePosition"; 25 import { PollData } from "./fetchPollData"; 26 import { SharedPageProps } from "./PostPages"; 27 - import { PostPrevNextButtons } from "./PostPrevNextButtons"; 28 29 export function LinearDocumentPage({ 30 blocks, ··· 56 57 const isSubpage = !!pageId; 58 59 - console.log("prev/next?: " + preferences.showPrevNext); 60 - 61 return ( 62 <> 63 <PageWrapper ··· 85 did={did} 86 prerenderedCodeBlocks={prerenderedCodeBlocks} 87 /> 88 - <PostPrevNextButtons 89 - showPrevNext={preferences.showPrevNext && !isSubpage} 90 - /> 91 <ExpandedInteractions 92 pageId={pageId} 93 showComments={preferences.showComments} 94 - showMentions={preferences.showMentions} 95 commentsCount={getCommentCount(document, pageId) || 0} 96 quotesCount={getQuoteCount(document, pageId) || 0} 97 />
··· 14 ExpandedInteractions, 15 getCommentCount, 16 getQuoteCount, 17 + Interactions, 18 } from "./Interactions/Interactions"; 19 import { PostContent } from "./PostContent"; 20 import { PostHeader } from "./PostHeader/PostHeader"; ··· 25 import { decodeQuotePosition } from "./quotePosition"; 26 import { PollData } from "./fetchPollData"; 27 import { SharedPageProps } from "./PostPages"; 28 29 export function LinearDocumentPage({ 30 blocks, ··· 56 57 const isSubpage = !!pageId; 58 59 return ( 60 <> 61 <PageWrapper ··· 83 did={did} 84 prerenderedCodeBlocks={prerenderedCodeBlocks} 85 /> 86 + 87 <ExpandedInteractions 88 pageId={pageId} 89 showComments={preferences.showComments} 90 commentsCount={getCommentCount(document, pageId) || 0} 91 quotesCount={getQuoteCount(document, pageId) || 0} 92 />
+18 -8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 20 } from "lexicons/api"; 21 22 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 23 - import { TextBlock } from "./TextBlock"; 24 import { Popover } from "components/Popover"; 25 import { theme } from "tailwind.config"; 26 import { ImageAltSmall } from "components/Icons/ImageAlt"; 27 - import { StaticMathBlock } from "./StaticMathBlock"; 28 - import { PubCodeBlock } from "./PubCodeBlock"; 29 import { AppBskyFeedDefs } from "@atproto/api"; 30 - import { PubBlueskyPostBlock } from "./PublishBskyPostBlock"; 31 import { openPage } from "./PostPages"; 32 import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; 33 - import { PublishedPageLinkBlock } from "./PublishedPageBlock"; 34 - import { PublishedPollBlock } from "./PublishedPollBlock"; 35 import { PollData } from "./fetchPollData"; 36 import { ButtonPrimary } from "components/Buttons"; 37 ··· 173 let uri = b.block.postRef.uri; 174 let post = bskyPostData.find((p) => p.uri === uri); 175 if (!post) return <div>no prefetched post rip</div>; 176 - return <PubBlueskyPostBlock post={post} className={className} pageId={pageId} />; 177 } 178 case PubLeafletBlocksIframe.isMain(b.block): { 179 return ( ··· 339 } 340 case PubLeafletBlocksText.isMain(b.block): 341 return ( 342 - <p className={`textBlock ${className}`} {...blockProps}> 343 <TextBlock 344 facets={b.block.facets} 345 plaintext={b.block.plaintext} ··· 349 /> 350 </p> 351 ); 352 case PubLeafletBlocksHeader.isMain(b.block): { 353 if (b.block.level === 1) 354 return (
··· 20 } from "lexicons/api"; 21 22 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 23 + import { TextBlock } from "./Blocks/TextBlock"; 24 import { Popover } from "components/Popover"; 25 import { theme } from "tailwind.config"; 26 import { ImageAltSmall } from "components/Icons/ImageAlt"; 27 + import { StaticMathBlock } from "./Blocks/StaticMathBlock"; 28 + import { PubCodeBlock } from "./Blocks/PubCodeBlock"; 29 import { AppBskyFeedDefs } from "@atproto/api"; 30 + import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock"; 31 import { openPage } from "./PostPages"; 32 import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; 33 + import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock"; 34 + import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; 35 import { PollData } from "./fetchPollData"; 36 import { ButtonPrimary } from "components/Buttons"; 37 ··· 173 let uri = b.block.postRef.uri; 174 let post = bskyPostData.find((p) => p.uri === uri); 175 if (!post) return <div>no prefetched post rip</div>; 176 + return ( 177 + <PubBlueskyPostBlock 178 + post={post} 179 + className={className} 180 + pageId={pageId} 181 + /> 182 + ); 183 } 184 case PubLeafletBlocksIframe.isMain(b.block): { 185 return ( ··· 345 } 346 case PubLeafletBlocksText.isMain(b.block): 347 return ( 348 + <p 349 + className={`textBlock ${className} ${b.block.textSize === "small" ? "text-sm text-secondary" : b.block.textSize === "large" ? "text-lg" : ""}`} 350 + {...blockProps} 351 + > 352 <TextBlock 353 facets={b.block.facets} 354 plaintext={b.block.plaintext} ··· 358 /> 359 </p> 360 ); 361 + 362 case PubLeafletBlocksHeader.isMain(b.block): { 363 if (b.block.level === 1) 364 return (
+1 -2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 23 export function PostHeader(props: { 24 data: PostPageData; 25 profile: ProfileViewDetailed; 26 - preferences: { showComments?: boolean; showMentions?: boolean }; 27 }) { 28 let { identity } = useIdentityData(); 29 let document = props.data; ··· 91 </div> 92 <Interactions 93 showComments={props.preferences.showComments} 94 - showMentions={props.preferences.showMentions} 95 quotesCount={getQuoteCount(document) || 0} 96 commentsCount={getCommentCount(document) || 0} 97 />
··· 23 export function PostHeader(props: { 24 data: PostPageData; 25 profile: ProfileViewDetailed; 26 + preferences: { showComments?: boolean }; 27 }) { 28 let { identity } = useIdentityData(); 29 let document = props.data; ··· 91 </div> 92 <Interactions 93 showComments={props.preferences.showComments} 94 quotesCount={getQuoteCount(document) || 0} 95 commentsCount={getCommentCount(document) || 0} 96 />
+4 -22
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 147 document: PostPageData; 148 did: string; 149 profile: ProfileViewDetailed; 150 - preferences: { 151 - showComments?: boolean; 152 - showMentions?: boolean; 153 - showPrevNext?: boolean; 154 - }; 155 pubRecord?: PubLeafletPublication.Record; 156 theme?: PubLeafletPublication.Theme | null; 157 prerenderedCodeBlocks?: Map<string, string>; ··· 210 did: string; 211 prerenderedCodeBlocks?: Map<string, string>; 212 bskyPostData: AppBskyFeedDefs.PostView[]; 213 - preferences: { 214 - showComments?: boolean; 215 - showMentions?: boolean; 216 - showPrevNext?: boolean; 217 - }; 218 pollData: PollData[]; 219 }) { 220 let drawer = useDrawerOpen(document_uri); ··· 269 270 {drawer && !drawer.pageId && ( 271 <InteractionDrawer 272 - showPageBackground={pubRecord?.theme?.showPageBackground} 273 document_uri={document.uri} 274 comments={ 275 pubRecord?.preferences?.showComments === false 276 ? [] 277 : document.comments_on_documents 278 } 279 - quotesAndMentions={ 280 - pubRecord?.preferences?.showMentions === false 281 - ? [] 282 - : quotesAndMentions 283 - } 284 did={did} 285 /> 286 )} ··· 360 /> 361 {drawer && drawer.pageId === page.id && ( 362 <InteractionDrawer 363 - showPageBackground={pubRecord?.theme?.showPageBackground} 364 pageId={page.id} 365 document_uri={document.uri} 366 comments={ ··· 368 ? [] 369 : document.comments_on_documents 370 } 371 - quotesAndMentions={ 372 - pubRecord?.preferences?.showMentions === false 373 - ? [] 374 - : quotesAndMentions 375 - } 376 did={did} 377 /> 378 )}
··· 147 document: PostPageData; 148 did: string; 149 profile: ProfileViewDetailed; 150 + preferences: { showComments?: boolean }; 151 pubRecord?: PubLeafletPublication.Record; 152 theme?: PubLeafletPublication.Theme | null; 153 prerenderedCodeBlocks?: Map<string, string>; ··· 206 did: string; 207 prerenderedCodeBlocks?: Map<string, string>; 208 bskyPostData: AppBskyFeedDefs.PostView[]; 209 + preferences: { showComments?: boolean }; 210 pollData: PollData[]; 211 }) { 212 let drawer = useDrawerOpen(document_uri); ··· 261 262 {drawer && !drawer.pageId && ( 263 <InteractionDrawer 264 document_uri={document.uri} 265 comments={ 266 pubRecord?.preferences?.showComments === false 267 ? [] 268 : document.comments_on_documents 269 } 270 + quotesAndMentions={quotesAndMentions} 271 did={did} 272 /> 273 )} ··· 347 /> 348 {drawer && drawer.pageId === page.id && ( 349 <InteractionDrawer 350 pageId={page.id} 351 document_uri={document.uri} 352 comments={ ··· 354 ? [] 355 : document.comments_on_documents 356 } 357 + quotesAndMentions={quotesAndMentions} 358 did={did} 359 /> 360 )}
-58
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
··· 1 - "use client"; 2 - import { PubLeafletDocument } from "lexicons/api"; 3 - import { usePublicationData } from "../dashboard/PublicationSWRProvider"; 4 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 - import { AtUri } from "@atproto/api"; 6 - import { useParams } from "next/navigation"; 7 - import { getPostPageData } from "./getPostPageData"; 8 - import { PostPageContext } from "./PostPageContext"; 9 - import { useContext } from "react"; 10 - import { SpeedyLink } from "components/SpeedyLink"; 11 - import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 12 - 13 - export const PostPrevNextButtons = (props: { 14 - showPrevNext: boolean | undefined; 15 - }) => { 16 - let postData = useContext(PostPageContext); 17 - let pub = postData?.documents_in_publications[0]?.publications; 18 - 19 - if (!props.showPrevNext || !pub || !postData) return; 20 - 21 - function getPostLink(uri: string) { 22 - return pub && uri 23 - ? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}` 24 - : "leaflet.pub/not-found"; 25 - } 26 - let prevPost = postData?.prevNext?.prev; 27 - let nextPost = postData?.prevNext?.next; 28 - 29 - return ( 30 - <div className="flex flex-col gap-1 w-full px-3 sm:px-4 pb-2 pt-2"> 31 - {/*<hr className="border-border-light" />*/} 32 - <div className="flex justify-between w-full gap-8 "> 33 - {nextPost ? ( 34 - <SpeedyLink 35 - href={getPostLink(nextPost.uri)} 36 - className="flex gap-1 items-center truncate min-w-0 basis-1/2" 37 - > 38 - <ArrowRightTiny className="rotate-180 shrink-0" /> 39 - <div className="min-w-0 truncate">{nextPost.title}</div> 40 - </SpeedyLink> 41 - ) : ( 42 - <div /> 43 - )} 44 - {prevPost ? ( 45 - <SpeedyLink 46 - href={getPostLink(prevPost.uri)} 47 - className="flex gap-1 items-center truncate min-w-0 basis-1/2 justify-end" 48 - > 49 - <div className="min-w-0 truncate">{prevPost.title}</div> 50 - <ArrowRightTiny className="shrink-0" /> 51 - </SpeedyLink> 52 - ) : ( 53 - <div /> 54 - )} 55 - </div> 56 - </div> 57 - ); 58 - };
···
-28
app/lish/[did]/[publication]/[rkey]/PubCodeBlock.tsx
··· 1 - "use client"; 2 - 3 - import { PubLeafletBlocksCode } from "lexicons/api"; 4 - import { useLayoutEffect, useState } from "react"; 5 - import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 6 - 7 - export function PubCodeBlock({ 8 - block, 9 - prerenderedCode, 10 - }: { 11 - block: PubLeafletBlocksCode.Main; 12 - prerenderedCode?: string; 13 - }) { 14 - const [html, setHTML] = useState<string | null>(prerenderedCode || null); 15 - 16 - useLayoutEffect(() => { 17 - const lang = bundledLanguagesInfo.find((l) => l.id === block.language)?.id || "plaintext"; 18 - const theme = bundledThemesInfo.find((t) => t.id === block.syntaxHighlightingTheme)?.id || "github-light"; 19 - 20 - codeToHtml(block.plaintext, { lang, theme }).then(setHTML); 21 - }, [block]); 22 - return ( 23 - <div 24 - className="w-full min-h-[42px] my-2 rounded-md border-border-light outline-border-light selected-outline" 25 - dangerouslySetInnerHTML={{ __html: html || "" }} 26 - /> 27 - ); 28 - }
···
-172
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 1 - import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 2 - import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 - import { Separator } from "components/Layout"; 4 - import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 5 - import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 - import { ThreadLink, QuotesLink } from "./PostLinks"; 9 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 - import { 11 - BlueskyEmbed, 12 - PostNotAvailable, 13 - } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 14 - import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 15 - import { openPage } from "./PostPages"; 16 - 17 - export const PubBlueskyPostBlock = (props: { 18 - post: PostView; 19 - className: string; 20 - pageId?: string; 21 - }) => { 22 - let post = props.post; 23 - 24 - const handleOpenThread = () => { 25 - openPage( 26 - props.pageId ? { type: "doc", id: props.pageId } : undefined, 27 - { type: "thread", uri: post.uri }, 28 - ); 29 - }; 30 - 31 - switch (true) { 32 - case AppBskyFeedDefs.isBlockedPost(post) || 33 - AppBskyFeedDefs.isBlockedAuthor(post) || 34 - AppBskyFeedDefs.isNotFoundPost(post): 35 - return ( 36 - <div className={`w-full`}> 37 - <PostNotAvailable /> 38 - </div> 39 - ); 40 - 41 - case AppBskyFeedDefs.validatePostView(post).success: 42 - let record = post.record as AppBskyFeedDefs.PostView["record"]; 43 - 44 - // silliness to get the text and timestamp from the record with proper types 45 - let timestamp: string | undefined = undefined; 46 - if (AppBskyFeedPost.isRecord(record)) { 47 - timestamp = (record as AppBskyFeedPost.Record).createdAt; 48 - } 49 - 50 - //getting the url to the post 51 - let postId = post.uri.split("/")[4]; 52 - let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 53 - 54 - const parent = props.pageId ? { type: "doc" as const, id: props.pageId } : undefined; 55 - 56 - return ( 57 - <div 58 - onClick={handleOpenThread} 59 - className={` 60 - ${props.className} 61 - block-border 62 - mb-2 63 - flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 64 - cursor-pointer hover:border-accent-contrast 65 - `} 66 - > 67 - {post.author && record && ( 68 - <> 69 - <div className="bskyAuthor w-full flex items-center gap-2"> 70 - {post.author.avatar && ( 71 - <img 72 - src={post.author?.avatar} 73 - alt={`${post.author?.displayName}'s avatar`} 74 - className="shink-0 w-8 h-8 rounded-full border border-border-light" 75 - /> 76 - )} 77 - <div className="grow flex flex-col gap-0.5 leading-tight"> 78 - <div className=" font-bold text-secondary"> 79 - {post.author?.displayName} 80 - </div> 81 - <a 82 - className="text-xs text-tertiary hover:underline" 83 - target="_blank" 84 - href={`https://bsky.app/profile/${post.author?.handle}`} 85 - onClick={(e) => e.stopPropagation()} 86 - > 87 - @{post.author?.handle} 88 - </a> 89 - </div> 90 - </div> 91 - 92 - <div className="flex flex-col gap-2 "> 93 - <div> 94 - <pre className="whitespace-pre-wrap"> 95 - {BlueskyRichText({ 96 - record: record as AppBskyFeedPost.Record | null, 97 - })} 98 - </pre> 99 - </div> 100 - {post.embed && ( 101 - <div onClick={(e) => e.stopPropagation()}> 102 - <BlueskyEmbed embed={post.embed} postUrl={url} /> 103 - </div> 104 - )} 105 - </div> 106 - </> 107 - )} 108 - <div className="w-full flex gap-2 items-center justify-between"> 109 - <ClientDate date={timestamp} /> 110 - <div className="flex gap-2 items-center"> 111 - {post.replyCount != null && post.replyCount > 0 && ( 112 - <> 113 - <ThreadLink 114 - threadUri={post.uri} 115 - parent={parent} 116 - className="flex items-center gap-1 hover:text-accent-contrast" 117 - onClick={(e) => e.stopPropagation()} 118 - > 119 - {post.replyCount} 120 - <CommentTiny /> 121 - </ThreadLink> 122 - <Separator classname="h-4" /> 123 - </> 124 - )} 125 - {post.quoteCount != null && post.quoteCount > 0 && ( 126 - <> 127 - <QuotesLink 128 - postUri={post.uri} 129 - parent={parent} 130 - className="flex items-center gap-1 hover:text-accent-contrast" 131 - onClick={(e) => e.stopPropagation()} 132 - > 133 - {post.quoteCount} 134 - <QuoteTiny /> 135 - </QuotesLink> 136 - <Separator classname="h-4" /> 137 - </> 138 - )} 139 - 140 - <a 141 - className="" 142 - target="_blank" 143 - href={url} 144 - onClick={(e) => e.stopPropagation()} 145 - > 146 - <BlueskyTiny /> 147 - </a> 148 - </div> 149 - </div> 150 - </div> 151 - ); 152 - } 153 - }; 154 - 155 - const ClientDate = (props: { date?: string }) => { 156 - let pageLoaded = useHasPageLoaded(); 157 - const formattedDate = useLocalizedDate( 158 - props.date || new Date().toISOString(), 159 - { 160 - month: "short", 161 - day: "numeric", 162 - year: "numeric", 163 - hour: "numeric", 164 - minute: "numeric", 165 - hour12: true, 166 - }, 167 - ); 168 - 169 - if (!pageLoaded) return null; 170 - 171 - return <div className="text-xs text-tertiary">{formattedDate}</div>; 172 - };
···
-340
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 1 - "use client"; 2 - 3 - import { useEntity, useReplicache } from "src/replicache"; 4 - import { useUIState } from "src/useUIState"; 5 - import { CSSProperties, useContext, useRef } from "react"; 6 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 - import { PostContent, Block } from "./PostContent"; 8 - import { 9 - PubLeafletBlocksHeader, 10 - PubLeafletBlocksText, 11 - PubLeafletComment, 12 - PubLeafletPagesLinearDocument, 13 - PubLeafletPagesCanvas, 14 - PubLeafletPublication, 15 - } from "lexicons/api"; 16 - import { AppBskyFeedDefs } from "@atproto/api"; 17 - import { TextBlock } from "./TextBlock"; 18 - import { PostPageContext } from "./PostPageContext"; 19 - import { openPage, useOpenPages } from "./PostPages"; 20 - import { 21 - openInteractionDrawer, 22 - setInteractionState, 23 - useInteractionState, 24 - } from "./Interactions/Interactions"; 25 - import { CommentTiny } from "components/Icons/CommentTiny"; 26 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 - import { CanvasBackgroundPattern } from "components/Canvas"; 28 - 29 - export function PublishedPageLinkBlock(props: { 30 - blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; 31 - parentPageId: string | undefined; 32 - pageId: string; 33 - did: string; 34 - preview?: boolean; 35 - className?: string; 36 - prerenderedCodeBlocks?: Map<string, string>; 37 - bskyPostData: AppBskyFeedDefs.PostView[]; 38 - isCanvas?: boolean; 39 - pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 40 - }) { 41 - //switch to use actually state 42 - let openPages = useOpenPages(); 43 - let isOpen = openPages.some( 44 - (p) => p.type === "doc" && p.id === props.pageId, 45 - ); 46 - return ( 47 - <div 48 - className={`w-full cursor-pointer 49 - pageLinkBlockWrapper relative group/pageLinkBlock 50 - bg-bg-page shadow-sm 51 - flex overflow-clip 52 - block-border 53 - ${isOpen && "!border-tertiary"} 54 - ${props.className} 55 - `} 56 - onClick={(e) => { 57 - if (e.isDefaultPrevented()) return; 58 - if (e.shiftKey) return; 59 - e.preventDefault(); 60 - e.stopPropagation(); 61 - 62 - openPage( 63 - props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 64 - { type: "doc", id: props.pageId }, 65 - ); 66 - }} 67 - > 68 - {props.isCanvas ? ( 69 - <CanvasLinkBlock 70 - blocks={props.blocks as PubLeafletPagesCanvas.Block[]} 71 - did={props.did} 72 - pageId={props.pageId} 73 - bskyPostData={props.bskyPostData} 74 - pages={props.pages || []} 75 - /> 76 - ) : ( 77 - <DocLinkBlock 78 - {...props} 79 - blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]} 80 - /> 81 - )} 82 - </div> 83 - ); 84 - } 85 - export function DocLinkBlock(props: { 86 - blocks: PubLeafletPagesLinearDocument.Block[]; 87 - pageId: string; 88 - parentPageId?: string; 89 - did: string; 90 - preview?: boolean; 91 - className?: string; 92 - prerenderedCodeBlocks?: Map<string, string>; 93 - bskyPostData: AppBskyFeedDefs.PostView[]; 94 - }) { 95 - let [title, description] = props.blocks 96 - .map((b) => b.block) 97 - .filter( 98 - (b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b), 99 - ); 100 - 101 - return ( 102 - <div 103 - style={{ "--list-marker-width": "20px" } as CSSProperties} 104 - className={` 105 - w-full h-[104px] 106 - `} 107 - > 108 - <> 109 - <div className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full"> 110 - <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip flex flex-col "> 111 - <div className="grow"> 112 - {title && ( 113 - <div 114 - className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 115 - > 116 - <TextBlock 117 - facets={title.facets} 118 - plaintext={title.plaintext} 119 - index={[]} 120 - preview 121 - /> 122 - </div> 123 - )} 124 - {description && ( 125 - <div 126 - className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 127 - > 128 - <TextBlock 129 - facets={description.facets} 130 - plaintext={description.plaintext} 131 - index={[]} 132 - preview 133 - /> 134 - </div> 135 - )} 136 - </div> 137 - 138 - <Interactions 139 - pageId={props.pageId} 140 - parentPageId={props.parentPageId} 141 - /> 142 - </div> 143 - {!props.preview && ( 144 - <PagePreview blocks={props.blocks} did={props.did} /> 145 - )} 146 - </div> 147 - </> 148 - </div> 149 - ); 150 - } 151 - 152 - export function PagePreview(props: { 153 - did: string; 154 - blocks: PubLeafletPagesLinearDocument.Block[]; 155 - }) { 156 - let previewRef = useRef<HTMLDivElement | null>(null); 157 - let { rootEntity } = useReplicache(); 158 - let data = useContext(PostPageContext); 159 - let theme = data?.theme; 160 - let pageWidth = `var(--page-width-unitless)`; 161 - let cardBorderHidden = !theme?.showPageBackground; 162 - return ( 163 - <div 164 - ref={previewRef} 165 - className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`} 166 - > 167 - <div 168 - className="absolute top-0 left-0 origin-top-left pointer-events-none " 169 - style={{ 170 - width: `calc(1px * ${pageWidth})`, 171 - height: `calc(100vh - 64px)`, 172 - transform: `scale(calc((120 / ${pageWidth} )))`, 173 - backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 174 - }} 175 - > 176 - {!cardBorderHidden && ( 177 - <div 178 - className={`pageLinkBlockBackground 179 - absolute top-0 left-0 right-0 bottom-0 180 - pointer-events-none 181 - `} 182 - /> 183 - )} 184 - <PostContent 185 - pollData={[]} 186 - pages={[]} 187 - did={props.did} 188 - blocks={props.blocks} 189 - preview 190 - bskyPostData={[]} 191 - /> 192 - </div> 193 - </div> 194 - ); 195 - } 196 - 197 - const Interactions = (props: { pageId: string; parentPageId?: string }) => { 198 - const data = useContext(PostPageContext); 199 - const document_uri = data?.uri; 200 - if (!document_uri) 201 - throw new Error("document_uri not available in PostPageContext"); 202 - let comments = data.comments_on_documents.filter( 203 - (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 204 - ).length; 205 - let quotes = data.document_mentions_in_bsky.filter((q) => 206 - q.link.includes(props.pageId), 207 - ).length; 208 - 209 - let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 210 - 211 - return ( 212 - <div 213 - className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`} 214 - > 215 - {quotes > 0 && ( 216 - <button 217 - className={`flex gap-1 items-center`} 218 - onClick={(e) => { 219 - e.preventDefault(); 220 - e.stopPropagation(); 221 - openPage( 222 - props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 223 - { type: "doc", id: props.pageId }, 224 - { scrollIntoView: false }, 225 - ); 226 - if (!drawerOpen || drawer !== "quotes") 227 - openInteractionDrawer("quotes", document_uri, props.pageId); 228 - else setInteractionState(document_uri, { drawerOpen: false }); 229 - }} 230 - > 231 - <span className="sr-only">Page quotes</span> 232 - <QuoteTiny aria-hidden /> {quotes}{" "} 233 - </button> 234 - )} 235 - {comments > 0 && ( 236 - <button 237 - className={`flex gap-1 items-center`} 238 - onClick={(e) => { 239 - e.preventDefault(); 240 - e.stopPropagation(); 241 - openPage( 242 - props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 243 - { type: "doc", id: props.pageId }, 244 - { scrollIntoView: false }, 245 - ); 246 - if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 247 - openInteractionDrawer("comments", document_uri, props.pageId); 248 - else setInteractionState(document_uri, { drawerOpen: false }); 249 - }} 250 - > 251 - <span className="sr-only">Page comments</span> 252 - <CommentTiny aria-hidden /> {comments}{" "} 253 - </button> 254 - )} 255 - </div> 256 - ); 257 - }; 258 - 259 - const CanvasLinkBlock = (props: { 260 - blocks: PubLeafletPagesCanvas.Block[]; 261 - did: string; 262 - pageId: string; 263 - bskyPostData: AppBskyFeedDefs.PostView[]; 264 - pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 265 - }) => { 266 - let pageWidth = `var(--page-width-unitless)`; 267 - let height = 268 - props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0; 269 - 270 - return ( 271 - <div 272 - style={{ contain: "size layout paint" }} 273 - className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`} 274 - > 275 - <div 276 - className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`} 277 - style={{ 278 - width: `calc(1px * ${pageWidth})`, 279 - height: "calc(1150px * 2)", 280 - transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`, 281 - }} 282 - > 283 - <div 284 - style={{ 285 - minHeight: height + 512, 286 - contain: "size layout paint", 287 - }} 288 - className="relative h-full w-[1272px]" 289 - > 290 - <div className="w-full h-full pointer-events-none"> 291 - <CanvasBackgroundPattern pattern="grid" /> 292 - </div> 293 - {props.blocks 294 - .sort((a, b) => { 295 - if (a.y === b.y) { 296 - return a.x - b.x; 297 - } 298 - return a.y - b.y; 299 - }) 300 - .map((canvasBlock, index) => { 301 - let { x, y, width, rotation } = canvasBlock; 302 - let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 303 - 304 - // Wrap the block in a LinearDocument.Block structure for compatibility 305 - let linearBlock: PubLeafletPagesLinearDocument.Block = { 306 - $type: "pub.leaflet.pages.linearDocument#block", 307 - block: canvasBlock.block, 308 - }; 309 - 310 - return ( 311 - <div 312 - key={index} 313 - className="absolute rounded-lg flex items-stretch origin-center p-3" 314 - style={{ 315 - top: 0, 316 - left: 0, 317 - width, 318 - transform, 319 - }} 320 - > 321 - <div className="contents"> 322 - <Block 323 - pollData={[]} 324 - pageId={props.pageId} 325 - pages={props.pages} 326 - bskyPostData={props.bskyPostData} 327 - block={linearBlock} 328 - did={props.did} 329 - index={[index]} 330 - preview={true} 331 - /> 332 - </div> 333 - </div> 334 - ); 335 - })} 336 - </div> 337 - </div> 338 - </div> 339 - ); 340 - };
···
-346
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
··· 1 - "use client"; 2 - 3 - import { 4 - PubLeafletBlocksPoll, 5 - PubLeafletPollDefinition, 6 - PubLeafletPollVote, 7 - } from "lexicons/api"; 8 - import { useState, useEffect } from "react"; 9 - import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 10 - import { useIdentityData } from "components/IdentityProvider"; 11 - import { AtpAgent } from "@atproto/api"; 12 - import { voteOnPublishedPoll } from "./voteOnPublishedPoll"; 13 - import { PollData } from "./fetchPollData"; 14 - import { Popover } from "components/Popover"; 15 - import LoginForm from "app/login/LoginForm"; 16 - import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 17 - import { getVoterIdentities, VoterIdentity } from "./getVoterIdentities"; 18 - import { Json } from "supabase/database.types"; 19 - import { InfoSmall } from "components/Icons/InfoSmall"; 20 - 21 - // Helper function to extract the first option from a vote record 22 - const getVoteOption = (voteRecord: any): string | null => { 23 - try { 24 - const record = voteRecord as PubLeafletPollVote.Record; 25 - return record.option && record.option.length > 0 ? record.option[0] : null; 26 - } catch { 27 - return null; 28 - } 29 - }; 30 - 31 - export const PublishedPollBlock = (props: { 32 - block: PubLeafletBlocksPoll.Main; 33 - pollData: PollData; 34 - className?: string; 35 - }) => { 36 - const { identity } = useIdentityData(); 37 - const [selectedOption, setSelectedOption] = useState<string | null>(null); 38 - const [isVoting, setIsVoting] = useState(false); 39 - const [showResults, setShowResults] = useState(false); 40 - const [optimisticVote, setOptimisticVote] = useState<{ 41 - option: string; 42 - voter_did: string; 43 - } | null>(null); 44 - let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 45 - let [isClient, setIsClient] = useState(false); 46 - useEffect(() => { 47 - setIsClient(true); 48 - }, []); 49 - 50 - const handleVote = async () => { 51 - if (!selectedOption || !identity?.atp_did) return; 52 - 53 - setIsVoting(true); 54 - 55 - // Optimistically add the vote 56 - setOptimisticVote({ 57 - option: selectedOption, 58 - voter_did: identity.atp_did, 59 - }); 60 - setShowResults(true); 61 - 62 - try { 63 - const result = await voteOnPublishedPoll( 64 - props.block.pollRef.uri, 65 - props.block.pollRef.cid, 66 - selectedOption, 67 - ); 68 - 69 - if (!result.success) { 70 - console.error("Failed to vote:", result.error); 71 - // Revert optimistic update on failure 72 - setOptimisticVote(null); 73 - setShowResults(false); 74 - } 75 - } catch (error) { 76 - console.error("Failed to vote:", error); 77 - // Revert optimistic update on failure 78 - setOptimisticVote(null); 79 - setShowResults(false); 80 - } finally { 81 - setIsVoting(false); 82 - } 83 - }; 84 - 85 - const hasVoted = 86 - !!identity?.atp_did && 87 - (!!props.pollData?.atp_poll_votes.find( 88 - (v) => v.voter_did === identity?.atp_did, 89 - ) || 90 - !!optimisticVote); 91 - let isCreator = 92 - identity?.atp_did && props.pollData.uri.includes(identity?.atp_did); 93 - const displayResults = showResults || hasVoted; 94 - 95 - return ( 96 - <div 97 - className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`} 98 - style={{ 99 - backgroundColor: 100 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 101 - }} 102 - > 103 - {displayResults ? ( 104 - <> 105 - <PollResults 106 - pollData={props.pollData} 107 - hasVoted={hasVoted} 108 - setShowResults={setShowResults} 109 - optimisticVote={optimisticVote} 110 - /> 111 - {!hasVoted && ( 112 - <div className="flex justify-start"> 113 - <button 114 - className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 115 - onClick={() => setShowResults(false)} 116 - > 117 - Back to Voting 118 - </button> 119 - </div> 120 - )} 121 - </> 122 - ) : ( 123 - <> 124 - {pollRecord.options.map((option, index) => ( 125 - <PollOptionButton 126 - key={index} 127 - option={option} 128 - optionIndex={index.toString()} 129 - selected={selectedOption === index.toString()} 130 - onSelect={() => setSelectedOption(index.toString())} 131 - disabled={!identity?.atp_did} 132 - /> 133 - ))} 134 - <div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-2 items-center pt-2"> 135 - <div className="text-sm text-tertiary">All votes are public</div> 136 - <div className="flex sm:gap-3 sm:flex-row flex-col-reverse sm:justify-end justify-center gap-1 items-center"> 137 - <button 138 - className="w-fit font-bold text-accent-contrast" 139 - onClick={() => setShowResults(!showResults)} 140 - > 141 - See Results 142 - </button> 143 - {identity?.atp_did ? ( 144 - <ButtonPrimary 145 - className="place-self-end" 146 - onClick={handleVote} 147 - disabled={!selectedOption || isVoting} 148 - > 149 - {isVoting ? "Voting..." : "Vote!"} 150 - </ButtonPrimary> 151 - ) : ( 152 - <Popover 153 - asChild 154 - trigger={ 155 - <ButtonPrimary className="place-self-center"> 156 - <BlueskyTiny /> Login to vote 157 - </ButtonPrimary> 158 - } 159 - > 160 - {isClient && ( 161 - <LoginForm 162 - text="Log in to vote on this poll!" 163 - noEmail 164 - redirectRoute={window?.location.href + "?refreshAuth"} 165 - /> 166 - )} 167 - </Popover> 168 - )} 169 - </div> 170 - </div> 171 - </> 172 - )} 173 - </div> 174 - ); 175 - }; 176 - 177 - const PollOptionButton = (props: { 178 - option: PubLeafletPollDefinition.Option; 179 - optionIndex: string; 180 - selected: boolean; 181 - onSelect: () => void; 182 - disabled?: boolean; 183 - }) => { 184 - const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary; 185 - 186 - return ( 187 - <div className="flex gap-2 items-center"> 188 - <ButtonComponent 189 - className="pollOption grow max-w-full flex" 190 - onClick={props.onSelect} 191 - disabled={props.disabled} 192 - > 193 - {props.option.text} 194 - </ButtonComponent> 195 - </div> 196 - ); 197 - }; 198 - 199 - const PollResults = (props: { 200 - pollData: PollData; 201 - hasVoted: boolean; 202 - setShowResults: (show: boolean) => void; 203 - optimisticVote: { option: string; voter_did: string } | null; 204 - }) => { 205 - // Merge optimistic vote with actual votes 206 - const allVotes = props.optimisticVote 207 - ? [ 208 - ...props.pollData.atp_poll_votes, 209 - { 210 - voter_did: props.optimisticVote.voter_did, 211 - record: { 212 - $type: "pub.leaflet.poll.vote", 213 - option: [props.optimisticVote.option], 214 - }, 215 - }, 216 - ] 217 - : props.pollData.atp_poll_votes; 218 - 219 - const totalVotes = allVotes.length || 0; 220 - let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 221 - let optionsWithCount = pollRecord.options.map((o, index) => ({ 222 - ...o, 223 - votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()), 224 - })); 225 - 226 - const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length)); 227 - return ( 228 - <> 229 - {pollRecord.options.map((option, index) => { 230 - const voteRecords = allVotes.filter( 231 - (v) => getVoteOption(v.record) === index.toString(), 232 - ); 233 - const isWinner = totalVotes > 0 && voteRecords.length === highestVotes; 234 - 235 - return ( 236 - <PollResult 237 - key={index} 238 - option={option} 239 - votes={voteRecords.length} 240 - voteRecords={voteRecords} 241 - totalVotes={totalVotes} 242 - winner={isWinner} 243 - /> 244 - ); 245 - })} 246 - </> 247 - ); 248 - }; 249 - 250 - const VoterListPopover = (props: { 251 - votes: number; 252 - voteRecords: { voter_did: string; record: Json }[]; 253 - }) => { 254 - const [voterIdentities, setVoterIdentities] = useState<VoterIdentity[]>([]); 255 - const [isLoading, setIsLoading] = useState(false); 256 - const [hasFetched, setHasFetched] = useState(false); 257 - 258 - const handleOpenChange = async () => { 259 - if (!hasFetched && props.voteRecords.length > 0) { 260 - setIsLoading(true); 261 - setHasFetched(true); 262 - try { 263 - const dids = props.voteRecords.map((v) => v.voter_did); 264 - const identities = await getVoterIdentities(dids); 265 - setVoterIdentities(identities); 266 - } catch (error) { 267 - console.error("Failed to fetch voter identities:", error); 268 - } finally { 269 - setIsLoading(false); 270 - } 271 - } 272 - }; 273 - 274 - return ( 275 - <Popover 276 - trigger={ 277 - <button 278 - className="hover:underline cursor-pointer" 279 - disabled={props.votes === 0} 280 - > 281 - {props.votes} 282 - </button> 283 - } 284 - onOpenChange={handleOpenChange} 285 - className="w-64 max-h-80" 286 - > 287 - {isLoading ? ( 288 - <div className="flex justify-center py-4"> 289 - <div className="text-sm text-secondary">Loading...</div> 290 - </div> 291 - ) : ( 292 - <div className="flex flex-col gap-1 text-sm py-0.5"> 293 - {voterIdentities.map((voter) => ( 294 - <a 295 - key={voter.did} 296 - href={`https://bsky.app/profile/${voter.handle || voter.did}`} 297 - target="_blank" 298 - rel="noopener noreferrer" 299 - className="" 300 - > 301 - @{voter.handle || voter.did} 302 - </a> 303 - ))} 304 - </div> 305 - )} 306 - </Popover> 307 - ); 308 - }; 309 - 310 - const PollResult = (props: { 311 - option: PubLeafletPollDefinition.Option; 312 - votes: number; 313 - voteRecords: { voter_did: string; record: Json }[]; 314 - totalVotes: number; 315 - winner: boolean; 316 - }) => { 317 - return ( 318 - <div 319 - className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 320 - > 321 - <div 322 - style={{ 323 - WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`, 324 - paintOrder: "stroke fill", 325 - }} 326 - className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10" 327 - > 328 - <div className="grow max-w-full truncate">{props.option.text}</div> 329 - <VoterListPopover votes={props.votes} voteRecords={props.voteRecords} /> 330 - </div> 331 - <div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0"> 332 - <div 333 - className="bg-accent-contrast rounded-[2px] m-0.5" 334 - style={{ 335 - maskImage: "var(--hatchSVG)", 336 - maskRepeat: "repeat repeat", 337 - ...(props.votes === 0 338 - ? { width: "4px" } 339 - : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 340 - }} 341 - /> 342 - <div /> 343 - </div> 344 - </div> 345 - ); 346 - };
···
+2 -3
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 186 <BlueskyLinkTiny className="shrink-0" /> 187 Bluesky 188 </a> 189 - <Separator classname="h-4!" /> 190 <button 191 id="copy-quote-link" 192 className="flex gap-1 items-center hover:font-bold px-1" ··· 211 </button> 212 {pubRecord?.preferences?.showComments !== false && identity?.atp_did && ( 213 <> 214 - <Separator classname="h-4! " /> 215 - 216 <button 217 className="flex gap-1 items-center hover:font-bold px-1" 218 onClick={() => {
··· 186 <BlueskyLinkTiny className="shrink-0" /> 187 Bluesky 188 </a> 189 + <Separator classname="h-4" /> 190 <button 191 id="copy-quote-link" 192 className="flex gap-1 items-center hover:font-bold px-1" ··· 211 </button> 212 {pubRecord?.preferences?.showComments !== false && identity?.atp_did && ( 213 <> 214 + <Separator classname="h-4" /> 215 <button 216 className="flex gap-1 items-center hover:font-bold px-1" 217 onClick={() => {
-20
app/lish/[did]/[publication]/[rkey]/StaticMathBlock.tsx
··· 1 - import { PubLeafletBlocksMath } from "lexicons/api"; 2 - import Katex from "katex"; 3 - import "katex/dist/katex.min.css"; 4 - 5 - export const StaticMathBlock = ({ 6 - block, 7 - }: { 8 - block: PubLeafletBlocksMath.Main; 9 - }) => { 10 - const html = Katex.renderToString(block.tex, { 11 - displayMode: true, 12 - output: "html", 13 - throwOnError: false, 14 - }); 15 - return ( 16 - <div className="math-block my-2"> 17 - <div dangerouslySetInnerHTML={{ __html: html }} /> 18 - </div> 19 - ); 20 - };
···
+2 -2
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 12 PubLeafletPagesLinearDocument, 13 } from "lexicons/api"; 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 - import { TextBlockCore, TextBlockCoreProps } from "./TextBlockCore"; 16 - import { StaticMathBlock } from "./StaticMathBlock"; 17 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 18 19 function StaticBaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
··· 12 PubLeafletPagesLinearDocument, 13 } from "lexicons/api"; 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 + import { TextBlockCore, TextBlockCoreProps } from "./Blocks/TextBlockCore"; 16 + import { StaticMathBlock } from "./Blocks/StaticMathBlock"; 17 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 18 19 function StaticBaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
-95
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
··· 1 - "use client"; 2 - import { UnicodeString } from "@atproto/api"; 3 - import { PubLeafletRichtextFacet } from "lexicons/api"; 4 - import { useMemo } from "react"; 5 - import { useHighlight } from "./useHighlight"; 6 - import { BaseTextBlock } from "./BaseTextBlock"; 7 - 8 - type Facet = PubLeafletRichtextFacet.Main; 9 - export function TextBlock(props: { 10 - plaintext: string; 11 - facets?: Facet[]; 12 - index: number[]; 13 - preview?: boolean; 14 - pageId?: string; 15 - }) { 16 - let children = []; 17 - let highlights = useHighlight(props.index, props.pageId); 18 - let facets = useMemo(() => { 19 - if (props.preview) return props.facets; 20 - let facets = [...(props.facets || [])]; 21 - for (let highlight of highlights) { 22 - const fragmentId = props.pageId 23 - ? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}` 24 - : `${props.index.join(".")}_${highlight.startOffset || 0}`; 25 - facets = addFacet( 26 - facets, 27 - { 28 - index: { 29 - byteStart: highlight.startOffset 30 - ? new UnicodeString( 31 - props.plaintext.slice(0, highlight.startOffset), 32 - ).length 33 - : 0, 34 - byteEnd: new UnicodeString( 35 - props.plaintext.slice(0, highlight.endOffset ?? undefined), 36 - ).length, 37 - }, 38 - features: [ 39 - { $type: "pub.leaflet.richtext.facet#highlight" }, 40 - { 41 - $type: "pub.leaflet.richtext.facet#id", 42 - id: fragmentId, 43 - }, 44 - ], 45 - }, 46 - new UnicodeString(props.plaintext).length, 47 - ); 48 - } 49 - return facets; 50 - }, [props.plaintext, props.facets, highlights, props.preview, props.pageId]); 51 - return <BaseTextBlock {...props} facets={facets} />; 52 - } 53 - 54 - function addFacet(facets: Facet[], newFacet: Facet, length: number) { 55 - if (facets.length === 0) { 56 - return [newFacet]; 57 - } 58 - 59 - const allFacets = [...facets, newFacet]; 60 - 61 - // Collect all boundary positions 62 - const boundaries = new Set<number>(); 63 - boundaries.add(0); 64 - boundaries.add(length); 65 - 66 - for (const facet of allFacets) { 67 - boundaries.add(facet.index.byteStart); 68 - boundaries.add(facet.index.byteEnd); 69 - } 70 - 71 - const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); 72 - const result: Facet[] = []; 73 - 74 - // Process segments between consecutive boundaries 75 - for (let i = 0; i < sortedBoundaries.length - 1; i++) { 76 - const start = sortedBoundaries[i]; 77 - const end = sortedBoundaries[i + 1]; 78 - 79 - // Find facets that are active at the start position 80 - const activeFacets = allFacets.filter( 81 - (facet) => facet.index.byteStart <= start && facet.index.byteEnd > start, 82 - ); 83 - 84 - // Only create facet if there are active facets (features present) 85 - if (activeFacets.length > 0) { 86 - const features = activeFacets.flatMap((f) => f.features); 87 - result.push({ 88 - index: { byteStart: start, byteEnd: end }, 89 - features, 90 - }); 91 - } 92 - } 93 - 94 - return result; 95 - }
···
-181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
··· 1 - import { UnicodeString } from "@atproto/api"; 2 - import { PubLeafletRichtextFacet } from "lexicons/api"; 3 - import { AtMentionLink } from "components/AtMentionLink"; 4 - import { ReactNode } from "react"; 5 - 6 - type Facet = PubLeafletRichtextFacet.Main; 7 - 8 - export type FacetRenderers = { 9 - DidMention?: (props: { did: string; children: ReactNode }) => ReactNode; 10 - }; 11 - 12 - export type TextBlockCoreProps = { 13 - plaintext: string; 14 - facets?: Facet[]; 15 - index: number[]; 16 - preview?: boolean; 17 - renderers?: FacetRenderers; 18 - }; 19 - 20 - export function TextBlockCore(props: TextBlockCoreProps) { 21 - let children = []; 22 - let richText = new RichText({ 23 - text: props.plaintext, 24 - facets: props.facets || [], 25 - }); 26 - let counter = 0; 27 - for (const segment of richText.segments()) { 28 - let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 29 - let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 30 - let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 31 - let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 32 - let isStrikethrough = segment.facet?.find( 33 - PubLeafletRichtextFacet.isStrikethrough, 34 - ); 35 - let isDidMention = segment.facet?.find( 36 - PubLeafletRichtextFacet.isDidMention, 37 - ); 38 - let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); 39 - let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 40 - let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 41 - let isHighlighted = segment.facet?.find( 42 - PubLeafletRichtextFacet.isHighlight, 43 - ); 44 - let className = ` 45 - ${isCode ? "inline-code" : ""} 46 - ${id ? "scroll-mt-12 scroll-mb-10" : ""} 47 - ${isBold ? "font-bold" : ""} 48 - ${isItalic ? "italic" : ""} 49 - ${isUnderline ? "underline" : ""} 50 - ${isStrikethrough ? "line-through decoration-tertiary" : ""} 51 - ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 52 - 53 - // Split text by newlines and insert <br> tags 54 - const textParts = segment.text.split("\n"); 55 - const renderedText = textParts.flatMap((part, i) => 56 - i < textParts.length - 1 57 - ? [part, <br key={`br-${counter}-${i}`} />] 58 - : [part], 59 - ); 60 - 61 - if (isCode) { 62 - children.push( 63 - <code key={counter} className={className} id={id?.id}> 64 - {renderedText} 65 - </code>, 66 - ); 67 - } else if (isDidMention) { 68 - const DidMentionRenderer = props.renderers?.DidMention; 69 - if (DidMentionRenderer) { 70 - children.push( 71 - <DidMentionRenderer key={counter} did={isDidMention.did}> 72 - <span className="mention">{renderedText}</span> 73 - </DidMentionRenderer>, 74 - ); 75 - } else { 76 - // Default: render as a simple link 77 - children.push( 78 - <a 79 - key={counter} 80 - href={`https://leaflet.pub/p/${isDidMention.did}`} 81 - target="_blank" 82 - className="no-underline" 83 - > 84 - <span className="mention">{renderedText}</span> 85 - </a>, 86 - ); 87 - } 88 - } else if (isAtMention) { 89 - children.push( 90 - <AtMentionLink 91 - key={counter} 92 - atURI={isAtMention.atURI} 93 - className={className} 94 - > 95 - {renderedText} 96 - </AtMentionLink>, 97 - ); 98 - } else if (link) { 99 - children.push( 100 - <a 101 - key={counter} 102 - href={link.uri.trim()} 103 - className={`text-accent-contrast hover:underline ${className}`} 104 - target="_blank" 105 - > 106 - {renderedText} 107 - </a>, 108 - ); 109 - } else { 110 - children.push( 111 - <span key={counter} className={className} id={id?.id}> 112 - {renderedText} 113 - </span>, 114 - ); 115 - } 116 - 117 - counter++; 118 - } 119 - return <>{children}</>; 120 - } 121 - 122 - type RichTextSegment = { 123 - text: string; 124 - facet?: Exclude<Facet["features"], { $type: string }>; 125 - }; 126 - 127 - export class RichText { 128 - unicodeText: UnicodeString; 129 - facets?: Facet[]; 130 - 131 - constructor(props: { text: string; facets: Facet[] }) { 132 - this.unicodeText = new UnicodeString(props.text); 133 - this.facets = props.facets; 134 - if (this.facets) { 135 - this.facets = this.facets 136 - .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 137 - .sort((a, b) => a.index.byteStart - b.index.byteStart); 138 - } 139 - } 140 - 141 - *segments(): Generator<RichTextSegment, void, void> { 142 - const facets = this.facets || []; 143 - if (!facets.length) { 144 - yield { text: this.unicodeText.utf16 }; 145 - return; 146 - } 147 - 148 - let textCursor = 0; 149 - let facetCursor = 0; 150 - do { 151 - const currFacet = facets[facetCursor]; 152 - if (textCursor < currFacet.index.byteStart) { 153 - yield { 154 - text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 155 - }; 156 - } else if (textCursor > currFacet.index.byteStart) { 157 - facetCursor++; 158 - continue; 159 - } 160 - if (currFacet.index.byteStart < currFacet.index.byteEnd) { 161 - const subtext = this.unicodeText.slice( 162 - currFacet.index.byteStart, 163 - currFacet.index.byteEnd, 164 - ); 165 - if (!subtext.trim()) { 166 - // dont empty string entities 167 - yield { text: subtext }; 168 - } else { 169 - yield { text: subtext, facet: currFacet.features }; 170 - } 171 - } 172 - textCursor = currFacet.index.byteEnd; 173 - facetCursor++; 174 - } while (facetCursor < facets.length); 175 - if (textCursor < this.unicodeText.length) { 176 - yield { 177 - text: this.unicodeText.slice(textCursor, this.unicodeText.length), 178 - }; 179 - } 180 - } 181 - }
···
+1 -58
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 10 data, 11 uri, 12 comments_on_documents(*, bsky_profiles(*)), 13 - documents_in_publications(publications(*, 14 - documents_in_publications(documents(uri, data)), 15 - publication_subscriptions(*)) 16 - ), 17 document_mentions_in_bsky(*), 18 leaflets_in_publications(*) 19 `, ··· 54 ?.record as PubLeafletPublication.Record 55 )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 56 57 - // Calculate prev/next documents from the fetched publication documents 58 - let prevNext: 59 - | { 60 - prev?: { uri: string; title: string }; 61 - next?: { uri: string; title: string }; 62 - } 63 - | undefined; 64 - 65 - const currentPublishedAt = (document.data as PubLeafletDocument.Record) 66 - ?.publishedAt; 67 - const allDocs = 68 - document.documents_in_publications[0]?.publications 69 - ?.documents_in_publications; 70 - 71 - if (currentPublishedAt && allDocs) { 72 - // Filter and sort documents by publishedAt 73 - const sortedDocs = allDocs 74 - .map((dip) => ({ 75 - uri: dip?.documents?.uri, 76 - title: (dip?.documents?.data as PubLeafletDocument.Record).title, 77 - publishedAt: (dip?.documents?.data as PubLeafletDocument.Record) 78 - .publishedAt, 79 - })) 80 - .filter((doc) => doc.publishedAt) // Only include docs with publishedAt 81 - .sort( 82 - (a, b) => 83 - new Date(a.publishedAt!).getTime() - 84 - new Date(b.publishedAt!).getTime(), 85 - ); 86 - 87 - // Find current document index 88 - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri); 89 - 90 - if (currentIndex !== -1) { 91 - prevNext = { 92 - prev: 93 - currentIndex > 0 94 - ? { 95 - uri: sortedDocs[currentIndex - 1].uri || "", 96 - title: sortedDocs[currentIndex - 1].title, 97 - } 98 - : undefined, 99 - next: 100 - currentIndex < sortedDocs.length - 1 101 - ? { 102 - uri: sortedDocs[currentIndex + 1].uri || "", 103 - title: sortedDocs[currentIndex + 1].title, 104 - } 105 - : undefined, 106 - }; 107 - } 108 - } 109 - 110 return { 111 ...document, 112 quotesAndMentions, 113 theme, 114 - prevNext, 115 }; 116 } 117
··· 10 data, 11 uri, 12 comments_on_documents(*, bsky_profiles(*)), 13 + documents_in_publications(publications(*, publication_subscriptions(*))), 14 document_mentions_in_bsky(*), 15 leaflets_in_publications(*) 16 `, ··· 51 ?.record as PubLeafletPublication.Record 52 )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 53 54 return { 55 ...document, 56 quotesAndMentions, 57 theme, 58 }; 59 } 60
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 140 commentsCount={comments} 141 tags={tags} 142 showComments={pubRecord?.preferences?.showComments} 143 - showMentions={pubRecord?.preferences?.showMentions} 144 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 145 /> 146 </div>
··· 140 commentsCount={comments} 141 tags={tags} 142 showComments={pubRecord?.preferences?.showComments} 143 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 /> 145 </div>
+25 -31
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 22 ? true 23 : record.preferences.showComments, 24 ); 25 - let [showMentions, setShowMentions] = useState( 26 - record?.preferences?.showMentions === undefined 27 - ? true 28 - : record.preferences.showMentions, 29 - ); 30 - let [showPrevNext, setShowPrevNext] = useState( 31 - record?.preferences?.showPrevNext === undefined 32 - ? true 33 - : record.preferences.showPrevNext, 34 - ); 35 36 let toast = useToaster(); 37 return ( 38 <form 39 onSubmit={async (e) => { 40 - if (!pubData) return; 41 - e.preventDefault(); 42 - props.setLoading(true); 43 - let data = await updatePublication({ 44 - name: record.name, 45 - uri: pubData.uri, 46 - preferences: { 47 - showInDiscover: 48 - record?.preferences?.showInDiscover === undefined 49 - ? true 50 - : record.preferences.showInDiscover, 51 - showComments: showComments, 52 - showMentions: showMentions, 53 - showPrevNext: showPrevNext, 54 - }, 55 - }); 56 - toast({ type: "success", content: <strong>Posts Updated!</strong> }); 57 - console.log(record.preferences?.showPrevNext); 58 - props.setLoading(false); 59 - mutate("publication-data"); 60 }} 61 className="text-primary flex flex-col" 62 > ··· 69 Post Options 70 </PubSettingsHeader> 71 <h4 className="mb-1">Layout</h4> 72 <Toggle 73 toggle={showPrevNext} 74 onToggle={() => { 75 setShowPrevNext(!showPrevNext); 76 }} 77 > 78 - <div className="font-bold">Show Prev/Next Buttons</div> 79 </Toggle> 80 <hr className="my-2 border-border-light" /> 81 <h4 className="mb-1">Interactions</h4>
··· 22 ? true 23 : record.preferences.showComments, 24 ); 25 + let [showMentions, setShowMentions] = useState(true); 26 + let [showPrevNext, setShowPrevNext] = useState(true); 27 28 let toast = useToaster(); 29 return ( 30 <form 31 onSubmit={async (e) => { 32 + // if (!pubData) return; 33 + // e.preventDefault(); 34 + // props.setLoading(true); 35 + // let data = await updatePublication({ 36 + // uri: pubData.uri, 37 + // name: nameValue, 38 + // description: descriptionValue, 39 + // iconFile: iconFile, 40 + // preferences: { 41 + // showInDiscover: showInDiscover, 42 + // showComments: showComments, 43 + // }, 44 + // }); 45 + // toast({ type: "success", content: "Posts Updated!" }); 46 + // props.setLoading(false); 47 + // mutate("publication-data"); 48 }} 49 className="text-primary flex flex-col" 50 > ··· 57 Post Options 58 </PubSettingsHeader> 59 <h4 className="mb-1">Layout</h4> 60 + {/*<div>Max Post Width</div>*/} 61 <Toggle 62 toggle={showPrevNext} 63 onToggle={() => { 64 setShowPrevNext(!showPrevNext); 65 }} 66 > 67 + <div className="flex flex-col justify-start"> 68 + <div className="font-bold">Show Prev/Next Buttons</div> 69 + <div className="text-tertiary text-sm leading-tight"> 70 + Show buttons that navigate to the previous and next posts 71 + </div> 72 + </div> 73 </Toggle> 74 <hr className="my-2 border-border-light" /> 75 <h4 className="mb-1">Interactions</h4>
+2 -2
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
··· 103 Theme and Layout 104 <ArrowRightTiny /> 105 </button> 106 - <button 107 className={menuItemClassName} 108 type="button" 109 onClick={() => props.setState("post-options")} 110 > 111 Post Options 112 <ArrowRightTiny /> 113 - </button> 114 </div> 115 ); 116 };
··· 103 Theme and Layout 104 <ArrowRightTiny /> 105 </button> 106 + {/*<button 107 className={menuItemClassName} 108 type="button" 109 onClick={() => props.setState("post-options")} 110 > 111 Post Options 112 <ArrowRightTiny /> 113 + </button>*/} 114 </div> 115 ); 116 };
+7 -3
app/lish/[did]/[publication]/page.tsx
··· 18 import { LocalizedDate } from "./LocalizedDate"; 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 import { PublicationAuthor } from "./PublicationAuthor"; 21 22 export default async function Publication(props: { 23 params: Promise<{ publication: string; did: string }>; ··· 147 </p> 148 </SpeedyLink> 149 150 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2"> 151 <p className="text-sm text-tertiary "> 152 {doc_record.publishedAt && ( 153 <LocalizedDate ··· 160 /> 161 )}{" "} 162 </p> 163 - {comments > 0 || quotes > 0 ? "| " : ""} 164 <InteractionPreview 165 quotesCount={quotes} 166 commentsCount={comments} 167 tags={tags} 168 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 169 showComments={record?.preferences?.showComments} 170 - showMentions={record?.preferences?.showMentions} 171 /> 172 </div> 173 </div>
··· 18 import { LocalizedDate } from "./LocalizedDate"; 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 import { PublicationAuthor } from "./PublicationAuthor"; 21 + import { Separator } from "components/Layout"; 22 23 export default async function Publication(props: { 24 params: Promise<{ publication: string; did: string }>; ··· 148 </p> 149 </SpeedyLink> 150 151 + <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 152 <p className="text-sm text-tertiary "> 153 {doc_record.publishedAt && ( 154 <LocalizedDate ··· 161 /> 162 )}{" "} 163 </p> 164 + {comments > 0 || quotes > 0 || tags.length > 0 ? ( 165 + <Separator classname="h-4! mx-1" /> 166 + ) : ( 167 + "" 168 + )} 169 <InteractionPreview 170 quotesCount={quotes} 171 commentsCount={comments} 172 tags={tags} 173 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 174 showComments={record?.preferences?.showComments} 175 /> 176 </div> 177 </div>
+2 -9
app/lish/createPub/CreatePubForm.tsx
··· 53 description: descriptionValue, 54 iconFile: logoFile, 55 subdomain: domainValue, 56 - preferences: { 57 - showInDiscover, 58 - showComments: true, 59 - showMentions: true, 60 - showPrevNext: false, 61 - }, 62 }); 63 64 if (!result.success) { ··· 73 setTimeout(() => { 74 setFormState("normal"); 75 if (result.publication) 76 - router.push( 77 - `${getBasePublicationURL(result.publication)}/dashboard`, 78 - ); 79 }, 500); 80 }} 81 >
··· 53 description: descriptionValue, 54 iconFile: logoFile, 55 subdomain: domainValue, 56 + preferences: { showInDiscover, showComments: true }, 57 }); 58 59 if (!result.success) { ··· 68 setTimeout(() => { 69 setFormState("normal"); 70 if (result.publication) 71 + router.push(`${getBasePublicationURL(result.publication)}/dashboard`); 72 }, 500); 73 }} 74 >
+14 -19
app/lish/createPub/UpdatePubForm.tsx
··· 21 import { Checkbox } from "components/Checkbox"; 22 import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 23 import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings"; 24 - import { Toggle } from "components/Toggle"; 25 26 export const EditPubForm = (props: { 27 backToMenuAction: () => void; ··· 44 ? true 45 : record.preferences.showComments, 46 ); 47 - let showMentions = 48 - record?.preferences?.showMentions === undefined 49 - ? true 50 - : record.preferences.showMentions; 51 - let showPrevNext = 52 - record?.preferences?.showPrevNext === undefined 53 - ? true 54 - : record.preferences.showPrevNext; 55 - 56 let [descriptionValue, setDescriptionValue] = useState( 57 record?.description || "", 58 ); ··· 84 preferences: { 85 showInDiscover: showInDiscover, 86 showComments: showComments, 87 - showMentions: showMentions, 88 - showPrevNext: showPrevNext, 89 }, 90 }); 91 toast({ type: "success", content: "Updated!" }); ··· 102 General Settings 103 </PubSettingsHeader> 104 <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2"> 105 - <div className="flex items-center justify-between gap-2 mt-2 "> 106 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 107 Logo <span className="font-normal">(optional)</span> 108 </p> ··· 172 <CustomDomainForm /> 173 <hr className="border-border-light" /> 174 175 - <Toggle 176 - toggle={showInDiscover} 177 - onToggle={() => setShowInDiscover(!showInDiscover)} 178 > 179 - <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 180 <p className="font-bold"> 181 Show In{" "} 182 <a href="/discover" target="_blank"> ··· 191 page. You can change this at any time! 192 </p> 193 </div> 194 - </Toggle> 195 196 - 197 </div> 198 </form> 199 );
··· 21 import { Checkbox } from "components/Checkbox"; 22 import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 23 import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings"; 24 25 export const EditPubForm = (props: { 26 backToMenuAction: () => void; ··· 43 ? true 44 : record.preferences.showComments, 45 ); 46 let [descriptionValue, setDescriptionValue] = useState( 47 record?.description || "", 48 ); ··· 74 preferences: { 75 showInDiscover: showInDiscover, 76 showComments: showComments, 77 }, 78 }); 79 toast({ type: "success", content: "Updated!" }); ··· 90 General Settings 91 </PubSettingsHeader> 92 <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2"> 93 + <div className="flex items-center justify-between gap-2 "> 94 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 95 Logo <span className="font-normal">(optional)</span> 96 </p> ··· 160 <CustomDomainForm /> 161 <hr className="border-border-light" /> 162 163 + <Checkbox 164 + checked={showInDiscover} 165 + onChange={(e) => setShowInDiscover(e.target.checked)} 166 > 167 + <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 168 <p className="font-bold"> 169 Show In{" "} 170 <a href="/discover" target="_blank"> ··· 179 page. You can change this at any time! 180 </p> 181 </div> 182 + </Checkbox> 183 184 + <Checkbox 185 + checked={showComments} 186 + onChange={(e) => setShowComments(e.target.checked)} 187 + > 188 + <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 189 + <p className="font-bold">Show comments on posts</p> 190 + </div> 191 + </Checkbox> 192 </div> 193 </form> 194 );
+2 -2
app/lish/createPub/updatePublication.ts
··· 25 }: { 26 uri: string; 27 name: string; 28 - description?: string; 29 - iconFile?: File | null; 30 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 31 }): Promise<UpdatePublicationResult> { 32 let identity = await getIdentityData();
··· 25 }: { 26 uri: string; 27 name: string; 28 + description: string; 29 + iconFile: File | null; 30 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 31 }): Promise<UpdatePublicationResult> { 32 let identity = await getIdentityData();
+2 -2
components/ActionBar/ActionButton.tsx
··· 70 > 71 <div className="shrink-0">{icon}</div> 72 <div 73 - className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 74 > 75 - <div className="truncate text-left pt-[1px]">{label}</div> 76 {subtext && ( 77 <div className="text-xs text-tertiary font-normal text-left"> 78 {subtext}
··· 70 > 71 <div className="shrink-0">{icon}</div> 72 <div 73 + className={`flex flex-col pr-1 ${subtext && "leading-snug"} max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 74 > 75 + <div className="truncate text-left">{label}</div> 76 {subtext && ( 77 <div className="text-xs text-tertiary font-normal text-left"> 78 {subtext}
+1 -1
components/Blocks/Block.tsx
··· 10 import { useHandleDrop } from "./useHandleDrop"; 11 import { useEntitySetContext } from "components/EntitySetProvider"; 12 13 - import { TextBlock } from "components/Blocks/TextBlock"; 14 import { ImageBlock } from "./ImageBlock"; 15 import { PageLinkBlock } from "./PageLinkBlock"; 16 import { ExternalLinkBlock } from "./ExternalLinkBlock";
··· 10 import { useHandleDrop } from "./useHandleDrop"; 11 import { useEntitySetContext } from "components/EntitySetProvider"; 12 13 + import { TextBlock } from "./TextBlock/index"; 14 import { ImageBlock } from "./ImageBlock"; 15 import { PageLinkBlock } from "./PageLinkBlock"; 16 import { ExternalLinkBlock } from "./ExternalLinkBlock";
+1 -1
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 8 import { Delta } from "src/utils/yjsFragmentToString"; 9 import { ProfilePopover } from "components/ProfilePopover"; 10 11 - type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 12 export function RenderYJSFragment({ 13 value, 14 wrapper,
··· 8 import { Delta } from "src/utils/yjsFragmentToString"; 9 import { ProfilePopover } from "components/ProfilePopover"; 10 11 + type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p" | "small"; 12 export function RenderYJSFragment({ 13 value, 14 wrapper,
+18 -4
components/Blocks/TextBlock/index.tsx
··· 120 }) { 121 let initialFact = useEntity(props.entityID, "block/text"); 122 let headingLevel = useEntity(props.entityID, "block/heading-level"); 123 let alignment = 124 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 125 let alignmentClass = { ··· 128 center: "text-center", 129 justify: "text-justify", 130 }[alignment]; 131 let { permissions } = useEntitySetContext(); 132 133 let content = <br />; ··· 159 className={` 160 ${alignmentClass} 161 ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""} 162 - ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 163 w-full whitespace-pre-wrap outline-hidden ${props.className} `} 164 > 165 {content} ··· 169 170 export function BaseTextBlock(props: BlockProps & { className?: string }) { 171 let headingLevel = useEntity(props.entityID, "block/heading-level"); 172 let alignment = 173 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 174 ··· 184 center: "text-center", 185 justify: "text-justify", 186 }[alignment]; 187 188 let editorState = useEditorStates( 189 (s) => s.editorStates[props.entityID], ··· 258 grow resize-none align-top whitespace-pre-wrap bg-transparent 259 outline-hidden 260 261 - ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 262 ${props.className}`} 263 ref={mountRef} 264 /> ··· 277 // if this is the only block on the page and is empty or is a canvas, show placeholder 278 <div 279 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col 280 - ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 281 `} 282 > 283 {props.type === "text" ··· 496 497 // Find the relative positioned parent container 498 const editorEl = view.dom; 499 - const container = editorEl.closest('.relative') as HTMLElement | null; 500 501 if (container) { 502 const containerRect = container.getBoundingClientRect();
··· 120 }) { 121 let initialFact = useEntity(props.entityID, "block/text"); 122 let headingLevel = useEntity(props.entityID, "block/heading-level"); 123 + let textSize = useEntity(props.entityID, "block/text-size"); 124 let alignment = 125 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 126 let alignmentClass = { ··· 129 center: "text-center", 130 justify: "text-justify", 131 }[alignment]; 132 + let textStyle = 133 + textSize?.data.value === "small" 134 + ? "text-sm" 135 + : textSize?.data.value === "large" 136 + ? "text-lg" 137 + : ""; 138 let { permissions } = useEntitySetContext(); 139 140 let content = <br />; ··· 166 className={` 167 ${alignmentClass} 168 ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""} 169 + ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 170 w-full whitespace-pre-wrap outline-hidden ${props.className} `} 171 > 172 {content} ··· 176 177 export function BaseTextBlock(props: BlockProps & { className?: string }) { 178 let headingLevel = useEntity(props.entityID, "block/heading-level"); 179 + let textSize = useEntity(props.entityID, "block/text-size"); 180 let alignment = 181 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 182 ··· 192 center: "text-center", 193 justify: "text-justify", 194 }[alignment]; 195 + let textStyle = 196 + textSize?.data.value === "small" 197 + ? "text-sm text-secondary" 198 + : textSize?.data.value === "large" 199 + ? "text-lg text-primary" 200 + : "text-base text-primary"; 201 202 let editorState = useEditorStates( 203 (s) => s.editorStates[props.entityID], ··· 272 grow resize-none align-top whitespace-pre-wrap bg-transparent 273 outline-hidden 274 275 + ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 276 ${props.className}`} 277 ref={mountRef} 278 /> ··· 291 // if this is the only block on the page and is empty or is a canvas, show placeholder 292 <div 293 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col 294 + ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 295 `} 296 > 297 {props.type === "text" ··· 510 511 // Find the relative positioned parent container 512 const editorEl = view.dom; 513 + const container = editorEl.closest(".relative") as HTMLElement | null; 514 515 if (container) { 516 const containerRect = container.getBoundingClientRect();
+14
components/Blocks/TextBlock/keymap.ts
··· 555 }, 556 }); 557 } 558 }; 559 asyncRun().then(() => { 560 useUIState.getState().setSelectedBlock({
··· 555 }, 556 }); 557 } 558 + let [textSize] = 559 + (await repRef.current?.query((tx) => 560 + scanIndex(tx).eav(propsRef.current.entityID, "block/text-size"), 561 + )) || []; 562 + if (textSize) { 563 + await repRef.current?.mutate.assertFact({ 564 + entity: newEntityID, 565 + attribute: "block/text-size", 566 + data: { 567 + type: "text-size-union", 568 + value: textSize.data.value, 569 + }, 570 + }); 571 + } 572 }; 573 asyncRun().then(() => { 574 useUIState.getState().setSelectedBlock({
+11
components/Blocks/TextBlock/useHandlePaste.ts
··· 299 }, 300 }); 301 } 302 if (child.tagName === "A") { 303 let href = child.getAttribute("href"); 304 let dataType = child.getAttribute("data-type");
··· 299 }, 300 }); 301 } 302 + let textSize = child.getAttribute("data-text-size"); 303 + if (textSize && ["default", "small", "large"].includes(textSize)) { 304 + rep.mutate.assertFact({ 305 + entity: entityID, 306 + attribute: "block/text-size", 307 + data: { 308 + type: "text-size-union", 309 + value: textSize as "default" | "small" | "large", 310 + }, 311 + }); 312 + } 313 if (child.tagName === "A") { 314 let href = child.getAttribute("href"); 315 let dataType = child.getAttribute("data-type");
+3 -6
components/Canvas.tsx
··· 170 171 let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 let showComments = pubRecord.preferences?.showComments; 173 - let showMentions = pubRecord.preferences?.showMentions; 174 175 return ( 176 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> ··· 179 <CommentTiny className="text-border" /> โ€” 180 </div> 181 )} 182 - {showComments && ( 183 - <div className="flex gap-1 text-tertiary items-center"> 184 - <QuoteTiny className="text-border" /> โ€” 185 - </div> 186 - )} 187 188 {!props.isSubpage && ( 189 <>
··· 170 171 let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 let showComments = pubRecord.preferences?.showComments; 173 174 return ( 175 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> ··· 178 <CommentTiny className="text-border" /> โ€” 179 </div> 180 )} 181 + <div className="flex gap-1 text-tertiary items-center"> 182 + <QuoteTiny className="text-border" /> โ€” 183 + </div> 184 185 {!props.isSubpage && ( 186 <>
+2 -4
components/InteractionsPreview.tsx
··· 14 tags?: string[]; 15 postUrl: string; 16 showComments: boolean | undefined; 17 - showMentions: boolean | undefined; 18 - 19 share?: boolean; 20 }) => { 21 let smoker = useSmoker(); 22 let interactionsAvailable = 23 - (props.quotesCount > 0 && props.showMentions !== false) || 24 (props.showComments !== false && props.commentsCount > 0); 25 26 const tagsCount = props.tags?.length || 0; ··· 38 </> 39 )} 40 41 - {props.showMentions === false || props.quotesCount === 0 ? null : ( 42 <SpeedyLink 43 aria-label="Post quotes" 44 href={`${props.postUrl}?interactionDrawer=quotes`}
··· 14 tags?: string[]; 15 postUrl: string; 16 showComments: boolean | undefined; 17 share?: boolean; 18 }) => { 19 let smoker = useSmoker(); 20 let interactionsAvailable = 21 + props.quotesCount > 0 || 22 (props.showComments !== false && props.commentsCount > 0); 23 24 const tagsCount = props.tags?.length || 0; ··· 36 </> 37 )} 38 39 + {props.quotesCount === 0 ? null : ( 40 <SpeedyLink 41 aria-label="Post quotes" 42 href={`${props.postUrl}?interactionDrawer=quotes`}
+3 -5
components/Pages/PublicationMetadata.tsx
··· 121 <Separator classname="h-4!" /> 122 </> 123 )} 124 - {pubRecord?.preferences?.showMentions && ( 125 - <div className="flex gap-1 items-center"> 126 - <QuoteTiny />โ€” 127 - </div> 128 - )} 129 {pubRecord?.preferences?.showComments && ( 130 <div className="flex gap-1 items-center"> 131 <CommentTiny />โ€”
··· 121 <Separator classname="h-4!" /> 122 </> 123 )} 124 + <div className="flex gap-1 items-center"> 125 + <QuoteTiny />โ€” 126 + </div> 127 {pubRecord?.preferences?.showComments && ( 128 <div className="flex gap-1 items-center"> 129 <CommentTiny />โ€”
-1
components/PostListing.tsx
··· 97 commentsCount={comments} 98 tags={tags} 99 showComments={pubRecord?.preferences?.showComments} 100 - showMentions={pubRecord?.preferences?.showMentions} 101 share 102 /> 103 </div>
··· 97 commentsCount={comments} 98 tags={tags} 99 showComments={pubRecord?.preferences?.showComments} 100 share 101 /> 102 </div>
+148 -1
components/SelectionManager/index.tsx
··· 89 }, 90 { 91 metaKey: true, 92 shift: true, 93 key: ["ArrowDown", "J"], 94 handler: async () => { ··· 684 } 685 return null; 686 } 687 - 688 689 function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 690 let everyBlockHasMark = blocks.reduce((acc, block) => {
··· 89 }, 90 { 91 metaKey: true, 92 + altKey: true, 93 + key: ["1", "ยก"], 94 + handler: async () => { 95 + let [sortedBlocks] = await getSortedSelectionBound(); 96 + for (let block of sortedBlocks) { 97 + await rep?.mutate.assertFact({ 98 + entity: block.value, 99 + attribute: "block/heading-level", 100 + data: { type: "number", value: 1 }, 101 + }); 102 + await rep?.mutate.assertFact({ 103 + entity: block.value, 104 + attribute: "block/type", 105 + data: { type: "block-type-union", value: "heading" }, 106 + }); 107 + } 108 + }, 109 + }, 110 + { 111 + metaKey: true, 112 + altKey: true, 113 + key: ["2", "โ„ข"], 114 + handler: async () => { 115 + let [sortedBlocks] = await getSortedSelectionBound(); 116 + for (let block of sortedBlocks) { 117 + await rep?.mutate.assertFact({ 118 + entity: block.value, 119 + attribute: "block/heading-level", 120 + data: { type: "number", value: 2 }, 121 + }); 122 + await rep?.mutate.assertFact({ 123 + entity: block.value, 124 + attribute: "block/type", 125 + data: { type: "block-type-union", value: "heading" }, 126 + }); 127 + } 128 + }, 129 + }, 130 + { 131 + metaKey: true, 132 + altKey: true, 133 + key: ["3", "ยฃ"], 134 + handler: async () => { 135 + let [sortedBlocks] = await getSortedSelectionBound(); 136 + for (let block of sortedBlocks) { 137 + await rep?.mutate.assertFact({ 138 + entity: block.value, 139 + attribute: "block/heading-level", 140 + data: { type: "number", value: 3 }, 141 + }); 142 + await rep?.mutate.assertFact({ 143 + entity: block.value, 144 + attribute: "block/type", 145 + data: { type: "block-type-union", value: "heading" }, 146 + }); 147 + } 148 + }, 149 + }, 150 + { 151 + metaKey: true, 152 + altKey: true, 153 + key: ["0", "ยบ"], 154 + handler: async () => { 155 + let [sortedBlocks] = await getSortedSelectionBound(); 156 + for (let block of sortedBlocks) { 157 + // Convert to text block 158 + await rep?.mutate.assertFact({ 159 + entity: block.value, 160 + attribute: "block/type", 161 + data: { type: "block-type-union", value: "text" }, 162 + }); 163 + // Remove heading level if exists 164 + let headingLevel = await rep?.query((tx) => 165 + scanIndex(tx).eav(block.value, "block/heading-level"), 166 + ); 167 + if (headingLevel?.[0]) { 168 + await rep?.mutate.retractFact({ factID: headingLevel[0].id }); 169 + } 170 + // Remove text-size to make it default 171 + let textSizeFact = await rep?.query((tx) => 172 + scanIndex(tx).eav(block.value, "block/text-size"), 173 + ); 174 + if (textSizeFact?.[0]) { 175 + await rep?.mutate.retractFact({ factID: textSizeFact[0].id }); 176 + } 177 + } 178 + }, 179 + }, 180 + { 181 + metaKey: true, 182 + altKey: true, 183 + key: ["+", "โ‰ "], 184 + handler: async () => { 185 + let [sortedBlocks] = await getSortedSelectionBound(); 186 + for (let block of sortedBlocks) { 187 + // Convert to text block 188 + await rep?.mutate.assertFact({ 189 + entity: block.value, 190 + attribute: "block/type", 191 + data: { type: "block-type-union", value: "text" }, 192 + }); 193 + // Remove heading level if exists 194 + let headingLevel = await rep?.query((tx) => 195 + scanIndex(tx).eav(block.value, "block/heading-level"), 196 + ); 197 + if (headingLevel?.[0]) { 198 + await rep?.mutate.retractFact({ factID: headingLevel[0].id }); 199 + } 200 + // Set text size to large 201 + await rep?.mutate.assertFact({ 202 + entity: block.value, 203 + attribute: "block/text-size", 204 + data: { type: "text-size-union", value: "large" }, 205 + }); 206 + } 207 + }, 208 + }, 209 + { 210 + metaKey: true, 211 + altKey: true, 212 + key: ["-", "โ€“"], 213 + handler: async () => { 214 + let [sortedBlocks] = await getSortedSelectionBound(); 215 + for (let block of sortedBlocks) { 216 + // Convert to text block 217 + await rep?.mutate.assertFact({ 218 + entity: block.value, 219 + attribute: "block/type", 220 + data: { type: "block-type-union", value: "text" }, 221 + }); 222 + // Remove heading level if exists 223 + let headingLevel = await rep?.query((tx) => 224 + scanIndex(tx).eav(block.value, "block/heading-level"), 225 + ); 226 + if (headingLevel?.[0]) { 227 + await rep?.mutate.retractFact({ factID: headingLevel[0].id }); 228 + } 229 + // Set text size to small 230 + await rep?.mutate.assertFact({ 231 + entity: block.value, 232 + attribute: "block/text-size", 233 + data: { type: "text-size-union", value: "small" }, 234 + }); 235 + } 236 + }, 237 + }, 238 + { 239 + metaKey: true, 240 shift: true, 241 key: ["ArrowDown", "J"], 242 handler: async () => { ··· 832 } 833 return null; 834 } 835 836 function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 837 let everyBlockHasMark = blocks.reduce((acc, block) => {
+3 -2
components/ThemeManager/ThemeSetter.tsx
··· 1 "use client"; 2 import { Popover } from "components/Popover"; 3 4 import { Color } from "react-aria-components"; 5 ··· 165 setOpenPicker={(pickers) => setOpenPicker(pickers)} 166 /> 167 <SectionArrow 168 - fill="rgb(var(--accent-2))" 169 - stroke="rgb(var(--accent-1))" 170 className="ml-2" 171 /> 172 </div>
··· 1 "use client"; 2 import { Popover } from "components/Popover"; 3 + import { theme } from "../../tailwind.config"; 4 5 import { Color } from "react-aria-components"; 6 ··· 166 setOpenPicker={(pickers) => setOpenPicker(pickers)} 167 /> 168 <SectionArrow 169 + fill={theme.colors["accent-2"]} 170 + stroke={theme.colors["accent-1"]} 171 className="ml-2" 172 /> 173 </div>
+9 -5
components/Toolbar/BlockToolbar.tsx
··· 5 import { useUIState } from "src/useUIState"; 6 import { LockBlockButton } from "./LockBlockButton"; 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 - import { ImageFullBleedButton, ImageAltTextButton, ImageCoverButton } from "./ImageToolbar"; 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 ··· 37 > 38 <DeleteSmall /> 39 </ToolbarButton> 40 - <Separator classname="h-6" /> 41 <MoveBlockButtons /> 42 {blockType === "image" && ( 43 <> ··· 46 <ImageAltTextButton setToolbarState={props.setToolbarState} /> 47 <ImageCoverButton /> 48 {focusedEntityType?.data.value !== "canvas" && ( 49 - <Separator classname="h-6" /> 50 )} 51 </> 52 )} ··· 54 <> 55 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 56 {focusedEntityType?.data.value !== "canvas" && ( 57 - <Separator classname="h-6" /> 58 )} 59 </> 60 )} ··· 175 > 176 <MoveBlockDown /> 177 </ToolbarButton> 178 - <Separator classname="h-6" /> 179 </> 180 ); 181 };
··· 5 import { useUIState } from "src/useUIState"; 6 import { LockBlockButton } from "./LockBlockButton"; 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 + import { 9 + ImageFullBleedButton, 10 + ImageAltTextButton, 11 + ImageCoverButton, 12 + } from "./ImageToolbar"; 13 import { DeleteSmall } from "components/Icons/DeleteSmall"; 14 import { getSortedSelection } from "components/SelectionManager/selectionState"; 15 ··· 41 > 42 <DeleteSmall /> 43 </ToolbarButton> 44 + <Separator classname="h-6!" /> 45 <MoveBlockButtons /> 46 {blockType === "image" && ( 47 <> ··· 50 <ImageAltTextButton setToolbarState={props.setToolbarState} /> 51 <ImageCoverButton /> 52 {focusedEntityType?.data.value !== "canvas" && ( 53 + <Separator classname="h-6!" /> 54 )} 55 </> 56 )} ··· 58 <> 59 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 60 {focusedEntityType?.data.value !== "canvas" && ( 61 + <Separator classname="h-6!" /> 62 )} 63 </> 64 )} ··· 179 > 180 <MoveBlockDown /> 181 </ToolbarButton> 182 + <Separator classname="h-6!" /> 183 </> 184 ); 185 };
+1 -1
components/Toolbar/HighlightToolbar.tsx
··· 126 setLastUsedHightlight={props.setLastUsedHighlight} 127 /> 128 129 - <Separator classname="h-6" /> 130 <HighlightColorSettings pageID={props.pageID} /> 131 </div> 132 </div>
··· 126 setLastUsedHightlight={props.setLastUsedHighlight} 127 /> 128 129 + <Separator classname="h-6!" /> 130 <HighlightColorSettings pageID={props.pageID} /> 131 </div> 132 </div>
+1 -1
components/Toolbar/InlineLinkToolbar.tsx
··· 132 return ( 133 <div className="w-full flex items-center gap-[6px] grow"> 134 <LinkSmall /> 135 - <Separator classname="h-6" /> 136 <Input 137 autoFocus 138 className="w-full grow bg-transparent border-none outline-hidden "
··· 132 return ( 133 <div className="w-full flex items-center gap-[6px] grow"> 134 <LinkSmall /> 135 + <Separator classname="h-6!" /> 136 <Input 137 autoFocus 138 className="w-full grow bg-transparent border-none outline-hidden "
+2 -2
components/Toolbar/ListToolbar.tsx
··· 131 > 132 <ListIndentIncreaseSmall /> 133 </ToolbarButton> 134 - <Separator classname="h-6" /> 135 <ToolbarButton 136 disabled={!isList?.data.value} 137 tooltipContent=<div className="flex flex-col gap-1 justify-center"> 138 <div className="text-center">Add a Checkbox</div> 139 <div className="flex gap-1 font-normal"> 140 - start line with <ShortcutKey>[</ShortcutKey> 141 <ShortcutKey>]</ShortcutKey> 142 </div> 143 </div>
··· 131 > 132 <ListIndentIncreaseSmall /> 133 </ToolbarButton> 134 + <Separator classname="h-6!" /> 135 <ToolbarButton 136 disabled={!isList?.data.value} 137 tooltipContent=<div className="flex flex-col gap-1 justify-center"> 138 <div className="text-center">Add a Checkbox</div> 139 <div className="flex gap-1 font-normal"> 140 + <ShortcutKey>[</ShortcutKey> 141 <ShortcutKey>]</ShortcutKey> 142 </div> 143 </div>
+154 -95
components/Toolbar/TextBlockTypeToolbar.tsx
··· 4 Header3Small, 5 } from "components/Icons/BlockTextSmall"; 6 import { Props } from "components/Icons/Props"; 7 - import { ShortcutKey } from "components/Layout"; 8 import { ToolbarButton } from "components/Toolbar"; 9 import { TextSelection } from "prosemirror-state"; 10 import { useCallback } from "react"; ··· 22 focusedBlock?.entityID || null, 23 "block/heading-level", 24 ); 25 let { rep } = useReplicache(); 26 27 let setLevel = useCallback( ··· 51 ); 52 return ( 53 // This Toolbar should close once the user starts typing again 54 - <div className="flex w-full justify-between items-center gap-4"> 55 - <div className="flex items-center gap-[6px]"> 56 - <ToolbarButton 57 - className={props.className} 58 - onClick={() => { 59 - setLevel(1); 60 - }} 61 - active={ 62 - blockType?.data.value === "heading" && 63 - headingLevel?.data.value === 1 64 - } 65 - tooltipContent={ 66 - <div className="flex flex-col justify-center"> 67 - <div className="font-bold text-center">Title</div> 68 - <div className="flex gap-1 font-normal"> 69 - start line with 70 - <ShortcutKey>#</ShortcutKey> 71 - </div> 72 </div> 73 - } 74 - > 75 - <Header1Small /> 76 - </ToolbarButton> 77 - <ToolbarButton 78 - className={props.className} 79 - onClick={() => { 80 - setLevel(2); 81 - }} 82 - active={ 83 - blockType?.data.value === "heading" && 84 - headingLevel?.data.value === 2 85 - } 86 - tooltipContent={ 87 - <div className="flex flex-col justify-center"> 88 - <div className="font-bold text-center">Heading</div> 89 - <div className="flex gap-1 font-normal"> 90 - start line with 91 - <ShortcutKey>##</ShortcutKey> 92 - </div> 93 </div> 94 } 95 - > 96 - <Header2Small /> 97 - </ToolbarButton> 98 - <ToolbarButton 99 - className={props.className} 100 - onClick={() => { 101 - setLevel(3); 102 - }} 103 - active={ 104 - blockType?.data.value === "heading" && 105 - headingLevel?.data.value === 3 106 - } 107 - tooltipContent={ 108 - <div className="flex flex-col justify-center"> 109 - <div className="font-bold text-center">Subheading</div> 110 - <div className="flex gap-1 font-normal"> 111 - start line with 112 - <ShortcutKey>###</ShortcutKey> 113 - </div> 114 - </div> 115 } 116 - > 117 - <Header3Small /> 118 - </ToolbarButton> 119 - <ToolbarButton 120 - className={`px-[6px] ${props.className}`} 121 - onClick={async () => { 122 if (headingLevel) 123 await rep?.mutate.retractFact({ factID: headingLevel.id }); 124 - if (!focusedBlock || !blockType) return; 125 - if (blockType.data.value !== "text") { 126 - let existingEditor = 127 - useEditorStates.getState().editorStates[focusedBlock.entityID]; 128 - let selection = existingEditor?.editor.selection; 129 - await rep?.mutate.assertFact({ 130 - entity: focusedBlock?.entityID, 131 - attribute: "block/type", 132 - data: { type: "block-type-union", value: "text" }, 133 - }); 134 - 135 - let newEditor = 136 - useEditorStates.getState().editorStates[focusedBlock.entityID]; 137 - if (!newEditor || !selection) return; 138 - newEditor.view?.dispatch( 139 - newEditor.editor.tr.setSelection( 140 - TextSelection.create(newEditor.editor.doc, selection.anchor), 141 - ), 142 - ); 143 - 144 - newEditor.view?.focus(); 145 - } 146 - }} 147 - active={blockType?.data.value === "text"} 148 - tooltipContent={<div>Paragraph</div>} 149 - > 150 - Paragraph 151 - </ToolbarButton> 152 - </div> 153 - </div> 154 ); 155 }; 156
··· 4 Header3Small, 5 } from "components/Icons/BlockTextSmall"; 6 import { Props } from "components/Icons/Props"; 7 + import { ShortcutKey, Separator } from "components/Layout"; 8 import { ToolbarButton } from "components/Toolbar"; 9 import { TextSelection } from "prosemirror-state"; 10 import { useCallback } from "react"; ··· 22 focusedBlock?.entityID || null, 23 "block/heading-level", 24 ); 25 + 26 + let textSize = useEntity(focusedBlock?.entityID || null, "block/text-size"); 27 let { rep } = useReplicache(); 28 29 let setLevel = useCallback( ··· 53 ); 54 return ( 55 // This Toolbar should close once the user starts typing again 56 + <> 57 + <ToolbarButton 58 + className={props.className} 59 + onClick={() => { 60 + setLevel(1); 61 + }} 62 + active={ 63 + blockType?.data.value === "heading" && headingLevel?.data.value === 1 64 + } 65 + tooltipContent={ 66 + <div className="flex flex-col justify-center"> 67 + <div className="font-bold text-center">Title</div> 68 + <div className="flex gap-1 font-normal"> 69 + start line with 70 + <ShortcutKey>#</ShortcutKey> 71 + </div> 72 + </div> 73 + } 74 + > 75 + <Header1Small /> 76 + </ToolbarButton> 77 + <ToolbarButton 78 + className={props.className} 79 + onClick={() => { 80 + setLevel(2); 81 + }} 82 + active={ 83 + blockType?.data.value === "heading" && headingLevel?.data.value === 2 84 + } 85 + tooltipContent={ 86 + <div className="flex flex-col justify-center"> 87 + <div className="font-bold text-center">Heading</div> 88 + <div className="flex gap-1 font-normal"> 89 + start line with 90 + <ShortcutKey>##</ShortcutKey> 91 </div> 92 + </div> 93 + } 94 + > 95 + <Header2Small /> 96 + </ToolbarButton> 97 + <ToolbarButton 98 + className={props.className} 99 + onClick={() => { 100 + setLevel(3); 101 + }} 102 + active={ 103 + blockType?.data.value === "heading" && headingLevel?.data.value === 3 104 + } 105 + tooltipContent={ 106 + <div className="flex flex-col justify-center"> 107 + <div className="font-bold text-center">Subheading</div> 108 + <div className="flex gap-1 font-normal"> 109 + start line with 110 + <ShortcutKey>###</ShortcutKey> 111 </div> 112 + </div> 113 + } 114 + > 115 + <Header3Small /> 116 + </ToolbarButton> 117 + <Separator classname="h-6!" /> 118 + <ToolbarButton 119 + className={`px-[6px] ${props.className}`} 120 + onClick={async () => { 121 + if (headingLevel) 122 + await rep?.mutate.retractFact({ factID: headingLevel.id }); 123 + if (textSize) await rep?.mutate.retractFact({ factID: textSize.id }); 124 + if (!focusedBlock || !blockType) return; 125 + if (blockType.data.value !== "text") { 126 + let existingEditor = 127 + useEditorStates.getState().editorStates[focusedBlock.entityID]; 128 + let selection = existingEditor?.editor.selection; 129 + await rep?.mutate.assertFact({ 130 + entity: focusedBlock?.entityID, 131 + attribute: "block/type", 132 + data: { type: "block-type-union", value: "text" }, 133 + }); 134 + 135 + let newEditor = 136 + useEditorStates.getState().editorStates[focusedBlock.entityID]; 137 + if (!newEditor || !selection) return; 138 + newEditor.view?.dispatch( 139 + newEditor.editor.tr.setSelection( 140 + TextSelection.create(newEditor.editor.doc, selection.anchor), 141 + ), 142 + ); 143 + 144 + newEditor.view?.focus(); 145 } 146 + }} 147 + active={ 148 + blockType?.data.value === "text" && 149 + textSize?.data.value !== "small" && 150 + textSize?.data.value !== "large" 151 + } 152 + tooltipContent={<div>Normal Text</div>} 153 + > 154 + Text 155 + </ToolbarButton> 156 + <ToolbarButton 157 + className={`px-[6px] text-lg ${props.className}`} 158 + onClick={async () => { 159 + if (!focusedBlock || !blockType) return; 160 + if (blockType.data.value !== "text") { 161 + // Convert to text block first if it's a heading 162 + if (headingLevel) 163 + await rep?.mutate.retractFact({ factID: headingLevel.id }); 164 + await rep?.mutate.assertFact({ 165 + entity: focusedBlock.entityID, 166 + attribute: "block/type", 167 + data: { type: "block-type-union", value: "text" }, 168 + }); 169 } 170 + // Set text size to large 171 + await rep?.mutate.assertFact({ 172 + entity: focusedBlock.entityID, 173 + attribute: "block/text-size", 174 + data: { type: "text-size-union", value: "large" }, 175 + }); 176 + }} 177 + active={ 178 + blockType?.data.value === "text" && textSize?.data.value === "large" 179 + } 180 + tooltipContent={<div>Large Text</div>} 181 + > 182 + <div className="leading-[1.625rem]">Large</div> 183 + </ToolbarButton> 184 + <ToolbarButton 185 + className={`px-[6px] text-sm text-secondary ${props.className}`} 186 + onClick={async () => { 187 + if (!focusedBlock || !blockType) return; 188 + if (blockType.data.value !== "text") { 189 + // Convert to text block first if it's a heading 190 if (headingLevel) 191 await rep?.mutate.retractFact({ factID: headingLevel.id }); 192 + await rep?.mutate.assertFact({ 193 + entity: focusedBlock.entityID, 194 + attribute: "block/type", 195 + data: { type: "block-type-union", value: "text" }, 196 + }); 197 + } 198 + // Set text size to small 199 + await rep?.mutate.assertFact({ 200 + entity: focusedBlock.entityID, 201 + attribute: "block/text-size", 202 + data: { type: "text-size-union", value: "small" }, 203 + }); 204 + }} 205 + active={ 206 + blockType?.data.value === "text" && textSize?.data.value === "small" 207 + } 208 + tooltipContent={<div>Small Text</div>} 209 + > 210 + <div className="leading-[1.625rem]">Small</div> 211 + </ToolbarButton> 212 + </> 213 ); 214 }; 215
+3 -3
components/Toolbar/TextToolbar.tsx
··· 74 lastUsedHighlight={props.lastUsedHighlight} 75 setToolbarState={props.setToolbarState} 76 /> 77 - <Separator classname="h-6" /> 78 <LinkButton setToolbarState={props.setToolbarState} /> 79 - <Separator classname="h-6" /> 80 <TextBlockTypeButton setToolbarState={props.setToolbarState} /> 81 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 82 <ListButton setToolbarState={props.setToolbarState} /> 83 - <Separator classname="h-6" /> 84 85 <LockBlockButton /> 86 </>
··· 74 lastUsedHighlight={props.lastUsedHighlight} 75 setToolbarState={props.setToolbarState} 76 /> 77 + <Separator classname="h-6!" /> 78 <LinkButton setToolbarState={props.setToolbarState} /> 79 + <Separator classname="h-6!" /> 80 <TextBlockTypeButton setToolbarState={props.setToolbarState} /> 81 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 82 <ListButton setToolbarState={props.setToolbarState} /> 83 + <Separator classname="h-6!" /> 84 85 <LockBlockButton /> 86 </>
+2 -2
components/utils/DotLoader.tsx
··· 1 import { useEffect, useState } from "react"; 2 3 - export function DotLoader() { 4 let [dots, setDots] = useState(1); 5 useEffect(() => { 6 let id = setInterval(() => { ··· 11 }; 12 }, []); 13 return ( 14 - <div className="w-[26px] h-[24px] text-center text-sm"> 15 {".".repeat(dots) + "\u00a0".repeat(3 - dots)} 16 </div> 17 );
··· 1 import { useEffect, useState } from "react"; 2 3 + export function DotLoader(props: { className?: string }) { 4 let [dots, setDots] = useState(1); 5 useEffect(() => { 6 let id = setInterval(() => { ··· 11 }; 12 }, []); 13 return ( 14 + <div className={`w-[26px] h-[24px] text-center text-sm ${props.className}`}> 15 {".".repeat(dots) + "\u00a0".repeat(3 - dots)} 16 </div> 17 );
+4 -8
lexicons/api/lexicons.ts
··· 1246 plaintext: { 1247 type: 'string', 1248 }, 1249 facets: { 1250 type: 'array', 1251 items: { ··· 1803 default: true, 1804 }, 1805 showComments: { 1806 - type: 'boolean', 1807 - default: true, 1808 - }, 1809 - showMentions: { 1810 - type: 'boolean', 1811 - default: true, 1812 - }, 1813 - showPrevNext: { 1814 type: 'boolean', 1815 default: true, 1816 },
··· 1246 plaintext: { 1247 type: 'string', 1248 }, 1249 + textSize: { 1250 + type: 'string', 1251 + enum: ['default', 'small', 'large'], 1252 + }, 1253 facets: { 1254 type: 'array', 1255 items: { ··· 1807 default: true, 1808 }, 1809 showComments: { 1810 type: 'boolean', 1811 default: true, 1812 },
+1
lexicons/api/types/pub/leaflet/blocks/text.ts
··· 18 export interface Main { 19 $type?: 'pub.leaflet.blocks.text' 20 plaintext: string 21 facets?: PubLeafletRichtextFacet.Main[] 22 } 23
··· 18 export interface Main { 19 $type?: 'pub.leaflet.blocks.text' 20 plaintext: string 21 + textSize?: 'default' | 'small' | 'large' 22 facets?: PubLeafletRichtextFacet.Main[] 23 } 24
-2
lexicons/api/types/pub/leaflet/publication.ts
··· 37 $type?: 'pub.leaflet.publication#preferences' 38 showInDiscover: boolean 39 showComments: boolean 40 - showMentions: boolean 41 - showPrevNext: boolean 42 } 43 44 const hashPreferences = 'preferences'
··· 37 $type?: 'pub.leaflet.publication#preferences' 38 showInDiscover: boolean 39 showComments: boolean 40 } 41 42 const hashPreferences = 'preferences'
+8
lexicons/pub/leaflet/blocks/text.json
··· 11 "plaintext": { 12 "type": "string" 13 }, 14 "facets": { 15 "type": "array", 16 "items": {
··· 11 "plaintext": { 12 "type": "string" 13 }, 14 + "textSize": { 15 + "type": "string", 16 + "enum": [ 17 + "default", 18 + "small", 19 + "large" 20 + ] 21 + }, 22 "facets": { 23 "type": "array", 24 "items": {
-8
lexicons/pub/leaflet/publication.json
··· 51 "showComments": { 52 "type": "boolean", 53 "default": true 54 - }, 55 - "showMentions": { 56 - "type": "boolean", 57 - "default": true 58 - }, 59 - "showPrevNext": { 60 - "type": "boolean", 61 - "default": true 62 } 63 } 64 },
··· 51 "showComments": { 52 "type": "boolean", 53 "default": true 54 } 55 } 56 },
+1
lexicons/src/blocks.ts
··· 10 required: ["plaintext"], 11 properties: { 12 plaintext: { type: "string" }, 13 facets: { 14 type: "array", 15 items: { type: "ref", ref: PubLeafletRichTextFacet.id },
··· 10 required: ["plaintext"], 11 properties: { 12 plaintext: { type: "string" }, 13 + textSize: { type: "string", enum: ["default", "small", "large"] }, 14 facets: { 15 type: "array", 16 items: { type: "ref", ref: PubLeafletRichTextFacet.id },
-2
lexicons/src/publication.ts
··· 27 properties: { 28 showInDiscover: { type: "boolean", default: true }, 29 showComments: { type: "boolean", default: true }, 30 - showMentions: { type: "boolean", default: true }, 31 - showPrevNext: { type: "boolean", default: false }, 32 }, 33 }, 34 theme: {
··· 27 properties: { 28 showInDiscover: { type: "boolean", default: true }, 29 showComments: { type: "boolean", default: true }, 30 }, 31 }, 32 theme: {
+8
src/replicache/attributes.ts
··· 71 type: "number", 72 cardinality: "one", 73 }, 74 "block/image": { 75 type: "image", 76 cardinality: "one", ··· 321 "text-alignment-type-union": { 322 type: "text-alignment-type-union"; 323 value: "right" | "left" | "center" | "justify"; 324 }; 325 "page-type-union": { type: "page-type-union"; value: "doc" | "canvas" }; 326 "block-type-union": {
··· 71 type: "number", 72 cardinality: "one", 73 }, 74 + "block/text-size": { 75 + type: "text-size-union", 76 + cardinality: "one", 77 + }, 78 "block/image": { 79 type: "image", 80 cardinality: "one", ··· 325 "text-alignment-type-union": { 326 type: "text-alignment-type-union"; 327 value: "right" | "left" | "center" | "justify"; 328 + }; 329 + "text-size-union": { 330 + type: "text-size-union"; 331 + value: "default" | "small" | "large"; 332 }; 333 "page-type-union": { type: "page-type-union"; value: "doc" | "canvas" }; 334 "block-type-union": {
+3
src/utils/getBlocksAsHTML.tsx
··· 171 }, 172 text: async (b, tx, a) => { 173 let [value] = await scanIndex(tx).eav(b.value, "block/text"); 174 return ( 175 <RenderYJSFragment 176 value={value?.data.value} 177 attrs={{ 178 "data-alignment": a, 179 }} 180 wrapper="p" 181 />
··· 171 }, 172 text: async (b, tx, a) => { 173 let [value] = await scanIndex(tx).eav(b.value, "block/text"); 174 + let [textSize] = await scanIndex(tx).eav(b.value, "block/text-size"); 175 + 176 return ( 177 <RenderYJSFragment 178 value={value?.data.value} 179 attrs={{ 180 "data-alignment": a, 181 + "data-text-size": textSize?.data.value, 182 }} 183 wrapper="p" 184 />