a tool for shared writing and social publishing

Merge branch 'main' of https://github.com/hyperlink-academy/minilink into feature/tags

+3487 -693
+6 -3
actions/getIdentityData.ts
··· 2 2 3 3 import { cookies } from "next/headers"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 - 6 - export async function getIdentityData() { 5 + import { cache } from "react"; 6 + export const getIdentityData = cache(uncachedGetIdentityData); 7 + export async function uncachedGetIdentityData() { 7 8 let cookieStore = await cookies(); 8 9 let auth_token = 9 10 cookieStore.get("auth_token")?.value || ··· 18 19 bsky_profiles(*), 19 20 publication_subscriptions(*), 20 21 custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), 21 - home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), 22 + home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*, 23 + entity_sets(entities(facts(*))) 24 + )), 22 25 permission_token_on_homepage( 23 26 created_at, 24 27 permission_tokens!inner(
+158 -22
actions/publishToPublication.ts
··· 12 12 PubLeafletBlocksUnorderedList, 13 13 PubLeafletDocument, 14 14 PubLeafletPagesLinearDocument, 15 + PubLeafletPagesCanvas, 15 16 PubLeafletRichtextFacet, 16 17 PubLeafletBlocksWebsite, 17 18 PubLeafletBlocksCode, ··· 21 22 PubLeafletBlocksBlockquote, 22 23 PubLeafletBlocksIframe, 23 24 PubLeafletBlocksPage, 25 + PubLeafletBlocksPoll, 26 + PubLeafletPollDefinition, 24 27 } from "lexicons/api"; 25 28 import { Block } from "components/Blocks/Block"; 26 29 import { TID } from "@atproto/common"; ··· 39 42 import { $Typed, UnicodeString } from "@atproto/api"; 40 43 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 41 44 import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 45 + import { Lock } from "src/utils/lock"; 42 46 43 47 export async function publishToPublication({ 44 48 root_entity, ··· 78 82 facts, 79 83 agent, 80 84 root_entity, 85 + credentialSession.did!, 81 86 ); 82 87 83 88 let existingRecord = ··· 95 100 $type: "pub.leaflet.pages.linearDocument", 96 101 blocks: firstPageBlocks, 97 102 }, 98 - ...pages.map((p) => ({ 99 - $type: "pub.leaflet.pages.linearDocument", 100 - id: p.id, 101 - blocks: p.blocks, 102 - })), 103 + ...pages.map((p) => { 104 + if (p.type === "canvas") { 105 + return { 106 + $type: "pub.leaflet.pages.canvas" as const, 107 + id: p.id, 108 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 109 + }; 110 + } else { 111 + return { 112 + $type: "pub.leaflet.pages.linearDocument" as const, 113 + id: p.id, 114 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 115 + }; 116 + } 117 + }), 103 118 ], 104 119 }; 105 120 let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); ··· 137 152 facts: Fact<any>[], 138 153 agent: AtpBaseClient, 139 154 root_entity: string, 155 + did: string, 140 156 ) { 141 157 let scan = scanIndexLocal(facts); 142 - let pages: { id: string; blocks: PubLeafletPagesLinearDocument.Block[] }[] = 143 - []; 158 + let pages: { 159 + id: string; 160 + blocks: 161 + | PubLeafletPagesLinearDocument.Block[] 162 + | PubLeafletPagesCanvas.Block[]; 163 + type: "doc" | "canvas"; 164 + }[] = []; 165 + 166 + // Create a lock to serialize image uploads 167 + const uploadLock = new Lock(); 144 168 145 169 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 146 170 if (!firstEntity) throw new Error("No root page"); 147 171 let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 148 - let b = await blocksToRecord(blocks); 172 + let b = await blocksToRecord(blocks, did); 149 173 return { firstPageBlocks: b, pages }; 150 174 151 175 async function uploadImage(src: string) { 152 176 let data = await fetch(src); 153 177 if (data.status !== 200) return; 154 178 let binary = await data.blob(); 155 - let blob = await agent.com.atproto.repo.uploadBlob(binary, { 156 - headers: { "Content-Type": binary.type }, 179 + return uploadLock.withLock(async () => { 180 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 181 + headers: { "Content-Type": binary.type }, 182 + }); 183 + return blob.data.blob; 157 184 }); 158 - return blob.data.blob; 159 185 } 160 186 async function blocksToRecord( 161 187 blocks: Block[], 188 + did: string, 162 189 ): Promise<PubLeafletPagesLinearDocument.Block[]> { 163 190 let parsedBlocks = parseBlocksToList(blocks); 164 191 return ( ··· 174 201 : alignmentValue === "right" 175 202 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 176 203 : undefined; 177 - let b = await blockToRecord(blockOrList.block); 204 + let b = await blockToRecord(blockOrList.block, did); 178 205 if (!b) return []; 179 206 let block: PubLeafletPagesLinearDocument.Block = { 180 207 $type: "pub.leaflet.pages.linearDocument#block", ··· 187 214 $type: "pub.leaflet.pages.linearDocument#block", 188 215 block: { 189 216 $type: "pub.leaflet.blocks.unorderedList", 190 - children: await childrenToRecord(blockOrList.children), 217 + children: await childrenToRecord(blockOrList.children, did), 191 218 }, 192 219 }; 193 220 return [block]; ··· 197 224 ).flat(); 198 225 } 199 226 200 - async function childrenToRecord(children: List[]) { 227 + async function childrenToRecord(children: List[], did: string) { 201 228 return ( 202 229 await Promise.all( 203 230 children.map(async (child) => { 204 - let content = await blockToRecord(child.block); 231 + let content = await blockToRecord(child.block, did); 205 232 if (!content) return []; 206 233 let record: PubLeafletBlocksUnorderedList.ListItem = { 207 234 $type: "pub.leaflet.blocks.unorderedList#listItem", 208 235 content, 209 - children: await childrenToRecord(child.children), 236 + children: await childrenToRecord(child.children, did), 210 237 }; 211 238 return record; 212 239 }), 213 240 ) 214 241 ).flat(); 215 242 } 216 - async function blockToRecord(b: Block) { 243 + async function blockToRecord(b: Block, did: string) { 217 244 const getBlockContent = (b: string) => { 218 245 let [content] = scan.eav(b, "block/text"); 219 246 if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; ··· 228 255 if (b.type === "card") { 229 256 let [page] = scan.eav(b.value, "block/card"); 230 257 if (!page) return; 231 - let blocks = getBlocksWithTypeLocal(facts, page.data.value); 232 - pages.push({ 233 - id: page.data.value, 234 - blocks: await blocksToRecord(blocks), 235 - }); 258 + let [pageType] = scan.eav(page.data.value, "page/type"); 259 + 260 + if (pageType?.data.value === "canvas") { 261 + let canvasBlocks = await canvasBlocksToRecord(page.data.value, did); 262 + pages.push({ 263 + id: page.data.value, 264 + blocks: canvasBlocks, 265 + type: "canvas", 266 + }); 267 + } else { 268 + let blocks = getBlocksWithTypeLocal(facts, page.data.value); 269 + pages.push({ 270 + id: page.data.value, 271 + blocks: await blocksToRecord(blocks, did), 272 + type: "doc", 273 + }); 274 + } 275 + 236 276 let block: $Typed<PubLeafletBlocksPage.Main> = { 237 277 $type: "pub.leaflet.blocks.page", 238 278 id: page.data.value, ··· 357 397 }; 358 398 return block; 359 399 } 400 + if (b.type === "poll") { 401 + // Get poll options from the entity 402 + let pollOptions = scan.eav(b.value, "poll/options"); 403 + let options: PubLeafletPollDefinition.Option[] = pollOptions.map( 404 + (opt) => { 405 + let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; 406 + return { 407 + $type: "pub.leaflet.poll.definition#option", 408 + text: optionName?.data.value || "", 409 + }; 410 + }, 411 + ); 412 + 413 + // Create the poll definition record 414 + let pollRecord: PubLeafletPollDefinition.Record = { 415 + $type: "pub.leaflet.poll.definition", 416 + name: "Poll", // Default name, can be customized 417 + options, 418 + }; 419 + 420 + // Upload the poll record 421 + let { data: pollResult } = await agent.com.atproto.repo.putRecord({ 422 + //use the entity id as the rkey so we can associate it in the editor 423 + rkey: b.value, 424 + repo: did, 425 + collection: pollRecord.$type, 426 + record: pollRecord, 427 + validate: false, 428 + }); 429 + 430 + // Optimistically write poll definition to database 431 + console.log( 432 + await supabaseServerClient.from("atp_poll_records").upsert({ 433 + uri: pollResult.uri, 434 + cid: pollResult.cid, 435 + record: pollRecord as Json, 436 + }), 437 + ); 438 + 439 + // Return a poll block with reference to the poll record 440 + let block: $Typed<PubLeafletBlocksPoll.Main> = { 441 + $type: "pub.leaflet.blocks.poll", 442 + pollRef: { 443 + uri: pollResult.uri, 444 + cid: pollResult.cid, 445 + }, 446 + }; 447 + return block; 448 + } 360 449 return; 450 + } 451 + 452 + async function canvasBlocksToRecord( 453 + pageID: string, 454 + did: string, 455 + ): Promise<PubLeafletPagesCanvas.Block[]> { 456 + let canvasBlocks = scan.eav(pageID, "canvas/block"); 457 + return ( 458 + await Promise.all( 459 + canvasBlocks.map(async (canvasBlock) => { 460 + let blockEntity = canvasBlock.data.value; 461 + let position = canvasBlock.data.position; 462 + 463 + // Get the block content 464 + let blockType = scan.eav(blockEntity, "block/type")?.[0]; 465 + if (!blockType) return null; 466 + 467 + let block: Block = { 468 + type: blockType.data.value, 469 + value: blockEntity, 470 + parent: pageID, 471 + position: "", 472 + factID: canvasBlock.id, 473 + }; 474 + 475 + let content = await blockToRecord(block, did); 476 + if (!content) return null; 477 + 478 + // Get canvas-specific properties 479 + let width = 480 + scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; 481 + let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] 482 + ?.data.value; 483 + 484 + let canvasBlockRecord: PubLeafletPagesCanvas.Block = { 485 + $type: "pub.leaflet.pages.canvas#block", 486 + block: content, 487 + x: Math.floor(position.x), 488 + y: Math.floor(position.y), 489 + width: Math.floor(width), 490 + ...(rotation !== undefined && { rotation: Math.floor(rotation) }), 491 + }; 492 + 493 + return canvasBlockRecord; 494 + }), 495 + ) 496 + ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); 361 497 } 362 498 } 363 499
+43
app/(home-pages)/home/page.tsx
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + 5 + import { HomeLayout } from "./HomeLayout"; 6 + 7 + export default async function Home() { 8 + let auth_res = await getIdentityData(); 9 + 10 + let [allLeafletFacts] = await Promise.all([ 11 + auth_res 12 + ? getFactsFromHomeLeaflets.handler( 13 + { 14 + tokens: auth_res.permission_token_on_homepage.map( 15 + (r) => r.permission_tokens.root_entity, 16 + ), 17 + }, 18 + { supabase: supabaseServerClient }, 19 + ) 20 + : undefined, 21 + ]); 22 + 23 + let home_docs_initialFacts = allLeafletFacts?.result || {}; 24 + 25 + return ( 26 + <HomeLayout 27 + titles={{ 28 + ...home_docs_initialFacts.titles, 29 + ...auth_res?.permission_token_on_homepage.reduce( 30 + (acc, tok) => { 31 + let title = 32 + tok.permission_tokens.leaflets_in_publications[0]?.title; 33 + if (title) acc[tok.permission_tokens.root_entity] = title; 34 + return acc; 35 + }, 36 + {} as { [k: string]: string }, 37 + ), 38 + }} 39 + entityID={auth_res?.home_leaflet?.root_entity || null} 40 + initialFacts={home_docs_initialFacts.facts || {}} 41 + /> 42 + ); 43 + }
+43
app/(home-pages)/layout.tsx
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { EntitySetProvider } from "components/EntitySetProvider"; 3 + import { 4 + ThemeProvider, 5 + ThemeBackgroundProvider, 6 + } from "components/ThemeManager/ThemeProvider"; 7 + import { ReplicacheProvider, type Fact } from "src/replicache"; 8 + 9 + export default async function HomePagesLayout(props: { 10 + children: React.ReactNode; 11 + }) { 12 + let identityData = await getIdentityData(); 13 + if (!identityData?.home_leaflet) 14 + return ( 15 + <> 16 + <ThemeProvider entityID={""}>{props.children}</ThemeProvider> 17 + </> 18 + ); 19 + let facts = 20 + (identityData?.home_leaflet?.permission_token_rights[0].entity_sets?.entities.flatMap( 21 + (e) => e.facts, 22 + ) || []) as Fact<any>[]; 23 + 24 + let root_entity = identityData.home_leaflet.root_entity; 25 + return ( 26 + <ReplicacheProvider 27 + rootEntity={identityData.home_leaflet.root_entity} 28 + token={identityData.home_leaflet} 29 + name={identityData.home_leaflet.root_entity} 30 + initialFacts={facts} 31 + > 32 + <EntitySetProvider 33 + set={identityData.home_leaflet.permission_token_rights[0].entity_set} 34 + > 35 + <ThemeProvider entityID={root_entity}> 36 + <ThemeBackgroundProvider entityID={root_entity}> 37 + {props.children} 38 + </ThemeBackgroundProvider> 39 + </ThemeProvider> 40 + </EntitySetProvider> 41 + </ReplicacheProvider> 42 + ); 43 + }
+3
app/(home-pages)/notifications/page.tsx
··· 1 + export default async function Notifications() { 2 + return <div>Notifications</div>; 3 + }
+38
app/(home-pages)/reader/page.tsx
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + 3 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 4 + import { ReaderContent } from "./ReaderContent"; 5 + import { SubscriptionsContent } from "./SubscriptionsContent"; 6 + import { getReaderFeed } from "./getReaderFeed"; 7 + import { getSubscriptions } from "./getSubscriptions"; 8 + 9 + export default async function Reader(props: {}) { 10 + let posts = await getReaderFeed(); 11 + let publications = await getSubscriptions(); 12 + return ( 13 + <DashboardLayout 14 + id="reader" 15 + cardBorderHidden={false} 16 + currentPage="reader" 17 + defaultTab="Read" 18 + actions={null} 19 + tabs={{ 20 + Read: { 21 + controls: null, 22 + content: ( 23 + <ReaderContent nextCursor={posts.nextCursor} posts={posts.posts} /> 24 + ), 25 + }, 26 + Subscriptions: { 27 + controls: null, 28 + content: ( 29 + <SubscriptionsContent 30 + publications={publications.subscriptions} 31 + nextCursor={publications.nextCursor} 32 + /> 33 + ), 34 + }, 35 + }} 36 + /> 37 + ); 38 + }
+1 -1
app/api/atproto_images/route.ts
··· 24 24 // Set cache-control header to cache indefinitely 25 25 cachedResponse.headers.set( 26 26 "Cache-Control", 27 - "public, max-age=31536000, immutable", 27 + "public, max-age=31536000, immutable, s-maxage=86400, stale-while-revalidate=604800", 28 28 ); 29 29 cachedResponse.headers.set( 30 30 "CDN-Cache-Control",
+19 -8
app/discover/PubListing.tsx app/(home-pages)/discover/PubListing.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PublicationSubscription } from "app/reader/getSubscriptions"; 3 + import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 4 import { PubIcon } from "components/ActionBar/Publications"; 5 5 import { Separator } from "components/Layout"; 6 6 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; ··· 31 31 <BaseThemeProvider {...theme} local> 32 32 <a 33 33 href={`https://${record.base_path}`} 34 - style={{ 35 - backgroundImage: `url(${backgroundImage})`, 36 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 37 - backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 38 - }} 39 34 className={`no-underline! flex flex-row gap-2 40 35 bg-bg-leaflet 41 36 border border-border-light rounded-lg 42 37 px-3 py-3 selected-outline 43 - hover:outline-accent-contrast hover:border-accent-contrast`} 38 + hover:outline-accent-contrast hover:border-accent-contrast 39 + relative overflow-hidden`} 44 40 > 41 + {backgroundImage && ( 42 + <img 43 + src={backgroundImage} 44 + alt="" 45 + loading="lazy" 46 + fetchPriority="low" 47 + className="absolute inset-0 pointer-events-none" 48 + style={{ 49 + width: backgroundImageRepeat ? `${backgroundImageSize}px` : "100%", 50 + height: backgroundImageRepeat ? "auto" : "100%", 51 + objectFit: backgroundImageRepeat ? "none" : "cover", 52 + objectPosition: "center", 53 + }} 54 + /> 55 + )} 45 56 <div 46 - className={`flex w-full flex-col justify-center text-center max-h-48 pt-4 pb-3 px-3 rounded-lg ${props.resizeHeight ? "" : "sm:h-48 h-full"} ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] " : ""}`} 57 + className={`flex w-full flex-col justify-center text-center max-h-48 pt-4 pb-3 px-3 rounded-lg relative z-10 ${props.resizeHeight ? "" : "sm:h-48 h-full"} ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] " : ""}`} 47 58 > 48 59 <div className="mx-auto pb-1"> 49 60 <PubIcon record={record} uri={props.uri} large />
app/discover/SortButtons.tsx app/(home-pages)/discover/SortButtons.tsx
+1 -6
app/discover/SortedPublicationList.tsx app/(home-pages)/discover/SortedPublicationList.tsx
··· 81 81 <div className="relative"> 82 82 <button 83 83 onClick={props.onClick} 84 - style={ 85 - props.selected 86 - ? { backgroundColor: `rgba(var(--accent-1), 0.2)` } 87 - : {} 88 - } 89 - className={`text-sm rounded-md px-[8px] py-0.5 border ${props.selected ? "border-accent-contrast text-accent-1 font-bold" : "text-tertiary border-border-light"}`} 84 + className={`text-sm bg-accent-1 text-accent-2 rounded-md px-[8px] py-0.5 border ${props.selected ? "border-accent-contrast font-bold" : "border-border-light"}`} 90 85 > 91 86 {props.children} 92 87 </button>
+13 -19
app/discover/page.tsx app/(home-pages)/discover/page.tsx
··· 4 4 import { Metadata } from "next"; 5 5 import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 6 6 7 - export const dynamic = "force-static"; 8 - export const revalidate = 60; 9 - 10 7 export type PublicationsList = Awaited<ReturnType<typeof getPublications>>; 11 8 async function getPublications() { 12 9 let { data: publications, error } = await supabaseServerClient ··· 34 31 searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 35 32 }) { 36 33 let order = ((await props.searchParams).order as string) || "recentlyUpdated"; 37 - let publications = await getPublications(); 38 34 39 35 return ( 40 - <div className="w-full h-full mx-auto bg-[#FDFCFA]"> 41 - <DashboardLayout 42 - id="discover" 43 - cardBorderHidden={false} 44 - currentPage="discover" 45 - defaultTab="default" 46 - actions={null} 47 - tabs={{ 48 - default: { 49 - controls: null, 50 - content: <DiscoverContent order={order} />, 51 - }, 52 - }} 53 - /> 54 - </div> 36 + <DashboardLayout 37 + id="discover" 38 + cardBorderHidden={false} 39 + currentPage="discover" 40 + defaultTab="default" 41 + actions={null} 42 + tabs={{ 43 + default: { 44 + controls: null, 45 + content: <DiscoverContent order={order} />, 46 + }, 47 + }} 48 + /> 55 49 ); 56 50 } 57 51
app/home/Actions/AccountSettings.tsx app/(home-pages)/home/Actions/AccountSettings.tsx
app/home/Actions/Actions.tsx app/(home-pages)/home/Actions/Actions.tsx
app/home/Actions/CreateNewButton.tsx app/(home-pages)/home/Actions/CreateNewButton.tsx
app/home/Actions/HomeHelp.tsx app/(home-pages)/home/Actions/HomeHelp.tsx
app/home/HomeEmpty/DiscoverIllo.tsx app/(home-pages)/home/HomeEmpty/DiscoverIllo.tsx
app/home/HomeEmpty/HomeEmpty.tsx app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx
+2 -2
app/home/HomeEmpty/WelcomeToLeafletIllo.tsx app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx
··· 14 14 fill="color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)" 15 15 /> 16 16 <path 17 - fill-rule="evenodd" 18 - clip-rule="evenodd" 17 + fillRule="evenodd" 18 + clipRule="evenodd" 19 19 d="M33.1936 19.9644C33.8224 20.2936 34.5595 20.6793 35.7586 19.9049C36.5426 19.3985 37.1772 17.1878 37.8852 14.7211C38.9202 11.1155 40.1122 6.96302 42.1573 6.78723C44.6243 6.57519 44.7981 7.92712 44.9319 8.96695C45.0257 9.69631 45.0997 10.2721 45.9315 10.047C46.7788 9.8176 47.3647 8.61374 48.0447 7.2167C48.984 5.28694 50.1027 2.98859 52.3373 2.38086C53.9128 1.95238 54.054 2.64187 54.1973 3.34108C54.2888 3.788 54.3812 4.2389 54.8494 4.40436C55.3107 4.56736 56.2896 4.23303 57.4149 3.84869C59.219 3.23253 61.3994 2.48783 62.4269 3.45769C63.3302 4.31032 62.738 5.52027 62.1704 6.67999C61.6896 7.66228 61.2264 8.60853 61.7046 9.27112C62.0948 9.81171 62.6783 10.0278 63.2502 10.2396C64.1857 10.5861 65.0904 10.9211 65.0681 12.6458C65.0445 14.4695 62.5034 15.5801 60.1214 16.6211C58.126 17.4931 56.2423 18.3164 56.0441 19.4692C55.9197 20.1921 56.464 20.3823 57.0738 20.5954C57.8026 20.85 58.6249 21.1373 58.5116 22.4058C58.2468 25.3711 55.1373 26.1276 52.219 26.8377C49.7141 27.4471 47.3501 28.0223 47.0466 29.9306C46.8714 31.0323 47.5477 31.0548 48.2898 31.0794C49.1981 31.1095 50.205 31.1429 49.869 33.1634C49.2261 37.03 43.6557 38.4183 38.5564 39.6893C36.6866 40.1553 34.8802 40.6056 33.4032 41.1564C33.2347 41.2193 33.1049 41.3564 33.0503 41.5278C32.2094 44.1647 31.8313 46.932 31.428 49.8838C31.3394 50.5323 31.2496 51.1896 31.1534 51.8565C31.0262 52.738 29.2486 55.1385 28.3628 55.2311C27.9656 55.2726 27.8339 52.2775 27.9611 51.3959C28.4347 48.1126 29.1515 45.0249 30.0528 42.1314C31.418 36.9751 34.9339 30.201 39.0683 24.7487C42.7265 19.8134 46.4117 15.8889 50.9826 12.4792C51.5547 12.0524 51.1837 11.3521 50.5642 11.7068C46.3602 14.1137 42.9934 17.6783 39.4855 21.2985C35.6701 25.2361 32.8282 29.9969 31.1838 33.3557C30.8939 33.9479 29.8993 33.7609 29.8756 33.102C29.6905 27.9469 29.636 23.1879 30.7621 21.1685C31.8313 19.2514 32.4334 19.5665 33.1936 19.9644Z" 20 20 fill={theme.colors["accent-1"]} 21 21 />
+1 -1
app/home/HomeLayout.tsx app/(home-pages)/home/HomeLayout.tsx
··· 63 63 }; 64 64 65 65 export const HomeLayout = (props: { 66 - entityID: string; 66 + entityID: string | null; 67 67 titles: { [root_entity: string]: string }; 68 68 initialFacts: { 69 69 [root_entity: string]: Fact<Attribute>[];
app/home/IdentitySetter.tsx app/(home-pages)/home/IdentitySetter.tsx
app/home/LeafletList/LeafletContent.tsx app/(home-pages)/home/LeafletList/LeafletContent.tsx
app/home/LeafletList/LeafletInfo.tsx app/(home-pages)/home/LeafletList/LeafletInfo.tsx
app/home/LeafletList/LeafletListItem.tsx app/(home-pages)/home/LeafletList/LeafletListItem.tsx
app/home/LeafletList/LeafletOptions.tsx app/(home-pages)/home/LeafletList/LeafletOptions.tsx
app/home/LeafletList/LeafletPreview.module.css app/(home-pages)/home/LeafletList/LeafletPreview.module.css
app/home/LeafletList/LeafletPreview.tsx app/(home-pages)/home/LeafletList/LeafletPreview.tsx
app/home/LoggedOutWarning.tsx app/(home-pages)/home/LoggedOutWarning.tsx
+1 -1
app/home/icon.tsx app/(home-pages)/home/icon.tsx
··· 1 1 import { ImageResponse } from "next/og"; 2 2 import type { Fact } from "src/replicache"; 3 3 import type { Attribute } from "src/replicache/attributes"; 4 - import { Database } from "../../supabase/database.types"; 4 + import { Database } from "supabase/database.types"; 5 5 import { createServerClient } from "@supabase/ssr"; 6 6 import { parseHSBToRGB } from "src/utils/parseHSB"; 7 7 import { cookies } from "next/headers";
-124
app/home/page.tsx
··· 1 - import { cookies } from "next/headers"; 2 - import { Fact, ReplicacheProvider, useEntity } from "src/replicache"; 3 - import type { Attribute } from "src/replicache/attributes"; 4 - import { 5 - ThemeBackgroundProvider, 6 - ThemeProvider, 7 - } from "components/ThemeManager/ThemeProvider"; 8 - import { EntitySetProvider } from "components/EntitySetProvider"; 9 - import { createIdentity } from "actions/createIdentity"; 10 - import { drizzle } from "drizzle-orm/node-postgres"; 11 - import { IdentitySetter } from "./IdentitySetter"; 12 - 13 - import { getIdentityData } from "actions/getIdentityData"; 14 - import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 15 - import { supabaseServerClient } from "supabase/serverClient"; 16 - import { pool } from "supabase/pool"; 17 - 18 - import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 19 - import { HomeLayout } from "./HomeLayout"; 20 - 21 - export default async function Home() { 22 - let cookieStore = await cookies(); 23 - let auth_res = await getIdentityData(); 24 - let identity: string | undefined; 25 - if (auth_res) identity = auth_res.id; 26 - else identity = cookieStore.get("identity")?.value; 27 - let needstosetcookie = false; 28 - if (!identity) { 29 - const client = await pool.connect(); 30 - const db = drizzle(client); 31 - let newIdentity = await createIdentity(db); 32 - client.release(); 33 - identity = newIdentity.id; 34 - needstosetcookie = true; 35 - } 36 - 37 - async function setCookie() { 38 - "use server"; 39 - 40 - (await cookies()).set("identity", identity as string, { 41 - sameSite: "strict", 42 - }); 43 - } 44 - 45 - let permission_token = auth_res?.home_leaflet; 46 - if (!permission_token) { 47 - let res = await supabaseServerClient 48 - .from("identities") 49 - .select( 50 - `*, 51 - permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)) 52 - `, 53 - ) 54 - .eq("id", identity) 55 - .single(); 56 - permission_token = res.data?.permission_tokens; 57 - } 58 - 59 - if (!permission_token) 60 - return ( 61 - <NotFoundLayout> 62 - <p className="font-bold">Sorry, we can't find this home!</p> 63 - <p> 64 - This may be a glitch on our end. If the issue persists please{" "} 65 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 66 - </p> 67 - </NotFoundLayout> 68 - ); 69 - let [homeLeafletFacts, allLeafletFacts] = await Promise.all([ 70 - supabaseServerClient.rpc("get_facts", { 71 - root: permission_token.root_entity, 72 - }), 73 - auth_res 74 - ? getFactsFromHomeLeaflets.handler( 75 - { 76 - tokens: auth_res.permission_token_on_homepage.map( 77 - (r) => r.permission_tokens.root_entity, 78 - ), 79 - }, 80 - { supabase: supabaseServerClient }, 81 - ) 82 - : undefined, 83 - ]); 84 - let initialFacts = 85 - (homeLeafletFacts.data as unknown as Fact<Attribute>[]) || []; 86 - 87 - let root_entity = permission_token.root_entity; 88 - let home_docs_initialFacts = allLeafletFacts?.result || {}; 89 - 90 - return ( 91 - <ReplicacheProvider 92 - rootEntity={root_entity} 93 - token={permission_token} 94 - name={root_entity} 95 - initialFacts={initialFacts} 96 - > 97 - <IdentitySetter cb={setCookie} call={needstosetcookie} /> 98 - <EntitySetProvider 99 - set={permission_token.permission_token_rights[0].entity_set} 100 - > 101 - <ThemeProvider entityID={root_entity}> 102 - <ThemeBackgroundProvider entityID={root_entity}> 103 - <HomeLayout 104 - titles={{ 105 - ...home_docs_initialFacts.titles, 106 - ...auth_res?.permission_token_on_homepage.reduce( 107 - (acc, tok) => { 108 - let title = 109 - tok.permission_tokens.leaflets_in_publications[0]?.title; 110 - if (title) acc[tok.permission_tokens.root_entity] = title; 111 - return acc; 112 - }, 113 - {} as { [k: string]: string }, 114 - ), 115 - }} 116 - entityID={root_entity} 117 - initialFacts={home_docs_initialFacts.facts || {}} 118 - /> 119 - </ThemeBackgroundProvider> 120 - </ThemeProvider> 121 - </EntitySetProvider> 122 - </ReplicacheProvider> 123 - ); 124 - }
app/home/storage.ts app/(home-pages)/home/storage.ts
+7 -4
app/layout.tsx
··· 7 7 import { PopUpProvider } from "components/Toast"; 8 8 import { IdentityProviderServer } from "components/IdentityProviderServer"; 9 9 import { headers } from "next/headers"; 10 - import { IPLocationProvider } from "components/Providers/IPLocationProvider"; 10 + import { RequestHeadersProvider } from "components/Providers/RequestHeadersProvider"; 11 11 import { RouteUIStateManager } from "components/RouteUIStateManger"; 12 12 13 13 export const metadata = { ··· 55 55 children: React.ReactNode; 56 56 } 57 57 ) { 58 - let ipLocation = (await headers()).get("X-Vercel-IP-Country"); 58 + let headersList = await headers(); 59 + let ipLocation = headersList.get("X-Vercel-IP-Country"); 60 + let acceptLanguage = headersList.get("accept-language"); 61 + let ipTimezone = headersList.get("X-Vercel-IP-Timezone"); 59 62 return ( 60 63 <html suppressHydrationWarning lang="en" className={`${quattro.variable}`}> 61 64 <body> ··· 77 80 <InitialPageLoad> 78 81 <PopUpProvider> 79 82 <IdentityProviderServer> 80 - <IPLocationProvider country={ipLocation}> 83 + <RequestHeadersProvider country={ipLocation} language={acceptLanguage} timezone={ipTimezone}> 81 84 <ViewportSizeLayout>{children}</ViewportSizeLayout> 82 85 <RouteUIStateManager /> 83 - </IPLocationProvider> 86 + </RequestHeadersProvider> 84 87 </IdentityProviderServer> 85 88 </PopUpProvider> 86 89 </InitialPageLoad>
+19 -3
app/lish/Subscribe.tsx
··· 23 23 import { useSearchParams } from "next/navigation"; 24 24 import LoginForm from "app/login/LoginForm"; 25 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 + import { SpeedyLink } from "components/SpeedyLink"; 26 27 27 28 type State = 28 29 | { state: "email" } ··· 217 218 pub_uri={props.pub_uri} 218 219 setSuccessModalOpen={setSuccessModalOpen} 219 220 /> 220 - <a href={`${props.base_url}/rss`} className="flex" target="_blank" aria-label="Subscribe to RSS"> 221 + <a 222 + href={`${props.base_url}/rss`} 223 + className="flex" 224 + target="_blank" 225 + aria-label="Subscribe to RSS" 226 + > 221 227 <RSSSmall className="self-center" aria-hidden /> 222 228 </a> 223 229 </div> ··· 246 252 className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 247 253 > 248 254 <div className="font-bold text-tertiary text-sm"> 249 - You&apos;re Subscribed{props.isPost ? ` to ${props.pubName}` : "!"} 255 + You&apos;re Subscribed{props.isPost ? ` to ` : "!"} 256 + {props.isPost && ( 257 + <SpeedyLink href={props.base_url} className="text-accent-contrast"> 258 + {props.pubName} 259 + </SpeedyLink> 260 + )} 250 261 </div> 251 262 <Popover 252 263 trigger={<div className="text-accent-contrast text-sm">Manage</div>} ··· 266 277 </a> 267 278 )} 268 279 269 - <a href={`${props.base_url}/rss`} className="flex" target="_blank" aria-label="Subscribe to RSS"> 280 + <a 281 + href={`${props.base_url}/rss`} 282 + className="flex" 283 + target="_blank" 284 + aria-label="Subscribe to RSS" 285 + > 270 286 <ButtonPrimary fullWidth compact> 271 287 Get RSS 272 288 </ButtonPrimary>
+10
app/lish/[did]/[publication]/LocalizedDate.tsx
··· 1 + "use client"; 2 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 3 + 4 + export function LocalizedDate(props: { 5 + dateString: string; 6 + options?: Intl.DateTimeFormatOptions; 7 + }) { 8 + const formattedDate = useLocalizedDate(props.dateString, props.options); 9 + return <>{formattedDate}</>; 10 + }
+250
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 1 + "use client"; 2 + import { 3 + PubLeafletPagesCanvas, 4 + PubLeafletPagesLinearDocument, 5 + PubLeafletPublication, 6 + } from "lexicons/api"; 7 + import { PostPageData } from "./getPostPageData"; 8 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 9 + import { AppBskyFeedDefs } from "@atproto/api"; 10 + import { PageWrapper } from "components/Pages/Page"; 11 + import { Block } from "./PostContent"; 12 + import { CanvasBackgroundPattern } from "components/Canvas"; 13 + import { 14 + getCommentCount, 15 + getQuoteCount, 16 + Interactions, 17 + } from "./Interactions/Interactions"; 18 + import { Separator } from "components/Layout"; 19 + import { Popover } from "components/Popover"; 20 + import { InfoSmall } from "components/Icons/InfoSmall"; 21 + import { PostHeader } from "./PostHeader/PostHeader"; 22 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 + import { PollData } from "./fetchPollData"; 24 + 25 + export function CanvasPage({ 26 + document, 27 + blocks, 28 + did, 29 + profile, 30 + preferences, 31 + pubRecord, 32 + prerenderedCodeBlocks, 33 + bskyPostData, 34 + pollData, 35 + document_uri, 36 + pageId, 37 + pageOptions, 38 + fullPageScroll, 39 + pages, 40 + }: { 41 + document_uri: string; 42 + document: PostPageData; 43 + blocks: PubLeafletPagesCanvas.Block[]; 44 + profile: ProfileViewDetailed; 45 + pubRecord: PubLeafletPublication.Record; 46 + did: string; 47 + prerenderedCodeBlocks?: Map<string, string>; 48 + bskyPostData: AppBskyFeedDefs.PostView[]; 49 + pollData: PollData[]; 50 + preferences: { showComments?: boolean }; 51 + pageId?: string; 52 + pageOptions?: React.ReactNode; 53 + fullPageScroll: boolean; 54 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 55 + }) { 56 + let hasPageBackground = !!pubRecord.theme?.showPageBackground; 57 + let isSubpage = !!pageId; 58 + let drawer = useDrawerOpen(document_uri); 59 + 60 + return ( 61 + <PageWrapper 62 + pageType="canvas" 63 + fullPageScroll={fullPageScroll} 64 + cardBorderHidden={!hasPageBackground} 65 + id={pageId ? `post-page-${pageId}` : "post-page"} 66 + drawerOpen={ 67 + !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 68 + } 69 + pageOptions={pageOptions} 70 + > 71 + <CanvasMetadata 72 + pageId={pageId} 73 + isSubpage={isSubpage} 74 + data={document} 75 + profile={profile} 76 + preferences={preferences} 77 + commentsCount={getCommentCount(document, pageId)} 78 + quotesCount={getQuoteCount(document, pageId)} 79 + /> 80 + <CanvasContent 81 + blocks={blocks} 82 + did={did} 83 + prerenderedCodeBlocks={prerenderedCodeBlocks} 84 + bskyPostData={bskyPostData} 85 + pollData={pollData} 86 + pageId={pageId} 87 + pages={pages} 88 + /> 89 + </PageWrapper> 90 + ); 91 + } 92 + 93 + function CanvasContent({ 94 + blocks, 95 + did, 96 + prerenderedCodeBlocks, 97 + bskyPostData, 98 + pageId, 99 + pollData, 100 + pages, 101 + }: { 102 + blocks: PubLeafletPagesCanvas.Block[]; 103 + did: string; 104 + prerenderedCodeBlocks?: Map<string, string>; 105 + pollData: PollData[]; 106 + bskyPostData: AppBskyFeedDefs.PostView[]; 107 + pageId?: string; 108 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 109 + }) { 110 + let height = blocks.length > 0 ? Math.max(...blocks.map((b) => b.y), 0) : 0; 111 + 112 + return ( 113 + <div className="canvasWrapper h-full w-fit overflow-y-scroll postContent"> 114 + <div 115 + style={{ 116 + minHeight: height + 512, 117 + contain: "size layout paint", 118 + }} 119 + className="relative h-full w-[1272px]" 120 + > 121 + <CanvasBackground /> 122 + 123 + {blocks 124 + .sort((a, b) => { 125 + if (a.y === b.y) { 126 + return a.x - b.x; 127 + } 128 + return a.y - b.y; 129 + }) 130 + .map((canvasBlock, index) => { 131 + return ( 132 + <CanvasBlock 133 + key={index} 134 + canvasBlock={canvasBlock} 135 + did={did} 136 + pollData={pollData} 137 + prerenderedCodeBlocks={prerenderedCodeBlocks} 138 + bskyPostData={bskyPostData} 139 + pageId={pageId} 140 + pages={pages} 141 + index={index} 142 + /> 143 + ); 144 + })} 145 + </div> 146 + </div> 147 + ); 148 + } 149 + 150 + function CanvasBlock({ 151 + canvasBlock, 152 + did, 153 + prerenderedCodeBlocks, 154 + bskyPostData, 155 + pollData, 156 + pageId, 157 + pages, 158 + index, 159 + }: { 160 + canvasBlock: PubLeafletPagesCanvas.Block; 161 + did: string; 162 + prerenderedCodeBlocks?: Map<string, string>; 163 + bskyPostData: AppBskyFeedDefs.PostView[]; 164 + pollData: PollData[]; 165 + pageId?: string; 166 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 167 + index: number; 168 + }) { 169 + let { x, y, width, rotation } = canvasBlock; 170 + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 171 + 172 + // Wrap the block in a LinearDocument.Block structure for compatibility 173 + let linearBlock: PubLeafletPagesLinearDocument.Block = { 174 + $type: "pub.leaflet.pages.linearDocument#block", 175 + block: canvasBlock.block, 176 + }; 177 + 178 + return ( 179 + <div 180 + className="absolute rounded-lg flex items-stretch origin-center p-3" 181 + style={{ 182 + top: 0, 183 + left: 0, 184 + width, 185 + transform, 186 + }} 187 + > 188 + <div className="contents"> 189 + <Block 190 + pollData={pollData} 191 + pageId={pageId} 192 + pages={pages} 193 + bskyPostData={bskyPostData} 194 + block={linearBlock} 195 + did={did} 196 + index={[index]} 197 + preview={false} 198 + prerenderedCodeBlocks={prerenderedCodeBlocks} 199 + /> 200 + </div> 201 + </div> 202 + ); 203 + } 204 + 205 + const CanvasMetadata = (props: { 206 + pageId: string | undefined; 207 + isSubpage: boolean | undefined; 208 + data: PostPageData; 209 + profile: ProfileViewDetailed; 210 + preferences: { showComments?: boolean }; 211 + quotesCount: number | undefined; 212 + commentsCount: number | undefined; 213 + }) => { 214 + return ( 215 + <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 216 + <Interactions 217 + quotesCount={props.quotesCount || 0} 218 + commentsCount={props.commentsCount || 0} 219 + compact 220 + showComments={props.preferences.showComments} 221 + pageId={props.pageId} 222 + /> 223 + {!props.isSubpage && ( 224 + <> 225 + <Separator classname="h-5" /> 226 + <Popover 227 + side="left" 228 + align="start" 229 + className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 230 + trigger={<InfoSmall />} 231 + > 232 + <PostHeader 233 + data={props.data} 234 + profile={props.profile} 235 + preferences={props.preferences} 236 + /> 237 + </Popover> 238 + </> 239 + )} 240 + </div> 241 + ); 242 + }; 243 + 244 + const CanvasBackground = () => { 245 + return ( 246 + <div className="w-full h-full pointer-events-none"> 247 + <CanvasBackgroundPattern pattern="grid" /> 248 + </div> 249 + ); 250 + };
+12 -14
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 17 17 import { usePathname } from "next/navigation"; 18 18 import { QuoteContent } from "../Quotes"; 19 19 import { timeAgo } from "src/utils/timeAgo"; 20 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 20 21 21 22 export type Comment = { 22 23 record: Json; ··· 250 251 }; 251 252 252 253 const DatePopover = (props: { date: string }) => { 253 - let [t, full] = useMemo(() => { 254 - return [ 255 - timeAgo(props.date), 256 - new Date(props.date).toLocaleTimeString(undefined, { 257 - year: "numeric", 258 - month: "2-digit", 259 - day: "2-digit", 260 - hour: "2-digit", 261 - minute: "2-digit", 262 - }), 263 - ]; 264 - }, [props.date]); 254 + const timeAgoText = useMemo(() => timeAgo(props.date), [props.date]); 255 + const fullDate = useLocalizedDate(props.date, { 256 + year: "numeric", 257 + month: "2-digit", 258 + day: "2-digit", 259 + hour: "2-digit", 260 + minute: "2-digit", 261 + }); 262 + 265 263 return ( 266 264 <Popover 267 265 trigger={ 268 - <div className="italic text-sm text-tertiary hover:underline">{t}</div> 266 + <div className="italic text-sm text-tertiary hover:underline">{timeAgoText}</div> 269 267 } 270 268 > 271 - <div className="text-sm text-secondary">{full}</div> 269 + <div className="text-sm text-secondary">{fullDate}</div> 272 270 </Popover> 273 271 ); 274 272 };
-2
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 8 8 import { SandwichSpacer } from "components/LeafletLayout"; 9 9 import { decodeQuotePosition } from "../quotePosition"; 10 10 11 - import { Post } from "app/reader/getReaderFeed"; 12 - 13 11 export const InteractionDrawer = (props: { 14 12 document_uri: string; 15 13 quotes: { link: string; bsky_posts: { post_view: Json } | null }[];
+31 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 5 5 import type { Json } from "supabase/database.types"; 6 6 import { create } from "zustand"; 7 7 import type { Comment } from "./Comments"; 8 - import { QuotePosition } from "../quotePosition"; 8 + import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 9 9 import { useContext } from "react"; 10 10 import { PostPageContext } from "../PostPageContext"; 11 11 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 12 import { TagTiny } from "components/Icons/TagTiny"; 13 13 import { Tag } from "components/Tags"; 14 14 import { Popover } from "components/Popover"; 15 + import { PostPageData } from "../getPostPageData"; 16 + import { PubLeafletComment } from "lexicons/api"; 15 17 16 18 export type InteractionState = { 17 19 drawerOpen: undefined | boolean; ··· 203 205 "them super", 204 206 "long", 205 207 ]; 208 + export function getQuoteCount(document: PostPageData, pageId?: string) { 209 + if (!document) return; 210 + 211 + if (pageId) 212 + return document.document_mentions_in_bsky.filter((q) => 213 + q.link.includes(pageId), 214 + ).length; 215 + else 216 + return document.document_mentions_in_bsky.filter((q) => { 217 + const url = new URL(q.link); 218 + const quoteParam = url.pathname.split("/l-quote/")[1]; 219 + if (!quoteParam) return null; 220 + const quotePosition = decodeQuotePosition(quoteParam); 221 + return !quotePosition?.pageId; 222 + }).length; 223 + } 224 + 225 + export function getCommentCount(document: PostPageData, pageId?: string) { 226 + if (!document) return; 227 + if (pageId) 228 + return document.comments_on_documents.filter( 229 + (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 230 + ).length; 231 + else 232 + return document.comments_on_documents.filter( 233 + (c) => !(c.record as PubLeafletComment.Record)?.onPage, 234 + ).length; 235 + }
+1
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 140 140 > 141 141 <div className="italic border border-border-light rounded-md px-2 pt-1"> 142 142 <PostContent 143 + pollData={[]} 143 144 pages={[]} 144 145 bskyPostData={[]} 145 146 blocks={content}
+140
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 1 + "use client"; 2 + import { 3 + PubLeafletComment, 4 + PubLeafletDocument, 5 + PubLeafletPagesLinearDocument, 6 + PubLeafletPublication, 7 + } from "lexicons/api"; 8 + import { PostPageData } from "./getPostPageData"; 9 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 + import { EditTiny } from "components/Icons/EditTiny"; 13 + import { 14 + getCommentCount, 15 + getQuoteCount, 16 + Interactions, 17 + } from "./Interactions/Interactions"; 18 + import { PostContent } from "./PostContent"; 19 + import { PostHeader } from "./PostHeader/PostHeader"; 20 + import { useIdentityData } from "components/IdentityProvider"; 21 + import { AppBskyFeedDefs } from "@atproto/api"; 22 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 + import { PageWrapper } from "components/Pages/Page"; 24 + import { decodeQuotePosition } from "./quotePosition"; 25 + import { PollData } from "./fetchPollData"; 26 + 27 + export function LinearDocumentPage({ 28 + document, 29 + blocks, 30 + did, 31 + profile, 32 + preferences, 33 + pubRecord, 34 + prerenderedCodeBlocks, 35 + bskyPostData, 36 + document_uri, 37 + pageId, 38 + pageOptions, 39 + pollData, 40 + fullPageScroll, 41 + }: { 42 + document_uri: string; 43 + document: PostPageData; 44 + blocks: PubLeafletPagesLinearDocument.Block[]; 45 + profile?: ProfileViewDetailed; 46 + pubRecord: PubLeafletPublication.Record; 47 + did: string; 48 + prerenderedCodeBlocks?: Map<string, string>; 49 + bskyPostData: AppBskyFeedDefs.PostView[]; 50 + pollData: PollData[]; 51 + preferences: { showComments?: boolean }; 52 + pageId?: string; 53 + pageOptions?: React.ReactNode; 54 + fullPageScroll: boolean; 55 + }) { 56 + let { identity } = useIdentityData(); 57 + let drawer = useDrawerOpen(document_uri); 58 + 59 + if (!document || !document.documents_in_publications[0].publications) 60 + return null; 61 + 62 + let hasPageBackground = !!pubRecord.theme?.showPageBackground; 63 + let record = document.data as PubLeafletDocument.Record; 64 + 65 + const isSubpage = !!pageId; 66 + 67 + return ( 68 + <> 69 + <PageWrapper 70 + pageType="doc" 71 + fullPageScroll={fullPageScroll} 72 + cardBorderHidden={!hasPageBackground} 73 + id={pageId ? `post-page-${pageId}` : "post-page"} 74 + drawerOpen={ 75 + !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 76 + } 77 + pageOptions={pageOptions} 78 + > 79 + {!isSubpage && profile && ( 80 + <PostHeader 81 + data={document} 82 + profile={profile} 83 + preferences={preferences} 84 + /> 85 + )} 86 + <PostContent 87 + pollData={pollData} 88 + pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 89 + pageId={pageId} 90 + bskyPostData={bskyPostData} 91 + blocks={blocks} 92 + did={did} 93 + prerenderedCodeBlocks={prerenderedCodeBlocks} 94 + /> 95 + <Interactions 96 + pageId={pageId} 97 + showComments={preferences.showComments} 98 + commentsCount={getCommentCount(document, pageId) || 0} 99 + quotesCount={getQuoteCount(document, pageId) || 0} 100 + /> 101 + {!isSubpage && ( 102 + <> 103 + <hr className="border-border-light mb-4 mt-4 sm:mx-4 mx-3" /> 104 + <div className="sm:px-4 px-3"> 105 + {identity && 106 + identity.atp_did === 107 + document.documents_in_publications[0]?.publications 108 + ?.identity_did && 109 + document.leaflets_in_publications[0] ? ( 110 + <a 111 + href={`https://leaflet.pub/${document.leaflets_in_publications[0]?.leaflet}`} 112 + className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1 mx-auto" 113 + > 114 + <EditTiny /> Edit Post 115 + </a> 116 + ) : ( 117 + <SubscribeWithBluesky 118 + isPost 119 + base_url={getPublicationURL( 120 + document.documents_in_publications[0].publications, 121 + )} 122 + pub_uri={ 123 + document.documents_in_publications[0].publications.uri 124 + } 125 + subscribers={ 126 + document.documents_in_publications[0].publications 127 + .publication_subscriptions 128 + } 129 + pubName={ 130 + document.documents_in_publications[0].publications.name 131 + } 132 + /> 133 + )} 134 + </div> 135 + </> 136 + )} 137 + </PageWrapper> 138 + </> 139 + ); 140 + }
+35 -5
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 9 9 PubLeafletBlocksWebsite, 10 10 PubLeafletDocument, 11 11 PubLeafletPagesLinearDocument, 12 + PubLeafletPagesCanvas, 12 13 PubLeafletBlocksHorizontalRule, 13 14 PubLeafletBlocksBlockquote, 14 15 PubLeafletBlocksBskyPost, 15 16 PubLeafletBlocksIframe, 16 17 PubLeafletBlocksPage, 18 + PubLeafletBlocksPoll, 17 19 } from "lexicons/api"; 18 20 19 21 import { blobRefToSrc } from "src/utils/blobRefToSrc"; ··· 28 30 import { openPage } from "./PostPages"; 29 31 import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; 30 32 import { PublishedPageLinkBlock } from "./PublishedPageBlock"; 33 + import { PublishedPollBlock } from "./PublishedPollBlock"; 34 + import { PollData } from "./fetchPollData"; 31 35 32 36 export function PostContent({ 33 37 blocks, ··· 38 42 bskyPostData, 39 43 pageId, 40 44 pages, 45 + pollData, 41 46 }: { 42 47 blocks: PubLeafletPagesLinearDocument.Block[]; 43 48 pageId?: string; ··· 46 51 className?: string; 47 52 prerenderedCodeBlocks?: Map<string, string>; 48 53 bskyPostData: AppBskyFeedDefs.PostView[]; 49 - pages: PubLeafletPagesLinearDocument.Main[]; 54 + pollData: PollData[]; 55 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 50 56 }) { 51 57 return ( 52 58 <div ··· 66 72 index={[index]} 67 73 preview={preview} 68 74 prerenderedCodeBlocks={prerenderedCodeBlocks} 75 + pollData={pollData} 69 76 /> 70 77 ); 71 78 })} ··· 73 80 ); 74 81 } 75 82 76 - let Block = ({ 83 + export let Block = ({ 77 84 block, 78 85 did, 79 86 isList, ··· 84 91 bskyPostData, 85 92 pageId, 86 93 pages, 94 + pollData, 87 95 }: { 88 96 pageId?: string; 89 97 preview?: boolean; ··· 91 99 block: PubLeafletPagesLinearDocument.Block; 92 100 did: string; 93 101 isList?: boolean; 94 - pages: PubLeafletPagesLinearDocument.Main[]; 102 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 95 103 previousBlock?: PubLeafletPagesLinearDocument.Block; 96 104 prerenderedCodeBlocks?: Map<string, string>; 97 105 bskyPostData: AppBskyFeedDefs.PostView[]; 106 + pollData: PollData[]; 98 107 }) => { 99 108 let b = block; 100 109 let blockProps = { ··· 136 145 let id = b.block.id; 137 146 let page = pages.find((p) => p.id === id); 138 147 if (!page) return; 148 + 149 + const isCanvas = PubLeafletPagesCanvas.isMain(page); 150 + 139 151 return ( 140 152 <PublishedPageLinkBlock 141 153 blocks={page.blocks} ··· 143 155 parentPageId={pageId} 144 156 did={did} 145 157 bskyPostData={bskyPostData} 158 + isCanvas={isCanvas} 159 + pages={pages} 146 160 className={className} 147 161 /> 148 162 ); ··· 168 182 case PubLeafletBlocksHorizontalRule.isMain(b.block): { 169 183 return <hr className="my-2 w-full border-border-light" />; 170 184 } 185 + case PubLeafletBlocksPoll.isMain(b.block): { 186 + let { cid, uri } = b.block.pollRef; 187 + const pollVoteData = pollData.find((p) => p.uri === uri && p.cid === cid); 188 + if (!pollVoteData) return null; 189 + return ( 190 + <PublishedPollBlock 191 + block={b.block} 192 + className={className} 193 + pollData={pollVoteData} 194 + /> 195 + ); 196 + } 171 197 case PubLeafletBlocksUnorderedList.isMain(b.block): { 172 198 return ( 173 199 <ul className="-ml-px sm:ml-[9px] pb-2"> 174 200 {b.block.children.map((child, i) => ( 175 201 <ListItem 202 + pollData={pollData} 176 203 pages={pages} 177 204 bskyPostData={bskyPostData} 178 205 index={[...index, i]} ··· 275 302 return ( 276 303 // all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding. 277 304 <blockquote 278 - className={` blockquote py-0! mb-2! last:mb-3! sm:last:mb-4! first:mt-2! sm:first:pt-3 ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2!" : "mt-1!"}`} 305 + className={` blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 279 306 {...blockProps} 280 307 > 281 308 <TextBlock ··· 354 381 355 382 function ListItem(props: { 356 383 index: number[]; 357 - pages: PubLeafletPagesLinearDocument.Main[]; 384 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 358 385 item: PubLeafletBlocksUnorderedList.ListItem; 359 386 did: string; 360 387 className?: string; 361 388 bskyPostData: AppBskyFeedDefs.PostView[]; 389 + pollData: PollData[]; 362 390 pageId?: string; 363 391 }) { 364 392 let children = props.item.children?.length ? ( ··· 366 394 {props.item.children.map((child, index) => ( 367 395 <ListItem 368 396 pages={props.pages} 397 + pollData={props.pollData} 369 398 bskyPostData={props.bskyPostData} 370 399 index={[...props.index, index]} 371 400 item={child} ··· 384 413 /> 385 414 <div className="flex flex-col w-full"> 386 415 <Block 416 + pollData={props.pollData} 387 417 pages={props.pages} 388 418 bskyPostData={props.bskyPostData} 389 419 block={{ block: props.item.content }}
+11 -11
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 13 13 import { SpeedyLink } from "components/SpeedyLink"; 14 14 import { decodeQuotePosition } from "../quotePosition"; 15 15 import { Separator } from "components/Layout"; 16 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 17 17 18 export function PostHeader(props: { 18 19 data: PostPageData; ··· 26 27 let profile = props.profile; 27 28 let pub = props.data?.documents_in_publications[0].publications; 28 29 let pubRecord = pub?.record as PubLeafletPublication.Record; 30 + 31 + const formattedDate = useLocalizedDate( 32 + record.publishedAt || new Date().toISOString(), 33 + { 34 + year: "numeric", 35 + month: "long", 36 + day: "2-digit", 37 + }, 38 + ); 29 39 30 40 if (!document?.data || !document.documents_in_publications[0].publications) 31 41 return; ··· 82 92 </a> 83 93 </> 84 94 ) : null} 85 - {record.publishedAt ? ( 86 - <p> 87 - {new Date(record.publishedAt).toLocaleDateString(undefined, { 88 - year: "numeric", 89 - month: "long", 90 - day: "2-digit", 91 - })} 92 - </p> 93 - ) : null} 95 + {record.publishedAt ? <p>{formattedDate}</p> : null} 94 96 </div> 95 - 96 - <Separator classname="h-4 sm:block hidden" /> 97 97 98 98 <Interactions 99 99 showComments={props.preferences.showComments}
+70 -69
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 1 1 "use client"; 2 2 import { 3 - PubLeafletComment, 4 3 PubLeafletDocument, 5 4 PubLeafletPagesLinearDocument, 5 + PubLeafletPagesCanvas, 6 6 PubLeafletPublication, 7 7 } from "lexicons/api"; 8 8 import { PostPageData } from "./getPostPageData"; 9 9 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 - import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 - import { EditTiny } from "components/Icons/EditTiny"; 13 - import { Interactions } from "./Interactions/Interactions"; 14 - import { PostContent } from "./PostContent"; 15 - import { PostHeader } from "./PostHeader/PostHeader"; 16 - import { useIdentityData } from "components/IdentityProvider"; 17 10 import { AppBskyFeedDefs } from "@atproto/api"; 18 11 import { create } from "zustand/react"; 19 12 import { ··· 23 16 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 24 17 import { PageOptionButton } from "components/Pages/PageOptions"; 25 18 import { CloseTiny } from "components/Icons/CloseTiny"; 26 - import { PageWrapper } from "components/Pages/Page"; 27 19 import { Fragment, useEffect } from "react"; 28 20 import { flushSync } from "react-dom"; 29 21 import { scrollIntoView } from "src/utils/scrollIntoView"; 30 22 import { useParams } from "next/navigation"; 31 23 import { decodeQuotePosition } from "./quotePosition"; 32 24 import { PostFooter } from "./PostFooter"; 25 + import { PollData } from "./fetchPollData"; 26 + import { LinearDocumentPage } from "./LinearDocumentPage"; 27 + import { CanvasPage } from "./CanvasPage"; 33 28 34 29 const usePostPageUIState = create(() => ({ 35 30 pages: [] as string[], ··· 114 109 prerenderedCodeBlocks, 115 110 bskyPostData, 116 111 document_uri, 112 + pollData, 117 113 }: { 118 114 document_uri: string; 119 115 document: PostPageData; ··· 124 120 prerenderedCodeBlocks?: Map<string, string>; 125 121 bskyPostData: AppBskyFeedDefs.PostView[]; 126 122 preferences: { showComments?: boolean }; 123 + pollData: PollData[]; 127 124 }) { 128 125 let drawer = useDrawerOpen(document_uri); 129 126 useInitializeOpenPages(); ··· 132 129 return null; 133 130 134 131 let hasPageBackground = !!pubRecord.theme?.showPageBackground; 135 - let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; 136 132 let record = document.data as PubLeafletDocument.Record; 133 + 134 + let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; 137 135 return ( 138 136 <> 139 137 {!fullPageScroll && <BookendSpacer />} 140 - <PageWrapper 141 - pageType="doc" 138 + <LinearDocumentPage 139 + document={document} 140 + blocks={blocks} 141 + did={did} 142 + profile={profile} 142 143 fullPageScroll={fullPageScroll} 143 - cardBorderHidden={!hasPageBackground} 144 - id={"post-page"} 145 - drawerOpen={!!drawer && !drawer.pageId} 146 - > 147 - <PostHeader 148 - data={document} 149 - profile={profile} 150 - preferences={preferences} 151 - /> 152 - <PostContent 153 - pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 154 - bskyPostData={bskyPostData} 155 - blocks={blocks} 156 - did={did} 157 - prerenderedCodeBlocks={prerenderedCodeBlocks} 158 - /> 159 - <PostFooter 160 - data={document} 161 - profile={profile} 162 - preferences={preferences} 163 - /> 164 - </PageWrapper> 144 + pollData={pollData} 145 + preferences={preferences} 146 + pubRecord={pubRecord} 147 + prerenderedCodeBlocks={prerenderedCodeBlocks} 148 + bskyPostData={bskyPostData} 149 + document_uri={document_uri} 150 + /> 165 151 166 152 {drawer && !drawer.pageId && ( 167 153 <InteractionDrawer ··· 178 164 179 165 {pages.map((p) => { 180 166 let page = record.pages.find( 181 - (page) => (page as PubLeafletPagesLinearDocument.Main).id === p, 182 - ) as PubLeafletPagesLinearDocument.Main | undefined; 167 + (page) => 168 + ( 169 + page as 170 + | PubLeafletPagesLinearDocument.Main 171 + | PubLeafletPagesCanvas.Main 172 + ).id === p, 173 + ) as 174 + | PubLeafletPagesLinearDocument.Main 175 + | PubLeafletPagesCanvas.Main 176 + | undefined; 183 177 if (!page) return null; 178 + 179 + const isCanvas = PubLeafletPagesCanvas.isMain(page); 180 + 184 181 return ( 185 182 <Fragment key={p}> 186 183 <SandwichSpacer /> 187 - 188 - <PageWrapper 189 - pageType="doc" 190 - cardBorderHidden={!hasPageBackground} 191 - id={`post-page-${p}`} 192 - fullPageScroll={false} 193 - drawerOpen={!!drawer && drawer.pageId === page.id} 194 - pageOptions={ 195 - <PageOptions 196 - onClick={() => closePage(page?.id!)} 197 - hasPageBackground={hasPageBackground} 198 - /> 199 - } 200 - > 201 - <PostContent 184 + {isCanvas ? ( 185 + <CanvasPage 186 + fullPageScroll={false} 187 + document={document} 188 + blocks={(page as PubLeafletPagesCanvas.Main).blocks} 189 + did={did} 190 + preferences={preferences} 191 + profile={profile} 192 + pubRecord={pubRecord} 193 + prerenderedCodeBlocks={prerenderedCodeBlocks} 194 + pollData={pollData} 195 + bskyPostData={bskyPostData} 196 + document_uri={document_uri} 197 + pageId={page.id} 202 198 pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 203 - pageId={page.id} 204 - bskyPostData={bskyPostData} 205 - blocks={page.blocks} 199 + pageOptions={ 200 + <PageOptions 201 + onClick={() => closePage(page?.id!)} 202 + hasPageBackground={hasPageBackground} 203 + /> 204 + } 205 + /> 206 + ) : ( 207 + <LinearDocumentPage 208 + fullPageScroll={false} 209 + document={document} 210 + blocks={(page as PubLeafletPagesLinearDocument.Main).blocks} 206 211 did={did} 212 + preferences={preferences} 213 + pubRecord={pubRecord} 214 + pollData={pollData} 207 215 prerenderedCodeBlocks={prerenderedCodeBlocks} 208 - /> 209 - <Interactions 216 + bskyPostData={bskyPostData} 217 + document_uri={document_uri} 210 218 pageId={page.id} 211 - showComments={preferences.showComments} 212 - quotesCount={ 213 - document.document_mentions_in_bsky.filter((q) => 214 - q.link.includes(page.id!), 215 - ).length 216 - } 217 - commentsCount={ 218 - document.comments_on_documents.filter( 219 - (c) => 220 - (c.record as PubLeafletComment.Record)?.onPage === 221 - page.id, 222 - ).length 219 + pageOptions={ 220 + <PageOptions 221 + onClick={() => closePage(page?.id!)} 222 + hasPageBackground={hasPageBackground} 223 + /> 223 224 } 224 225 /> 225 - </PageWrapper> 226 + )} 226 227 {drawer && drawer.pageId === page.id && ( 227 228 <InteractionDrawer 228 229 pageId={page.id}
+11 -13
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 10 10 import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 11 11 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 12 12 import { CommentTiny } from "components/Icons/CommentTiny"; 13 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 13 14 import { 14 15 BlueskyEmbed, 15 16 PostNotAvailable, ··· 121 122 122 123 const ClientDate = (props: { date?: string }) => { 123 124 let pageLoaded = useInitialPageLoad(); 125 + const formattedDate = useLocalizedDate(props.date || new Date().toISOString(), { 126 + month: "short", 127 + day: "numeric", 128 + year: "numeric", 129 + hour: "numeric", 130 + minute: "numeric", 131 + hour12: true, 132 + }); 133 + 124 134 if (!pageLoaded) return null; 125 135 126 - let datetimeFormatted = new Date(props.date ? props.date : "").toLocaleString( 127 - "en-US", 128 - { 129 - month: "short", 130 - day: "numeric", 131 - year: "numeric", 132 - hour: "numeric", 133 - minute: "numeric", 134 - hour12: true, 135 - }, 136 - ); 137 - 138 - return <div className="text-xs text-tertiary">{datetimeFormatted}</div>; 136 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 139 137 };
+106 -5
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 4 4 import { useUIState } from "src/useUIState"; 5 5 import { CSSProperties, useContext, useRef } from "react"; 6 6 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 - import { PostContent } from "./PostContent"; 7 + import { PostContent, Block } from "./PostContent"; 8 8 import { 9 9 PubLeafletBlocksHeader, 10 10 PubLeafletBlocksText, 11 11 PubLeafletComment, 12 12 PubLeafletPagesLinearDocument, 13 + PubLeafletPagesCanvas, 13 14 PubLeafletPublication, 14 15 } from "lexicons/api"; 15 16 import { AppBskyFeedDefs } from "@atproto/api"; ··· 23 24 } from "./Interactions/Interactions"; 24 25 import { CommentTiny } from "components/Icons/CommentTiny"; 25 26 import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 + import { CanvasBackgroundPattern } from "components/Canvas"; 26 28 27 29 export function PublishedPageLinkBlock(props: { 28 - blocks: PubLeafletPagesLinearDocument.Block[]; 30 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; 29 31 parentPageId: string | undefined; 30 32 pageId: string; 31 33 did: string; ··· 33 35 className?: string; 34 36 prerenderedCodeBlocks?: Map<string, string>; 35 37 bskyPostData: AppBskyFeedDefs.PostView[]; 38 + isCanvas?: boolean; 39 + pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 36 40 }) { 37 41 //switch to use actually state 38 42 let openPages = useOpenPages(); ··· 56 60 openPage(props.parentPageId, props.pageId); 57 61 }} 58 62 > 59 - <DocLinkBlock {...props} /> 63 + {props.isCanvas ? ( 64 + <CanvasLinkBlock 65 + blocks={props.blocks as PubLeafletPagesCanvas.Block[]} 66 + did={props.did} 67 + pageId={props.pageId} 68 + bskyPostData={props.bskyPostData} 69 + pages={props.pages || []} 70 + /> 71 + ) : ( 72 + <DocLinkBlock 73 + {...props} 74 + blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]} 75 + /> 76 + )} 60 77 </div> 61 78 ); 62 79 } ··· 161 178 /> 162 179 )} 163 180 <PostContent 181 + pollData={[]} 164 182 pages={[]} 165 183 did={props.did} 166 184 blocks={props.blocks} ··· 203 221 openInteractionDrawer("quotes", document_uri, props.pageId); 204 222 else setInteractionState(document_uri, { drawerOpen: false }); 205 223 }} 206 - aria-label="Page quotes" 207 224 > 225 + <span className="sr-only">Page quotes</span> 208 226 <QuoteTiny aria-hidden /> {quotes}{" "} 209 227 </button> 210 228 )} ··· 221 239 openInteractionDrawer("comments", document_uri, props.pageId); 222 240 else setInteractionState(document_uri, { drawerOpen: false }); 223 241 }} 224 - aria-label="Page comments" 225 242 > 243 + <span className="sr-only">Page comments</span> 226 244 <CommentTiny aria-hidden /> {comments}{" "} 227 245 </button> 228 246 )} 229 247 </div> 230 248 ); 231 249 }; 250 + 251 + const CanvasLinkBlock = (props: { 252 + blocks: PubLeafletPagesCanvas.Block[]; 253 + did: string; 254 + pageId: string; 255 + bskyPostData: AppBskyFeedDefs.PostView[]; 256 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 257 + }) => { 258 + let pageWidth = `var(--page-width-unitless)`; 259 + let height = 260 + props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0; 261 + 262 + return ( 263 + <div 264 + style={{ contain: "size layout paint" }} 265 + className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`} 266 + > 267 + <div 268 + className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`} 269 + style={{ 270 + width: `calc(1px * ${pageWidth})`, 271 + height: "calc(1150px * 2)", 272 + transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`, 273 + }} 274 + > 275 + <div 276 + style={{ 277 + minHeight: height + 512, 278 + contain: "size layout paint", 279 + }} 280 + className="relative h-full w-[1272px]" 281 + > 282 + <div className="w-full h-full pointer-events-none"> 283 + <CanvasBackgroundPattern pattern="grid" /> 284 + </div> 285 + {props.blocks 286 + .sort((a, b) => { 287 + if (a.y === b.y) { 288 + return a.x - b.x; 289 + } 290 + return a.y - b.y; 291 + }) 292 + .map((canvasBlock, index) => { 293 + let { x, y, width, rotation } = canvasBlock; 294 + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 295 + 296 + // Wrap the block in a LinearDocument.Block structure for compatibility 297 + let linearBlock: PubLeafletPagesLinearDocument.Block = { 298 + $type: "pub.leaflet.pages.linearDocument#block", 299 + block: canvasBlock.block, 300 + }; 301 + 302 + return ( 303 + <div 304 + key={index} 305 + className="absolute rounded-lg flex items-stretch origin-center p-3" 306 + style={{ 307 + top: 0, 308 + left: 0, 309 + width, 310 + transform, 311 + }} 312 + > 313 + <div className="contents"> 314 + <Block 315 + pollData={[]} 316 + pageId={props.pageId} 317 + pages={props.pages} 318 + bskyPostData={props.bskyPostData} 319 + block={linearBlock} 320 + did={props.did} 321 + index={[index]} 322 + preview={true} 323 + /> 324 + </div> 325 + </div> 326 + ); 327 + })} 328 + </div> 329 + </div> 330 + </div> 331 + ); 332 + };
+278
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
··· 1 + "use client"; 2 + 3 + import { PubLeafletBlocksPoll, PubLeafletPollDefinition, PubLeafletPollVote } from "lexicons/api"; 4 + import { useState, useEffect } from "react"; 5 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 6 + import { useIdentityData } from "components/IdentityProvider"; 7 + import { AtpAgent } from "@atproto/api"; 8 + import { voteOnPublishedPoll } from "./voteOnPublishedPoll"; 9 + import { PollData } from "./fetchPollData"; 10 + import { Popover } from "components/Popover"; 11 + import LoginForm from "app/login/LoginForm"; 12 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 13 + 14 + // Helper function to extract the first option from a vote record 15 + const getVoteOption = (voteRecord: any): string | null => { 16 + try { 17 + const record = voteRecord as PubLeafletPollVote.Record; 18 + return record.option && record.option.length > 0 ? record.option[0] : null; 19 + } catch { 20 + return null; 21 + } 22 + }; 23 + 24 + export const PublishedPollBlock = (props: { 25 + block: PubLeafletBlocksPoll.Main; 26 + pollData: PollData; 27 + className?: string; 28 + }) => { 29 + const { identity } = useIdentityData(); 30 + const [selectedOption, setSelectedOption] = useState<string | null>(null); 31 + const [isVoting, setIsVoting] = useState(false); 32 + const [showResults, setShowResults] = useState(false); 33 + const [optimisticVote, setOptimisticVote] = useState<{ 34 + option: string; 35 + voter_did: string; 36 + } | null>(null); 37 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 38 + let [isClient, setIsClient] = useState(false); 39 + useEffect(() => { 40 + setIsClient(true); 41 + }, []); 42 + 43 + const handleVote = async () => { 44 + if (!selectedOption || !identity?.atp_did) return; 45 + 46 + setIsVoting(true); 47 + 48 + // Optimistically add the vote 49 + setOptimisticVote({ 50 + option: selectedOption, 51 + voter_did: identity.atp_did, 52 + }); 53 + setShowResults(true); 54 + 55 + try { 56 + const result = await voteOnPublishedPoll( 57 + props.block.pollRef.uri, 58 + props.block.pollRef.cid, 59 + selectedOption, 60 + ); 61 + 62 + if (!result.success) { 63 + console.error("Failed to vote:", result.error); 64 + // Revert optimistic update on failure 65 + setOptimisticVote(null); 66 + setShowResults(false); 67 + } 68 + } catch (error) { 69 + console.error("Failed to vote:", error); 70 + // Revert optimistic update on failure 71 + setOptimisticVote(null); 72 + setShowResults(false); 73 + } finally { 74 + setIsVoting(false); 75 + } 76 + }; 77 + 78 + const hasVoted = 79 + !!identity?.atp_did && 80 + (!!props.pollData?.atp_poll_votes.find( 81 + (v) => v.voter_did === identity?.atp_did, 82 + ) || 83 + !!optimisticVote); 84 + let isCreator = 85 + identity?.atp_did && props.pollData.uri.includes(identity?.atp_did); 86 + const displayResults = showResults || hasVoted; 87 + 88 + return ( 89 + <div 90 + className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`} 91 + style={{ 92 + backgroundColor: 93 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 94 + }} 95 + > 96 + {displayResults ? ( 97 + <> 98 + <PollResults 99 + pollData={props.pollData} 100 + hasVoted={hasVoted} 101 + setShowResults={setShowResults} 102 + optimisticVote={optimisticVote} 103 + /> 104 + {isCreator && !hasVoted && ( 105 + <div className="flex justify-start"> 106 + <button 107 + className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 108 + onClick={() => setShowResults(false)} 109 + > 110 + Back to Voting 111 + </button> 112 + </div> 113 + )} 114 + </> 115 + ) : ( 116 + <> 117 + {pollRecord.options.map((option, index) => ( 118 + <PollOptionButton 119 + key={index} 120 + option={option} 121 + optionIndex={index.toString()} 122 + selected={selectedOption === index.toString()} 123 + onSelect={() => setSelectedOption(index.toString())} 124 + disabled={!identity?.atp_did} 125 + /> 126 + ))} 127 + <div className="flex justify-between items-center"> 128 + <div className="flex justify-end gap-2"> 129 + {isCreator && ( 130 + <button 131 + className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 132 + onClick={() => setShowResults(!showResults)} 133 + > 134 + See Results 135 + </button> 136 + )} 137 + </div> 138 + {identity?.atp_did ? ( 139 + <ButtonPrimary 140 + className="place-self-end" 141 + onClick={handleVote} 142 + disabled={!selectedOption || isVoting} 143 + > 144 + {isVoting ? "Voting..." : "Vote!"} 145 + </ButtonPrimary> 146 + ) : ( 147 + <Popover 148 + asChild 149 + trigger={ 150 + <ButtonPrimary className="place-self-center"> 151 + <BlueskyTiny /> Login to vote 152 + </ButtonPrimary> 153 + } 154 + > 155 + {isClient && ( 156 + <LoginForm 157 + text="Log in to vote on this poll!" 158 + noEmail 159 + redirectRoute={window?.location.href + "?refreshAuth"} 160 + /> 161 + )} 162 + </Popover> 163 + )} 164 + </div> 165 + </> 166 + )} 167 + </div> 168 + ); 169 + }; 170 + 171 + const PollOptionButton = (props: { 172 + option: PubLeafletPollDefinition.Option; 173 + optionIndex: string; 174 + selected: boolean; 175 + onSelect: () => void; 176 + disabled?: boolean; 177 + }) => { 178 + const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary; 179 + 180 + return ( 181 + <div className="flex gap-2 items-center"> 182 + <ButtonComponent 183 + className="pollOption grow max-w-full flex" 184 + onClick={props.onSelect} 185 + disabled={props.disabled} 186 + > 187 + {props.option.text} 188 + </ButtonComponent> 189 + </div> 190 + ); 191 + }; 192 + 193 + const PollResults = (props: { 194 + pollData: PollData; 195 + hasVoted: boolean; 196 + setShowResults: (show: boolean) => void; 197 + optimisticVote: { option: string; voter_did: string } | null; 198 + }) => { 199 + // Merge optimistic vote with actual votes 200 + const allVotes = props.optimisticVote 201 + ? [ 202 + ...props.pollData.atp_poll_votes, 203 + { 204 + voter_did: props.optimisticVote.voter_did, 205 + record: { 206 + $type: "pub.leaflet.poll.vote", 207 + option: [props.optimisticVote.option], 208 + }, 209 + }, 210 + ] 211 + : props.pollData.atp_poll_votes; 212 + 213 + const totalVotes = allVotes.length || 0; 214 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 215 + let optionsWithCount = pollRecord.options.map((o, index) => ({ 216 + ...o, 217 + votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()), 218 + })); 219 + 220 + const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length)); 221 + return ( 222 + <> 223 + {pollRecord.options.map((option, index) => { 224 + const votes = allVotes.filter( 225 + (v) => getVoteOption(v.record) === index.toString(), 226 + ).length; 227 + const isWinner = totalVotes > 0 && votes === highestVotes; 228 + 229 + return ( 230 + <PollResult 231 + key={index} 232 + option={option} 233 + votes={votes} 234 + totalVotes={totalVotes} 235 + winner={isWinner} 236 + /> 237 + ); 238 + })} 239 + </> 240 + ); 241 + }; 242 + 243 + const PollResult = (props: { 244 + option: PubLeafletPollDefinition.Option; 245 + votes: number; 246 + totalVotes: number; 247 + winner: boolean; 248 + }) => { 249 + return ( 250 + <div 251 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 252 + > 253 + <div 254 + style={{ 255 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`, 256 + paintOrder: "stroke fill", 257 + }} 258 + className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10" 259 + > 260 + <div className="grow max-w-full truncate">{props.option.text}</div> 261 + <div>{props.votes}</div> 262 + </div> 263 + <div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0"> 264 + <div 265 + className="bg-accent-contrast rounded-[2px] m-0.5" 266 + style={{ 267 + maskImage: "var(--hatchSVG)", 268 + maskRepeat: "repeat repeat", 269 + ...(props.votes === 0 270 + ? { width: "4px" } 271 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 272 + }} 273 + /> 274 + <div /> 275 + </div> 276 + </div> 277 + ); 278 + };
+25
app/lish/[did]/[publication]/[rkey]/fetchPollData.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { Json } from "supabase/database.types"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + 7 + export type PollData = { 8 + uri: string; 9 + cid: string; 10 + record: Json; 11 + atp_poll_votes: { record: Json; voter_did: string }[]; 12 + }; 13 + 14 + export async function fetchPollData(pollUris: string[]): Promise<PollData[]> { 15 + // Get current user's identity to check if they've voted 16 + const identity = await getIdentityData(); 17 + const userDid = identity?.atp_did; 18 + 19 + const { data } = await supabaseServerClient 20 + .from("atp_poll_records") 21 + .select(`*, atp_poll_votes(*)`) 22 + .in("uri", pollUris); 23 + 24 + return data || []; 25 + }
+12
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 20 20 import { PostPages } from "./PostPages"; 21 21 import { extractCodeBlocks } from "./extractCodeBlocks"; 22 22 import { LeafletLayout } from "components/LeafletLayout"; 23 + import { fetchPollData } from "./fetchPollData"; 23 24 24 25 export async function generateMetadata(props: { 25 26 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 115 116 { headers: {} }, 116 117 ) 117 118 : { data: { posts: [] } }; 119 + 120 + // Extract poll blocks and fetch vote data 121 + let pollBlocks = record.pages.flatMap((p) => { 122 + let page = p as PubLeafletPagesLinearDocument.Main; 123 + return page.blocks?.filter( 124 + (b) => b.block.$type === ids.PubLeafletBlocksPoll, 125 + ) || []; 126 + }); 127 + let pollData = await fetchPollData(pollBlocks.map(b => (b.block as any).pollRef.uri)); 128 + 118 129 let firstPage = record.pages[0]; 119 130 let blocks: PubLeafletPagesLinearDocument.Block[] = []; 120 131 if (PubLeafletPagesLinearDocument.isMain(firstPage)) { ··· 165 176 did={did} 166 177 blocks={blocks} 167 178 prerenderedCodeBlocks={prerenderedCodeBlocks} 179 + pollData={pollData} 168 180 /> 169 181 </LeafletLayout> 170 182
+64
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
··· 1 + "use server"; 2 + 3 + import { createOauthClient } from "src/atproto-oauth"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + import { AtpBaseClient, AtUri } from "@atproto/api"; 6 + import { PubLeafletPollVote } from "lexicons/api"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { Json } from "supabase/database.types"; 9 + import { TID } from "@atproto/common"; 10 + 11 + export async function voteOnPublishedPoll( 12 + pollUri: string, 13 + pollCid: string, 14 + selectedOption: string, 15 + ): Promise<{ success: boolean; error?: string }> { 16 + try { 17 + const identity = await getIdentityData(); 18 + 19 + if (!identity?.atp_did) { 20 + return { success: false, error: "Not authenticated" }; 21 + } 22 + 23 + const oauthClient = await createOauthClient(); 24 + const session = await oauthClient.restore(identity.atp_did); 25 + let agent = new AtpBaseClient(session.fetchHandler.bind(session)); 26 + 27 + const voteRecord: PubLeafletPollVote.Record = { 28 + $type: "pub.leaflet.poll.vote", 29 + poll: { 30 + uri: pollUri, 31 + cid: pollCid, 32 + }, 33 + option: [selectedOption], 34 + }; 35 + 36 + const rkey = TID.nextStr(); 37 + const voteUri = AtUri.make(identity.atp_did, "pub.leaflet.poll.vote", rkey); 38 + 39 + // Write to database optimistically before creating the record 40 + await supabaseServerClient.from("atp_poll_votes").upsert({ 41 + uri: voteUri.toString(), 42 + voter_did: identity.atp_did, 43 + poll_uri: pollUri, 44 + poll_cid: pollCid, 45 + record: voteRecord as unknown as Json, 46 + }); 47 + 48 + // Create the record on ATP 49 + await agent.com.atproto.repo.createRecord({ 50 + repo: identity.atp_did, 51 + collection: "pub.leaflet.poll.vote", 52 + rkey, 53 + record: voteRecord, 54 + }); 55 + 56 + return { success: true }; 57 + } catch (error) { 58 + console.error("Failed to vote:", error); 59 + return { 60 + success: false, 61 + error: error instanceof Error ? error.message : "Failed to vote", 62 + }; 63 + } 64 + }
+1
app/lish/[did]/[publication]/atom/route.ts
··· 19 19 return new Response(feed.atom1(), { 20 20 headers: { 21 21 "Content-Type": "application/atom+xml", 22 + "Cache-Control": "s-maxage=300, stale-while-revalidate=3600", 22 23 "CDN-Cache-Control": "s-maxage=300, stale-while-revalidate=3600", 23 24 }, 24 25 });
+1 -1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 3 3 import { NewDraftSecondaryButton } from "./NewDraftButton"; 4 4 import React from "react"; 5 5 import { usePublicationData } from "./PublicationSWRProvider"; 6 - import { LeafletList } from "app/home/HomeLayout"; 6 + import { LeafletList } from "app/(home-pages)/home/HomeLayout"; 7 7 8 8 export function DraftList(props: { 9 9 searchValue: string;
+15 -7
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 8 8 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 9 import { Checkbox } from "components/Checkbox"; 10 10 import { useEffect, useState } from "react"; 11 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 11 12 12 13 type subscriber = { email: string | undefined; did: string | undefined }; 13 14 ··· 198 199 @{props.handle} 199 200 </a> 200 201 )} 201 - <div className="px-1 py-0 h-max rounded-md text-sm italic text-tertiary"> 202 - {new Date(props.createdAt).toLocaleString(undefined, { 203 - year: "2-digit", 204 - month: "2-digit", 205 - day: "2-digit", 206 - })} 207 - </div> 202 + <SubscriberDate createdAt={props.createdAt} /> 208 203 </div> 209 204 </> 210 205 // </Checkbox> ··· 235 230 </Menu> 236 231 ); 237 232 }; 233 + 234 + function SubscriberDate(props: { createdAt: string }) { 235 + const formattedDate = useLocalizedDate(props.createdAt, { 236 + year: "2-digit", 237 + month: "2-digit", 238 + day: "2-digit", 239 + }); 240 + return ( 241 + <div className="px-1 py-0 h-max rounded-md text-sm italic text-tertiary"> 242 + {formattedDate} 243 + </div> 244 + ); 245 + }
+12 -11
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 18 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 20 import { InteractionPreview } from "components/InteractionsPreview"; 21 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 22 22 23 export function PublishedPostsList(props: { 23 24 searchValue: string; ··· 100 101 ) : null} 101 102 <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 102 103 {postRecord.publishedAt ? ( 103 - <p className="text-sm text-tertiary"> 104 - Published{" "} 105 - {new Date(postRecord.publishedAt).toLocaleDateString( 106 - undefined, 107 - { 108 - year: "numeric", 109 - month: "long", 110 - day: "2-digit", 111 - }, 112 - )} 113 - </p> 104 + <PublishedDate dateString={postRecord.publishedAt} /> 114 105 ) : null} 115 106 <InteractionPreview 116 107 quotesCount={quotes} ··· 228 219 ); 229 220 } 230 221 } 222 + 223 + function PublishedDate(props: { dateString: string }) { 224 + const formattedDate = useLocalizedDate(props.dateString, { 225 + year: "numeric", 226 + month: "long", 227 + day: "2-digit", 228 + }); 229 + 230 + return <p className="text-sm text-tertiary">Published {formattedDate}</p>; 231 + }
+2 -1
app/lish/[did]/[publication]/icon.ts
··· 59 59 headers: { 60 60 "Content-Type": "image/png", 61 61 "CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400", 62 - "Cache-Control": "public, max-age=3600", 62 + "Cache-Control": 63 + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 63 64 }, 64 65 }); 65 66 } catch (e) {
+11 -8
app/lish/[did]/[publication]/page.tsx
··· 15 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 17 import { InteractionPreview } from "components/InteractionsPreview"; 18 + import { LocalizedDate } from "./LocalizedDate"; 18 19 19 20 export default async function Publication(props: { 20 21 params: Promise<{ publication: string; did: string }>; ··· 153 154 154 155 <div className="text-sm text-tertiary flex gap-3 items-center justify-start pt-2"> 155 156 <p className="text-sm text-tertiary "> 156 - {doc_record.publishedAt && 157 - new Date( 158 - doc_record.publishedAt, 159 - ).toLocaleDateString(undefined, { 160 - year: "numeric", 161 - month: "long", 162 - day: "2-digit", 163 - })}{" "} 157 + {doc_record.publishedAt && ( 158 + <LocalizedDate 159 + dateString={doc_record.publishedAt} 160 + options={{ 161 + year: "numeric", 162 + month: "long", 163 + day: "2-digit", 164 + }} 165 + /> 166 + )}{" "} 164 167 </p> 165 168 166 169 <InteractionPreview
+1
app/lish/[did]/[publication]/rss/route.ts
··· 19 19 return new Response(feed.rss2(), { 20 20 headers: { 21 21 "Content-Type": "application/rss+xml", 22 + "Cache-Control": "s-maxage=300, stale-while-revalidate=3600", 22 23 "CDN-Cache-Control": "s-maxage=300, stale-while-revalidate=3600", 23 24 }, 24 25 });
+1 -1
app/login/LoginForm.tsx
··· 5 5 } from "actions/emailAuth"; 6 6 import { loginWithEmailToken } from "actions/login"; 7 7 import { ActionAfterSignIn } from "app/api/oauth/[route]/afterSignInActions"; 8 - import { getHomeDocs } from "app/home/storage"; 8 + import { getHomeDocs } from "app/(home-pages)/home/storage"; 9 9 import { ButtonPrimary } from "components/Buttons"; 10 10 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 11 11 import { BlueskySmall } from "components/Icons/BlueskySmall";
+1 -1
app/reader/ReaderContent.tsx app/(home-pages)/reader/ReaderContent.tsx
··· 7 7 import { useEffect, useRef } from "react"; 8 8 import Link from "next/link"; 9 9 import { PostLink } from "components/PostLink"; 10 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 11 11 12 export const ReaderContent = (props: { 12 - root_entity: string; 13 13 posts: Post[]; 14 14 nextCursor: Cursor | null; 15 15 }) => {
+1 -1
app/reader/SubscriptionsContent.tsx app/(home-pages)/reader/SubscriptionsContent.tsx
··· 1 1 "use client"; 2 - import { PubListing } from "app/discover/PubListing"; 2 + import { PubListing } from "app/(home-pages)/discover/PubListing"; 3 3 import { ButtonPrimary } from "components/Buttons"; 4 4 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 5 5 import { Json } from "supabase/database.types";
app/reader/getReaderFeed.ts app/(home-pages)/reader/getReaderFeed.ts
app/reader/getSubscriptions.ts app/(home-pages)/reader/getSubscriptions.ts
app/reader/idResolver.ts app/(home-pages)/reader/idResolver.ts
-104
app/reader/page.tsx
··· 1 - import { cookies } from "next/headers"; 2 - import { Fact, ReplicacheProvider } from "src/replicache"; 3 - import type { Attribute } from "src/replicache/attributes"; 4 - import { 5 - ThemeBackgroundProvider, 6 - ThemeProvider, 7 - } from "components/ThemeManager/ThemeProvider"; 8 - import { EntitySetProvider } from "components/EntitySetProvider"; 9 - import { getIdentityData } from "actions/getIdentityData"; 10 - import { supabaseServerClient } from "supabase/serverClient"; 11 - 12 - import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 13 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 14 - import { ReaderContent, ReaderEmpty } from "./ReaderContent"; 15 - import { 16 - SubscriptionsContent, 17 - SubscriptionsEmpty, 18 - } from "./SubscriptionsContent"; 19 - import { getReaderFeed } from "./getReaderFeed"; 20 - import { getSubscriptions } from "./getSubscriptions"; 21 - 22 - export default async function Reader(props: {}) { 23 - let cookieStore = await cookies(); 24 - let auth_res = await getIdentityData(); 25 - let identity: string | undefined; 26 - let permission_token = auth_res?.home_leaflet; 27 - if (!permission_token) 28 - return ( 29 - <DashboardLayout 30 - id="reader" 31 - cardBorderHidden={false} 32 - currentPage="reader" 33 - defaultTab="Read" 34 - actions={null} 35 - tabs={{ 36 - Read: { 37 - controls: null, 38 - content: <ReaderEmpty />, 39 - }, 40 - Subscriptions: { 41 - controls: null, 42 - content: <SubscriptionsEmpty />, 43 - }, 44 - }} 45 - /> 46 - ); 47 - let [homeLeafletFacts] = await Promise.all([ 48 - supabaseServerClient.rpc("get_facts", { 49 - root: permission_token.root_entity, 50 - }), 51 - ]); 52 - let initialFacts = 53 - (homeLeafletFacts.data as unknown as Fact<Attribute>[]) || []; 54 - let root_entity = permission_token.root_entity; 55 - 56 - if (!auth_res?.atp_did) return; 57 - let posts = await getReaderFeed(); 58 - let publications = await getSubscriptions(); 59 - return ( 60 - <ReplicacheProvider 61 - rootEntity={root_entity} 62 - token={permission_token} 63 - name={root_entity} 64 - initialFacts={initialFacts} 65 - > 66 - <EntitySetProvider 67 - set={permission_token.permission_token_rights[0].entity_set} 68 - > 69 - <ThemeProvider entityID={root_entity}> 70 - <ThemeBackgroundProvider entityID={root_entity}> 71 - <DashboardLayout 72 - id="reader" 73 - cardBorderHidden={false} 74 - currentPage="reader" 75 - defaultTab="Read" 76 - actions={null} 77 - tabs={{ 78 - Read: { 79 - controls: null, 80 - content: ( 81 - <ReaderContent 82 - root_entity={root_entity} 83 - nextCursor={posts.nextCursor} 84 - posts={posts.posts} 85 - /> 86 - ), 87 - }, 88 - Subscriptions: { 89 - controls: null, 90 - content: ( 91 - <SubscriptionsContent 92 - publications={publications.subscriptions} 93 - nextCursor={publications.nextCursor} 94 - /> 95 - ), 96 - }, 97 - }} 98 - /> 99 - </ThemeBackgroundProvider> 100 - </ThemeProvider> 101 - </EntitySetProvider> 102 - </ReplicacheProvider> 103 - ); 104 - }
+46
appview/index.ts
··· 9 9 PubLeafletGraphSubscription, 10 10 PubLeafletPublication, 11 11 PubLeafletComment, 12 + PubLeafletPollVote, 13 + PubLeafletPollDefinition, 12 14 } from "lexicons/api"; 13 15 import { 14 16 AppBskyEmbedExternal, ··· 44 46 ids.PubLeafletPublication, 45 47 ids.PubLeafletGraphSubscription, 46 48 ids.PubLeafletComment, 49 + ids.PubLeafletPollVote, 50 + ids.PubLeafletPollDefinition, 47 51 // ids.AppBskyActorProfile, 48 52 "app.bsky.feed.post", 49 53 ], ··· 112 116 publication: record.value.publication, 113 117 document: evt.uri.toString(), 114 118 }); 119 + await supabase 120 + .from("documents_in_publications") 121 + .delete() 122 + .neq("publication", record.value.publication) 123 + .eq("document", evt.uri.toString()); 115 124 if (docInPublicationResult.error) 116 125 console.log(docInPublicationResult.error); 117 126 } ··· 165 174 if (evt.event === "delete") { 166 175 await supabase 167 176 .from("comments_on_documents") 177 + .delete() 178 + .eq("uri", evt.uri.toString()); 179 + } 180 + } 181 + if (evt.collection === ids.PubLeafletPollVote) { 182 + if (evt.event === "create" || evt.event === "update") { 183 + let record = PubLeafletPollVote.validateRecord(evt.record); 184 + if (!record.success) return; 185 + let { error } = await supabase.from("atp_poll_votes").upsert({ 186 + uri: evt.uri.toString(), 187 + voter_did: evt.did, 188 + poll_uri: record.value.poll.uri, 189 + poll_cid: record.value.poll.cid, 190 + record: record.value as Json, 191 + }); 192 + } 193 + if (evt.event === "delete") { 194 + await supabase 195 + .from("atp_poll_votes") 196 + .delete() 197 + .eq("uri", evt.uri.toString()); 198 + } 199 + } 200 + if (evt.collection === ids.PubLeafletPollDefinition) { 201 + if (evt.event === "create" || evt.event === "update") { 202 + let record = PubLeafletPollDefinition.validateRecord(evt.record); 203 + if (!record.success) return; 204 + let { error } = await supabase.from("atp_poll_records").upsert({ 205 + uri: evt.uri.toString(), 206 + cid: evt.cid.toString(), 207 + record: record.value as Json, 208 + }); 209 + if (error) console.log("Error upserting poll definition:", error); 210 + } 211 + if (evt.event === "delete") { 212 + await supabase 213 + .from("atp_poll_records") 168 214 .delete() 169 215 .eq("uri", evt.uri.toString()); 170 216 }
+9 -9
components/ActionBar/Publications.tsx
··· 102 102 let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; 103 103 104 104 return props.record.icon ? ( 105 - <div 106 - style={{ 107 - backgroundRepeat: "no-repeat", 108 - backgroundPosition: "center", 109 - backgroundSize: "cover", 110 - backgroundImage: `url(/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]})`, 111 - }} 112 - className={`${iconSizeClassName} ${props.className}`} 113 - /> 105 + <div className={`${iconSizeClassName} ${props.className} relative overflow-hidden`}> 106 + <img 107 + src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`} 108 + alt={`${props.record.name} icon`} 109 + loading="lazy" 110 + fetchPriority="low" 111 + className="absolute inset-0 w-full h-full object-cover object-center" 112 + /> 113 + </div> 114 114 ) : ( 115 115 <div className={`${iconSizeClassName} bg-accent-1 relative`}> 116 116 <div
+19 -1
components/Blocks/Block.tsx
··· 7 7 import { useBlockKeyboardHandlers } from "./useBlockKeyboardHandlers"; 8 8 import { useLongPress } from "src/hooks/useLongPress"; 9 9 import { focusBlock } from "src/utils/focusBlock"; 10 + import { useHandleDrop } from "./useHandleDrop"; 11 + import { useEntitySetContext } from "components/EntitySetProvider"; 10 12 11 13 import { TextBlock } from "components/Blocks/TextBlock"; 12 14 import { ImageBlock } from "./ImageBlock"; ··· 15 17 import { EmbedBlock } from "./EmbedBlock"; 16 18 import { MailboxBlock } from "./MailboxBlock"; 17 19 import { AreYouSure } from "./DeleteBlock"; 18 - import { useEntitySetContext } from "components/EntitySetProvider"; 19 20 import { useIsMobile } from "src/hooks/isMobile"; 20 21 import { DateTimeBlock } from "./DateTimeBlock"; 21 22 import { RSVPBlock } from "./RSVPBlock"; ··· 63 64 // and shared styling like padding and flex for list layouting 64 65 65 66 let mouseHandlers = useBlockMouseHandlers(props); 67 + let handleDrop = useHandleDrop({ 68 + parent: props.parent, 69 + position: props.position, 70 + nextPosition: props.nextPosition, 71 + }); 72 + let entity_set = useEntitySetContext(); 66 73 67 74 let { isLongPress, handlers } = useLongPress(() => { 68 75 if (isTextBlock[props.type]) return; ··· 93 100 {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})} 94 101 id={ 95 102 !props.preview ? elementId.block(props.entityID).container : undefined 103 + } 104 + onDragOver={ 105 + !props.preview && entity_set.permissions.write 106 + ? (e) => { 107 + e.preventDefault(); 108 + e.stopPropagation(); 109 + } 110 + : undefined 111 + } 112 + onDrop={ 113 + !props.preview && entity_set.permissions.write ? handleDrop : undefined 96 114 } 97 115 className={` 98 116 blockWrapper relative
-2
components/Blocks/BlockCommands.tsx
··· 235 235 name: "Poll", 236 236 icon: <BlockPollSmall />, 237 237 type: "block", 238 - hiddenInPublication: true, 239 238 onSelect: async (rep, props, um) => { 240 239 let entity = await createBlockWithType(rep, props, "poll"); 241 240 let pollOptionEntity = v7(); ··· 369 368 name: "New Canvas", 370 369 icon: <BlockCanvasPageSmall />, 371 370 type: "page", 372 - hiddenInPublication: true, 373 371 onSelect: async (rep, props, um) => { 374 372 props.entityID && clearCommandSearchText(props.entityID); 375 373 let entity = await createBlockWithType(rep, props, "card");
+14 -14
components/Blocks/BlueskyPostBlock/index.tsx
··· 13 13 import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 14 14 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 15 15 import { CommentTiny } from "components/Icons/CommentTiny"; 16 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 17 17 18 export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => { 18 19 let { permissions } = useEntitySetContext(); ··· 28 29 input?.focus(); 29 30 } else input?.blur(); 30 31 }, [isSelected, props.entityID, props.preview]); 31 - 32 - let initialPageLoad = useInitialPageLoad(); 33 32 34 33 switch (true) { 35 34 case !post: ··· 81 80 //getting the url to the post 82 81 let postId = post.post.uri.split("/")[4]; 83 82 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 84 - 85 - let datetimeFormatted = initialPageLoad 86 - ? new Date(timestamp ? timestamp : "").toLocaleString("en-US", { 87 - month: "short", 88 - day: "numeric", 89 - year: "numeric", 90 - hour: "numeric", 91 - minute: "numeric", 92 - hour12: true, 93 - }) 94 - : ""; 95 83 96 84 return ( 97 85 <div ··· 141 129 </> 142 130 )} 143 131 <div className="w-full flex gap-2 items-center justify-between"> 144 - <div className="text-xs text-tertiary">{datetimeFormatted}</div> 132 + {timestamp && <PostDate timestamp={timestamp} />} 145 133 <div className="flex gap-2 items-center"> 146 134 {post.post.replyCount && post.post.replyCount > 0 && ( 147 135 <> ··· 166 154 ); 167 155 } 168 156 }; 157 + 158 + function PostDate(props: { timestamp: string }) { 159 + const formattedDate = useLocalizedDate(props.timestamp, { 160 + month: "short", 161 + day: "numeric", 162 + year: "numeric", 163 + hour: "numeric", 164 + minute: "numeric", 165 + hour12: true, 166 + }); 167 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 168 + }
+3
components/Blocks/CodeBlock.tsx
··· 123 123 data-entityid={props.entityID} 124 124 id={elementId.block(props.entityID).input} 125 125 block={props} 126 + spellCheck={false} 127 + autoCapitalize="none" 128 + autoCorrect="off" 126 129 className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2" 127 130 value={content?.data.value} 128 131 onChange={async (e) => {
+46 -25
components/Blocks/ImageBlock.tsx
··· 51 51 } 52 52 }, [isSelected, props.preview, props.entityID]); 53 53 54 + const handleImageUpload = async (file: File) => { 55 + if (!rep) return; 56 + let entity = props.entityID; 57 + if (!entity) { 58 + entity = v7(); 59 + await rep?.mutate.addBlock({ 60 + parent: props.parent, 61 + factID: v7(), 62 + permission_set: entity_set.set, 63 + type: "text", 64 + position: generateKeyBetween( 65 + props.position, 66 + props.nextPosition, 67 + ), 68 + newEntityID: entity, 69 + }); 70 + } 71 + await rep.mutate.assertFact({ 72 + entity, 73 + attribute: "block/type", 74 + data: { type: "block-type-union", value: "image" }, 75 + }); 76 + await addImage(file, rep, { 77 + entityID: entity, 78 + attribute: "block/image", 79 + }); 80 + }; 81 + 54 82 if (!image) { 55 83 if (!entity_set.permissions.write) return null; 56 84 return ( ··· 65 93 ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 66 94 ${props.pageType === "canvas" && "bg-bg-page"}`} 67 95 onMouseDown={(e) => e.preventDefault()} 96 + onDragOver={(e) => { 97 + e.preventDefault(); 98 + e.stopPropagation(); 99 + }} 100 + onDrop={async (e) => { 101 + e.preventDefault(); 102 + e.stopPropagation(); 103 + if (isLocked) return; 104 + const files = e.dataTransfer.files; 105 + if (files && files.length > 0) { 106 + const file = files[0]; 107 + if (file.type.startsWith('image/')) { 108 + await handleImageUpload(file); 109 + } 110 + } 111 + }} 68 112 > 69 113 <div className="flex gap-2"> 70 114 <BlockImageSmall ··· 79 123 accept="image/*" 80 124 onChange={async (e) => { 81 125 let file = e.currentTarget.files?.[0]; 82 - if (!file || !rep) return; 83 - let entity = props.entityID; 84 - if (!entity) { 85 - entity = v7(); 86 - await rep?.mutate.addBlock({ 87 - parent: props.parent, 88 - factID: v7(), 89 - permission_set: entity_set.set, 90 - type: "text", 91 - position: generateKeyBetween( 92 - props.position, 93 - props.nextPosition, 94 - ), 95 - newEntityID: entity, 96 - }); 97 - } 98 - await rep.mutate.assertFact({ 99 - entity, 100 - attribute: "block/type", 101 - data: { type: "block-type-union", value: "image" }, 102 - }); 103 - await addImage(file, rep, { 104 - entityID: entity, 105 - attribute: "block/image", 106 - }); 126 + if (!file) return; 127 + await handleImageUpload(file); 107 128 }} 108 129 /> 109 130 </label>
+3
components/Blocks/MathBlock.tsx
··· 35 35 <BaseTextareaBlock 36 36 id={elementId.block(props.entityID).input} 37 37 block={props} 38 + spellCheck={false} 39 + autoCapitalize="none" 40 + autoCorrect="off" 38 41 className="bg-border-light rounded-md p-2 w-full min-h-[48px] whitespace-nowrap overflow-auto! border-border-light outline-border-light selected-outline" 39 42 placeholder="write some Tex here..." 40 43 value={content?.data.value}
+12 -1
components/Blocks/PollBlock.tsx
··· 8 8 import { theme } from "tailwind.config"; 9 9 import { useEntity, useReplicache } from "src/replicache"; 10 10 import { v7 } from "uuid"; 11 - import { usePollData } from "components/PageSWRDataProvider"; 11 + import { 12 + useLeafletPublicationData, 13 + usePollData, 14 + } from "components/PageSWRDataProvider"; 12 15 import { voteOnPoll } from "actions/pollActions"; 13 16 import { create } from "zustand"; 14 17 import { elementId } from "src/utils/elementId"; 15 18 import { CheckTiny } from "components/Icons/CheckTiny"; 16 19 import { CloseTiny } from "components/Icons/CloseTiny"; 20 + import { PublicationPollBlock } from "./PublicationPollBlock"; 17 21 18 22 export let usePollBlockUIState = create( 19 23 () => ··· 21 25 [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 22 26 }, 23 27 ); 28 + 24 29 export const PollBlock = (props: BlockProps) => { 30 + let { data: pub } = useLeafletPublicationData(); 31 + if (!pub) return <LeafletPollBlock {...props} />; 32 + return <PublicationPollBlock {...props} />; 33 + }; 34 + 35 + export const LeafletPollBlock = (props: BlockProps) => { 25 36 let isSelected = useUIState((s) => 26 37 s.selectedBlocks.find((b) => b.value === props.entityID), 27 38 );
+187
components/Blocks/PublicationPollBlock.tsx
··· 1 + import { useUIState } from "src/useUIState"; 2 + import { BlockProps } from "./Block"; 3 + import { useMemo } from "react"; 4 + import { focusElement, AsyncValueInput } from "components/Input"; 5 + import { useEntitySetContext } from "components/EntitySetProvider"; 6 + import { useEntity, useReplicache } from "src/replicache"; 7 + import { v7 } from "uuid"; 8 + import { elementId } from "src/utils/elementId"; 9 + import { CloseTiny } from "components/Icons/CloseTiny"; 10 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 11 + import { 12 + PubLeafletBlocksPoll, 13 + PubLeafletDocument, 14 + PubLeafletPagesLinearDocument, 15 + } from "lexicons/api"; 16 + import { ids } from "lexicons/api/lexicons"; 17 + 18 + /** 19 + * PublicationPollBlock is used for editing polls in publication documents. 20 + * It allows adding/editing options when the poll hasn't been published yet, 21 + * but disables adding new options once the poll record exists (indicated by pollUri). 22 + */ 23 + export const PublicationPollBlock = (props: BlockProps) => { 24 + let { data: publicationData } = useLeafletPublicationData(); 25 + let isSelected = useUIState((s) => 26 + s.selectedBlocks.find((b) => b.value === props.entityID), 27 + ); 28 + // Check if this poll has been published in a publication document 29 + const isPublished = useMemo(() => { 30 + if (!publicationData?.documents?.data) return false; 31 + 32 + const docRecord = publicationData.documents 33 + .data as PubLeafletDocument.Record; 34 + console.log(docRecord); 35 + 36 + // Search through all pages and blocks to find if this poll entity has been published 37 + for (const page of docRecord.pages || []) { 38 + if (page.$type === "pub.leaflet.pages.linearDocument") { 39 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 40 + for (const blockWrapper of linearPage.blocks || []) { 41 + if (blockWrapper.block?.$type === ids.PubLeafletBlocksPoll) { 42 + const pollBlock = blockWrapper.block as PubLeafletBlocksPoll.Main; 43 + console.log(pollBlock); 44 + // Check if this poll's rkey matches our entity ID 45 + const rkey = pollBlock.pollRef.uri.split("/").pop(); 46 + if (rkey === props.entityID) { 47 + return true; 48 + } 49 + } 50 + } 51 + } 52 + } 53 + return false; 54 + }, [publicationData, props.entityID]); 55 + 56 + return ( 57 + <div 58 + className={`poll flex flex-col gap-2 p-3 w-full 59 + ${isSelected ? "block-border-selected " : "block-border"}`} 60 + style={{ 61 + backgroundColor: 62 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 63 + }} 64 + > 65 + <EditPollForPublication 66 + entityID={props.entityID} 67 + isPublished={isPublished} 68 + /> 69 + </div> 70 + ); 71 + }; 72 + 73 + const EditPollForPublication = (props: { 74 + entityID: string; 75 + isPublished: boolean; 76 + }) => { 77 + let pollOptions = useEntity(props.entityID, "poll/options"); 78 + let { rep } = useReplicache(); 79 + let permission_set = useEntitySetContext(); 80 + 81 + return ( 82 + <> 83 + {props.isPublished && ( 84 + <div className="text-sm italic text-tertiary"> 85 + This poll has been published. You can't edit the options. 86 + </div> 87 + )} 88 + 89 + {pollOptions.length === 0 && !props.isPublished && ( 90 + <div className="text-center italic text-tertiary text-sm"> 91 + no options yet... 92 + </div> 93 + )} 94 + 95 + {pollOptions.map((p) => ( 96 + <EditPollOptionForPublication 97 + key={p.id} 98 + entityID={p.data.value} 99 + pollEntity={props.entityID} 100 + disabled={props.isPublished} 101 + canDelete={!props.isPublished} 102 + /> 103 + ))} 104 + 105 + {!props.isPublished && permission_set.permissions.write && ( 106 + <button 107 + className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 108 + onClick={async () => { 109 + let pollOptionEntity = v7(); 110 + await rep?.mutate.addPollOption({ 111 + pollEntity: props.entityID, 112 + pollOptionEntity, 113 + pollOptionName: "", 114 + permission_set: permission_set.set, 115 + factID: v7(), 116 + }); 117 + 118 + focusElement( 119 + document.getElementById( 120 + elementId.block(props.entityID).pollInput(pollOptionEntity), 121 + ) as HTMLInputElement | null, 122 + ); 123 + }} 124 + > 125 + Add an Option 126 + </button> 127 + )} 128 + </> 129 + ); 130 + }; 131 + 132 + const EditPollOptionForPublication = (props: { 133 + entityID: string; 134 + pollEntity: string; 135 + disabled: boolean; 136 + canDelete: boolean; 137 + }) => { 138 + let { rep } = useReplicache(); 139 + let { permissions } = useEntitySetContext(); 140 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 141 + 142 + return ( 143 + <div className="flex gap-2 items-center"> 144 + <AsyncValueInput 145 + id={elementId.block(props.pollEntity).pollInput(props.entityID)} 146 + type="text" 147 + className="pollOptionInput w-full input-with-border" 148 + placeholder="Option here..." 149 + disabled={props.disabled || !permissions.write} 150 + value={optionName || ""} 151 + onChange={async (e) => { 152 + await rep?.mutate.assertFact([ 153 + { 154 + entity: props.entityID, 155 + attribute: "poll-option/name", 156 + data: { type: "string", value: e.currentTarget.value }, 157 + }, 158 + ]); 159 + }} 160 + onKeyDown={(e) => { 161 + if ( 162 + props.canDelete && 163 + e.key === "Backspace" && 164 + !e.currentTarget.value 165 + ) { 166 + e.preventDefault(); 167 + rep?.mutate.removePollOption({ optionEntity: props.entityID }); 168 + } 169 + }} 170 + /> 171 + 172 + {permissions.write && props.canDelete && ( 173 + <button 174 + tabIndex={-1} 175 + className="text-accent-contrast" 176 + onMouseDown={async () => { 177 + await rep?.mutate.removePollOption({ 178 + optionEntity: props.entityID, 179 + }); 180 + }} 181 + > 182 + <CloseTiny /> 183 + </button> 184 + )} 185 + </div> 186 + ); 187 + };
-59
components/Blocks/QuoteEmbedBlock.tsx
··· 1 - import { GoToArrow } from "components/Icons/GoToArrow"; 2 - import { ExternalLinkBlock } from "./ExternalLinkBlock"; 3 - import { Separator } from "components/Layout"; 4 - 5 - export const QuoteEmbedBlockLine = () => { 6 - return ( 7 - <div className="quoteEmbedBlock flex sm:mx-4 mx-3 my-3 sm:my-4 text-secondary text-sm italic"> 8 - <div className="w-2 h-full bg-border" /> 9 - <div className="flex flex-col pl-4"> 10 - <div className="quoteEmbedContent "> 11 - Hello, this is a long quote that I am writing to you! I am so excited 12 - that you decided to quote my stuff. I would love to take a moments and 13 - just say whatever the heck i feel like. Unforunately for you, it is a 14 - rather boring todo list. I need to add an author and pub name, i need 15 - to add a back link, and i need to link about text formatting, if we 16 - want to handle it. 17 - </div> 18 - <div className="quoteEmbedFooter flex gap-2 pt-2 "> 19 - <div className="flex flex-col leading-tight grow"> 20 - <div className="font-bold ">This was made to be quoted</div> 21 - <div className="text-tertiary text-xs">celine</div> 22 - </div> 23 - </div> 24 - </div> 25 - </div> 26 - ); 27 - }; 28 - 29 - export const QuoteEmbedBlock = () => { 30 - return ( 31 - <div className="quoteEmbedBlock transparent-container sm:mx-4 mx-3 my-3 sm:my-4 text-secondary text-sm"> 32 - <div className="quoteEmbedContent p-3"> 33 - Hello, this is a long quote that I am writing to you! I am so excited 34 - that you decided to quote my stuff. I would love to take a moments and 35 - just say whatever the heck i feel like. Unforunately for you, it is a 36 - rather boring todo list. I need to add an author and pub name, i need to 37 - add a back link, and i need to link about text formatting, if we want to 38 - handle it. 39 - </div> 40 - <hr className="border-border-light" /> 41 - <a 42 - className="quoteEmbedFooter flex max-w-full gap-2 px-3 py-2 hover:no-underline! text-secondary" 43 - href="#" 44 - > 45 - <div className="flex flex-col w-[calc(100%-28px)] grow"> 46 - <div className="font-bold w-full truncate"> 47 - This was made to be quoted and if it's very long, to truncate 48 - </div> 49 - <div className="flex gap-[6px] text-tertiary text-xs items-center"> 50 - <div className="underline">lab.leaflet.pub</div> 51 - <Separator classname="h-2" /> 52 - <div>celine</div> 53 - </div> 54 - </div> 55 - <div className=" shrink-0 pt-px bg-test w-5 h-5 rounded-full"></div> 56 - </a> 57 - </div> 58 - ); 59 - };
+3 -3
components/Blocks/RSVPBlock/ContactDetailsForm.tsx
··· 12 12 import { Separator } from "components/Layout"; 13 13 import { createPhoneAuthToken } from "actions/phone_auth/request_phone_auth_token"; 14 14 import { Input, InputWithLabel } from "components/Input"; 15 - import { IPLocationContext } from "components/Providers/IPLocationProvider"; 15 + import { RequestHeadersContext } from "components/Providers/RequestHeadersProvider"; 16 16 import { Popover } from "components/Popover"; 17 17 import { theme } from "tailwind.config"; 18 18 import { InfoSmall } from "components/Icons/InfoSmall"; ··· 41 41 data.authToken.phone_number === rsvp.phone_number, 42 42 )?.plus_ones || 0, 43 43 ); 44 - let ipLocation = useContext(IPLocationContext) || "US"; 44 + let requestHeaders = useContext(RequestHeadersContext); 45 45 const [formState, setFormState] = useState({ 46 46 country_code: 47 - countryCodes.find((c) => c[1].toUpperCase() === ipLocation)?.[2] || "1", 47 + countryCodes.find((c) => c[1].toUpperCase() === (requestHeaders.country || "US"))?.[2] || "1", 48 48 phone_number: "", 49 49 confirmationCode: "", 50 50 });
+11 -2
components/Blocks/TextBlock/index.tsx
··· 67 67 className={props.className} 68 68 first={first} 69 69 pageType={props.pageType} 70 + previousBlock={props.previousBlock} 70 71 /> 71 72 )} 72 73 {permission && !props.preview && !isLocked?.data.value && ( ··· 124 125 first?: boolean; 125 126 pageType?: "canvas" | "doc"; 126 127 type: BlockProps["type"]; 128 + previousBlock?: BlockProps["previousBlock"]; 127 129 }) { 128 130 let initialFact = useEntity(props.entityID, "block/text"); 129 131 let headingLevel = useEntity(props.entityID, "block/heading-level"); ··· 165 167 style={{ wordBreak: "break-word" }} // better than tailwind break-all! 166 168 className={` 167 169 ${alignmentClass} 168 - ${props.type === "blockquote" ? " blockquote " : ""} 170 + ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""} 169 171 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 170 172 w-full whitespace-pre-wrap outline-hidden ${props.className} `} 171 173 > ··· 338 340 <div 339 341 className={`flex items-center justify-between w-full 340 342 ${selected && props.pageType === "canvas" && "bg-bg-page rounded-md"} 341 - ${props.type === "blockquote" ? " blockquote " : ""} 343 + ${ 344 + props.type === "blockquote" 345 + ? props.previousBlock?.type === "blockquote" && !props.listData 346 + ? "blockquote pt-3" 347 + : "blockquote" 348 + : "" 349 + } 350 + 342 351 `} 343 352 > 344 353 <pre
+11 -1
components/Blocks/index.tsx
··· 16 16 import { Block } from "./Block"; 17 17 import { useEffect } from "react"; 18 18 import { addShortcut } from "src/shortcuts"; 19 - import { QuoteEmbedBlock } from "./QuoteEmbedBlock"; 19 + import { useHandleDrop } from "./useHandleDrop"; 20 20 21 21 export function Blocks(props: { entityID: string }) { 22 22 let rep = useReplicache(); ··· 231 231 }) => { 232 232 let { rep } = useReplicache(); 233 233 let entity_set = useEntitySetContext(); 234 + let handleDrop = useHandleDrop({ 235 + parent: props.entityID, 236 + position: props.lastRootBlock?.position || null, 237 + nextPosition: null, 238 + }); 234 239 235 240 if (!entity_set.permissions.write) return; 236 241 return ( ··· 267 272 }, 10); 268 273 } 269 274 }} 275 + onDragOver={(e) => { 276 + e.preventDefault(); 277 + e.stopPropagation(); 278 + }} 279 + onDrop={handleDrop} 270 280 /> 271 281 ); 272 282 };
+233
components/Blocks/useHandleCanvasDrop.ts
··· 1 + import { useCallback } from "react"; 2 + import { useReplicache, useEntity } from "src/replicache"; 3 + import { useEntitySetContext } from "components/EntitySetProvider"; 4 + import { v7 } from "uuid"; 5 + import { supabaseBrowserClient } from "supabase/browserClient"; 6 + import { localImages } from "src/utils/addImage"; 7 + import { rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; 8 + 9 + // Helper function to load image dimensions and thumbhash 10 + const processImage = async ( 11 + file: File, 12 + ): Promise<{ 13 + width: number; 14 + height: number; 15 + thumbhash: string; 16 + }> => { 17 + // Load image to get dimensions 18 + const img = new Image(); 19 + const url = URL.createObjectURL(file); 20 + 21 + const dimensions = await new Promise<{ width: number; height: number }>( 22 + (resolve, reject) => { 23 + img.onload = () => { 24 + resolve({ width: img.width, height: img.height }); 25 + }; 26 + img.onerror = reject; 27 + img.src = url; 28 + }, 29 + ); 30 + 31 + // Generate thumbhash 32 + const arrayBuffer = await file.arrayBuffer(); 33 + const blob = new Blob([arrayBuffer], { type: file.type }); 34 + const imageBitmap = await createImageBitmap(blob); 35 + 36 + const canvas = document.createElement("canvas"); 37 + const context = canvas.getContext("2d") as CanvasRenderingContext2D; 38 + const maxDimension = 100; 39 + let width = imageBitmap.width; 40 + let height = imageBitmap.height; 41 + 42 + if (width > height) { 43 + if (width > maxDimension) { 44 + height *= maxDimension / width; 45 + width = maxDimension; 46 + } 47 + } else { 48 + if (height > maxDimension) { 49 + width *= maxDimension / height; 50 + height = maxDimension; 51 + } 52 + } 53 + 54 + canvas.width = width; 55 + canvas.height = height; 56 + context.drawImage(imageBitmap, 0, 0, width, height); 57 + 58 + const imageData = context.getImageData(0, 0, width, height); 59 + const thumbhash = thumbHashToDataURL( 60 + rgbaToThumbHash(imageData.width, imageData.height, imageData.data), 61 + ); 62 + 63 + URL.revokeObjectURL(url); 64 + 65 + return { 66 + width: dimensions.width, 67 + height: dimensions.height, 68 + thumbhash, 69 + }; 70 + }; 71 + 72 + export const useHandleCanvasDrop = (entityID: string) => { 73 + let { rep } = useReplicache(); 74 + let entity_set = useEntitySetContext(); 75 + let blocks = useEntity(entityID, "canvas/block"); 76 + 77 + return useCallback( 78 + async (e: React.DragEvent) => { 79 + e.preventDefault(); 80 + e.stopPropagation(); 81 + 82 + if (!rep) return; 83 + 84 + const files = e.dataTransfer.files; 85 + if (!files || files.length === 0) return; 86 + 87 + // Filter for image files only 88 + const imageFiles = Array.from(files).filter((file) => 89 + file.type.startsWith("image/"), 90 + ); 91 + 92 + if (imageFiles.length === 0) return; 93 + 94 + const parentRect = e.currentTarget.getBoundingClientRect(); 95 + const dropX = Math.max(e.clientX - parentRect.left, 0); 96 + const dropY = Math.max(e.clientY - parentRect.top, 0); 97 + 98 + const SPACING = 0; 99 + const DEFAULT_WIDTH = 360; 100 + 101 + // Process all images to get dimensions and thumbhashes 102 + const processedImages = await Promise.all( 103 + imageFiles.map((file) => processImage(file)), 104 + ); 105 + 106 + // Calculate grid dimensions based on image count 107 + const COLUMNS = Math.ceil(Math.sqrt(imageFiles.length)); 108 + 109 + // Calculate the width and height for each column and row 110 + const colWidths: number[] = []; 111 + const rowHeights: number[] = []; 112 + 113 + for (let i = 0; i < imageFiles.length; i++) { 114 + const col = i % COLUMNS; 115 + const row = Math.floor(i / COLUMNS); 116 + const dims = processedImages[i]; 117 + 118 + // Scale image to fit within DEFAULT_WIDTH while maintaining aspect ratio 119 + const scale = DEFAULT_WIDTH / dims.width; 120 + const scaledWidth = DEFAULT_WIDTH; 121 + const scaledHeight = dims.height * scale; 122 + 123 + // Track max width for each column and max height for each row 124 + colWidths[col] = Math.max(colWidths[col] || 0, scaledWidth); 125 + rowHeights[row] = Math.max(rowHeights[row] || 0, scaledHeight); 126 + } 127 + 128 + const client = supabaseBrowserClient(); 129 + const cache = await caches.open("minilink-user-assets"); 130 + 131 + // Calculate positions and prepare data for all images 132 + const imageBlocks = imageFiles.map((file, index) => { 133 + const entity = v7(); 134 + const fileID = v7(); 135 + const row = Math.floor(index / COLUMNS); 136 + const col = index % COLUMNS; 137 + 138 + // Calculate x position by summing all previous column widths 139 + let x = dropX; 140 + for (let c = 0; c < col; c++) { 141 + x += colWidths[c] + SPACING; 142 + } 143 + 144 + // Calculate y position by summing all previous row heights 145 + let y = dropY; 146 + for (let r = 0; r < row; r++) { 147 + y += rowHeights[r] + SPACING; 148 + } 149 + 150 + const url = client.storage 151 + .from("minilink-user-assets") 152 + .getPublicUrl(fileID).data.publicUrl; 153 + 154 + return { 155 + file, 156 + entity, 157 + fileID, 158 + url, 159 + position: { x, y }, 160 + dimensions: processedImages[index], 161 + }; 162 + }); 163 + 164 + // Create all blocks with image facts 165 + for (const block of imageBlocks) { 166 + // Add to cache for immediate display 167 + await cache.put( 168 + new URL(block.url + "?local"), 169 + new Response(block.file, { 170 + headers: { 171 + "Content-Type": block.file.type, 172 + "Content-Length": block.file.size.toString(), 173 + }, 174 + }), 175 + ); 176 + localImages.set(block.url, true); 177 + 178 + // Create canvas block 179 + await rep.mutate.addCanvasBlock({ 180 + newEntityID: block.entity, 181 + parent: entityID, 182 + position: block.position, 183 + factID: v7(), 184 + type: "image", 185 + permission_set: entity_set.set, 186 + }); 187 + 188 + // Add image fact with local version for immediate display 189 + if (navigator.serviceWorker) { 190 + await rep.mutate.assertFact({ 191 + entity: block.entity, 192 + attribute: "block/image", 193 + data: { 194 + fallback: block.dimensions.thumbhash, 195 + type: "image", 196 + local: rep.clientID, 197 + src: block.url, 198 + height: block.dimensions.height, 199 + width: block.dimensions.width, 200 + }, 201 + }); 202 + } 203 + } 204 + 205 + // Upload all files to storage in parallel 206 + await Promise.all( 207 + imageBlocks.map(async (block) => { 208 + await client.storage 209 + .from("minilink-user-assets") 210 + .upload(block.fileID, block.file, { 211 + cacheControl: "public, max-age=31560000, immutable", 212 + }); 213 + 214 + // Update fact with final version 215 + await rep.mutate.assertFact({ 216 + entity: block.entity, 217 + attribute: "block/image", 218 + data: { 219 + fallback: block.dimensions.thumbhash, 220 + type: "image", 221 + src: block.url, 222 + height: block.dimensions.height, 223 + width: block.dimensions.width, 224 + }, 225 + }); 226 + }), 227 + ); 228 + 229 + return true; 230 + }, 231 + [rep, entityID, entity_set.set, blocks], 232 + ); 233 + };
+74
components/Blocks/useHandleDrop.ts
··· 1 + import { useCallback } from "react"; 2 + import { useReplicache } from "src/replicache"; 3 + import { generateKeyBetween } from "fractional-indexing"; 4 + import { addImage } from "src/utils/addImage"; 5 + import { useEntitySetContext } from "components/EntitySetProvider"; 6 + import { v7 } from "uuid"; 7 + 8 + export const useHandleDrop = (params: { 9 + parent: string; 10 + position: string | null; 11 + nextPosition: string | null; 12 + }) => { 13 + let { rep } = useReplicache(); 14 + let entity_set = useEntitySetContext(); 15 + 16 + return useCallback( 17 + async (e: React.DragEvent) => { 18 + e.preventDefault(); 19 + e.stopPropagation(); 20 + 21 + if (!rep) return; 22 + 23 + const files = e.dataTransfer.files; 24 + if (!files || files.length === 0) return; 25 + 26 + // Filter for image files only 27 + const imageFiles = Array.from(files).filter((file) => 28 + file.type.startsWith("image/"), 29 + ); 30 + 31 + if (imageFiles.length === 0) return; 32 + 33 + let currentPosition = params.position; 34 + 35 + // Calculate positions for all images first 36 + const imageBlocks = imageFiles.map((file) => { 37 + const entity = v7(); 38 + const position = generateKeyBetween( 39 + currentPosition, 40 + params.nextPosition, 41 + ); 42 + currentPosition = position; 43 + return { file, entity, position }; 44 + }); 45 + 46 + // Create all blocks in parallel 47 + await Promise.all( 48 + imageBlocks.map((block) => 49 + rep.mutate.addBlock({ 50 + parent: params.parent, 51 + factID: v7(), 52 + permission_set: entity_set.set, 53 + type: "image", 54 + position: block.position, 55 + newEntityID: block.entity, 56 + }), 57 + ), 58 + ); 59 + 60 + // Upload all images in parallel 61 + await Promise.all( 62 + imageBlocks.map((block) => 63 + addImage(block.file, rep, { 64 + entityID: block.entity, 65 + attribute: "block/image", 66 + }), 67 + ), 68 + ); 69 + 70 + return true; 71 + }, 72 + [rep, params.position, params.nextPosition, params.parent, entity_set.set], 73 + ); 74 + };
+66 -30
components/Canvas.tsx
··· 14 14 import { TooltipButton } from "./Buttons"; 15 15 import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers"; 16 16 import { AddSmall } from "./Icons/AddSmall"; 17 + import { InfoSmall } from "./Icons/InfoSmall"; 18 + import { Popover } from "./Popover"; 19 + import { Separator } from "./Layout"; 20 + import { CommentTiny } from "./Icons/CommentTiny"; 21 + import { QuoteTiny } from "./Icons/QuoteTiny"; 22 + import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 + import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 + import { 25 + PubLeafletPublication, 26 + PubLeafletPublicationRecord, 27 + } from "lexicons/api"; 28 + import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 17 29 18 - export function Canvas(props: { entityID: string; preview?: boolean }) { 30 + export function Canvas(props: { 31 + entityID: string; 32 + preview?: boolean; 33 + first?: boolean; 34 + }) { 19 35 let entity_set = useEntitySetContext(); 20 36 let ref = useRef<HTMLDivElement>(null); 21 37 useEffect(() => { ··· 44 60 return () => abort.abort(); 45 61 }); 46 62 47 - let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data 48 - .value; 49 - 50 63 return ( 51 64 <div 52 65 ref={ref} ··· 58 71 `} 59 72 > 60 73 <AddCanvasBlockButton entityID={props.entityID} entity_set={entity_set} /> 74 + 75 + <CanvasMetadata isSubpage={!props.first} /> 76 + 61 77 <CanvasContent {...props} /> 62 - <CanvasWidthHandle entityID={props.entityID} /> 63 78 </div> 64 79 ); 65 80 } ··· 69 84 let { rep } = useReplicache(); 70 85 let entity_set = useEntitySetContext(); 71 86 let height = Math.max(...blocks.map((f) => f.data.position.y), 0); 87 + let handleDrop = useHandleCanvasDrop(props.entityID); 88 + 72 89 return ( 73 90 <div 74 91 onClick={async (e) => { ··· 106 123 ); 107 124 } 108 125 }} 126 + onDragOver={ 127 + !props.preview && entity_set.permissions.write 128 + ? (e) => { 129 + e.preventDefault(); 130 + e.stopPropagation(); 131 + } 132 + : undefined 133 + } 134 + onDrop={ 135 + !props.preview && entity_set.permissions.write ? handleDrop : undefined 136 + } 109 137 style={{ 110 138 minHeight: height + 512, 111 139 contain: "size layout paint", ··· 136 164 ); 137 165 } 138 166 139 - function CanvasWidthHandle(props: { entityID: string }) { 140 - let canvasFocused = useUIState((s) => s.focusedEntity?.entityType === "page"); 141 - let { rep } = useReplicache(); 142 - let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data 143 - .value; 167 + const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 168 + let { data: pub } = useLeafletPublicationData(); 169 + if (!pub || !pub.publications) return null; 170 + 171 + let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 + let showComments = pubRecord.preferences?.showComments; 173 + 144 174 return ( 145 - <button 146 - onClick={() => { 147 - rep?.mutate.assertFact({ 148 - entity: props.entityID, 149 - attribute: "canvas/narrow-width", 150 - data: { 151 - type: "boolean", 152 - value: !narrowWidth, 153 - }, 154 - }); 155 - }} 156 - className={`resizeHandle 157 - ${narrowWidth ? "cursor-e-resize" : "cursor-w-resize"} shrink-0 z-10 158 - ${canvasFocused ? "sm:block hidden" : "hidden"} 159 - w-[8px] h-12 160 - absolute top-1/2 right-0 -translate-y-1/2 translate-x-[3px] 161 - rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 162 - /> 175 + <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 176 + {showComments && ( 177 + <div className="flex gap-1 text-tertiary items-center"> 178 + <CommentTiny className="text-border" /> — 179 + </div> 180 + )} 181 + <div className="flex gap-1 text-tertiary items-center"> 182 + <QuoteTiny className="text-border" /> — 183 + </div> 184 + 185 + {!props.isSubpage && ( 186 + <> 187 + <Separator classname="h-5" /> 188 + <Popover 189 + side="left" 190 + align="start" 191 + className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 192 + trigger={<InfoSmall />} 193 + > 194 + <PublicationMetadata /> 195 + </Popover> 196 + </> 197 + )} 198 + </div> 163 199 ); 164 - } 200 + }; 165 201 166 202 const AddCanvasBlockButton = (props: { 167 203 entityID: string; ··· 173 209 174 210 if (!permissions.write) return null; 175 211 return ( 176 - <div className="absolute right-2 sm:top-4 sm:right-4 bottom-2 sm:bottom-auto z-10 flex flex-col gap-1 justify-center"> 212 + <div className="absolute right-2 sm:bottom-4 sm:right-4 bottom-2 sm:top-auto z-10 flex flex-col gap-1 justify-center"> 177 213 <TooltipButton 178 214 side="left" 179 215 open={blocks.length === 0 ? true : undefined}
+1 -1
components/Input.tsx
··· 102 102 JSX.IntrinsicElements["textarea"], 103 103 ) => { 104 104 let { label, textarea, ...inputProps } = props; 105 - let style = `appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 ${props.className} outline-hidden resize-none`; 105 + let style = `appearance-none w-full font-normal not-italic bg-transparent text-base text-primary focus:outline-0 ${props.className} outline-hidden resize-none`; 106 106 return ( 107 107 <label className=" input-with-border flex flex-col gap-px text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]!"> 108 108 {props.label}
+8 -14
components/Pages/Page.tsx
··· 33 33 return focusedPageID === props.entityID; 34 34 }); 35 35 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 36 - let canvasNarrow = 37 - pageType === "canvas" && 38 - useEntity(props.entityID, "canvas/narrow-width")?.data.value; 39 36 let cardBorderHidden = useCardBorderHidden(props.entityID); 37 + 40 38 let drawerOpen = useDrawerOpen(props.entityID); 41 39 return ( 42 40 <CardThemeProvider entityID={props.entityID}> ··· 54 52 isFocused={isFocused} 55 53 fullPageScroll={props.fullPageScroll} 56 54 pageType={pageType} 57 - canvasNarrow={canvasNarrow} 58 55 pageOptions={ 59 56 <PageOptions 60 57 entityID={props.entityID} ··· 68 65 <PublicationMetadata /> 69 66 </> 70 67 )} 71 - <PageContent entityID={props.entityID} /> 68 + <PageContent entityID={props.entityID} first={props.first} /> 72 69 </PageWrapper> 73 70 <DesktopPageFooter pageID={props.entityID} /> 74 71 </CardThemeProvider> ··· 84 81 isFocused?: boolean; 85 82 onClickAction?: (e: React.MouseEvent) => void; 86 83 pageType: "canvas" | "doc"; 87 - canvasNarrow?: boolean | undefined; 88 84 drawerOpen: boolean | undefined; 89 85 }) => { 90 86 return ( ··· 104 100 className={` 105 101 pageScrollWrapper 106 102 grow 107 - 108 103 shrink-0 snap-center 109 104 overflow-y-scroll 110 105 ${ ··· 120 115 ${ 121 116 props.pageType === "canvas" && 122 117 !props.fullPageScroll && 123 - (props.canvasNarrow 124 - ? "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]" 125 - : "sm:max-w-[calc(100vw-128px)] lg:max-w-fit lg:w-[calc(var(--page-width-units)*2 + 24px))]") 118 + "max-w-[var(--page-width-units)] sm:max-w-[calc(100vw-128px)] lg:max-w-fit lg:w-[calc(var(--page-width-units)*2 + 24px))]" 126 119 } 127 120 128 121 `} ··· 133 126 `} 134 127 > 135 128 {props.children} 136 - <div className="h-4 sm:h-6 w-full" /> 129 + {props.pageType === "doc" && <div className="h-4 sm:h-6 w-full" />} 137 130 </div> 138 131 </div> 139 132 {props.pageOptions} 140 133 </div> 141 134 ); 142 135 }; 143 - // ${narrowWidth ? " sm:max-w-(--page-width-units)" : } 144 - const PageContent = (props: { entityID: string }) => { 136 + 137 + const PageContent = (props: { entityID: string; first?: boolean }) => { 145 138 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 146 139 if (pageType === "doc") return <DocContent entityID={props.entityID} />; 147 - return <Canvas entityID={props.entityID} />; 140 + return <Canvas entityID={props.entityID} first={props.first} />; 148 141 }; 149 142 150 143 const DocContent = (props: { entityID: string }) => { ··· 210 203 /> 211 204 ) : null} 212 205 <Blocks entityID={props.entityID} /> 206 + <div className="h-4 sm:h-6 w-full" /> 213 207 {/* we handle page bg in this sepate div so that 214 208 we can apply an opacity the background image 215 209 without affecting the opacity of the rest of the page */}
+1 -1
components/Pages/useCardBorderHidden.ts
··· 2 2 import { PubLeafletPublication } from "lexicons/api"; 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 4 5 - export function useCardBorderHidden(entityID: string) { 5 + export function useCardBorderHidden(entityID: string | null) { 6 6 let { rootEntity } = useReplicache(); 7 7 let { data: pub } = useLeafletPublicationData(); 8 8 let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
+1 -1
components/PostLink.tsx
··· 9 9 import { useSmoker } from "components/Toast"; 10 10 import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 11 11 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 12 - import type { Post } from "app/reader/getReaderFeed"; 12 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 13 13 14 14 import Link from "next/link"; 15 15 import { InteractionPreview } from "./InteractionsPreview";
-14
components/Providers/IPLocationProvider.tsx
··· 1 - "use client"; 2 - import { createContext } from "react"; 3 - 4 - export const IPLocationContext = createContext<string | null>(null); 5 - export const IPLocationProvider = (props: { 6 - country: string | null; 7 - children: React.ReactNode; 8 - }) => { 9 - return ( 10 - <IPLocationContext.Provider value={props.country}> 11 - {props.children} 12 - </IPLocationContext.Provider> 13 - ); 14 - };
+29
components/Providers/RequestHeadersProvider.tsx
··· 1 + "use client"; 2 + import { createContext } from "react"; 3 + 4 + export type RequestHeaders = { 5 + country: string | null; 6 + language: string | null; 7 + timezone: string | null; 8 + }; 9 + 10 + export const RequestHeadersContext = createContext<RequestHeaders>({ 11 + country: null, 12 + language: null, 13 + timezone: null, 14 + }); 15 + 16 + export const RequestHeadersProvider = (props: { 17 + country: string | null; 18 + language: string | null; 19 + timezone: string | null; 20 + children: React.ReactNode; 21 + }) => { 22 + return ( 23 + <RequestHeadersContext.Provider 24 + value={{ country: props.country, language: props.language, timezone: props.timezone }} 25 + > 26 + {props.children} 27 + </RequestHeadersContext.Provider> 28 + ); 29 + };
+1 -1
components/ShareOptions/index.tsx
··· 6 6 import { Menu, MenuItem } from "components/Layout"; 7 7 import { ActionButton } from "components/ActionBar/ActionButton"; 8 8 import useSWR from "swr"; 9 - import { useTemplateState } from "app/home/Actions/CreateNewButton"; 9 + import { useTemplateState } from "app/(home-pages)/home/Actions/CreateNewButton"; 10 10 import LoginForm from "app/login/LoginForm"; 11 11 import { CustomDomainMenu } from "./DomainOptions"; 12 12 import { useIdentityData } from "components/IdentityProvider";
+1 -1
components/ThemeManager/ThemeProvider.tsx
··· 38 38 39 39 // define the color defaults for everything 40 40 export const ThemeDefaults = { 41 - "theme/page-background": "#F0F7FA", 41 + "theme/page-background": "#FDFCFA", 42 42 "theme/card-background": "#FFFFFF", 43 43 "theme/primary": "#272727", 44 44 "theme/highlight-1": "#FFFFFF",
+1 -1
components/utils/AddLeafletToHomepage.tsx
··· 1 1 "use client"; 2 2 3 - import { addDocToHome } from "app/home/storage"; 3 + import { addDocToHome } from "app/(home-pages)/home/storage"; 4 4 import { useIdentityData } from "components/IdentityProvider"; 5 5 import { useEffect } from "react"; 6 6 import { useReplicache } from "src/replicache";
+12 -1
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema"; 2 + import { identities, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema"; 3 3 4 4 export const publicationsRelations = relations(publications, ({one, many}) => ({ 5 5 identity: one(identities, { ··· 180 180 fields: [email_subscriptions_to_entity.token], 181 181 references: [permission_tokens.id] 182 182 }), 183 + })); 184 + 185 + export const atp_poll_votesRelations = relations(atp_poll_votes, ({one}) => ({ 186 + atp_poll_record: one(atp_poll_records, { 187 + fields: [atp_poll_votes.poll_uri], 188 + references: [atp_poll_records.uri] 189 + }), 190 + })); 191 + 192 + export const atp_poll_recordsRelations = relations(atp_poll_records, ({many}) => ({ 193 + atp_poll_votes: many(atp_poll_votes), 183 194 })); 184 195 185 196 export const bsky_followsRelations = relations(bsky_follows, ({one}) => ({
+24 -1
drizzle/schema.ts
··· 204 204 indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 205 205 }); 206 206 207 + export const atp_poll_votes = pgTable("atp_poll_votes", { 208 + uri: text("uri").primaryKey().notNull(), 209 + record: jsonb("record").notNull(), 210 + voter_did: text("voter_did").notNull(), 211 + poll_uri: text("poll_uri").notNull().references(() => atp_poll_records.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 212 + poll_cid: text("poll_cid").notNull(), 213 + option: text("option").notNull(), 214 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 215 + }, 216 + (table) => { 217 + return { 218 + poll_uri_idx: index("atp_poll_votes_poll_uri_idx").on(table.poll_uri), 219 + voter_did_idx: index("atp_poll_votes_voter_did_idx").on(table.voter_did), 220 + } 221 + }); 222 + 223 + export const atp_poll_records = pgTable("atp_poll_records", { 224 + uri: text("uri").primaryKey().notNull(), 225 + cid: text("cid").notNull(), 226 + record: jsonb("record").notNull(), 227 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 228 + }); 229 + 207 230 export const oauth_session_store = pgTable("oauth_session_store", { 208 231 key: text("key").primaryKey().notNull(), 209 232 session: jsonb("session").notNull(), 210 233 }); 211 234 212 235 export const bsky_follows = pgTable("bsky_follows", { 213 - identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 236 + identity: text("identity").default('').notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 214 237 follows: text("follows").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 215 238 }, 216 239 (table) => {
+187
lexicons/api/index.ts
··· 32 32 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 33 33 import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 34 34 import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 35 + import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 35 36 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 36 37 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 37 38 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' 38 39 import * as PubLeafletComment from './types/pub/leaflet/comment' 39 40 import * as PubLeafletDocument from './types/pub/leaflet/document' 40 41 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 42 + import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 41 43 import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 44 + import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 45 + import * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' 42 46 import * as PubLeafletPublication from './types/pub/leaflet/publication' 43 47 import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 44 48 import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' ··· 67 71 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 68 72 export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 69 73 export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 74 + export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 70 75 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 71 76 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 72 77 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' 73 78 export * as PubLeafletComment from './types/pub/leaflet/comment' 74 79 export * as PubLeafletDocument from './types/pub/leaflet/document' 75 80 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 81 + export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 76 82 export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 83 + export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 84 + export * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' 77 85 export * as PubLeafletPublication from './types/pub/leaflet/publication' 78 86 export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 79 87 export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 80 88 export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 81 89 82 90 export const PUB_LEAFLET_PAGES = { 91 + CanvasTextAlignLeft: 'pub.leaflet.pages.canvas#textAlignLeft', 92 + CanvasTextAlignCenter: 'pub.leaflet.pages.canvas#textAlignCenter', 93 + CanvasTextAlignRight: 'pub.leaflet.pages.canvas#textAlignRight', 83 94 LinearDocumentTextAlignLeft: 'pub.leaflet.pages.linearDocument#textAlignLeft', 84 95 LinearDocumentTextAlignCenter: 85 96 'pub.leaflet.pages.linearDocument#textAlignCenter', ··· 380 391 blocks: PubLeafletBlocksNS 381 392 graph: PubLeafletGraphNS 382 393 pages: PubLeafletPagesNS 394 + poll: PubLeafletPollNS 383 395 richtext: PubLeafletRichtextNS 384 396 theme: PubLeafletThemeNS 385 397 ··· 388 400 this.blocks = new PubLeafletBlocksNS(client) 389 401 this.graph = new PubLeafletGraphNS(client) 390 402 this.pages = new PubLeafletPagesNS(client) 403 + this.poll = new PubLeafletPollNS(client) 391 404 this.richtext = new PubLeafletRichtextNS(client) 392 405 this.theme = new PubLeafletThemeNS(client) 393 406 this.comment = new PubLeafletCommentRecord(client) ··· 502 515 503 516 constructor(client: XrpcClient) { 504 517 this._client = client 518 + } 519 + } 520 + 521 + export class PubLeafletPollNS { 522 + _client: XrpcClient 523 + definition: PubLeafletPollDefinitionRecord 524 + vote: PubLeafletPollVoteRecord 525 + 526 + constructor(client: XrpcClient) { 527 + this._client = client 528 + this.definition = new PubLeafletPollDefinitionRecord(client) 529 + this.vote = new PubLeafletPollVoteRecord(client) 530 + } 531 + } 532 + 533 + export class PubLeafletPollDefinitionRecord { 534 + _client: XrpcClient 535 + 536 + constructor(client: XrpcClient) { 537 + this._client = client 538 + } 539 + 540 + async list( 541 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 542 + ): Promise<{ 543 + cursor?: string 544 + records: { uri: string; value: PubLeafletPollDefinition.Record }[] 545 + }> { 546 + const res = await this._client.call('com.atproto.repo.listRecords', { 547 + collection: 'pub.leaflet.poll.definition', 548 + ...params, 549 + }) 550 + return res.data 551 + } 552 + 553 + async get( 554 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 555 + ): Promise<{ 556 + uri: string 557 + cid: string 558 + value: PubLeafletPollDefinition.Record 559 + }> { 560 + const res = await this._client.call('com.atproto.repo.getRecord', { 561 + collection: 'pub.leaflet.poll.definition', 562 + ...params, 563 + }) 564 + return res.data 565 + } 566 + 567 + async create( 568 + params: OmitKey< 569 + ComAtprotoRepoCreateRecord.InputSchema, 570 + 'collection' | 'record' 571 + >, 572 + record: Un$Typed<PubLeafletPollDefinition.Record>, 573 + headers?: Record<string, string>, 574 + ): Promise<{ uri: string; cid: string }> { 575 + const collection = 'pub.leaflet.poll.definition' 576 + const res = await this._client.call( 577 + 'com.atproto.repo.createRecord', 578 + undefined, 579 + { collection, ...params, record: { ...record, $type: collection } }, 580 + { encoding: 'application/json', headers }, 581 + ) 582 + return res.data 583 + } 584 + 585 + async put( 586 + params: OmitKey< 587 + ComAtprotoRepoPutRecord.InputSchema, 588 + 'collection' | 'record' 589 + >, 590 + record: Un$Typed<PubLeafletPollDefinition.Record>, 591 + headers?: Record<string, string>, 592 + ): Promise<{ uri: string; cid: string }> { 593 + const collection = 'pub.leaflet.poll.definition' 594 + const res = await this._client.call( 595 + 'com.atproto.repo.putRecord', 596 + undefined, 597 + { collection, ...params, record: { ...record, $type: collection } }, 598 + { encoding: 'application/json', headers }, 599 + ) 600 + return res.data 601 + } 602 + 603 + async delete( 604 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 605 + headers?: Record<string, string>, 606 + ): Promise<void> { 607 + await this._client.call( 608 + 'com.atproto.repo.deleteRecord', 609 + undefined, 610 + { collection: 'pub.leaflet.poll.definition', ...params }, 611 + { headers }, 612 + ) 613 + } 614 + } 615 + 616 + export class PubLeafletPollVoteRecord { 617 + _client: XrpcClient 618 + 619 + constructor(client: XrpcClient) { 620 + this._client = client 621 + } 622 + 623 + async list( 624 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 625 + ): Promise<{ 626 + cursor?: string 627 + records: { uri: string; value: PubLeafletPollVote.Record }[] 628 + }> { 629 + const res = await this._client.call('com.atproto.repo.listRecords', { 630 + collection: 'pub.leaflet.poll.vote', 631 + ...params, 632 + }) 633 + return res.data 634 + } 635 + 636 + async get( 637 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 638 + ): Promise<{ uri: string; cid: string; value: PubLeafletPollVote.Record }> { 639 + const res = await this._client.call('com.atproto.repo.getRecord', { 640 + collection: 'pub.leaflet.poll.vote', 641 + ...params, 642 + }) 643 + return res.data 644 + } 645 + 646 + async create( 647 + params: OmitKey< 648 + ComAtprotoRepoCreateRecord.InputSchema, 649 + 'collection' | 'record' 650 + >, 651 + record: Un$Typed<PubLeafletPollVote.Record>, 652 + headers?: Record<string, string>, 653 + ): Promise<{ uri: string; cid: string }> { 654 + const collection = 'pub.leaflet.poll.vote' 655 + const res = await this._client.call( 656 + 'com.atproto.repo.createRecord', 657 + undefined, 658 + { collection, ...params, record: { ...record, $type: collection } }, 659 + { encoding: 'application/json', headers }, 660 + ) 661 + return res.data 662 + } 663 + 664 + async put( 665 + params: OmitKey< 666 + ComAtprotoRepoPutRecord.InputSchema, 667 + 'collection' | 'record' 668 + >, 669 + record: Un$Typed<PubLeafletPollVote.Record>, 670 + headers?: Record<string, string>, 671 + ): Promise<{ uri: string; cid: string }> { 672 + const collection = 'pub.leaflet.poll.vote' 673 + const res = await this._client.call( 674 + 'com.atproto.repo.putRecord', 675 + undefined, 676 + { collection, ...params, record: { ...record, $type: collection } }, 677 + { encoding: 'application/json', headers }, 678 + ) 679 + return res.data 680 + } 681 + 682 + async delete( 683 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 684 + headers?: Record<string, string>, 685 + ): Promise<void> { 686 + await this._client.call( 687 + 'com.atproto.repo.deleteRecord', 688 + undefined, 689 + { collection: 'pub.leaflet.poll.vote', ...params }, 690 + { headers }, 691 + ) 505 692 } 506 693 } 507 694
+195 -1
lexicons/api/lexicons.ts
··· 1200 1200 }, 1201 1201 }, 1202 1202 }, 1203 + PubLeafletBlocksPoll: { 1204 + lexicon: 1, 1205 + id: 'pub.leaflet.blocks.poll', 1206 + defs: { 1207 + main: { 1208 + type: 'object', 1209 + required: ['pollRef'], 1210 + properties: { 1211 + pollRef: { 1212 + type: 'ref', 1213 + ref: 'lex:com.atproto.repo.strongRef', 1214 + }, 1215 + }, 1216 + }, 1217 + }, 1218 + }, 1203 1219 PubLeafletBlocksText: { 1204 1220 lexicon: 1, 1205 1221 id: 'pub.leaflet.blocks.text', ··· 1405 1421 type: 'array', 1406 1422 items: { 1407 1423 type: 'union', 1408 - refs: ['lex:pub.leaflet.pages.linearDocument'], 1424 + refs: [ 1425 + 'lex:pub.leaflet.pages.linearDocument', 1426 + 'lex:pub.leaflet.pages.canvas', 1427 + ], 1409 1428 }, 1410 1429 }, 1411 1430 }, ··· 1434 1453 }, 1435 1454 }, 1436 1455 }, 1456 + PubLeafletPagesCanvas: { 1457 + lexicon: 1, 1458 + id: 'pub.leaflet.pages.canvas', 1459 + defs: { 1460 + main: { 1461 + type: 'object', 1462 + required: ['blocks'], 1463 + properties: { 1464 + id: { 1465 + type: 'string', 1466 + }, 1467 + blocks: { 1468 + type: 'array', 1469 + items: { 1470 + type: 'ref', 1471 + ref: 'lex:pub.leaflet.pages.canvas#block', 1472 + }, 1473 + }, 1474 + }, 1475 + }, 1476 + block: { 1477 + type: 'object', 1478 + required: ['block', 'x', 'y', 'width'], 1479 + properties: { 1480 + block: { 1481 + type: 'union', 1482 + refs: [ 1483 + 'lex:pub.leaflet.blocks.iframe', 1484 + 'lex:pub.leaflet.blocks.text', 1485 + 'lex:pub.leaflet.blocks.blockquote', 1486 + 'lex:pub.leaflet.blocks.header', 1487 + 'lex:pub.leaflet.blocks.image', 1488 + 'lex:pub.leaflet.blocks.unorderedList', 1489 + 'lex:pub.leaflet.blocks.website', 1490 + 'lex:pub.leaflet.blocks.math', 1491 + 'lex:pub.leaflet.blocks.code', 1492 + 'lex:pub.leaflet.blocks.horizontalRule', 1493 + 'lex:pub.leaflet.blocks.bskyPost', 1494 + 'lex:pub.leaflet.blocks.page', 1495 + 'lex:pub.leaflet.blocks.poll', 1496 + ], 1497 + }, 1498 + x: { 1499 + type: 'integer', 1500 + }, 1501 + y: { 1502 + type: 'integer', 1503 + }, 1504 + width: { 1505 + type: 'integer', 1506 + }, 1507 + height: { 1508 + type: 'integer', 1509 + }, 1510 + rotation: { 1511 + type: 'integer', 1512 + description: 'The rotation of the block in degrees', 1513 + }, 1514 + }, 1515 + }, 1516 + textAlignLeft: { 1517 + type: 'token', 1518 + }, 1519 + textAlignCenter: { 1520 + type: 'token', 1521 + }, 1522 + textAlignRight: { 1523 + type: 'token', 1524 + }, 1525 + quote: { 1526 + type: 'object', 1527 + required: ['start', 'end'], 1528 + properties: { 1529 + start: { 1530 + type: 'ref', 1531 + ref: 'lex:pub.leaflet.pages.canvas#position', 1532 + }, 1533 + end: { 1534 + type: 'ref', 1535 + ref: 'lex:pub.leaflet.pages.canvas#position', 1536 + }, 1537 + }, 1538 + }, 1539 + position: { 1540 + type: 'object', 1541 + required: ['block', 'offset'], 1542 + properties: { 1543 + block: { 1544 + type: 'array', 1545 + items: { 1546 + type: 'integer', 1547 + }, 1548 + }, 1549 + offset: { 1550 + type: 'integer', 1551 + }, 1552 + }, 1553 + }, 1554 + }, 1555 + }, 1437 1556 PubLeafletPagesLinearDocument: { 1438 1557 lexicon: 1, 1439 1558 id: 'pub.leaflet.pages.linearDocument', ··· 1473 1592 'lex:pub.leaflet.blocks.horizontalRule', 1474 1593 'lex:pub.leaflet.blocks.bskyPost', 1475 1594 'lex:pub.leaflet.blocks.page', 1595 + 'lex:pub.leaflet.blocks.poll', 1476 1596 ], 1477 1597 }, 1478 1598 alignment: { ··· 1521 1641 }, 1522 1642 offset: { 1523 1643 type: 'integer', 1644 + }, 1645 + }, 1646 + }, 1647 + }, 1648 + }, 1649 + PubLeafletPollDefinition: { 1650 + lexicon: 1, 1651 + id: 'pub.leaflet.poll.definition', 1652 + defs: { 1653 + main: { 1654 + type: 'record', 1655 + key: 'tid', 1656 + description: 'Record declaring a poll', 1657 + record: { 1658 + type: 'object', 1659 + required: ['name', 'options'], 1660 + properties: { 1661 + name: { 1662 + type: 'string', 1663 + maxLength: 500, 1664 + maxGraphemes: 100, 1665 + }, 1666 + options: { 1667 + type: 'array', 1668 + items: { 1669 + type: 'ref', 1670 + ref: 'lex:pub.leaflet.poll.definition#option', 1671 + }, 1672 + }, 1673 + endDate: { 1674 + type: 'string', 1675 + format: 'datetime', 1676 + }, 1677 + }, 1678 + }, 1679 + }, 1680 + option: { 1681 + type: 'object', 1682 + properties: { 1683 + text: { 1684 + type: 'string', 1685 + maxLength: 500, 1686 + maxGraphemes: 50, 1687 + }, 1688 + }, 1689 + }, 1690 + }, 1691 + }, 1692 + PubLeafletPollVote: { 1693 + lexicon: 1, 1694 + id: 'pub.leaflet.poll.vote', 1695 + defs: { 1696 + main: { 1697 + type: 'record', 1698 + key: 'tid', 1699 + description: 'Record declaring a vote on a poll', 1700 + record: { 1701 + type: 'object', 1702 + required: ['poll', 'option'], 1703 + properties: { 1704 + poll: { 1705 + type: 'ref', 1706 + ref: 'lex:com.atproto.repo.strongRef', 1707 + }, 1708 + option: { 1709 + type: 'array', 1710 + items: { 1711 + type: 'string', 1712 + }, 1713 + }, 1524 1714 }, 1525 1715 }, 1526 1716 }, ··· 1867 2057 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 1868 2058 PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 1869 2059 PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 2060 + PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', 1870 2061 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 1871 2062 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 1872 2063 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', 1873 2064 PubLeafletComment: 'pub.leaflet.comment', 1874 2065 PubLeafletDocument: 'pub.leaflet.document', 1875 2066 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 2067 + PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', 1876 2068 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 2069 + PubLeafletPollDefinition: 'pub.leaflet.poll.definition', 2070 + PubLeafletPollVote: 'pub.leaflet.poll.vote', 1877 2071 PubLeafletPublication: 'pub.leaflet.publication', 1878 2072 PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 1879 2073 PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage',
+31
lexicons/api/types/pub/leaflet/blocks/poll.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'pub.leaflet.blocks.poll' 17 + 18 + export interface Main { 19 + $type?: 'pub.leaflet.blocks.poll' 20 + pollRef: ComAtprotoRepoStrongRef.Main 21 + } 22 + 23 + const hashMain = 'main' 24 + 25 + export function isMain<V>(v: V) { 26 + return is$typed(v, id, hashMain) 27 + } 28 + 29 + export function validateMain<V>(v: V) { 30 + return validate<Main & V>(v, id, hashMain) 31 + }
+6 -1
lexicons/api/types/pub/leaflet/document.ts
··· 7 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 8 import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 9 import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 10 + import type * as PubLeafletPagesCanvas from './pages/canvas' 10 11 11 12 const is$typed = _is$typed, 12 13 validate = _validate ··· 20 21 publishedAt?: string 21 22 publication: string 22 23 author: string 23 - pages: ($Typed<PubLeafletPagesLinearDocument.Main> | { $type: string })[] 24 + pages: ( 25 + | $Typed<PubLeafletPagesLinearDocument.Main> 26 + | $Typed<PubLeafletPagesCanvas.Main> 27 + | { $type: string } 28 + )[] 24 29 [k: string]: unknown 25 30 } 26 31
+115
lexicons/api/types/pub/leaflet/pages/canvas.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as PubLeafletBlocksIframe from '../blocks/iframe' 13 + import type * as PubLeafletBlocksText from '../blocks/text' 14 + import type * as PubLeafletBlocksBlockquote from '../blocks/blockquote' 15 + import type * as PubLeafletBlocksHeader from '../blocks/header' 16 + import type * as PubLeafletBlocksImage from '../blocks/image' 17 + import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 18 + import type * as PubLeafletBlocksWebsite from '../blocks/website' 19 + import type * as PubLeafletBlocksMath from '../blocks/math' 20 + import type * as PubLeafletBlocksCode from '../blocks/code' 21 + import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' 22 + import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' 23 + import type * as PubLeafletBlocksPage from '../blocks/page' 24 + import type * as PubLeafletBlocksPoll from '../blocks/poll' 25 + 26 + const is$typed = _is$typed, 27 + validate = _validate 28 + const id = 'pub.leaflet.pages.canvas' 29 + 30 + export interface Main { 31 + $type?: 'pub.leaflet.pages.canvas' 32 + id?: string 33 + blocks: Block[] 34 + } 35 + 36 + const hashMain = 'main' 37 + 38 + export function isMain<V>(v: V) { 39 + return is$typed(v, id, hashMain) 40 + } 41 + 42 + export function validateMain<V>(v: V) { 43 + return validate<Main & V>(v, id, hashMain) 44 + } 45 + 46 + export interface Block { 47 + $type?: 'pub.leaflet.pages.canvas#block' 48 + block: 49 + | $Typed<PubLeafletBlocksIframe.Main> 50 + | $Typed<PubLeafletBlocksText.Main> 51 + | $Typed<PubLeafletBlocksBlockquote.Main> 52 + | $Typed<PubLeafletBlocksHeader.Main> 53 + | $Typed<PubLeafletBlocksImage.Main> 54 + | $Typed<PubLeafletBlocksUnorderedList.Main> 55 + | $Typed<PubLeafletBlocksWebsite.Main> 56 + | $Typed<PubLeafletBlocksMath.Main> 57 + | $Typed<PubLeafletBlocksCode.Main> 58 + | $Typed<PubLeafletBlocksHorizontalRule.Main> 59 + | $Typed<PubLeafletBlocksBskyPost.Main> 60 + | $Typed<PubLeafletBlocksPage.Main> 61 + | $Typed<PubLeafletBlocksPoll.Main> 62 + | { $type: string } 63 + x: number 64 + y: number 65 + width: number 66 + height?: number 67 + /** The rotation of the block in degrees */ 68 + rotation?: number 69 + } 70 + 71 + const hashBlock = 'block' 72 + 73 + export function isBlock<V>(v: V) { 74 + return is$typed(v, id, hashBlock) 75 + } 76 + 77 + export function validateBlock<V>(v: V) { 78 + return validate<Block & V>(v, id, hashBlock) 79 + } 80 + 81 + export const TEXTALIGNLEFT = `${id}#textAlignLeft` 82 + export const TEXTALIGNCENTER = `${id}#textAlignCenter` 83 + export const TEXTALIGNRIGHT = `${id}#textAlignRight` 84 + 85 + export interface Quote { 86 + $type?: 'pub.leaflet.pages.canvas#quote' 87 + start: Position 88 + end: Position 89 + } 90 + 91 + const hashQuote = 'quote' 92 + 93 + export function isQuote<V>(v: V) { 94 + return is$typed(v, id, hashQuote) 95 + } 96 + 97 + export function validateQuote<V>(v: V) { 98 + return validate<Quote & V>(v, id, hashQuote) 99 + } 100 + 101 + export interface Position { 102 + $type?: 'pub.leaflet.pages.canvas#position' 103 + block: number[] 104 + offset: number 105 + } 106 + 107 + const hashPosition = 'position' 108 + 109 + export function isPosition<V>(v: V) { 110 + return is$typed(v, id, hashPosition) 111 + } 112 + 113 + export function validatePosition<V>(v: V) { 114 + return validate<Position & V>(v, id, hashPosition) 115 + }
+2
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 21 21 import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' 22 22 import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' 23 23 import type * as PubLeafletBlocksPage from '../blocks/page' 24 + import type * as PubLeafletBlocksPoll from '../blocks/poll' 24 25 25 26 const is$typed = _is$typed, 26 27 validate = _validate ··· 57 58 | $Typed<PubLeafletBlocksHorizontalRule.Main> 58 59 | $Typed<PubLeafletBlocksBskyPost.Main> 59 60 | $Typed<PubLeafletBlocksPage.Main> 61 + | $Typed<PubLeafletBlocksPoll.Main> 60 62 | { $type: string } 61 63 alignment?: 62 64 | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft'
+48
lexicons/api/types/pub/leaflet/poll/definition.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'pub.leaflet.poll.definition' 16 + 17 + export interface Record { 18 + $type: 'pub.leaflet.poll.definition' 19 + name: string 20 + options: Option[] 21 + endDate?: string 22 + [k: string]: unknown 23 + } 24 + 25 + const hashRecord = 'main' 26 + 27 + export function isRecord<V>(v: V) { 28 + return is$typed(v, id, hashRecord) 29 + } 30 + 31 + export function validateRecord<V>(v: V) { 32 + return validate<Record & V>(v, id, hashRecord, true) 33 + } 34 + 35 + export interface Option { 36 + $type?: 'pub.leaflet.poll.definition#option' 37 + text?: string 38 + } 39 + 40 + const hashOption = 'option' 41 + 42 + export function isOption<V>(v: V) { 43 + return is$typed(v, id, hashOption) 44 + } 45 + 46 + export function validateOption<V>(v: V) { 47 + return validate<Option & V>(v, id, hashOption) 48 + }
+33
lexicons/api/types/pub/leaflet/poll/vote.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'pub.leaflet.poll.vote' 17 + 18 + export interface Record { 19 + $type: 'pub.leaflet.poll.vote' 20 + poll: ComAtprotoRepoStrongRef.Main 21 + option: string[] 22 + [k: string]: unknown 23 + } 24 + 25 + const hashRecord = 'main' 26 + 27 + export function isRecord<V>(v: V) { 28 + return is$typed(v, id, hashRecord) 29 + } 30 + 31 + export function validateRecord<V>(v: V) { 32 + return validate<Record & V>(v, id, hashRecord, true) 33 + }
+3
lexicons/build.ts
··· 2 2 import { BlockLexicons } from "./src/blocks"; 3 3 import { PubLeafletDocument } from "./src/document"; 4 4 import * as PublicationLexicons from "./src/publication"; 5 + import * as PollLexicons from "./src/polls"; 5 6 import { ThemeLexicons } from "./src/theme"; 6 7 7 8 import * as fs from "fs"; ··· 21 22 PubLeafletComment, 22 23 PubLeafletRichTextFacet, 23 24 PageLexicons.PubLeafletPagesLinearDocument, 25 + PageLexicons.PubLeafletPagesCanvasDocument, 24 26 ...ThemeLexicons, 25 27 ...BlockLexicons, 26 28 ...Object.values(PublicationLexicons), 29 + ...Object.values(PollLexicons), 27 30 ]; 28 31 29 32 // Write each lexicon to a file
+18
lexicons/pub/leaflet/blocks/poll.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.poll", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "pollRef" 9 + ], 10 + "properties": { 11 + "pollRef": { 12 + "type": "ref", 13 + "ref": "com.atproto.repo.strongRef" 14 + } 15 + } 16 + } 17 + } 18 + }
+2 -1
lexicons/pub/leaflet/document.json
··· 48 48 "items": { 49 49 "type": "union", 50 50 "refs": [ 51 - "pub.leaflet.pages.linearDocument" 51 + "pub.leaflet.pages.linearDocument", 52 + "pub.leaflet.pages.canvas" 52 53 ] 53 54 } 54 55 }
+113
lexicons/pub/leaflet/pages/canvas.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.pages.canvas", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "blocks" 9 + ], 10 + "properties": { 11 + "id": { 12 + "type": "string" 13 + }, 14 + "blocks": { 15 + "type": "array", 16 + "items": { 17 + "type": "ref", 18 + "ref": "#block" 19 + } 20 + } 21 + } 22 + }, 23 + "block": { 24 + "type": "object", 25 + "required": [ 26 + "block", 27 + "x", 28 + "y", 29 + "width" 30 + ], 31 + "properties": { 32 + "block": { 33 + "type": "union", 34 + "refs": [ 35 + "pub.leaflet.blocks.iframe", 36 + "pub.leaflet.blocks.text", 37 + "pub.leaflet.blocks.blockquote", 38 + "pub.leaflet.blocks.header", 39 + "pub.leaflet.blocks.image", 40 + "pub.leaflet.blocks.unorderedList", 41 + "pub.leaflet.blocks.website", 42 + "pub.leaflet.blocks.math", 43 + "pub.leaflet.blocks.code", 44 + "pub.leaflet.blocks.horizontalRule", 45 + "pub.leaflet.blocks.bskyPost", 46 + "pub.leaflet.blocks.page", 47 + "pub.leaflet.blocks.poll" 48 + ] 49 + }, 50 + "x": { 51 + "type": "integer" 52 + }, 53 + "y": { 54 + "type": "integer" 55 + }, 56 + "width": { 57 + "type": "integer" 58 + }, 59 + "height": { 60 + "type": "integer" 61 + }, 62 + "rotation": { 63 + "type": "integer", 64 + "description": "The rotation of the block in degrees" 65 + } 66 + } 67 + }, 68 + "textAlignLeft": { 69 + "type": "token" 70 + }, 71 + "textAlignCenter": { 72 + "type": "token" 73 + }, 74 + "textAlignRight": { 75 + "type": "token" 76 + }, 77 + "quote": { 78 + "type": "object", 79 + "required": [ 80 + "start", 81 + "end" 82 + ], 83 + "properties": { 84 + "start": { 85 + "type": "ref", 86 + "ref": "#position" 87 + }, 88 + "end": { 89 + "type": "ref", 90 + "ref": "#position" 91 + } 92 + } 93 + }, 94 + "position": { 95 + "type": "object", 96 + "required": [ 97 + "block", 98 + "offset" 99 + ], 100 + "properties": { 101 + "block": { 102 + "type": "array", 103 + "items": { 104 + "type": "integer" 105 + } 106 + }, 107 + "offset": { 108 + "type": "integer" 109 + } 110 + } 111 + } 112 + } 113 + }
+2 -1
lexicons/pub/leaflet/pages/linearDocument.json
··· 40 40 "pub.leaflet.blocks.code", 41 41 "pub.leaflet.blocks.horizontalRule", 42 42 "pub.leaflet.blocks.bskyPost", 43 - "pub.leaflet.blocks.page" 43 + "pub.leaflet.blocks.page", 44 + "pub.leaflet.blocks.poll" 44 45 ] 45 46 }, 46 47 "alignment": {
+46
lexicons/pub/leaflet/poll/definition.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.poll.definition", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record declaring a poll", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "name", 13 + "options" 14 + ], 15 + "properties": { 16 + "name": { 17 + "type": "string", 18 + "maxLength": 500, 19 + "maxGraphemes": 100 20 + }, 21 + "options": { 22 + "type": "array", 23 + "items": { 24 + "type": "ref", 25 + "ref": "#option" 26 + } 27 + }, 28 + "endDate": { 29 + "type": "string", 30 + "format": "datetime" 31 + } 32 + } 33 + } 34 + }, 35 + "option": { 36 + "type": "object", 37 + "properties": { 38 + "text": { 39 + "type": "string", 40 + "maxLength": 500, 41 + "maxGraphemes": 50 42 + } 43 + } 44 + } 45 + } 46 + }
+30
lexicons/pub/leaflet/poll/vote.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.poll.vote", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record declaring a vote on a poll", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "poll", 13 + "option" 14 + ], 15 + "properties": { 16 + "poll": { 17 + "type": "ref", 18 + "ref": "com.atproto.repo.strongRef" 19 + }, 20 + "option": { 21 + "type": "array", 22 + "items": { 23 + "type": "string" 24 + } 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+16
lexicons/src/blocks.ts
··· 264 264 }, 265 265 }, 266 266 }; 267 + 268 + export const PubLeafletBlocksPoll: LexiconDoc = { 269 + lexicon: 1, 270 + id: "pub.leaflet.blocks.poll", 271 + defs: { 272 + main: { 273 + type: "object", 274 + required: ["pollRef"], 275 + properties: { 276 + pollRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, 277 + }, 278 + }, 279 + }, 280 + }; 281 + 267 282 export const BlockLexicons = [ 268 283 PubLeafletBlocksIFrame, 269 284 PubLeafletBlocksText, ··· 277 292 PubLeafletBlocksHorizontalRule, 278 293 PubLeafletBlocksBskyPost, 279 294 PubLeafletBlocksPage, 295 + PubLeafletBlocksPoll, 280 296 ]; 281 297 export const BlockUnion: LexRefUnion = { 282 298 type: "union",
+5 -1
lexicons/src/document.ts
··· 1 1 import { LexiconDoc } from "@atproto/lexicon"; 2 2 import { PubLeafletPagesLinearDocument } from "./pages/LinearDocument"; 3 + import { PubLeafletPagesCanvasDocument } from "./pages"; 3 4 4 5 export const PubLeafletDocument: LexiconDoc = { 5 6 lexicon: 1, ··· 25 26 type: "array", 26 27 items: { 27 28 type: "union", 28 - refs: [PubLeafletPagesLinearDocument.id], 29 + refs: [ 30 + PubLeafletPagesLinearDocument.id, 31 + PubLeafletPagesCanvasDocument.id, 32 + ], 29 33 }, 30 34 }, 31 35 },
+51
lexicons/src/pages/Canvas.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + import { BlockUnion } from "../blocks"; 3 + 4 + export const PubLeafletPagesCanvasDocument: LexiconDoc = { 5 + lexicon: 1, 6 + id: "pub.leaflet.pages.canvas", 7 + defs: { 8 + main: { 9 + type: "object", 10 + required: ["blocks"], 11 + properties: { 12 + id: { type: "string" }, 13 + blocks: { type: "array", items: { type: "ref", ref: "#block" } }, 14 + }, 15 + }, 16 + block: { 17 + type: "object", 18 + required: ["block", "x", "y", "width"], 19 + properties: { 20 + block: BlockUnion, 21 + x: { type: "integer" }, 22 + y: { type: "integer" }, 23 + width: { type: "integer" }, 24 + height: { type: "integer" }, 25 + rotation: { 26 + type: "integer", 27 + description: "The rotation of the block in degrees", 28 + }, 29 + }, 30 + }, 31 + textAlignLeft: { type: "token" }, 32 + textAlignCenter: { type: "token" }, 33 + textAlignRight: { type: "token" }, 34 + quote: { 35 + type: "object", 36 + required: ["start", "end"], 37 + properties: { 38 + start: { type: "ref", ref: "#position" }, 39 + end: { type: "ref", ref: "#position" }, 40 + }, 41 + }, 42 + position: { 43 + type: "object", 44 + required: ["block", "offset"], 45 + properties: { 46 + block: { type: "array", items: { type: "integer" } }, 47 + offset: { type: "integer" }, 48 + }, 49 + }, 50 + }, 51 + };
+1
lexicons/src/pages/index.ts
··· 1 1 export { PubLeafletPagesLinearDocument } from "./LinearDocument"; 2 + export { PubLeafletPagesCanvasDocument } from "./Canvas";
+48
lexicons/src/polls/index.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + 3 + export const PubLeafletPollDefinition: LexiconDoc = { 4 + lexicon: 1, 5 + id: "pub.leaflet.poll.definition", 6 + defs: { 7 + main: { 8 + type: "record", 9 + key: "tid", 10 + description: "Record declaring a poll", 11 + record: { 12 + type: "object", 13 + required: ["name", "options"], 14 + properties: { 15 + name: { type: "string", maxLength: 500, maxGraphemes: 100 }, 16 + options: { type: "array", items: { type: "ref", ref: "#option" } }, 17 + endDate: { type: "string", format: "datetime" }, 18 + }, 19 + }, 20 + }, 21 + option: { 22 + type: "object", 23 + properties: { 24 + text: { type: "string", maxLength: 500, maxGraphemes: 50 }, 25 + }, 26 + }, 27 + }, 28 + }; 29 + 30 + export const PubLeafletPollVote: LexiconDoc = { 31 + lexicon: 1, 32 + id: "pub.leaflet.poll.vote", 33 + defs: { 34 + main: { 35 + type: "record", 36 + key: "tid", 37 + description: "Record declaring a vote on a poll", 38 + record: { 39 + type: "object", 40 + required: ["poll", "option"], 41 + properties: { 42 + poll: { type: "ref", ref: "com.atproto.repo.strongRef" }, 43 + option: { type: "array", items: { type: "string" } }, 44 + }, 45 + }, 46 + }, 47 + }, 48 + };
+18
package-lock.json
··· 48 48 "ioredis": "^5.6.1", 49 49 "katex": "^0.16.22", 50 50 "linkifyjs": "^4.2.0", 51 + "luxon": "^3.7.2", 51 52 "multiformats": "^13.3.2", 52 53 "next": "^15.5.3", 53 54 "pg": "^8.16.3", ··· 88 89 "@cloudflare/workers-types": "^4.20240512.0", 89 90 "@tailwindcss/postcss": "^4.1.13", 90 91 "@types/katex": "^0.16.7", 92 + "@types/luxon": "^3.7.1", 91 93 "@types/node": "^22.15.17", 92 94 "@types/react": "19.1.3", 93 95 "@types/react-dom": "19.1.3", ··· 6614 6616 "license": "MIT", 6615 6617 "peer": true 6616 6618 }, 6619 + "node_modules/@types/luxon": { 6620 + "version": "3.7.1", 6621 + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", 6622 + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", 6623 + "dev": true, 6624 + "license": "MIT" 6625 + }, 6617 6626 "node_modules/@types/markdown-it": { 6618 6627 "version": "14.1.2", 6619 6628 "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", ··· 12262 12271 "dev": true, 12263 12272 "dependencies": { 12264 12273 "es5-ext": "~0.10.2" 12274 + } 12275 + }, 12276 + "node_modules/luxon": { 12277 + "version": "3.7.2", 12278 + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", 12279 + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", 12280 + "license": "MIT", 12281 + "engines": { 12282 + "node": ">=12" 12265 12283 } 12266 12284 }, 12267 12285 "node_modules/magic-string": {
+2
package.json
··· 58 58 "ioredis": "^5.6.1", 59 59 "katex": "^0.16.22", 60 60 "linkifyjs": "^4.2.0", 61 + "luxon": "^3.7.2", 61 62 "multiformats": "^13.3.2", 62 63 "next": "^15.5.3", 63 64 "pg": "^8.16.3", ··· 98 99 "@cloudflare/workers-types": "^4.20240512.0", 99 100 "@tailwindcss/postcss": "^4.1.13", 100 101 "@types/katex": "^0.16.7", 102 + "@types/luxon": "^3.7.1", 101 103 "@types/node": "^22.15.17", 102 104 "@types/react": "19.1.3", 103 105 "@types/react-dom": "19.1.3",
+53
src/hooks/useLocalizedDate.ts
··· 1 + "use client"; 2 + import { useContext, useMemo } from "react"; 3 + import { DateTime } from "luxon"; 4 + import { RequestHeadersContext } from "components/Providers/RequestHeadersProvider"; 5 + import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 6 + 7 + /** 8 + * Hook that formats a date string using Luxon with timezone and locale from request headers. 9 + * On initial page load, uses the timezone from request headers. After hydration, uses the system timezone. 10 + * 11 + * @param dateString - ISO date string to format 12 + * @param options - Intl.DateTimeFormatOptions for formatting 13 + * @returns Formatted date string 14 + * 15 + * @example 16 + * const formatted = useLocalizedDate("2024-01-15T10:30:00Z", { dateStyle: 'full', timeStyle: 'short' }); 17 + */ 18 + export function useLocalizedDate( 19 + dateString: string, 20 + options?: Intl.DateTimeFormatOptions, 21 + ): string { 22 + const { timezone, language } = useContext(RequestHeadersContext); 23 + const isInitialPageLoad = useInitialPageLoad(); 24 + 25 + return useMemo(() => { 26 + // Parse the date string to Luxon DateTime 27 + let dateTime = DateTime.fromISO(dateString); 28 + 29 + // On initial page load, use header timezone. After hydration, use system timezone 30 + const effectiveTimezone = isInitialPageLoad 31 + ? timezone 32 + : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 + 34 + // Apply timezone if available 35 + if (effectiveTimezone) { 36 + dateTime = dateTime.setZone(effectiveTimezone); 37 + } 38 + 39 + // On initial page load, use header locale. After hydration, use system locale 40 + // Parse locale from accept-language header (take first locale) 41 + // accept-language format: "en-US,en;q=0.9,es;q=0.8" 42 + const effectiveLocale = isInitialPageLoad 43 + ? language?.split(",")[0]?.split(";")[0]?.trim() || "en-US" 44 + : Intl.DateTimeFormat().resolvedOptions().locale; 45 + 46 + try { 47 + return dateTime.toLocaleString(options, { locale: effectiveLocale }); 48 + } catch (error) { 49 + // Fallback to en-US if locale is invalid 50 + return dateTime.toLocaleString(options, { locale: "en-US" }); 51 + } 52 + }, [dateString, options, timezone, language, isInitialPageLoad]); 53 + }
+6 -1
src/utils/getMicroLinkOgImage.ts
··· 20 20 }, 21 21 ); 22 22 const clonedResponse = response.clone(); 23 - if (clonedResponse.status == 200) 23 + if (clonedResponse.status == 200) { 24 24 clonedResponse.headers.set( 25 25 "CDN-Cache-Control", 26 26 "s-maxage=600, stale-while-revalidate=3600", 27 27 ); 28 + clonedResponse.headers.set( 29 + "Cache-Control", 30 + "s-maxage=600, stale-while-revalidate=3600", 31 + ); 32 + } 28 33 29 34 return clonedResponse; 30 35 }
+3 -55
src/utils/scrollIntoView.ts
··· 1 - // Generated with claude code, sonnet 4.5 2 - /** 3 - * Scrolls an element into view within a scrolling container using Intersection Observer 4 - * and the scrollTo API, instead of the native scrollIntoView. 5 - * 6 - * @param elementId - The ID of the element to scroll into view 7 - * @param scrollContainerId - The ID of the scrolling container (defaults to "pages") 8 - * @param threshold - Intersection observer threshold (0-1, defaults to 0.2 for 20%) 9 - */ 1 + import { scrollIntoViewIfNeeded } from "./scrollIntoViewIfNeeded"; 2 + 10 3 export function scrollIntoView( 11 4 elementId: string, 12 5 scrollContainerId: string = "pages", 13 6 threshold: number = 0.9, 14 7 ) { 15 8 const element = document.getElementById(elementId); 16 - const scrollContainer = document.getElementById(scrollContainerId); 17 - 18 - if (!element || !scrollContainer) { 19 - console.warn(`scrollIntoView: element or container not found`, { 20 - elementId, 21 - scrollContainerId, 22 - element, 23 - scrollContainer, 24 - }); 25 - return; 26 - } 27 - 28 - // Create an intersection observer to check if element is visible 29 - const observer = new IntersectionObserver( 30 - (entries) => { 31 - const entry = entries[0]; 32 - 33 - // If element is not sufficiently visible, scroll to it 34 - if (!entry.isIntersecting || entry.intersectionRatio < threshold) { 35 - const elementRect = element.getBoundingClientRect(); 36 - const containerRect = scrollContainer.getBoundingClientRect(); 37 - 38 - // Calculate the target scroll position 39 - // We want to center the element horizontally in the container 40 - const targetScrollLeft = 41 - scrollContainer.scrollLeft + 42 - elementRect.left - 43 - containerRect.left - 44 - (containerRect.width - elementRect.width) / 2; 45 - 46 - scrollContainer.scrollTo({ 47 - left: targetScrollLeft, 48 - behavior: "smooth", 49 - }); 50 - } 51 - 52 - // Disconnect after checking once 53 - observer.disconnect(); 54 - }, 55 - { 56 - root: scrollContainer, 57 - threshold: threshold, 58 - }, 59 - ); 60 - 61 - observer.observe(element); 9 + scrollIntoViewIfNeeded(element, false, "smooth"); 62 10 }
+56
supabase/database.types.ts
··· 34 34 } 35 35 public: { 36 36 Tables: { 37 + atp_poll_records: { 38 + Row: { 39 + cid: string 40 + created_at: string 41 + record: Json 42 + uri: string 43 + } 44 + Insert: { 45 + cid: string 46 + created_at?: string 47 + record: Json 48 + uri: string 49 + } 50 + Update: { 51 + cid?: string 52 + created_at?: string 53 + record?: Json 54 + uri?: string 55 + } 56 + Relationships: [] 57 + } 58 + atp_poll_votes: { 59 + Row: { 60 + indexed_at: string 61 + poll_cid: string 62 + poll_uri: string 63 + record: Json 64 + uri: string 65 + voter_did: string 66 + } 67 + Insert: { 68 + indexed_at?: string 69 + poll_cid: string 70 + poll_uri: string 71 + record: Json 72 + uri: string 73 + voter_did: string 74 + } 75 + Update: { 76 + indexed_at?: string 77 + poll_cid?: string 78 + poll_uri?: string 79 + record?: Json 80 + uri?: string 81 + voter_did?: string 82 + } 83 + Relationships: [ 84 + { 85 + foreignKeyName: "atp_poll_votes_poll_uri_fkey" 86 + columns: ["poll_uri"] 87 + isOneToOne: false 88 + referencedRelation: "atp_poll_records" 89 + referencedColumns: ["uri"] 90 + }, 91 + ] 92 + } 37 93 bsky_follows: { 38 94 Row: { 39 95 follows: string
+123
supabase/migrations/20251023200453_atp_poll_votes.sql
··· 1 + create table "public"."atp_poll_votes" ( 2 + "uri" text not null, 3 + "record" jsonb not null, 4 + "voter_did" text not null, 5 + "poll_uri" text not null, 6 + "poll_cid" text not null, 7 + "option" text not null, 8 + "indexed_at" timestamp with time zone not null default now() 9 + ); 10 + 11 + alter table "public"."atp_poll_votes" enable row level security; 12 + 13 + CREATE UNIQUE INDEX atp_poll_votes_pkey ON public.atp_poll_votes USING btree (uri); 14 + 15 + alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_pkey" PRIMARY KEY using index "atp_poll_votes_pkey"; 16 + 17 + CREATE INDEX atp_poll_votes_poll_uri_idx ON public.atp_poll_votes USING btree (poll_uri); 18 + 19 + CREATE INDEX atp_poll_votes_voter_did_idx ON public.atp_poll_votes USING btree (voter_did); 20 + 21 + grant delete on table "public"."atp_poll_votes" to "anon"; 22 + 23 + grant insert on table "public"."atp_poll_votes" to "anon"; 24 + 25 + grant references on table "public"."atp_poll_votes" to "anon"; 26 + 27 + grant select on table "public"."atp_poll_votes" to "anon"; 28 + 29 + grant trigger on table "public"."atp_poll_votes" to "anon"; 30 + 31 + grant truncate on table "public"."atp_poll_votes" to "anon"; 32 + 33 + grant update on table "public"."atp_poll_votes" to "anon"; 34 + 35 + grant delete on table "public"."atp_poll_votes" to "authenticated"; 36 + 37 + grant insert on table "public"."atp_poll_votes" to "authenticated"; 38 + 39 + grant references on table "public"."atp_poll_votes" to "authenticated"; 40 + 41 + grant select on table "public"."atp_poll_votes" to "authenticated"; 42 + 43 + grant trigger on table "public"."atp_poll_votes" to "authenticated"; 44 + 45 + grant truncate on table "public"."atp_poll_votes" to "authenticated"; 46 + 47 + grant update on table "public"."atp_poll_votes" to "authenticated"; 48 + 49 + grant delete on table "public"."atp_poll_votes" to "service_role"; 50 + 51 + grant insert on table "public"."atp_poll_votes" to "service_role"; 52 + 53 + grant references on table "public"."atp_poll_votes" to "service_role"; 54 + 55 + grant select on table "public"."atp_poll_votes" to "service_role"; 56 + 57 + grant trigger on table "public"."atp_poll_votes" to "service_role"; 58 + 59 + grant truncate on table "public"."atp_poll_votes" to "service_role"; 60 + 61 + grant update on table "public"."atp_poll_votes" to "service_role"; 62 + 63 + create table "public"."atp_poll_records" ( 64 + "uri" text not null, 65 + "cid" text not null, 66 + "record" jsonb not null, 67 + "created_at" timestamp with time zone not null default now() 68 + ); 69 + 70 + 71 + alter table "public"."atp_poll_records" enable row level security; 72 + 73 + alter table "public"."bsky_follows" alter column "identity" set default ''::text; 74 + 75 + CREATE UNIQUE INDEX atp_poll_records_pkey ON public.atp_poll_records USING btree (uri); 76 + 77 + alter table "public"."atp_poll_records" add constraint "atp_poll_records_pkey" PRIMARY KEY using index "atp_poll_records_pkey"; 78 + 79 + alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_poll_uri_fkey" FOREIGN KEY (poll_uri) REFERENCES atp_poll_records(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid; 80 + 81 + alter table "public"."atp_poll_votes" validate constraint "atp_poll_votes_poll_uri_fkey"; 82 + 83 + grant delete on table "public"."atp_poll_records" to "anon"; 84 + 85 + grant insert on table "public"."atp_poll_records" to "anon"; 86 + 87 + grant references on table "public"."atp_poll_records" to "anon"; 88 + 89 + grant select on table "public"."atp_poll_records" to "anon"; 90 + 91 + grant trigger on table "public"."atp_poll_records" to "anon"; 92 + 93 + grant truncate on table "public"."atp_poll_records" to "anon"; 94 + 95 + grant update on table "public"."atp_poll_records" to "anon"; 96 + 97 + grant delete on table "public"."atp_poll_records" to "authenticated"; 98 + 99 + grant insert on table "public"."atp_poll_records" to "authenticated"; 100 + 101 + grant references on table "public"."atp_poll_records" to "authenticated"; 102 + 103 + grant select on table "public"."atp_poll_records" to "authenticated"; 104 + 105 + grant trigger on table "public"."atp_poll_records" to "authenticated"; 106 + 107 + grant truncate on table "public"."atp_poll_records" to "authenticated"; 108 + 109 + grant update on table "public"."atp_poll_records" to "authenticated"; 110 + 111 + grant delete on table "public"."atp_poll_records" to "service_role"; 112 + 113 + grant insert on table "public"."atp_poll_records" to "service_role"; 114 + 115 + grant references on table "public"."atp_poll_records" to "service_role"; 116 + 117 + grant select on table "public"."atp_poll_records" to "service_role"; 118 + 119 + grant trigger on table "public"."atp_poll_records" to "service_role"; 120 + 121 + grant truncate on table "public"."atp_poll_records" to "service_role"; 122 + 123 + grant update on table "public"."atp_poll_records" to "service_role";
+1
supabase/migrations/20251027212752_remove_option_col_from_atp_poll_votes.sql
··· 1 + alter table "public"."atp_poll_votes" drop column "option";