a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

Changed files
+11109 -5530
actions
app
(home-pages)
[leaflet_id]
api
lish
login
p
[didOrHandle]
appview
components
feeds
lexicons
api
types
pub
leaflet
pub
leaflet
src
src
supabase
+6
actions/createPublicationDraft.ts
··· 11 redirectUser: false, 12 firstBlockType: "text", 13 }); 14 15 await supabaseServerClient 16 .from("leaflets_in_publications")
··· 11 redirectUser: false, 12 firstBlockType: "text", 13 }); 14 + let { data: publication } = await supabaseServerClient 15 + .from("publications") 16 + .select("*") 17 + .eq("uri", publication_uri) 18 + .single(); 19 + if (publication?.identity_did !== identity.atp_did) return; 20 21 await supabaseServerClient 22 .from("leaflets_in_publications")
+56
actions/deleteLeaflet.ts
··· 16 export async function deleteLeaflet(permission_token: PermissionToken) { 17 const client = await pool.connect(); 18 const db = drizzle(client); 19 await db.transaction(async (tx) => { 20 let [token] = await tx 21 .select()
··· 16 export async function deleteLeaflet(permission_token: PermissionToken) { 17 const client = await pool.connect(); 18 const db = drizzle(client); 19 + 20 + // Get the current user's identity 21 + let identity = await getIdentityData(); 22 + 23 + // Check publication and document ownership in one query 24 + let { data: tokenData } = await supabaseServerClient 25 + .from("permission_tokens") 26 + .select( 27 + ` 28 + id, 29 + leaflets_in_publications(publication, publications!inner(identity_did)), 30 + leaflets_to_documents(document, documents!inner(uri)) 31 + `, 32 + ) 33 + .eq("id", permission_token.id) 34 + .single(); 35 + 36 + if (tokenData) { 37 + // Check if leaflet is in a publication 38 + const leafletInPubs = tokenData.leaflets_in_publications || []; 39 + if (leafletInPubs.length > 0) { 40 + if (!identity) { 41 + throw new Error( 42 + "Unauthorized: You must be logged in to delete a leaflet in a publication", 43 + ); 44 + } 45 + const isOwner = leafletInPubs.some( 46 + (pub: any) => pub.publications.identity_did === identity.atp_did, 47 + ); 48 + if (!isOwner) { 49 + throw new Error( 50 + "Unauthorized: You must own the publication to delete this leaflet", 51 + ); 52 + } 53 + } 54 + 55 + // Check if there's a standalone published document 56 + const leafletDocs = tokenData.leaflets_to_documents || []; 57 + if (leafletDocs.length > 0) { 58 + if (!identity) { 59 + throw new Error( 60 + "Unauthorized: You must be logged in to delete a published leaflet", 61 + ); 62 + } 63 + for (let leafletDoc of leafletDocs) { 64 + const docUri = leafletDoc.documents?.uri; 65 + // Extract the DID from the document URI (format: at://did:plc:xxx/...) 66 + if (docUri && identity.atp_did && !docUri.includes(identity.atp_did)) { 67 + throw new Error( 68 + "Unauthorized: You must own the published document to delete this leaflet", 69 + ); 70 + } 71 + } 72 + } 73 + } 74 + 75 await db.transaction(async (tx) => { 76 let [token] = await tx 77 .select()
+1
actions/getIdentityData.ts
··· 30 id, 31 root_entity, 32 permission_token_rights(*), 33 leaflets_in_publications(*, publications(*), documents(*)) 34 ) 35 )
··· 30 id, 31 root_entity, 32 permission_token_rights(*), 33 + leaflets_to_documents(*, documents(*)), 34 leaflets_in_publications(*, publications(*), documents(*)) 35 ) 36 )
+33
actions/publications/moveLeafletToPublication.ts
···
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + 6 + export async function moveLeafletToPublication( 7 + leaflet_id: string, 8 + publication_uri: string, 9 + metadata: { title: string; description: string }, 10 + entitiesToDelete: string[], 11 + ) { 12 + let identity = await getIdentityData(); 13 + if (!identity || !identity.atp_did) return null; 14 + let { data: publication } = await supabaseServerClient 15 + .from("publications") 16 + .select("*") 17 + .eq("uri", publication_uri) 18 + .single(); 19 + if (publication?.identity_did !== identity.atp_did) return; 20 + 21 + await supabaseServerClient.from("leaflets_in_publications").insert({ 22 + publication: publication_uri, 23 + leaflet: leaflet_id, 24 + doc: null, 25 + title: metadata.title, 26 + description: metadata.description, 27 + }); 28 + 29 + await supabaseServerClient 30 + .from("entities") 31 + .delete() 32 + .in("id", entitiesToDelete); 33 + }
-26
actions/publications/updateLeafletDraftMetadata.ts
··· 1 - "use server"; 2 - 3 - import { getIdentityData } from "actions/getIdentityData"; 4 - import { supabaseServerClient } from "supabase/serverClient"; 5 - 6 - export async function updateLeafletDraftMetadata( 7 - leafletID: string, 8 - publication_uri: string, 9 - title: string, 10 - description: string, 11 - ) { 12 - let identity = await getIdentityData(); 13 - if (!identity?.atp_did) return null; 14 - let { data: publication } = await supabaseServerClient 15 - .from("publications") 16 - .select() 17 - .eq("uri", publication_uri) 18 - .single(); 19 - if (!publication || publication.identity_did !== identity.atp_did) 20 - return null; 21 - await supabaseServerClient 22 - .from("leaflets_in_publications") 23 - .update({ title, description }) 24 - .eq("leaflet", leafletID) 25 - .eq("publication", publication_uri); 26 - }
···
+391 -64
actions/publishToPublication.ts
··· 32 import { scanIndexLocal } from "src/replicache/utils"; 33 import type { Fact } from "src/replicache"; 34 import type { Attribute } from "src/replicache/attributes"; 35 - import { 36 - Delta, 37 - YJSFragmentToString, 38 - } from "components/Blocks/TextBlock/RenderYJSFragment"; 39 import { ids } from "lexicons/api/lexicons"; 40 import { BlobRef } from "@atproto/lexicon"; 41 import { AtUri } from "@atproto/syntax"; ··· 44 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 45 import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 46 import { Lock } from "src/utils/lock"; 47 48 export async function publishToPublication({ 49 root_entity, ··· 51 leaflet_id, 52 title, 53 description, 54 }: { 55 root_entity: string; 56 - publication_uri: string; 57 leaflet_id: string; 58 title?: string; 59 description?: string; 60 }) { 61 const oauthClient = await createOauthClient(); 62 let identity = await getIdentityData(); ··· 66 let agent = new AtpBaseClient( 67 credentialSession.fetchHandler.bind(credentialSession), 68 ); 69 - let { data: draft } = await supabaseServerClient 70 - .from("leaflets_in_publications") 71 - .select("*, publications(*), documents(*)") 72 - .eq("publication", publication_uri) 73 - .eq("leaflet", leaflet_id) 74 - .single(); 75 - if (!draft || identity.atp_did !== draft?.publications?.identity_did) 76 - throw new Error("No draft or not publisher"); 77 let { data } = await supabaseServerClient.rpc("get_facts", { 78 root: root_entity, 79 }); 80 let facts = (data as unknown as Fact<Attribute>[]) || []; 81 82 - let { firstPageBlocks, pages } = await processBlocksToPages( 83 facts, 84 agent, 85 root_entity, ··· 88 89 let existingRecord = 90 (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 91 let record: PubLeafletDocument.Record = { 92 - $type: "pub.leaflet.document", 93 - author: credentialSession.did!, 94 - publication: publication_uri, 95 publishedAt: new Date().toISOString(), 96 ...existingRecord, 97 title: title || "Untitled", 98 description: description || "", 99 - pages: [ 100 - { 101 - $type: "pub.leaflet.pages.linearDocument", 102 - blocks: firstPageBlocks, 103 - }, 104 - ...pages.map((p) => { 105 - if (p.type === "canvas") { 106 - return { 107 - $type: "pub.leaflet.pages.canvas" as const, 108 - id: p.id, 109 - blocks: p.blocks as PubLeafletPagesCanvas.Block[], 110 - }; 111 - } else { 112 - return { 113 - $type: "pub.leaflet.pages.linearDocument" as const, 114 - id: p.id, 115 - blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 116 - }; 117 - } 118 - }), 119 - ], 120 }; 121 - let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); 122 let { data: result } = await agent.com.atproto.repo.putRecord({ 123 rkey, 124 repo: credentialSession.did!, ··· 127 validate: false, //TODO publish the lexicon so we can validate! 128 }); 129 130 await supabaseServerClient.from("documents").upsert({ 131 uri: result.uri, 132 data: record as Json, 133 }); 134 - await Promise.all([ 135 - //Optimistically put these in! 136 - supabaseServerClient.from("documents_in_publications").upsert({ 137 - publication: record.publication, 138 document: result.uri, 139 - }), 140 - supabaseServerClient 141 - .from("leaflets_in_publications") 142 - .update({ 143 - doc: result.uri, 144 - }) 145 - .eq("leaflet", leaflet_id) 146 - .eq("publication", publication_uri), 147 - ]); 148 149 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 150 } ··· 169 170 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 171 if (!firstEntity) throw new Error("No root page"); 172 - let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 173 - let b = await blocksToRecord(blocks, did); 174 - return { firstPageBlocks: b, pages }; 175 176 async function uploadImage(src: string) { 177 let data = await fetch(src); ··· 257 Y.applyUpdate(doc, update); 258 let nodes = doc.getXmlElement("prosemirror").toArray(); 259 let stringValue = YJSFragmentToString(nodes[0]); 260 - let facets = YJSFragmentToFacets(nodes[0]); 261 return [stringValue, facets] as const; 262 }; 263 if (b.type === "card") { ··· 518 519 function YJSFragmentToFacets( 520 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 521 - ): PubLeafletRichtextFacet.Main[] { 522 if (node.constructor === Y.XmlElement) { 523 - return node 524 - .toArray() 525 - .map((f) => YJSFragmentToFacets(f)) 526 - .flat(); 527 } 528 if (node.constructor === Y.XmlText) { 529 let facets: PubLeafletRichtextFacet.Main[] = []; 530 let delta = node.toDelta() as Delta[]; 531 - let byteStart = 0; 532 for (let d of delta) { 533 let unicodestring = new UnicodeString(d.insert); 534 let facet: PubLeafletRichtextFacet.Main = { ··· 561 }); 562 if (facet.features.length > 0) facets.push(facet); 563 byteStart += unicodestring.length; 564 } 565 - return facets; 566 } 567 - return []; 568 } 569 570 type ExcludeString<T> = T extends string ··· 572 ? never 573 : T /* maybe literal, not the whole `string` */ 574 : T; /* not a string */
··· 32 import { scanIndexLocal } from "src/replicache/utils"; 33 import type { Fact } from "src/replicache"; 34 import type { Attribute } from "src/replicache/attributes"; 35 + import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 36 import { ids } from "lexicons/api/lexicons"; 37 import { BlobRef } from "@atproto/lexicon"; 38 import { AtUri } from "@atproto/syntax"; ··· 41 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 42 import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 43 import { Lock } from "src/utils/lock"; 44 + import type { PubLeafletPublication } from "lexicons/api"; 45 + import { 46 + ColorToRGB, 47 + ColorToRGBA, 48 + } from "components/ThemeManager/colorToLexicons"; 49 + import { parseColor } from "@react-stately/color"; 50 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 51 + import { v7 } from "uuid"; 52 53 export async function publishToPublication({ 54 root_entity, ··· 56 leaflet_id, 57 title, 58 description, 59 + tags, 60 + entitiesToDelete, 61 }: { 62 root_entity: string; 63 + publication_uri?: string; 64 leaflet_id: string; 65 title?: string; 66 description?: string; 67 + tags?: string[]; 68 + entitiesToDelete?: string[]; 69 }) { 70 const oauthClient = await createOauthClient(); 71 let identity = await getIdentityData(); ··· 75 let agent = new AtpBaseClient( 76 credentialSession.fetchHandler.bind(credentialSession), 77 ); 78 + 79 + // Check if we're publishing to a publication or standalone 80 + let draft: any = null; 81 + let existingDocUri: string | null = null; 82 + 83 + if (publication_uri) { 84 + // Publishing to a publication - use leaflets_in_publications 85 + let { data, error } = await supabaseServerClient 86 + .from("publications") 87 + .select("*, leaflets_in_publications(*, documents(*))") 88 + .eq("uri", publication_uri) 89 + .eq("leaflets_in_publications.leaflet", leaflet_id) 90 + .single(); 91 + console.log(error); 92 + 93 + if (!data || identity.atp_did !== data?.identity_did) 94 + throw new Error("No draft or not publisher"); 95 + draft = data.leaflets_in_publications[0]; 96 + existingDocUri = draft?.doc; 97 + } else { 98 + // Publishing standalone - use leaflets_to_documents 99 + let { data } = await supabaseServerClient 100 + .from("leaflets_to_documents") 101 + .select("*, documents(*)") 102 + .eq("leaflet", leaflet_id) 103 + .single(); 104 + draft = data; 105 + existingDocUri = draft?.document; 106 + } 107 + 108 + // Heuristic: Remove title entities if this is the first time publishing 109 + // (when coming from a standalone leaflet with entitiesToDelete passed in) 110 + if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { 111 + await supabaseServerClient 112 + .from("entities") 113 + .delete() 114 + .in("id", entitiesToDelete); 115 + } 116 + 117 let { data } = await supabaseServerClient.rpc("get_facts", { 118 root: root_entity, 119 }); 120 let facts = (data as unknown as Fact<Attribute>[]) || []; 121 122 + let { pages } = await processBlocksToPages( 123 facts, 124 agent, 125 root_entity, ··· 128 129 let existingRecord = 130 (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 131 + 132 + // Extract theme for standalone documents (not for publications) 133 + let theme: PubLeafletPublication.Theme | undefined; 134 + if (!publication_uri) { 135 + theme = await extractThemeFromFacts(facts, root_entity, agent); 136 + } 137 + 138 let record: PubLeafletDocument.Record = { 139 publishedAt: new Date().toISOString(), 140 ...existingRecord, 141 + $type: "pub.leaflet.document", 142 + author: credentialSession.did!, 143 + ...(publication_uri && { publication: publication_uri }), 144 + ...(theme && { theme }), 145 title: title || "Untitled", 146 description: description || "", 147 + ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 148 + pages: pages.map((p) => { 149 + if (p.type === "canvas") { 150 + return { 151 + $type: "pub.leaflet.pages.canvas" as const, 152 + id: p.id, 153 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 154 + }; 155 + } else { 156 + return { 157 + $type: "pub.leaflet.pages.linearDocument" as const, 158 + id: p.id, 159 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 160 + }; 161 + } 162 + }), 163 }; 164 + 165 + // Keep the same rkey if updating an existing document 166 + let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 167 let { data: result } = await agent.com.atproto.repo.putRecord({ 168 rkey, 169 repo: credentialSession.did!, ··· 172 validate: false, //TODO publish the lexicon so we can validate! 173 }); 174 175 + // Optimistically create database entries 176 await supabaseServerClient.from("documents").upsert({ 177 uri: result.uri, 178 data: record as Json, 179 }); 180 + 181 + if (publication_uri) { 182 + // Publishing to a publication - update both tables 183 + await Promise.all([ 184 + supabaseServerClient.from("documents_in_publications").upsert({ 185 + publication: publication_uri, 186 + document: result.uri, 187 + }), 188 + supabaseServerClient.from("leaflets_in_publications").upsert({ 189 + doc: result.uri, 190 + leaflet: leaflet_id, 191 + publication: publication_uri, 192 + title: title, 193 + description: description, 194 + }), 195 + ]); 196 + } else { 197 + // Publishing standalone - update leaflets_to_documents 198 + await supabaseServerClient.from("leaflets_to_documents").upsert({ 199 + leaflet: leaflet_id, 200 document: result.uri, 201 + title: title || "Untitled", 202 + description: description || "", 203 + }); 204 + 205 + // Heuristic: Remove title entities if this is the first time publishing standalone 206 + // (when entitiesToDelete is provided and there's no existing document) 207 + if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { 208 + await supabaseServerClient 209 + .from("entities") 210 + .delete() 211 + .in("id", entitiesToDelete); 212 + } 213 + } 214 + 215 + // Create notifications for mentions (only on first publish) 216 + if (!existingDocUri) { 217 + await createMentionNotifications(result.uri, record, credentialSession.did!); 218 + } 219 220 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 221 } ··· 240 241 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 242 if (!firstEntity) throw new Error("No root page"); 243 + 244 + // Check if the first page is a canvas or linear document 245 + let [pageType] = scan.eav(firstEntity.data.value, "page/type"); 246 + 247 + if (pageType?.data.value === "canvas") { 248 + // First page is a canvas 249 + let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did); 250 + pages.unshift({ 251 + id: firstEntity.data.value, 252 + blocks: canvasBlocks, 253 + type: "canvas", 254 + }); 255 + } else { 256 + // First page is a linear document 257 + let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 258 + let b = await blocksToRecord(blocks, did); 259 + pages.unshift({ 260 + id: firstEntity.data.value, 261 + blocks: b, 262 + type: "doc", 263 + }); 264 + } 265 + 266 + return { pages }; 267 268 async function uploadImage(src: string) { 269 let data = await fetch(src); ··· 349 Y.applyUpdate(doc, update); 350 let nodes = doc.getXmlElement("prosemirror").toArray(); 351 let stringValue = YJSFragmentToString(nodes[0]); 352 + let { facets } = YJSFragmentToFacets(nodes[0]); 353 return [stringValue, facets] as const; 354 }; 355 if (b.type === "card") { ··· 610 611 function YJSFragmentToFacets( 612 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 613 + byteOffset: number = 0, 614 + ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 615 if (node.constructor === Y.XmlElement) { 616 + // Handle inline mention nodes 617 + if (node.nodeName === "didMention") { 618 + const text = node.getAttribute("text") || ""; 619 + const unicodestring = new UnicodeString(text); 620 + const facet: PubLeafletRichtextFacet.Main = { 621 + index: { 622 + byteStart: byteOffset, 623 + byteEnd: byteOffset + unicodestring.length, 624 + }, 625 + features: [ 626 + { 627 + $type: "pub.leaflet.richtext.facet#didMention", 628 + did: node.getAttribute("did"), 629 + }, 630 + ], 631 + }; 632 + return { facets: [facet], byteLength: unicodestring.length }; 633 + } 634 + 635 + if (node.nodeName === "atMention") { 636 + const text = node.getAttribute("text") || ""; 637 + const unicodestring = new UnicodeString(text); 638 + const facet: PubLeafletRichtextFacet.Main = { 639 + index: { 640 + byteStart: byteOffset, 641 + byteEnd: byteOffset + unicodestring.length, 642 + }, 643 + features: [ 644 + { 645 + $type: "pub.leaflet.richtext.facet#atMention", 646 + atURI: node.getAttribute("atURI"), 647 + }, 648 + ], 649 + }; 650 + return { facets: [facet], byteLength: unicodestring.length }; 651 + } 652 + 653 + if (node.nodeName === "hard_break") { 654 + const unicodestring = new UnicodeString("\n"); 655 + return { facets: [], byteLength: unicodestring.length }; 656 + } 657 + 658 + // For other elements (like paragraph), process children 659 + let allFacets: PubLeafletRichtextFacet.Main[] = []; 660 + let currentOffset = byteOffset; 661 + for (const child of node.toArray()) { 662 + const result = YJSFragmentToFacets(child, currentOffset); 663 + allFacets.push(...result.facets); 664 + currentOffset += result.byteLength; 665 + } 666 + return { facets: allFacets, byteLength: currentOffset - byteOffset }; 667 } 668 + 669 if (node.constructor === Y.XmlText) { 670 let facets: PubLeafletRichtextFacet.Main[] = []; 671 let delta = node.toDelta() as Delta[]; 672 + let byteStart = byteOffset; 673 + let totalLength = 0; 674 for (let d of delta) { 675 let unicodestring = new UnicodeString(d.insert); 676 let facet: PubLeafletRichtextFacet.Main = { ··· 703 }); 704 if (facet.features.length > 0) facets.push(facet); 705 byteStart += unicodestring.length; 706 + totalLength += unicodestring.length; 707 } 708 + return { facets, byteLength: totalLength }; 709 } 710 + return { facets: [], byteLength: 0 }; 711 } 712 713 type ExcludeString<T> = T extends string ··· 715 ? never 716 : T /* maybe literal, not the whole `string` */ 717 : T; /* not a string */ 718 + 719 + async function extractThemeFromFacts( 720 + facts: Fact<any>[], 721 + root_entity: string, 722 + agent: AtpBaseClient, 723 + ): Promise<PubLeafletPublication.Theme | undefined> { 724 + let scan = scanIndexLocal(facts); 725 + let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data 726 + .value; 727 + let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data 728 + .value; 729 + let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value; 730 + let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0] 731 + ?.data.value; 732 + let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value; 733 + let showPageBackground = !scan.eav( 734 + root_entity, 735 + "theme/card-border-hidden", 736 + )?.[0]?.data.value; 737 + let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0]; 738 + let backgroundImageRepeat = scan.eav( 739 + root_entity, 740 + "theme/background-image-repeat", 741 + )?.[0]; 742 + 743 + let theme: PubLeafletPublication.Theme = { 744 + showPageBackground: showPageBackground ?? true, 745 + }; 746 + 747 + if (pageBackground) 748 + theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`)); 749 + if (cardBackground) 750 + theme.pageBackground = ColorToRGBA(parseColor(`hsba(${cardBackground})`)); 751 + if (primary) theme.primary = ColorToRGB(parseColor(`hsba(${primary})`)); 752 + if (accentBackground) 753 + theme.accentBackground = ColorToRGB( 754 + parseColor(`hsba(${accentBackground})`), 755 + ); 756 + if (accentText) 757 + theme.accentText = ColorToRGB(parseColor(`hsba(${accentText})`)); 758 + 759 + // Upload background image if present 760 + if (backgroundImage?.data) { 761 + let imageData = await fetch(backgroundImage.data.src); 762 + if (imageData.status === 200) { 763 + let binary = await imageData.blob(); 764 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 765 + headers: { "Content-Type": binary.type }, 766 + }); 767 + 768 + theme.backgroundImage = { 769 + $type: "pub.leaflet.theme.backgroundImage", 770 + image: blob.data.blob, 771 + repeat: backgroundImageRepeat?.data.value ? true : false, 772 + ...(backgroundImageRepeat?.data.value && { 773 + width: backgroundImageRepeat.data.value, 774 + }), 775 + }; 776 + } 777 + } 778 + 779 + // Only return theme if at least one property is set 780 + if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) { 781 + return theme; 782 + } 783 + 784 + return undefined; 785 + } 786 + 787 + /** 788 + * Extract mentions from a published document and create notifications 789 + */ 790 + async function createMentionNotifications( 791 + documentUri: string, 792 + record: PubLeafletDocument.Record, 793 + authorDid: string, 794 + ) { 795 + const mentionedDids = new Set<string>(); 796 + const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 797 + const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 798 + 799 + // Extract mentions from all text blocks in all pages 800 + for (const page of record.pages) { 801 + if (page.$type === "pub.leaflet.pages.linearDocument") { 802 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 803 + for (const blockWrapper of linearPage.blocks) { 804 + const block = blockWrapper.block; 805 + if (block.$type === "pub.leaflet.blocks.text") { 806 + const textBlock = block as PubLeafletBlocksText.Main; 807 + if (textBlock.facets) { 808 + for (const facet of textBlock.facets) { 809 + for (const feature of facet.features) { 810 + // Check for DID mentions 811 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 812 + if (feature.did !== authorDid) { 813 + mentionedDids.add(feature.did); 814 + } 815 + } 816 + // Check for AT URI mentions (publications and documents) 817 + if (PubLeafletRichtextFacet.isAtMention(feature)) { 818 + const uri = new AtUri(feature.atURI); 819 + 820 + if (uri.collection === "pub.leaflet.publication") { 821 + // Get the publication owner's DID 822 + const { data: publication } = await supabaseServerClient 823 + .from("publications") 824 + .select("identity_did") 825 + .eq("uri", feature.atURI) 826 + .single(); 827 + 828 + if (publication && publication.identity_did !== authorDid) { 829 + mentionedPublications.set(publication.identity_did, feature.atURI); 830 + } 831 + } else if (uri.collection === "pub.leaflet.document") { 832 + // Get the document owner's DID 833 + const { data: document } = await supabaseServerClient 834 + .from("documents") 835 + .select("uri, data") 836 + .eq("uri", feature.atURI) 837 + .single(); 838 + 839 + if (document) { 840 + const docRecord = document.data as PubLeafletDocument.Record; 841 + if (docRecord.author !== authorDid) { 842 + mentionedDocuments.set(docRecord.author, feature.atURI); 843 + } 844 + } 845 + } 846 + } 847 + } 848 + } 849 + } 850 + } 851 + } 852 + } 853 + } 854 + 855 + // Create notifications for DID mentions 856 + for (const did of mentionedDids) { 857 + const notification: Notification = { 858 + id: v7(), 859 + recipient: did, 860 + data: { 861 + type: "mention", 862 + document_uri: documentUri, 863 + mention_type: "did", 864 + }, 865 + }; 866 + await supabaseServerClient.from("notifications").insert(notification); 867 + await pingIdentityToUpdateNotification(did); 868 + } 869 + 870 + // Create notifications for publication mentions 871 + for (const [recipientDid, publicationUri] of mentionedPublications) { 872 + const notification: Notification = { 873 + id: v7(), 874 + recipient: recipientDid, 875 + data: { 876 + type: "mention", 877 + document_uri: documentUri, 878 + mention_type: "publication", 879 + mentioned_uri: publicationUri, 880 + }, 881 + }; 882 + await supabaseServerClient.from("notifications").insert(notification); 883 + await pingIdentityToUpdateNotification(recipientDid); 884 + } 885 + 886 + // Create notifications for document mentions 887 + for (const [recipientDid, mentionedDocUri] of mentionedDocuments) { 888 + const notification: Notification = { 889 + id: v7(), 890 + recipient: recipientDid, 891 + data: { 892 + type: "mention", 893 + document_uri: documentUri, 894 + mention_type: "document", 895 + mentioned_uri: mentionedDocUri, 896 + }, 897 + }; 898 + await supabaseServerClient.from("notifications").insert(notification); 899 + await pingIdentityToUpdateNotification(recipientDid); 900 + } 901 + }
+25
actions/searchTags.ts
···
··· 1 + "use server"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + 4 + export type TagSearchResult = { 5 + name: string; 6 + document_count: number; 7 + }; 8 + 9 + export async function searchTags( 10 + query: string, 11 + ): Promise<TagSearchResult[] | null> { 12 + const searchQuery = query.trim().toLowerCase(); 13 + 14 + // Use raw SQL query to extract and aggregate tags 15 + const { data, error } = await supabaseServerClient.rpc("search_tags", { 16 + search_query: searchQuery, 17 + }); 18 + 19 + if (error) { 20 + console.error("Error searching tags:", error); 21 + return null; 22 + } 23 + 24 + return data; 25 + }
+1 -1
actions/subscriptions/subscribeToMailboxWithEmail.ts
··· 11 import type { Attribute } from "src/replicache/attributes"; 12 import { Database } from "supabase/database.types"; 13 import * as Y from "yjs"; 14 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 15 import { pool } from "supabase/pool"; 16 17 let supabase = createServerClient<Database>(
··· 11 import type { Attribute } from "src/replicache/attributes"; 12 import { Database } from "supabase/database.types"; 13 import * as Y from "yjs"; 14 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 15 import { pool } from "supabase/pool"; 16 17 let supabase = createServerClient<Database>(
+2 -1
app/(home-pages)/discover/PubListing.tsx
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 import { PubIcon } from "components/ActionBar/Publications"; 5 import { Separator } from "components/Layout"; 6 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; ··· 16 }, 17 ) => { 18 let record = props.record as PubLeafletPublication.Record; 19 - let theme = usePubTheme(record); 20 let backgroundImage = record?.theme?.backgroundImage?.image?.ref 21 ? blobRefToSrc( 22 record?.theme?.backgroundImage?.image?.ref,
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 5 import { PubIcon } from "components/ActionBar/Publications"; 6 import { Separator } from "components/Layout"; 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; ··· 17 }, 18 ) => { 19 let record = props.record as PubLeafletPublication.Record; 20 + let theme = usePubTheme(record.theme); 21 let backgroundImage = record?.theme?.backgroundImage?.image?.ref 22 ? blobRefToSrc( 23 record?.theme?.backgroundImage?.image?.ref,
+1 -3
app/(home-pages)/discover/page.tsx
··· 17 return ( 18 <DashboardLayout 19 id="discover" 20 - cardBorderHidden={false} 21 currentPage="discover" 22 defaultTab="default" 23 actions={null} ··· 32 } 33 34 const DiscoverContent = async (props: { order: string }) => { 35 - const orderValue = 36 - props.order === "popular" ? "popular" : "recentlyUpdated"; 37 let { publications, nextCursor } = await getPublications(orderValue); 38 39 return (
··· 17 return ( 18 <DashboardLayout 19 id="discover" 20 currentPage="discover" 21 defaultTab="default" 22 actions={null} ··· 31 } 32 33 const DiscoverContent = async (props: { order: string }) => { 34 + const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated"; 35 let { publications, nextCursor } = await getPublications(orderValue); 36 37 return (
+1 -2
app/(home-pages)/home/Actions/Actions.tsx
··· 1 "use client"; 2 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 3 import { CreateNewLeafletButton } from "./CreateNewButton"; 4 - import { HelpPopover } from "components/HelpPopover"; 5 import { AccountSettings } from "./AccountSettings"; 6 import { useIdentityData } from "components/IdentityProvider"; 7 import { useReplicache } from "src/replicache"; ··· 18 ) : ( 19 <LoginActionButton /> 20 )} 21 - <HelpPopover /> 22 </> 23 ); 24 };
··· 1 "use client"; 2 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 3 import { CreateNewLeafletButton } from "./CreateNewButton"; 4 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 5 import { AccountSettings } from "./AccountSettings"; 6 import { useIdentityData } from "components/IdentityProvider"; 7 import { useReplicache } from "src/replicache"; ··· 18 ) : ( 19 <LoginActionButton /> 20 )} 21 </> 22 ); 23 };
+17 -29
app/(home-pages)/home/HomeLayout.tsx
··· 20 useDashboardState, 21 } from "components/PageLayouts/DashboardLayout"; 22 import { Actions } from "./Actions/Actions"; 23 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 24 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 25 import { useState } from "react"; 26 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; ··· 30 PublicationBanner, 31 } from "./HomeEmpty/HomeEmpty"; 32 33 - type Leaflet = { 34 added_at: string; 35 archived?: boolean | null; 36 token: PermissionToken & { ··· 38 GetLeafletDataReturnType["result"]["data"], 39 null 40 >["leaflets_in_publications"]; 41 }; 42 }; 43 ··· 52 props.entityID, 53 "theme/background-image", 54 ); 55 - let cardBorderHidden = !!useCardBorderHidden(props.entityID); 56 57 let [searchValue, setSearchValue] = useState(""); 58 let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); ··· 77 return ( 78 <DashboardLayout 79 id="home" 80 - cardBorderHidden={cardBorderHidden} 81 currentPage="home" 82 defaultTab="home" 83 actions={<Actions />} ··· 97 <HomeLeafletList 98 titles={props.titles} 99 initialFacts={props.initialFacts} 100 - cardBorderHidden={cardBorderHidden} 101 searchValue={debouncedSearchValue} 102 /> 103 ), ··· 113 [root_entity: string]: Fact<Attribute>[]; 114 }; 115 searchValue: string; 116 - cardBorderHidden: boolean; 117 }) { 118 let { identity } = useIdentityData(); 119 let { data: initialFacts } = useSWR( ··· 130 ...identity.permission_token_on_homepage.reduce( 131 (acc, tok) => { 132 let title = 133 - tok.permission_tokens.leaflets_in_publications[0]?.title; 134 if (title) acc[tok.permission_tokens.root_entity] = title; 135 return acc; 136 }, ··· 166 searchValue={props.searchValue} 167 leaflets={leaflets} 168 titles={initialFacts?.titles || {}} 169 - cardBorderHidden={props.cardBorderHidden} 170 initialFacts={initialFacts?.facts || {}} 171 showPreview 172 /> ··· 187 [root_entity: string]: Fact<Attribute>[]; 188 }; 189 searchValue: string; 190 - cardBorderHidden: boolean; 191 showPreview?: boolean; 192 }) { 193 let { identity } = useIdentityData(); ··· 222 value={{ 223 ...leaflet, 224 leaflets_in_publications: leaflet.leaflets_in_publications || [], 225 blocked_by_admin: null, 226 custom_domain_routes: [], 227 }} ··· 229 <LeafletListItem 230 title={props?.titles?.[leaflet.root_entity]} 231 archived={archived} 232 - token={leaflet} 233 - draftInPublication={ 234 - leaflet.leaflets_in_publications?.[0]?.publication 235 - } 236 - published={!!leaflet.leaflets_in_publications?.find((l) => l.doc)} 237 - publishedAt={ 238 - leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents 239 - ?.indexed_at 240 - } 241 - document_uri={ 242 - leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents 243 - ?.uri 244 - } 245 - leaflet_id={leaflet.root_entity} 246 loggedIn={!!identity} 247 display={display} 248 added_at={added_at} 249 - cardBorderHidden={props.cardBorderHidden} 250 index={index} 251 showPreview={props.showPreview} 252 isHidden={ ··· 292 293 let filteredLeaflets = sortedLeaflets.filter( 294 ({ token: leaflet, archived: archived }) => { 295 - let published = !!leaflet.leaflets_in_publications?.find((l) => l.doc); 296 let drafts = !!leaflet.leaflets_in_publications?.length && !published; 297 let docs = !leaflet.leaflets_in_publications?.length && !archived; 298 - // If no filters are active, show all 299 if ( 300 !filter.drafts && 301 !filter.published && ··· 304 ) 305 return archived === false || archived === null || archived == undefined; 306 307 return ( 308 - (filter.drafts && drafts) || 309 - (filter.published && published) || 310 - (filter.docs && docs) || 311 (filter.archived && archived) 312 ); 313 },
··· 20 useDashboardState, 21 } from "components/PageLayouts/DashboardLayout"; 22 import { Actions } from "./Actions/Actions"; 23 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 24 import { useState } from "react"; 25 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; ··· 29 PublicationBanner, 30 } from "./HomeEmpty/HomeEmpty"; 31 32 + export type Leaflet = { 33 added_at: string; 34 archived?: boolean | null; 35 token: PermissionToken & { ··· 37 GetLeafletDataReturnType["result"]["data"], 38 null 39 >["leaflets_in_publications"]; 40 + leaflets_to_documents?: Exclude< 41 + GetLeafletDataReturnType["result"]["data"], 42 + null 43 + >["leaflets_to_documents"]; 44 }; 45 }; 46 ··· 55 props.entityID, 56 "theme/background-image", 57 ); 58 59 let [searchValue, setSearchValue] = useState(""); 60 let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); ··· 79 return ( 80 <DashboardLayout 81 id="home" 82 currentPage="home" 83 defaultTab="home" 84 actions={<Actions />} ··· 98 <HomeLeafletList 99 titles={props.titles} 100 initialFacts={props.initialFacts} 101 searchValue={debouncedSearchValue} 102 /> 103 ), ··· 113 [root_entity: string]: Fact<Attribute>[]; 114 }; 115 searchValue: string; 116 }) { 117 let { identity } = useIdentityData(); 118 let { data: initialFacts } = useSWR( ··· 129 ...identity.permission_token_on_homepage.reduce( 130 (acc, tok) => { 131 let title = 132 + tok.permission_tokens.leaflets_in_publications[0]?.title || 133 + tok.permission_tokens.leaflets_to_documents[0]?.title; 134 if (title) acc[tok.permission_tokens.root_entity] = title; 135 return acc; 136 }, ··· 166 searchValue={props.searchValue} 167 leaflets={leaflets} 168 titles={initialFacts?.titles || {}} 169 initialFacts={initialFacts?.facts || {}} 170 showPreview 171 /> ··· 186 [root_entity: string]: Fact<Attribute>[]; 187 }; 188 searchValue: string; 189 showPreview?: boolean; 190 }) { 191 let { identity } = useIdentityData(); ··· 220 value={{ 221 ...leaflet, 222 leaflets_in_publications: leaflet.leaflets_in_publications || [], 223 + leaflets_to_documents: leaflet.leaflets_to_documents || [], 224 blocked_by_admin: null, 225 custom_domain_routes: [], 226 }} ··· 228 <LeafletListItem 229 title={props?.titles?.[leaflet.root_entity]} 230 archived={archived} 231 loggedIn={!!identity} 232 display={display} 233 added_at={added_at} 234 index={index} 235 showPreview={props.showPreview} 236 isHidden={ ··· 276 277 let filteredLeaflets = sortedLeaflets.filter( 278 ({ token: leaflet, archived: archived }) => { 279 + let published = 280 + !!leaflet.leaflets_in_publications?.find((l) => l.doc) || 281 + !!leaflet.leaflets_to_documents?.find((l) => l.document); 282 let drafts = !!leaflet.leaflets_in_publications?.length && !published; 283 let docs = !leaflet.leaflets_in_publications?.length && !archived; 284 + 285 + // If no filters are active, show everything that is not archived 286 if ( 287 !filter.drafts && 288 !filter.published && ··· 291 ) 292 return archived === false || archived === null || archived == undefined; 293 294 + //if a filter is on, return itemsd of that filter that are also NOT archived 295 return ( 296 + (filter.drafts && drafts && !archived) || 297 + (filter.published && published && !archived) || 298 + (filter.docs && docs && !archived) || 299 (filter.archived && archived) 300 ); 301 },
+13 -27
app/(home-pages)/home/LeafletList/LeafletInfo.tsx
··· 1 "use client"; 2 - import { PermissionToken, useEntity } from "src/replicache"; 3 import { LeafletOptions } from "./LeafletOptions"; 4 - import Link from "next/link"; 5 - import { use, useState } from "react"; 6 import { timeAgo } from "src/utils/timeAgo"; 7 - import { usePublishLink } from "components/ShareOptions"; 8 - import { Separator } from "components/Layout"; 9 import { usePageTitle } from "components/utils/UpdateLeafletTitle"; 10 11 export const LeafletInfo = (props: { 12 title?: string; 13 - draftInPublication?: string; 14 - published?: boolean; 15 - token: PermissionToken; 16 - leaflet_id: string; 17 - loggedIn: boolean; 18 className?: string; 19 display: "grid" | "list"; 20 added_at: string; 21 - publishedAt?: string; 22 - document_uri?: string; 23 archived?: boolean | null; 24 }) => { 25 - let [prefetch, setPrefetch] = useState(false); 26 let prettyCreatedAt = props.added_at ? timeAgo(props.added_at) : ""; 27 - let prettyPublishedAt = props.publishedAt ? timeAgo(props.publishedAt) : ""; 28 29 // Look up root page first, like UpdateLeafletTitle does 30 - let firstPage = useEntity(props.leaflet_id, "root/page")[0]; 31 - let entityID = firstPage?.data.value || props.leaflet_id; 32 let titleFromDb = usePageTitle(entityID); 33 34 let title = props.title ?? titleFromDb ?? "Untitled"; ··· 42 {title} 43 </h3> 44 <div className="flex gap-1 shrink-0"> 45 - <LeafletOptions 46 - leaflet={props.token} 47 - draftInPublication={props.draftInPublication} 48 - document_uri={props.document_uri} 49 - shareLink={`/${props.token.id}`} 50 - archived={props.archived} 51 - loggedIn={props.loggedIn} 52 - /> 53 </div> 54 </div> 55 <div className="flex gap-2 items-center"> 56 {props.archived ? ( 57 <div className="text-xs text-tertiary truncate">Archived</div> 58 - ) : props.draftInPublication || props.published ? ( 59 <div 60 - className={`text-xs w-max grow truncate ${props.published ? "font-bold text-tertiary" : "text-tertiary"}`} 61 > 62 - {props.published 63 ? `Published ${prettyPublishedAt}` 64 : `Draft ${prettyCreatedAt}`} 65 </div>
··· 1 "use client"; 2 + import { useEntity } from "src/replicache"; 3 import { LeafletOptions } from "./LeafletOptions"; 4 import { timeAgo } from "src/utils/timeAgo"; 5 import { usePageTitle } from "components/utils/UpdateLeafletTitle"; 6 + import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 8 export const LeafletInfo = (props: { 9 title?: string; 10 className?: string; 11 display: "grid" | "list"; 12 added_at: string; 13 archived?: boolean | null; 14 + loggedIn: boolean; 15 }) => { 16 + const pubStatus = useLeafletPublicationStatus(); 17 let prettyCreatedAt = props.added_at ? timeAgo(props.added_at) : ""; 18 + let prettyPublishedAt = pubStatus?.publishedAt 19 + ? timeAgo(pubStatus.publishedAt) 20 + : ""; 21 22 // Look up root page first, like UpdateLeafletTitle does 23 + let firstPage = useEntity(pubStatus?.leafletId ?? "", "root/page")[0]; 24 + let entityID = firstPage?.data.value || pubStatus?.leafletId || ""; 25 let titleFromDb = usePageTitle(entityID); 26 27 let title = props.title ?? titleFromDb ?? "Untitled"; ··· 35 {title} 36 </h3> 37 <div className="flex gap-1 shrink-0"> 38 + <LeafletOptions archived={props.archived} loggedIn={props.loggedIn} /> 39 </div> 40 </div> 41 <div className="flex gap-2 items-center"> 42 {props.archived ? ( 43 <div className="text-xs text-tertiary truncate">Archived</div> 44 + ) : pubStatus?.draftInPublication || pubStatus?.isPublished ? ( 45 <div 46 + className={`text-xs w-max grow truncate ${pubStatus?.isPublished ? "font-bold text-tertiary" : "text-tertiary"}`} 47 > 48 + {pubStatus?.isPublished 49 ? `Published ${prettyPublishedAt}` 50 : `Draft ${prettyCreatedAt}`} 51 </div>
+26 -20
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
··· 1 "use client"; 2 - import { PermissionToken } from "src/replicache"; 3 import { LeafletListPreview, LeafletGridPreview } from "./LeafletPreview"; 4 import { LeafletInfo } from "./LeafletInfo"; 5 import { useState, useRef, useEffect } from "react"; 6 import { SpeedyLink } from "components/SpeedyLink"; 7 8 export const LeafletListItem = (props: { 9 - token: PermissionToken; 10 archived?: boolean | null; 11 - leaflet_id: string; 12 loggedIn: boolean; 13 display: "list" | "grid"; 14 - cardBorderHidden: boolean; 15 added_at: string; 16 title?: string; 17 - draftInPublication?: string; 18 - published?: boolean; 19 - publishedAt?: string; 20 - document_uri?: string; 21 index: number; 22 isHidden: boolean; 23 showPreview?: boolean; 24 }) => { 25 let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); 26 let previewRef = useRef<HTMLDivElement | null>(null); 27 ··· 43 return () => observer.disconnect(); 44 }, [previewRef]); 45 46 if (props.display === "list") 47 return ( 48 <> ··· 50 ref={previewRef} 51 className={`relative flex gap-3 w-full 52 ${props.isHidden ? "hidden" : "flex"} 53 - ${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 54 style={{ 55 - backgroundColor: props.cardBorderHidden 56 ? "transparent" 57 : "rgba(var(--bg-page), var(--bg-page-alpha))", 58 }} 59 > 60 <SpeedyLink 61 - href={`/${props.token.id}`} 62 className={`absolute w-full h-full top-0 left-0 no-underline hover:no-underline! text-primary`} 63 /> 64 - {props.showPreview && ( 65 - <LeafletListPreview isVisible={isOnScreen} {...props} /> 66 - )} 67 - <LeafletInfo {...props} /> 68 </div> 69 - {props.cardBorderHidden && ( 70 <hr 71 className="last:hidden border-border-light" 72 style={{ ··· 86 ${props.isHidden ? "hidden" : "flex"} 87 `} 88 style={{ 89 - backgroundColor: props.cardBorderHidden 90 ? "transparent" 91 : "rgba(var(--bg-page), var(--bg-page-alpha))", 92 }} 93 > 94 <SpeedyLink 95 - href={`/${props.token.id}`} 96 className={`absolute w-full h-full top-0 left-0 no-underline hover:no-underline! text-primary`} 97 /> 98 <div className="grow"> 99 - <LeafletGridPreview {...props} isVisible={isOnScreen} /> 100 </div> 101 <LeafletInfo 102 className="px-1 pb-0.5 shrink-0" 103 - {...props} 104 /> 105 </div> 106 );
··· 1 "use client"; 2 import { LeafletListPreview, LeafletGridPreview } from "./LeafletPreview"; 3 import { LeafletInfo } from "./LeafletInfo"; 4 import { useState, useRef, useEffect } from "react"; 5 import { SpeedyLink } from "components/SpeedyLink"; 6 + import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 8 9 export const LeafletListItem = (props: { 10 archived?: boolean | null; 11 loggedIn: boolean; 12 display: "list" | "grid"; 13 added_at: string; 14 title?: string; 15 index: number; 16 isHidden: boolean; 17 showPreview?: boolean; 18 }) => { 19 + const cardBorderHidden = useCardBorderHidden(); 20 + const pubStatus = useLeafletPublicationStatus(); 21 let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); 22 let previewRef = useRef<HTMLDivElement | null>(null); 23 ··· 39 return () => observer.disconnect(); 40 }, [previewRef]); 41 42 + const tokenId = pubStatus?.shareLink ?? ""; 43 + 44 if (props.display === "list") 45 return ( 46 <> ··· 48 ref={previewRef} 49 className={`relative flex gap-3 w-full 50 ${props.isHidden ? "hidden" : "flex"} 51 + ${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 52 style={{ 53 + backgroundColor: cardBorderHidden 54 ? "transparent" 55 : "rgba(var(--bg-page), var(--bg-page-alpha))", 56 }} 57 > 58 <SpeedyLink 59 + href={`/${tokenId}`} 60 className={`absolute w-full h-full top-0 left-0 no-underline hover:no-underline! text-primary`} 61 /> 62 + {props.showPreview && <LeafletListPreview isVisible={isOnScreen} />} 63 + <LeafletInfo 64 + title={props.title} 65 + display={props.display} 66 + added_at={props.added_at} 67 + archived={props.archived} 68 + loggedIn={props.loggedIn} 69 + /> 70 </div> 71 + {cardBorderHidden && ( 72 <hr 73 className="last:hidden border-border-light" 74 style={{ ··· 88 ${props.isHidden ? "hidden" : "flex"} 89 `} 90 style={{ 91 + backgroundColor: cardBorderHidden 92 ? "transparent" 93 : "rgba(var(--bg-page), var(--bg-page-alpha))", 94 }} 95 > 96 <SpeedyLink 97 + href={`/${tokenId}`} 98 className={`absolute w-full h-full top-0 left-0 no-underline hover:no-underline! text-primary`} 99 /> 100 <div className="grow"> 101 + <LeafletGridPreview isVisible={isOnScreen} /> 102 </div> 103 <LeafletInfo 104 className="px-1 pb-0.5 shrink-0" 105 + title={props.title} 106 + display={props.display} 107 + added_at={props.added_at} 108 + archived={props.archived} 109 + loggedIn={props.loggedIn} 110 /> 111 </div> 112 );
+150 -184
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
··· 17 deletePost, 18 unpublishPost, 19 } from "app/lish/[did]/[publication]/dashboard/deletePost"; 20 - import { ShareButton } from "components/ShareOptions"; 21 import { ShareSmall } from "components/Icons/ShareSmall"; 22 import { HideSmall } from "components/Icons/HideSmall"; 23 import { hideDoc } from "../storage"; 24 25 - import { PermissionToken } from "src/replicache"; 26 import { 27 useIdentityData, 28 mutateIdentityData, ··· 31 usePublicationData, 32 mutatePublicationData, 33 } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 34 35 export const LeafletOptions = (props: { 36 - leaflet: PermissionToken; 37 - draftInPublication?: string; 38 - document_uri?: string; 39 - shareLink: string; 40 archived?: boolean | null; 41 loggedIn?: boolean; 42 }) => { 43 - let [state, setState] = useState<"normal" | "areYouSure">( 44 - "normal", 45 - ); 46 let [open, setOpen] = useState(false); 47 let { identity } = useIdentityData(); 48 let isPublicationOwner = 49 - !!identity?.atp_did && !!props.document_uri?.includes(identity.atp_did); 50 return ( 51 <> 52 <Menu ··· 70 > 71 {state === "normal" ? ( 72 !props.loggedIn ? ( 73 - <LoggedOutOptions 74 - leaflet={props.leaflet} 75 - setState={setState} 76 - shareLink={props.shareLink} 77 - /> 78 - ) : props.document_uri && isPublicationOwner ? ( 79 - <PublishedPostOptions 80 - setState={setState} 81 - document_uri={props.document_uri} 82 - {...props} 83 - /> 84 ) : ( 85 - <DefaultOptions 86 - setState={setState} 87 - {...props} 88 - /> 89 ) 90 ) : state === "areYouSure" ? ( 91 - <DeleteAreYouSureForm 92 - backToMenu={() => setState("normal")} 93 - leaflet={props.leaflet} 94 - document_uri={props.document_uri} 95 - draft={!!props.draftInPublication} 96 - /> 97 ) : null} 98 </Menu> 99 </> ··· 102 103 const DefaultOptions = (props: { 104 setState: (s: "areYouSure") => void; 105 - draftInPublication?: string; 106 - leaflet: PermissionToken; 107 - shareLink: string; 108 archived?: boolean | null; 109 }) => { 110 - let toaster = useToaster(); 111 - let { mutate: mutatePub } = usePublicationData(); 112 - let { mutate: mutateIdentity } = useIdentityData(); 113 return ( 114 <> 115 - <ShareButton 116 - text={ 117 - <div className="flex gap-2"> 118 - <ShareSmall /> 119 - Copy Edit Link 120 - </div> 121 - } 122 - subtext="" 123 - smokerText="Link copied!" 124 - id="get-link" 125 - link={`/${props.shareLink}`} 126 - /> 127 <hr className="border-border-light" /> 128 <MenuItem 129 onSelect={async () => { 130 if (!props.archived) { 131 - mutateIdentityData(mutateIdentity, (data) => { 132 - let item = data.permission_token_on_homepage.find( 133 - (p) => p.permission_tokens?.id === props.leaflet.id, 134 - ); 135 - if (item) item.archived = true; 136 - }); 137 - mutatePublicationData(mutatePub, (data) => { 138 - let item = data.publication?.leaflets_in_publications.find( 139 - (l) => l.permission_tokens?.id === props.leaflet.id, 140 - ); 141 - if (item) item.archived = true; 142 - }); 143 - await archivePost(props.leaflet.id); 144 toaster({ 145 content: ( 146 - <div className="font-bold flex gap-2"> 147 - Archived{props.draftInPublication ? " Draft" : " Leaflet"}! 148 <ButtonTertiary 149 className="underline text-accent-2!" 150 onClick={async () => { 151 - mutateIdentityData(mutateIdentity, (data) => { 152 - let item = data.permission_token_on_homepage.find( 153 - (p) => p.permission_tokens?.id === props.leaflet.id, 154 - ); 155 - if (item) item.archived = false; 156 - }); 157 - mutatePublicationData(mutatePub, (data) => { 158 - let item = 159 - data.publication?.leaflets_in_publications.find( 160 - (l) => l.permission_tokens?.id === props.leaflet.id, 161 - ); 162 - if (item) item.archived = false; 163 - }); 164 - await unarchivePost(props.leaflet.id); 165 toaster({ 166 - content: ( 167 - <div className="font-bold flex gap-2"> 168 - Unarchived! 169 - </div> 170 - ), 171 type: "success", 172 }); 173 }} ··· 179 type: "success", 180 }); 181 } else { 182 - mutateIdentityData(mutateIdentity, (data) => { 183 - let item = data.permission_token_on_homepage.find( 184 - (p) => p.permission_tokens?.id === props.leaflet.id, 185 - ); 186 - if (item) item.archived = false; 187 - }); 188 - mutatePublicationData(mutatePub, (data) => { 189 - let item = data.publication?.leaflets_in_publications.find( 190 - (l) => l.permission_tokens?.id === props.leaflet.id, 191 - ); 192 - if (item) item.archived = false; 193 - }); 194 - await unarchivePost(props.leaflet.id); 195 toaster({ 196 content: <div className="font-bold">Unarchived!</div>, 197 type: "success", ··· 200 }} 201 > 202 <ArchiveSmall /> 203 - {!props.archived ? " Archive" : "Unarchive"} 204 - {props.draftInPublication ? " Draft" : " Leaflet"} 205 </MenuItem> 206 - <MenuItem 207 - onSelect={(e) => { 208 - e.preventDefault(); 209 - props.setState("areYouSure"); 210 - }} 211 - > 212 - <DeleteSmall /> 213 - Delete Forever 214 - </MenuItem> 215 </> 216 ); 217 }; 218 219 - const LoggedOutOptions = (props: { 220 - leaflet: PermissionToken; 221 - setState: (s: "areYouSure") => void; 222 - shareLink: string; 223 - }) => { 224 - let toaster = useToaster(); 225 return ( 226 <> 227 - <ShareButton 228 - text={ 229 - <div className="flex gap-2"> 230 - <ShareSmall /> 231 - Copy Edit Link 232 - </div> 233 - } 234 - subtext="" 235 - smokerText="Link copied!" 236 - id="get-link" 237 - link={`/${props.shareLink}`} 238 - /> 239 <hr className="border-border-light" /> 240 <MenuItem 241 onSelect={() => { 242 - hideDoc(props.leaflet); 243 toaster({ 244 content: <div className="font-bold">Removed from Home!</div>, 245 type: "success", ··· 249 <HideSmall /> 250 Remove from Home 251 </MenuItem> 252 - <MenuItem 253 onSelect={(e) => { 254 e.preventDefault(); 255 props.setState("areYouSure"); 256 }} 257 - > 258 - <DeleteSmall /> 259 - Delete Forever 260 - </MenuItem> 261 </> 262 ); 263 }; 264 265 const PublishedPostOptions = (props: { 266 setState: (s: "areYouSure") => void; 267 - document_uri: string; 268 - leaflet: PermissionToken; 269 - shareLink: string; 270 }) => { 271 - let toaster = useToaster(); 272 return ( 273 <> 274 <ShareButton ··· 280 } 281 smokerText="Link copied!" 282 id="get-link" 283 - link={`${props.shareLink}`} 284 /> 285 - 286 <hr className="border-border-light" /> 287 <MenuItem 288 onSelect={async () => { 289 - if (props.document_uri) { 290 - await unpublishPost(props.document_uri); 291 } 292 toaster({ 293 content: <div className="font-bold">Unpublished Post!</div>, ··· 303 </div> 304 </div> 305 </MenuItem> 306 - <MenuItem 307 onSelect={(e) => { 308 e.preventDefault(); 309 props.setState("areYouSure"); 310 }} 311 - > 312 - <DeleteSmall /> 313 - <div className="flex flex-col"> 314 - Delete Post 315 - <div className="text-tertiary text-sm font-normal!"> 316 - Unpublish AND delete 317 - </div> 318 - </div> 319 - </MenuItem> 320 </> 321 ); 322 }; 323 324 - const DeleteAreYouSureForm = (props: { 325 - backToMenu: () => void; 326 - document_uri?: string; 327 - leaflet: PermissionToken; 328 - draft?: boolean; 329 - }) => { 330 - let toaster = useToaster(); 331 - let { mutate: mutatePub } = usePublicationData(); 332 - let { mutate: mutateIdentity } = useIdentityData(); 333 334 return ( 335 <div className="flex flex-col justify-center p-2 text-center"> ··· 343 </ButtonTertiary> 344 <ButtonPrimary 345 onClick={async () => { 346 - mutateIdentityData(mutateIdentity, (data) => { 347 - data.permission_token_on_homepage = 348 - data.permission_token_on_homepage.filter( 349 - (p) => p.permission_tokens?.id !== props.leaflet.id, 350 - ); 351 - }); 352 - mutatePublicationData(mutatePub, (data) => { 353 - if (!data.publication) return; 354 - data.publication.leaflets_in_publications = 355 - data.publication.leaflets_in_publications.filter( 356 - (l) => l.permission_tokens?.id !== props.leaflet.id, 357 - ); 358 - }); 359 - if (props.document_uri) { 360 - await deletePost(props.document_uri); 361 } 362 - deleteLeaflet(props.leaflet); 363 364 toaster({ 365 - content: ( 366 - <div className="font-bold"> 367 - Deleted{" "} 368 - {props.document_uri 369 - ? "Post!" 370 - : props.draft 371 - ? "Draft" 372 - : "Leaflet!"} 373 - </div> 374 - ), 375 type: "success", 376 }); 377 }} ··· 383 ); 384 }; 385
··· 17 deletePost, 18 unpublishPost, 19 } from "app/lish/[did]/[publication]/dashboard/deletePost"; 20 import { ShareSmall } from "components/Icons/ShareSmall"; 21 import { HideSmall } from "components/Icons/HideSmall"; 22 import { hideDoc } from "../storage"; 23 24 import { 25 useIdentityData, 26 mutateIdentityData, ··· 29 usePublicationData, 30 mutatePublicationData, 31 } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 32 + import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; 33 + import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 34 35 export const LeafletOptions = (props: { 36 archived?: boolean | null; 37 loggedIn?: boolean; 38 }) => { 39 + const pubStatus = useLeafletPublicationStatus(); 40 + let [state, setState] = useState<"normal" | "areYouSure">("normal"); 41 let [open, setOpen] = useState(false); 42 let { identity } = useIdentityData(); 43 let isPublicationOwner = 44 + !!identity?.atp_did && !!pubStatus?.documentUri?.includes(identity.atp_did); 45 return ( 46 <> 47 <Menu ··· 65 > 66 {state === "normal" ? ( 67 !props.loggedIn ? ( 68 + <LoggedOutOptions setState={setState} /> 69 + ) : pubStatus?.documentUri && isPublicationOwner ? ( 70 + <PublishedPostOptions setState={setState} /> 71 ) : ( 72 + <DefaultOptions setState={setState} archived={props.archived} /> 73 ) 74 ) : state === "areYouSure" ? ( 75 + <DeleteAreYouSureForm backToMenu={() => setState("normal")} /> 76 ) : null} 77 </Menu> 78 </> ··· 81 82 const DefaultOptions = (props: { 83 setState: (s: "areYouSure") => void; 84 archived?: boolean | null; 85 }) => { 86 + const pubStatus = useLeafletPublicationStatus(); 87 + const toaster = useToaster(); 88 + const { setArchived } = useArchiveMutations(); 89 + const { identity } = useIdentityData(); 90 + const tokenId = pubStatus?.token.id; 91 + const itemType = pubStatus?.draftInPublication ? "Draft" : "Leaflet"; 92 + 93 + // Check if this is a published post/document and if user is the owner 94 + const isPublishedPostOwner = 95 + !!identity?.atp_did && !!pubStatus?.documentUri?.includes(identity.atp_did); 96 + const canDelete = !pubStatus?.documentUri || isPublishedPostOwner; 97 + 98 return ( 99 <> 100 + <EditLinkShareButton link={pubStatus?.shareLink ?? ""} /> 101 <hr className="border-border-light" /> 102 <MenuItem 103 onSelect={async () => { 104 + if (!tokenId) return; 105 + setArchived(tokenId, !props.archived); 106 + 107 if (!props.archived) { 108 + await archivePost(tokenId); 109 toaster({ 110 content: ( 111 + <div className="font-bold flex gap-2 items-center"> 112 + Archived {itemType}! 113 <ButtonTertiary 114 className="underline text-accent-2!" 115 onClick={async () => { 116 + setArchived(tokenId, false); 117 + await unarchivePost(tokenId); 118 toaster({ 119 + content: <div className="font-bold">Unarchived!</div>, 120 type: "success", 121 }); 122 }} ··· 128 type: "success", 129 }); 130 } else { 131 + await unarchivePost(tokenId); 132 toaster({ 133 content: <div className="font-bold">Unarchived!</div>, 134 type: "success", ··· 137 }} 138 > 139 <ArchiveSmall /> 140 + {!props.archived ? " Archive" : "Unarchive"} {itemType} 141 </MenuItem> 142 + {canDelete && ( 143 + <DeleteForeverMenuItem 144 + onSelect={(e) => { 145 + e.preventDefault(); 146 + props.setState("areYouSure"); 147 + }} 148 + /> 149 + )} 150 </> 151 ); 152 }; 153 154 + const LoggedOutOptions = (props: { setState: (s: "areYouSure") => void }) => { 155 + const pubStatus = useLeafletPublicationStatus(); 156 + const toaster = useToaster(); 157 + 158 return ( 159 <> 160 + <EditLinkShareButton link={`/${pubStatus?.shareLink ?? ""}`} /> 161 <hr className="border-border-light" /> 162 <MenuItem 163 onSelect={() => { 164 + if (pubStatus?.token) hideDoc(pubStatus.token); 165 toaster({ 166 content: <div className="font-bold">Removed from Home!</div>, 167 type: "success", ··· 171 <HideSmall /> 172 Remove from Home 173 </MenuItem> 174 + <DeleteForeverMenuItem 175 onSelect={(e) => { 176 e.preventDefault(); 177 props.setState("areYouSure"); 178 }} 179 + /> 180 </> 181 ); 182 }; 183 184 const PublishedPostOptions = (props: { 185 setState: (s: "areYouSure") => void; 186 }) => { 187 + const pubStatus = useLeafletPublicationStatus(); 188 + const toaster = useToaster(); 189 + const postLink = pubStatus?.postShareLink ?? ""; 190 + const isFullUrl = postLink.includes("http"); 191 + 192 return ( 193 <> 194 <ShareButton ··· 200 } 201 smokerText="Link copied!" 202 id="get-link" 203 + link={postLink} 204 + fullLink={isFullUrl ? postLink : undefined} 205 /> 206 <hr className="border-border-light" /> 207 <MenuItem 208 onSelect={async () => { 209 + if (pubStatus?.documentUri) { 210 + await unpublishPost(pubStatus.documentUri); 211 } 212 toaster({ 213 content: <div className="font-bold">Unpublished Post!</div>, ··· 223 </div> 224 </div> 225 </MenuItem> 226 + <DeleteForeverMenuItem 227 onSelect={(e) => { 228 e.preventDefault(); 229 props.setState("areYouSure"); 230 }} 231 + subtext="Post" 232 + /> 233 </> 234 ); 235 }; 236 237 + const DeleteAreYouSureForm = (props: { backToMenu: () => void }) => { 238 + const pubStatus = useLeafletPublicationStatus(); 239 + const toaster = useToaster(); 240 + const { removeFromLists } = useArchiveMutations(); 241 + const tokenId = pubStatus?.token.id; 242 + 243 + const itemType = pubStatus?.documentUri 244 + ? "Post" 245 + : pubStatus?.draftInPublication 246 + ? "Draft" 247 + : "Leaflet"; 248 249 return ( 250 <div className="flex flex-col justify-center p-2 text-center"> ··· 258 </ButtonTertiary> 259 <ButtonPrimary 260 onClick={async () => { 261 + if (tokenId) removeFromLists(tokenId); 262 + if (pubStatus?.documentUri) { 263 + await deletePost(pubStatus.documentUri); 264 } 265 + if (pubStatus?.token) deleteLeaflet(pubStatus.token); 266 267 toaster({ 268 + content: <div className="font-bold">Deleted {itemType}!</div>, 269 type: "success", 270 }); 271 }} ··· 277 ); 278 }; 279 280 + // Shared menu items 281 + const EditLinkShareButton = (props: { link: string }) => ( 282 + <ShareButton 283 + text={ 284 + <div className="flex gap-2"> 285 + <ShareSmall /> 286 + Copy Edit Link 287 + </div> 288 + } 289 + subtext="" 290 + smokerText="Link copied!" 291 + id="get-link" 292 + link={props.link} 293 + /> 294 + ); 295 + 296 + const DeleteForeverMenuItem = (props: { 297 + onSelect: (e: Event) => void; 298 + subtext?: string; 299 + }) => ( 300 + <MenuItem onSelect={props.onSelect}> 301 + <DeleteSmall /> 302 + {props.subtext ? ( 303 + <div className="flex flex-col"> 304 + Delete {props.subtext} 305 + <div className="text-tertiary text-sm font-normal!"> 306 + Unpublish AND delete 307 + </div> 308 + </div> 309 + ) : ( 310 + "Delete Forever" 311 + )} 312 + </MenuItem> 313 + ); 314 + 315 + // Helper to update archived state in both identity and publication data 316 + function useArchiveMutations() { 317 + const { mutate: mutatePub } = usePublicationData(); 318 + const { mutate: mutateIdentity } = useIdentityData(); 319 + 320 + return { 321 + setArchived: (tokenId: string, archived: boolean) => { 322 + mutateIdentityData(mutateIdentity, (data) => { 323 + const item = data.permission_token_on_homepage.find( 324 + (p) => p.permission_tokens?.id === tokenId, 325 + ); 326 + if (item) item.archived = archived; 327 + }); 328 + mutatePublicationData(mutatePub, (data) => { 329 + const item = data.publication?.leaflets_in_publications.find( 330 + (l) => l.permission_tokens?.id === tokenId, 331 + ); 332 + if (item) item.archived = archived; 333 + }); 334 + }, 335 + removeFromLists: (tokenId: string) => { 336 + mutateIdentityData(mutateIdentity, (data) => { 337 + data.permission_token_on_homepage = 338 + data.permission_token_on_homepage.filter( 339 + (p) => p.permission_tokens?.id !== tokenId, 340 + ); 341 + }); 342 + mutatePublicationData(mutatePub, (data) => { 343 + if (!data.publication) return; 344 + data.publication.leaflets_in_publications = 345 + data.publication.leaflets_in_publications.filter( 346 + (l) => l.permission_tokens?.id !== tokenId, 347 + ); 348 + }); 349 + }, 350 + }; 351 + }
+58 -95
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
··· 3 ThemeBackgroundProvider, 4 ThemeProvider, 5 } from "components/ThemeManager/ThemeProvider"; 6 - import { 7 - PermissionToken, 8 - useEntity, 9 - useReferenceToEntity, 10 - } from "src/replicache"; 11 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 12 import { LeafletContent } from "./LeafletContent"; 13 import { Tooltip } from "components/Tooltip"; 14 15 - export const LeafletListPreview = (props: { 16 - draft?: boolean; 17 - published?: boolean; 18 - isVisible: boolean; 19 - token: PermissionToken; 20 - leaflet_id: string; 21 - loggedIn: boolean; 22 - }) => { 23 - let root = 24 - useReferenceToEntity("root/page", props.leaflet_id)[0]?.entity || 25 - props.leaflet_id; 26 - let firstPage = useEntity(root, "root/page")[0]; 27 - let page = firstPage?.data.value || root; 28 29 - let cardBorderHidden = useCardBorderHidden(root); 30 - let rootBackgroundImage = useEntity(root, "theme/card-background-image"); 31 - let rootBackgroundRepeat = useEntity( 32 root, 33 "theme/card-background-image-repeat", 34 ); 35 - let rootBackgroundOpacity = useEntity( 36 root, 37 "theme/card-background-image-opacity", 38 ); 39 40 return ( 41 <Tooltip 42 open={true} ··· 73 <ThemeProvider local entityID={root} className="rounded-sm"> 74 <ThemeBackgroundProvider entityID={root}> 75 <div className="leafletPreview grow shrink-0 h-44 w-64 px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none rounded-[2px] "> 76 - <div 77 - className={`leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`} 78 - style={ 79 - cardBorderHidden 80 - ? {} 81 - : { 82 - backgroundImage: rootBackgroundImage 83 - ? `url(${rootBackgroundImage.data.src}), url(${rootBackgroundImage.data.fallback})` 84 - : undefined, 85 - backgroundRepeat: rootBackgroundRepeat 86 - ? "repeat" 87 - : "no-repeat", 88 - backgroundPosition: "center", 89 - backgroundSize: !rootBackgroundRepeat 90 - ? "cover" 91 - : rootBackgroundRepeat?.data.value / 3, 92 - opacity: 93 - rootBackgroundImage?.data.src && rootBackgroundOpacity 94 - ? rootBackgroundOpacity.data.value 95 - : 1, 96 - backgroundColor: 97 - "rgba(var(--bg-page), var(--bg-page-alpha))", 98 - } 99 - } 100 - > 101 <LeafletContent entityID={page} isOnScreen={props.isVisible} /> 102 </div> 103 </div> ··· 107 ); 108 }; 109 110 - export const LeafletGridPreview = (props: { 111 - draft?: boolean; 112 - published?: boolean; 113 - token: PermissionToken; 114 - leaflet_id: string; 115 - loggedIn: boolean; 116 - isVisible: boolean; 117 - }) => { 118 - let root = 119 - useReferenceToEntity("root/page", props.leaflet_id)[0]?.entity || 120 - props.leaflet_id; 121 - let firstPage = useEntity(root, "root/page")[0]; 122 - let page = firstPage?.data.value || root; 123 124 - let cardBorderHidden = useCardBorderHidden(root); 125 - let rootBackgroundImage = useEntity(root, "theme/card-background-image"); 126 - let rootBackgroundRepeat = useEntity( 127 - root, 128 - "theme/card-background-image-repeat", 129 - ); 130 - let rootBackgroundOpacity = useEntity( 131 - root, 132 - "theme/card-background-image-opacity", 133 - ); 134 return ( 135 <ThemeProvider local entityID={root} className="w-full!"> 136 <div className="border border-border-light rounded-md w-full h-full overflow-hidden "> ··· 140 inert 141 className="leafletPreview grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none" 142 > 143 - <div 144 - className={`leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`} 145 - style={ 146 - cardBorderHidden 147 - ? {} 148 - : { 149 - backgroundImage: rootBackgroundImage 150 - ? `url(${rootBackgroundImage.data.src}), url(${rootBackgroundImage.data.fallback})` 151 - : undefined, 152 - backgroundRepeat: rootBackgroundRepeat 153 - ? "repeat" 154 - : "no-repeat", 155 - backgroundPosition: "center", 156 - backgroundSize: !rootBackgroundRepeat 157 - ? "cover" 158 - : rootBackgroundRepeat?.data.value / 3, 159 - opacity: 160 - rootBackgroundImage?.data.src && rootBackgroundOpacity 161 - ? rootBackgroundOpacity.data.value 162 - : 1, 163 - backgroundColor: 164 - "rgba(var(--bg-page), var(--bg-page-alpha))", 165 - } 166 - } 167 - > 168 <LeafletContent entityID={page} isOnScreen={props.isVisible} /> 169 </div> 170 </div>
··· 3 ThemeBackgroundProvider, 4 ThemeProvider, 5 } from "components/ThemeManager/ThemeProvider"; 6 + import { useEntity, useReferenceToEntity } from "src/replicache"; 7 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 8 import { LeafletContent } from "./LeafletContent"; 9 import { Tooltip } from "components/Tooltip"; 10 + import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 11 + import { CSSProperties } from "react"; 12 13 + function useLeafletPreviewData() { 14 + const pubStatus = useLeafletPublicationStatus(); 15 + const leafletId = pubStatus?.leafletId ?? ""; 16 + const root = 17 + useReferenceToEntity("root/page", leafletId)[0]?.entity || leafletId; 18 + const firstPage = useEntity(root, "root/page")[0]; 19 + const page = firstPage?.data.value || root; 20 21 + const cardBorderHidden = useEntity(root, "theme/card-border-hidden")?.data 22 + .value; 23 + const rootBackgroundImage = useEntity(root, "theme/card-background-image"); 24 + const rootBackgroundRepeat = useEntity( 25 root, 26 "theme/card-background-image-repeat", 27 ); 28 + const rootBackgroundOpacity = useEntity( 29 root, 30 "theme/card-background-image-opacity", 31 ); 32 33 + const contentWrapperStyle: CSSProperties = cardBorderHidden 34 + ? {} 35 + : { 36 + backgroundImage: rootBackgroundImage 37 + ? `url(${rootBackgroundImage.data.src}), url(${rootBackgroundImage.data.fallback})` 38 + : undefined, 39 + backgroundRepeat: rootBackgroundRepeat ? "repeat" : "no-repeat", 40 + backgroundPosition: "center", 41 + backgroundSize: !rootBackgroundRepeat 42 + ? "cover" 43 + : rootBackgroundRepeat?.data.value / 3, 44 + opacity: 45 + rootBackgroundImage?.data.src && rootBackgroundOpacity 46 + ? rootBackgroundOpacity.data.value 47 + : 1, 48 + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 49 + }; 50 + 51 + const contentWrapperClass = `leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`; 52 + 53 + return { 54 + root, 55 + page, 56 + cardBorderHidden, 57 + contentWrapperStyle, 58 + contentWrapperClass, 59 + }; 60 + } 61 + 62 + export const LeafletListPreview = (props: { isVisible: boolean }) => { 63 + const { 64 + root, 65 + page, 66 + cardBorderHidden, 67 + contentWrapperStyle, 68 + contentWrapperClass, 69 + } = useLeafletPreviewData(); 70 + 71 return ( 72 <Tooltip 73 open={true} ··· 104 <ThemeProvider local entityID={root} className="rounded-sm"> 105 <ThemeBackgroundProvider entityID={root}> 106 <div className="leafletPreview grow shrink-0 h-44 w-64 px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none rounded-[2px] "> 107 + <div className={contentWrapperClass} style={contentWrapperStyle}> 108 <LeafletContent entityID={page} isOnScreen={props.isVisible} /> 109 </div> 110 </div> ··· 114 ); 115 }; 116 117 + export const LeafletGridPreview = (props: { isVisible: boolean }) => { 118 + const { root, page, contentWrapperStyle, contentWrapperClass } = 119 + useLeafletPreviewData(); 120 121 return ( 122 <ThemeProvider local entityID={root} className="w-full!"> 123 <div className="border border-border-light rounded-md w-full h-full overflow-hidden "> ··· 127 inert 128 className="leafletPreview grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none" 129 > 130 + <div className={contentWrapperClass} style={contentWrapperStyle}> 131 <LeafletContent entityID={page} isOnScreen={props.isVisible} /> 132 </div> 133 </div>
+2 -1
app/(home-pages)/home/page.tsx
··· 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 },
··· 29 ...auth_res?.permission_token_on_homepage.reduce( 30 (acc, tok) => { 31 let title = 32 + tok.permission_tokens.leaflets_in_publications[0]?.title || 33 + tok.permission_tokens.leaflets_to_documents[0]?.title; 34 if (title) acc[tok.permission_tokens.root_entity] = title; 35 return acc; 36 },
+110
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
···
··· 1 + "use client"; 2 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 + import { useState } from "react"; 4 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 5 + import { Fact, PermissionToken } from "src/replicache"; 6 + import { Attribute } from "src/replicache/attributes"; 7 + import { Actions } from "../home/Actions/Actions"; 8 + import { callRPC } from "app/api/rpc/client"; 9 + import { useIdentityData } from "components/IdentityProvider"; 10 + import useSWR from "swr"; 11 + import { getHomeDocs } from "../home/storage"; 12 + import { Leaflet, LeafletList } from "../home/HomeLayout"; 13 + 14 + export const LooseleafsLayout = (props: { 15 + entityID: string | null; 16 + titles: { [root_entity: string]: string }; 17 + initialFacts: { 18 + [root_entity: string]: Fact<Attribute>[]; 19 + }; 20 + }) => { 21 + let [searchValue, setSearchValue] = useState(""); 22 + let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); 23 + 24 + useDebouncedEffect( 25 + () => { 26 + setDebouncedSearchValue(searchValue); 27 + }, 28 + 200, 29 + [searchValue], 30 + ); 31 + 32 + return ( 33 + <DashboardLayout 34 + id="looseleafs" 35 + currentPage="looseleafs" 36 + defaultTab="home" 37 + actions={<Actions />} 38 + tabs={{ 39 + home: { 40 + controls: null, 41 + content: ( 42 + <LooseleafList 43 + titles={props.titles} 44 + initialFacts={props.initialFacts} 45 + searchValue={debouncedSearchValue} 46 + /> 47 + ), 48 + }, 49 + }} 50 + /> 51 + ); 52 + }; 53 + 54 + export const LooseleafList = (props: { 55 + titles: { [root_entity: string]: string }; 56 + initialFacts: { 57 + [root_entity: string]: Fact<Attribute>[]; 58 + }; 59 + searchValue: string; 60 + }) => { 61 + let { identity } = useIdentityData(); 62 + let { data: initialFacts } = useSWR( 63 + "home-leaflet-data", 64 + async () => { 65 + if (identity) { 66 + let { result } = await callRPC("getFactsFromHomeLeaflets", { 67 + tokens: identity.permission_token_on_homepage.map( 68 + (ptrh) => ptrh.permission_tokens.root_entity, 69 + ), 70 + }); 71 + let titles = { 72 + ...result.titles, 73 + ...identity.permission_token_on_homepage.reduce( 74 + (acc, tok) => { 75 + let title = 76 + tok.permission_tokens.leaflets_in_publications[0]?.title || 77 + tok.permission_tokens.leaflets_to_documents[0]?.title; 78 + if (title) acc[tok.permission_tokens.root_entity] = title; 79 + return acc; 80 + }, 81 + {} as { [k: string]: string }, 82 + ), 83 + }; 84 + return { ...result, titles }; 85 + } 86 + }, 87 + { fallbackData: { facts: props.initialFacts, titles: props.titles } }, 88 + ); 89 + 90 + let leaflets: Leaflet[] = identity 91 + ? identity.permission_token_on_homepage 92 + .filter( 93 + (ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0, 94 + ) 95 + .map((ptoh) => ({ 96 + added_at: ptoh.created_at, 97 + token: ptoh.permission_tokens as PermissionToken, 98 + })) 99 + : []; 100 + return ( 101 + <LeafletList 102 + defaultDisplay="list" 103 + searchValue={props.searchValue} 104 + leaflets={leaflets} 105 + titles={initialFacts?.titles || {}} 106 + initialFacts={initialFacts?.facts || {}} 107 + showPreview 108 + /> 109 + ); 110 + };
+47
app/(home-pages)/looseleafs/page.tsx
···
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 + import { Actions } from "../home/Actions/Actions"; 4 + import { Fact } from "src/replicache"; 5 + import { Attribute } from "src/replicache/attributes"; 6 + import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { LooseleafsLayout } from "./LooseleafsLayout"; 9 + 10 + export default async function Home() { 11 + let auth_res = await getIdentityData(); 12 + 13 + let [allLeafletFacts] = await Promise.all([ 14 + auth_res 15 + ? getFactsFromHomeLeaflets.handler( 16 + { 17 + tokens: auth_res.permission_token_on_homepage.map( 18 + (r) => r.permission_tokens.root_entity, 19 + ), 20 + }, 21 + { supabase: supabaseServerClient }, 22 + ) 23 + : undefined, 24 + ]); 25 + 26 + let home_docs_initialFacts = allLeafletFacts?.result || {}; 27 + 28 + return ( 29 + <LooseleafsLayout 30 + entityID={auth_res?.home_leaflet?.root_entity || null} 31 + titles={{ 32 + ...home_docs_initialFacts.titles, 33 + ...auth_res?.permission_token_on_homepage.reduce( 34 + (acc, tok) => { 35 + let title = 36 + tok.permission_tokens.leaflets_in_publications[0]?.title || 37 + tok.permission_tokens.leaflets_to_documents[0]?.title; 38 + if (title) acc[tok.permission_tokens.root_entity] = title; 39 + return acc; 40 + }, 41 + {} as { [k: string]: string }, 42 + ), 43 + }} 44 + initialFacts={home_docs_initialFacts.facts || {}} 45 + /> 46 + ); 47 + }
+98
app/(home-pages)/notifications/CommentMentionNotification.tsx
···
··· 1 + import { 2 + AppBskyActorProfile, 3 + PubLeafletComment, 4 + PubLeafletDocument, 5 + PubLeafletPublication, 6 + } from "lexicons/api"; 7 + import { HydratedCommentMentionNotification } from "src/notifications"; 8 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 9 + import { MentionTiny } from "components/Icons/MentionTiny"; 10 + import { 11 + CommentInNotification, 12 + ContentLayout, 13 + Notification, 14 + } from "./Notification"; 15 + import { AtUri } from "@atproto/api"; 16 + 17 + export const CommentMentionNotification = ( 18 + props: HydratedCommentMentionNotification, 19 + ) => { 20 + const docRecord = props.commentData.documents 21 + ?.data as PubLeafletDocument.Record; 22 + const commentRecord = props.commentData.record as PubLeafletComment.Record; 23 + const profileRecord = props.commentData.bsky_profiles 24 + ?.record as AppBskyActorProfile.Record; 25 + const pubRecord = props.commentData.documents?.documents_in_publications[0] 26 + ?.publications?.record as PubLeafletPublication.Record | undefined; 27 + const docUri = new AtUri(props.commentData.documents?.uri!); 28 + const rkey = docUri.rkey; 29 + const did = docUri.host; 30 + 31 + const href = pubRecord 32 + ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 33 + : `/p/${did}/${rkey}?interactionDrawer=comments`; 34 + 35 + const commenter = props.commenterHandle 36 + ? `@${props.commenterHandle}` 37 + : "Someone"; 38 + 39 + let actionText: React.ReactNode; 40 + let mentionedDocRecord = props.mentionedDocument 41 + ?.data as PubLeafletDocument.Record; 42 + 43 + if (props.mention_type === "did") { 44 + actionText = <>{commenter} mentioned you in a comment</>; 45 + } else if ( 46 + props.mention_type === "publication" && 47 + props.mentionedPublication 48 + ) { 49 + const mentionedPubRecord = props.mentionedPublication 50 + .record as PubLeafletPublication.Record; 51 + actionText = ( 52 + <> 53 + {commenter} mentioned your publication{" "} 54 + <span className="italic">{mentionedPubRecord.name}</span> in a comment 55 + </> 56 + ); 57 + } else if (props.mention_type === "document" && props.mentionedDocument) { 58 + actionText = ( 59 + <> 60 + {commenter} mentioned your post{" "} 61 + <span className="italic">{mentionedDocRecord.title}</span> in a comment 62 + </> 63 + ); 64 + } else { 65 + actionText = <>{commenter} mentioned you in a comment</>; 66 + } 67 + 68 + return ( 69 + <Notification 70 + timestamp={props.created_at} 71 + href={href} 72 + icon={<MentionTiny />} 73 + actionText={actionText} 74 + content={ 75 + <ContentLayout postTitle={docRecord?.title} pubRecord={pubRecord}> 76 + <CommentInNotification 77 + className="" 78 + avatar={ 79 + profileRecord?.avatar?.ref && 80 + blobRefToSrc( 81 + profileRecord?.avatar?.ref, 82 + props.commentData.bsky_profiles?.did || "", 83 + ) 84 + } 85 + displayName={ 86 + profileRecord?.displayName || 87 + props.commentData.bsky_profiles?.handle || 88 + "Someone" 89 + } 90 + index={[]} 91 + plaintext={commentRecord.plaintext} 92 + facets={commentRecord.facets} 93 + /> 94 + </ContentLayout> 95 + } 96 + /> 97 + ); 98 + };
+9 -3
app/(home-pages)/notifications/CommentNotication.tsx
··· 27 props.commentData.bsky_profiles?.handle || 28 "Someone"; 29 const pubRecord = props.commentData.documents?.documents_in_publications[0] 30 - ?.publications?.record as PubLeafletPublication.Record; 31 - let rkey = new AtUri(props.commentData.documents?.uri!).rkey; 32 33 return ( 34 <Notification 35 timestamp={props.commentData.indexed_at} 36 - href={`https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`} 37 icon={<CommentTiny />} 38 actionText={<>{displayName} commented on your post</>} 39 content={
··· 27 props.commentData.bsky_profiles?.handle || 28 "Someone"; 29 const pubRecord = props.commentData.documents?.documents_in_publications[0] 30 + ?.publications?.record as PubLeafletPublication.Record | undefined; 31 + let docUri = new AtUri(props.commentData.documents?.uri!); 32 + let rkey = docUri.rkey; 33 + let did = docUri.host; 34 + 35 + const href = pubRecord 36 + ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 37 + : `/p/${did}/${rkey}?interactionDrawer=comments`; 38 39 return ( 40 <Notification 41 timestamp={props.commentData.indexed_at} 42 + href={href} 43 icon={<CommentTiny />} 44 actionText={<>{displayName} commented on your post</>} 45 content={
+55 -40
app/(home-pages)/notifications/MentionNotification.tsx
··· 1 import { MentionTiny } from "components/Icons/MentionTiny"; 2 import { ContentLayout, Notification } from "./Notification"; 3 4 - export const DummyPostMentionNotification = (props: {}) => { 5 - return ( 6 - <Notification 7 - timestamp={""} 8 - href="/" 9 - icon={<MentionTiny />} 10 - actionText={<>celine mentioned your post</>} 11 - content={ 12 - <ContentLayout 13 - postTitle={"Post Title Here"} 14 - pubRecord={{ name: "My Publication" } as any} 15 - > 16 - I'm just gonna put the description here. The surrounding context is 17 - just sort of a pain to figure out 18 - <div className="border border-border-light rounded-md p-1 my-1 text-xs text-secondary"> 19 - <div className="font-bold">Title of the Mentioned Post</div> 20 - <div className="text-tertiary"> 21 - And here is the description that follows it 22 - </div> 23 - </div> 24 - </ContentLayout> 25 - } 26 - /> 27 - ); 28 - }; 29 30 - export const DummyUserMentionNotification = (props: { 31 - cardBorderHidden: boolean; 32 - }) => { 33 return ( 34 <Notification 35 - timestamp={""} 36 - href="/" 37 icon={<MentionTiny />} 38 - actionText={<>celine mentioned you</>} 39 content={ 40 - <ContentLayout 41 - postTitle={"Post Title Here"} 42 - pubRecord={{ name: "My Publication" } as any} 43 - > 44 - <div> 45 - ...llo this is the content of a post or whatever here it comes{" "} 46 - <span className="text-accent-contrast">@celine </span> and here it 47 - was! ooooh heck yeah the high is unre... 48 - </div> 49 </ContentLayout> 50 } 51 />
··· 1 import { MentionTiny } from "components/Icons/MentionTiny"; 2 import { ContentLayout, Notification } from "./Notification"; 3 + import { HydratedMentionNotification } from "src/notifications"; 4 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 + import { Agent, AtUri } from "@atproto/api"; 6 7 + export const MentionNotification = (props: HydratedMentionNotification) => { 8 + const docRecord = props.document.data as PubLeafletDocument.Record; 9 + const pubRecord = props.document.documents_in_publications?.[0]?.publications 10 + ?.record as PubLeafletPublication.Record | undefined; 11 + const docUri = new AtUri(props.document.uri); 12 + const rkey = docUri.rkey; 13 + const did = docUri.host; 14 + 15 + const href = pubRecord 16 + ? `https://${pubRecord.base_path}/${rkey}` 17 + : `/p/${did}/${rkey}`; 18 + 19 + let actionText: React.ReactNode; 20 + let mentionedItemName: string | undefined; 21 + let mentionedDocRecord = props.mentionedDocument 22 + ?.data as PubLeafletDocument.Record; 23 + 24 + const mentioner = props.documentCreatorHandle 25 + ? `@${props.documentCreatorHandle}` 26 + : "Someone"; 27 + 28 + if (props.mention_type === "did") { 29 + actionText = <>{mentioner} mentioned you</>; 30 + } else if ( 31 + props.mention_type === "publication" && 32 + props.mentionedPublication 33 + ) { 34 + const mentionedPubRecord = props.mentionedPublication 35 + .record as PubLeafletPublication.Record; 36 + mentionedItemName = mentionedPubRecord.name; 37 + actionText = ( 38 + <> 39 + {mentioner} mentioned your publication{" "} 40 + <span className="italic">{mentionedItemName}</span> 41 + </> 42 + ); 43 + } else if (props.mention_type === "document" && props.mentionedDocument) { 44 + mentionedItemName = mentionedDocRecord.title; 45 + actionText = ( 46 + <> 47 + {mentioner} mentioned your post{" "} 48 + <span className="italic">{mentionedItemName}</span> 49 + </> 50 + ); 51 + } else { 52 + actionText = <>{mentioner} mentioned you</>; 53 + } 54 55 return ( 56 <Notification 57 + timestamp={props.created_at} 58 + href={href} 59 icon={<MentionTiny />} 60 + actionText={actionText} 61 content={ 62 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 63 + {docRecord.description && docRecord.description} 64 </ContentLayout> 65 } 66 />
+3 -3
app/(home-pages)/notifications/Notification.tsx
··· 69 <div 70 className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`} 71 > 72 - <div className="text-tertiary text-sm italic font-bold pb-1"> 73 {props.postTitle} 74 </div> 75 - {props.children} 76 {props.pubRecord && ( 77 <> 78 - <hr className="mt-3 mb-1 border-border-light" /> 79 <a 80 href={`https://${props.pubRecord.base_path}`} 81 className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!"
··· 69 <div 70 className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`} 71 > 72 + <div className="text-tertiary text-sm italic font-bold "> 73 {props.postTitle} 74 </div> 75 + {props.children && <div className="mb-2 text-sm">{props.children}</div>} 76 {props.pubRecord && ( 77 <> 78 + <hr className="mt-1 mb-1 border-border-light" /> 79 <a 80 href={`https://${props.pubRecord.base_path}`} 81 className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!"
+12
app/(home-pages)/notifications/NotificationList.tsx
··· 7 import { ReplyNotification } from "./ReplyNotification"; 8 import { useIdentityData } from "components/IdentityProvider"; 9 import { FollowNotification } from "./FollowNotification"; 10 11 export function NotificationList({ 12 notifications, ··· 41 } 42 if (n.type === "subscribe") { 43 return <FollowNotification key={n.id} {...n} />; 44 } 45 })} 46 </div>
··· 7 import { ReplyNotification } from "./ReplyNotification"; 8 import { useIdentityData } from "components/IdentityProvider"; 9 import { FollowNotification } from "./FollowNotification"; 10 + import { QuoteNotification } from "./QuoteNotification"; 11 + import { MentionNotification } from "./MentionNotification"; 12 + import { CommentMentionNotification } from "./CommentMentionNotification"; 13 14 export function NotificationList({ 15 notifications, ··· 44 } 45 if (n.type === "subscribe") { 46 return <FollowNotification key={n.id} {...n} />; 47 + } 48 + if (n.type === "quote") { 49 + return <QuoteNotification key={n.id} {...n} />; 50 + } 51 + if (n.type === "mention") { 52 + return <MentionNotification key={n.id} {...n} />; 53 + } 54 + if (n.type === "comment_mention") { 55 + return <CommentMentionNotification key={n.id} {...n} />; 56 } 57 })} 58 </div>
+48
app/(home-pages)/notifications/QuoteNotification.tsx
···
··· 1 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 2 + import { ContentLayout, Notification } from "./Notification"; 3 + import { HydratedQuoteNotification } from "src/notifications"; 4 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 + import { AtUri } from "@atproto/api"; 6 + import { Avatar } from "components/Avatar"; 7 + 8 + export const QuoteNotification = (props: HydratedQuoteNotification) => { 9 + const postView = props.bskyPost.post_view as any; 10 + const author = postView.author; 11 + const displayName = author.displayName || author.handle || "Someone"; 12 + const docRecord = props.document.data as PubLeafletDocument.Record; 13 + const pubRecord = props.document.documents_in_publications[0]?.publications 14 + ?.record as PubLeafletPublication.Record | undefined; 15 + const docUri = new AtUri(props.document.uri); 16 + const rkey = docUri.rkey; 17 + const did = docUri.host; 18 + const postText = postView.record?.text || ""; 19 + 20 + const href = pubRecord 21 + ? `https://${pubRecord.base_path}/${rkey}` 22 + : `/p/${did}/${rkey}`; 23 + 24 + return ( 25 + <Notification 26 + timestamp={props.created_at} 27 + href={href} 28 + icon={<QuoteTiny />} 29 + actionText={<>{displayName} quoted your post</>} 30 + content={ 31 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 32 + <div className="flex gap-2 text-sm w-full"> 33 + <Avatar 34 + src={author.avatar} 35 + displayName={displayName} 36 + /> 37 + <pre 38 + style={{ wordBreak: "break-word" }} 39 + className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6" 40 + > 41 + {postText} 42 + </pre> 43 + </div> 44 + </ContentLayout> 45 + } 46 + /> 47 + ); 48 + };
+9 -3
app/(home-pages)/notifications/ReplyNotification.tsx
··· 34 props.parentData?.bsky_profiles?.handle || 35 "Someone"; 36 37 - let rkey = new AtUri(props.commentData.documents?.uri!).rkey; 38 const pubRecord = props.commentData.documents?.documents_in_publications[0] 39 - ?.publications?.record as PubLeafletPublication.Record; 40 41 return ( 42 <Notification 43 timestamp={props.commentData.indexed_at} 44 - href={`https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`} 45 icon={<ReplyTiny />} 46 actionText={`${displayName} replied to your comment`} 47 content={
··· 34 props.parentData?.bsky_profiles?.handle || 35 "Someone"; 36 37 + let docUri = new AtUri(props.commentData.documents?.uri!); 38 + let rkey = docUri.rkey; 39 + let did = docUri.host; 40 const pubRecord = props.commentData.documents?.documents_in_publications[0] 41 + ?.publications?.record as PubLeafletPublication.Record | undefined; 42 + 43 + const href = pubRecord 44 + ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 45 + : `/p/${did}/${rkey}?interactionDrawer=comments`; 46 47 return ( 48 <Notification 49 timestamp={props.commentData.indexed_at} 50 + href={href} 51 icon={<ReplyTiny />} 52 actionText={`${displayName} replied to your comment`} 53 content={
-1
app/(home-pages)/notifications/page.tsx
··· 10 return ( 11 <DashboardLayout 12 id="discover" 13 - cardBorderHidden={true} 14 currentPage="notifications" 15 defaultTab="default" 16 actions={null}
··· 10 return ( 11 <DashboardLayout 12 id="discover" 13 currentPage="notifications" 14 defaultTab="default" 15 actions={null}
+88
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
···
··· 1 + "use client"; 2 + 3 + import { PostListing } from "components/PostListing"; 4 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 + import type { Cursor } from "./getProfilePosts"; 6 + import { getProfilePosts } from "./getProfilePosts"; 7 + import useSWRInfinite from "swr/infinite"; 8 + import { useEffect, useRef } from "react"; 9 + 10 + export const ProfilePostsContent = (props: { 11 + did: string; 12 + posts: Post[]; 13 + nextCursor: Cursor | null; 14 + }) => { 15 + const getKey = ( 16 + pageIndex: number, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 21 + ) => { 22 + // Reached the end 23 + if (previousPageData && !previousPageData.nextCursor) return null; 24 + 25 + // First page, we don't have previousPageData 26 + if (pageIndex === 0) return ["profile-posts", props.did, null] as const; 27 + 28 + // Add the cursor to the key 29 + return ["profile-posts", props.did, previousPageData?.nextCursor] as const; 30 + }; 31 + 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 33 + getKey, 34 + ([_, did, cursor]) => getProfilePosts(did, cursor), 35 + { 36 + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 37 + revalidateFirstPage: false, 38 + }, 39 + ); 40 + 41 + const loadMoreRef = useRef<HTMLDivElement>(null); 42 + 43 + // Set up intersection observer to load more when trigger element is visible 44 + useEffect(() => { 45 + const observer = new IntersectionObserver( 46 + (entries) => { 47 + if (entries[0].isIntersecting && !isValidating) { 48 + const hasMore = data && data[data.length - 1]?.nextCursor; 49 + if (hasMore) { 50 + setSize(size + 1); 51 + } 52 + } 53 + }, 54 + { threshold: 0.1 }, 55 + ); 56 + 57 + if (loadMoreRef.current) { 58 + observer.observe(loadMoreRef.current); 59 + } 60 + 61 + return () => observer.disconnect(); 62 + }, [data, size, setSize, isValidating]); 63 + 64 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 65 + 66 + if (allPosts.length === 0 && !isValidating) { 67 + return <div className="text-tertiary text-center py-4">No posts yet</div>; 68 + } 69 + 70 + return ( 71 + <div className="flex flex-col gap-3 text-left relative"> 72 + {allPosts.map((post) => ( 73 + <PostListing key={post.documents.uri} {...post} /> 74 + ))} 75 + {/* Trigger element for loading more posts */} 76 + <div 77 + ref={loadMoreRef} 78 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 79 + aria-hidden="true" 80 + /> 81 + {isValidating && ( 82 + <div className="text-center text-tertiary py-4"> 83 + Loading more posts... 84 + </div> 85 + )} 86 + </div> 87 + ); 88 + };
+200
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
···
··· 1 + "use client"; 2 + import { Avatar } from "components/Avatar"; 3 + import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 4 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 + import type { ProfileData } from "./layout"; 6 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 7 + import { colorToString } from "components/ThemeManager/useColorAttribute"; 8 + import { PubIcon } from "components/ActionBar/Publications"; 9 + import { Json } from "supabase/database.types"; 10 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 11 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 12 + import { SpeedyLink } from "components/SpeedyLink"; 13 + import { ReactNode } from "react"; 14 + 15 + export const ProfileHeader = (props: { 16 + profile: ProfileViewDetailed; 17 + publications: { record: Json; uri: string }[]; 18 + popover?: boolean; 19 + }) => { 20 + let profileRecord = props.profile; 21 + const profileUrl = `/p/${props.profile.handle}`; 22 + 23 + const avatarElement = ( 24 + <Avatar 25 + src={profileRecord.avatar} 26 + displayName={profileRecord.displayName} 27 + className="mx-auto mt-3 sm:mt-4" 28 + giant 29 + /> 30 + ); 31 + 32 + const displayNameElement = ( 33 + <h3 className=" px-3 sm:px-4 pt-2 leading-tight"> 34 + {profileRecord.displayName 35 + ? profileRecord.displayName 36 + : `@${props.profile.handle}`} 37 + </h3> 38 + ); 39 + 40 + const handleElement = profileRecord.displayName && ( 41 + <div 42 + className={`text-tertiary ${props.popover ? "text-xs" : "text-sm"} pb-1 italic px-3 sm:px-4 truncate`} 43 + > 44 + @{props.profile.handle} 45 + </div> 46 + ); 47 + 48 + return ( 49 + <div 50 + className={`flex flex-col relative ${props.popover && "text-sm"}`} 51 + id="profile-header" 52 + > 53 + <ProfileLinks handle={props.profile.handle || ""} /> 54 + <div className="flex flex-col"> 55 + <div className="flex flex-col group"> 56 + {props.popover ? ( 57 + <SpeedyLink className={"hover:no-underline!"} href={profileUrl}> 58 + {avatarElement} 59 + </SpeedyLink> 60 + ) : ( 61 + avatarElement 62 + )} 63 + {props.popover ? ( 64 + <SpeedyLink 65 + className={" text-primary group-hover:underline"} 66 + href={profileUrl} 67 + > 68 + {displayNameElement} 69 + </SpeedyLink> 70 + ) : ( 71 + displayNameElement 72 + )} 73 + {props.popover && handleElement ? ( 74 + <SpeedyLink className={"group-hover:underline"} href={profileUrl}> 75 + {handleElement} 76 + </SpeedyLink> 77 + ) : ( 78 + handleElement 79 + )} 80 + </div> 81 + <pre className="text-secondary px-3 sm:px-4 whitespace-pre-wrap"> 82 + {profileRecord.description 83 + ? parseDescription(profileRecord.description) 84 + : null} 85 + </pre> 86 + <div className=" w-full overflow-x-scroll py-3 mb-3 "> 87 + <div 88 + className={`grid grid-flow-col gap-2 mx-auto w-fit px-3 sm:px-4 ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`} 89 + > 90 + {props.publications.map((p) => ( 91 + <PublicationCard 92 + key={p.uri} 93 + record={p.record as PubLeafletPublication.Record} 94 + uri={p.uri} 95 + /> 96 + ))} 97 + </div> 98 + </div> 99 + </div> 100 + </div> 101 + ); 102 + }; 103 + 104 + const ProfileLinks = (props: { handle: string }) => { 105 + return ( 106 + <div className="absolute sm:top-4 top-3 sm:right-4 right-3 flex flex-row gap-2"> 107 + <a 108 + className="text-tertiary hover:text-accent-contrast hover:no-underline!" 109 + href={`https://bsky.app/profile/${props.handle}`} 110 + > 111 + <BlueskyTiny /> 112 + </a> 113 + </div> 114 + ); 115 + }; 116 + const PublicationCard = (props: { 117 + record: PubLeafletPublication.Record; 118 + uri: string; 119 + }) => { 120 + const { record, uri } = props; 121 + const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); 122 + 123 + return ( 124 + <a 125 + href={`https://${record.base_path}`} 126 + className="border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2" 127 + style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }} 128 + > 129 + <div 130 + className="rounded-md p-2 flex flex-row gap-2" 131 + style={{ 132 + backgroundColor: record.theme?.showPageBackground 133 + ? `rgb(${colorToString(bgPage, "rgb")})` 134 + : undefined, 135 + }} 136 + > 137 + <PubIcon record={record} uri={uri} /> 138 + <h4 139 + className="truncate min-w-0" 140 + style={{ 141 + color: `rgb(${colorToString(primary, "rgb")})`, 142 + }} 143 + > 144 + {record.name} 145 + </h4> 146 + </div> 147 + </a> 148 + ); 149 + }; 150 + 151 + function parseDescription(description: string): ReactNode[] { 152 + const combinedRegex = /(@\S+|https?:\/\/\S+)/g; 153 + 154 + const parts: ReactNode[] = []; 155 + let lastIndex = 0; 156 + let match; 157 + let key = 0; 158 + 159 + while ((match = combinedRegex.exec(description)) !== null) { 160 + // Add text before this match 161 + if (match.index > lastIndex) { 162 + parts.push(description.slice(lastIndex, match.index)); 163 + } 164 + 165 + const matched = match[0]; 166 + 167 + if (matched.startsWith("@")) { 168 + // It's a mention 169 + const handle = matched.slice(1); 170 + parts.push( 171 + <SpeedyLink key={key++} href={`/p/${handle}`}> 172 + {matched} 173 + </SpeedyLink>, 174 + ); 175 + } else { 176 + // It's a URL 177 + const urlWithoutProtocol = matched 178 + .replace(/^https?:\/\//, "") 179 + .replace(/\/+$/, ""); 180 + const displayText = 181 + urlWithoutProtocol.length > 50 182 + ? urlWithoutProtocol.slice(0, 50) + "โ€ฆ" 183 + : urlWithoutProtocol; 184 + parts.push( 185 + <a key={key++} href={matched} target="_blank" rel="noopener noreferrer"> 186 + {displayText} 187 + </a>, 188 + ); 189 + } 190 + 191 + lastIndex = match.index + matched.length; 192 + } 193 + 194 + // Add remaining text after last match 195 + if (lastIndex < description.length) { 196 + parts.push(description.slice(lastIndex)); 197 + } 198 + 199 + return parts; 200 + }
+24
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
···
··· 1 + "use client"; 2 + 3 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 4 + 5 + export function ProfileLayout(props: { children: React.ReactNode }) { 6 + let cardBorderHidden = useCardBorderHidden(); 7 + return ( 8 + <div 9 + id="profile-content" 10 + className={` 11 + ${ 12 + cardBorderHidden 13 + ? "" 14 + : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page" 15 + } 16 + max-w-prose mx-auto w-full 17 + flex flex-col 18 + text-center 19 + `} 20 + > 21 + {props.children} 22 + </div> 23 + ); 24 + }
+119
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
···
··· 1 + "use client"; 2 + 3 + import { SpeedyLink } from "components/SpeedyLink"; 4 + import { useSelectedLayoutSegment } from "next/navigation"; 5 + import { useState, useEffect } from "react"; 6 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 + 8 + export type ProfileTabType = "posts" | "comments" | "subscriptions"; 9 + 10 + export const ProfileTabs = (props: { didOrHandle: string }) => { 11 + const cardBorderHidden = useCardBorderHidden(); 12 + const segment = useSelectedLayoutSegment(); 13 + const currentTab = (segment || "posts") as ProfileTabType; 14 + const [scrollPosWithinTabContent, setScrollPosWithinTabContent] = useState(0); 15 + const [headerHeight, setHeaderHeight] = useState(0); 16 + useEffect(() => { 17 + let headerHeight = 18 + document.getElementById("profile-header")?.clientHeight || 0; 19 + setHeaderHeight(headerHeight); 20 + 21 + const profileContent = cardBorderHidden 22 + ? document.getElementById("home-content") 23 + : document.getElementById("profile-content"); 24 + const handleScroll = () => { 25 + if (profileContent) { 26 + setScrollPosWithinTabContent( 27 + profileContent.scrollTop - headerHeight > 0 28 + ? profileContent.scrollTop - headerHeight 29 + : 0, 30 + ); 31 + } 32 + }; 33 + 34 + if (profileContent) { 35 + profileContent.addEventListener("scroll", handleScroll); 36 + return () => profileContent.removeEventListener("scroll", handleScroll); 37 + } 38 + }, []); 39 + 40 + const baseUrl = `/p/${props.didOrHandle}`; 41 + const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 42 + 43 + return ( 44 + <div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 sm:px-4 px-3"> 45 + <div 46 + style={ 47 + scrollPosWithinTabContent < 20 48 + ? { 49 + paddingLeft: `calc(${scrollPosWithinTabContent / 20} * 12px )`, 50 + paddingRight: `calc(${scrollPosWithinTabContent / 20} * 12px )`, 51 + } 52 + : { paddingLeft: "12px", paddingRight: "12px" } 53 + } 54 + > 55 + <div 56 + className={` 57 + border rounded-lg 58 + ${scrollPosWithinTabContent > 20 ? "border-border-light" : "border-transparent"} 59 + py-1 60 + w-full `} 61 + style={ 62 + scrollPosWithinTabContent < 20 63 + ? { 64 + backgroundColor: !cardBorderHidden 65 + ? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60 + 0.75})` 66 + : `rgba(${bgColor}, ${scrollPosWithinTabContent / 20})`, 67 + paddingLeft: !cardBorderHidden 68 + ? "4px" 69 + : `calc(${scrollPosWithinTabContent / 20} * 4px)`, 70 + paddingRight: !cardBorderHidden 71 + ? "4px" 72 + : `calc(${scrollPosWithinTabContent / 20} * 4px)`, 73 + } 74 + : { 75 + backgroundColor: `rgb(${bgColor})`, 76 + paddingLeft: "4px", 77 + paddingRight: "4px", 78 + } 79 + } 80 + > 81 + <div className="flex gap-2 justify-between"> 82 + <div className="flex gap-2"> 83 + <TabLink 84 + href={baseUrl} 85 + name="Posts" 86 + selected={currentTab === "posts"} 87 + /> 88 + <TabLink 89 + href={`${baseUrl}/comments`} 90 + name="Comments" 91 + selected={currentTab === "comments"} 92 + /> 93 + </div> 94 + <TabLink 95 + href={`${baseUrl}/subscriptions`} 96 + name="Subscriptions" 97 + selected={currentTab === "subscriptions"} 98 + /> 99 + </div> 100 + </div> 101 + </div> 102 + </div> 103 + ); 104 + }; 105 + 106 + const TabLink = (props: { href: string; name: string; selected: boolean }) => { 107 + return ( 108 + <SpeedyLink 109 + href={props.href} 110 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer hover:no-underline! ${ 111 + props.selected 112 + ? "text-accent-2 bg-accent-1 font-bold -mb-px" 113 + : "text-tertiary" 114 + }`} 115 + > 116 + {props.name} 117 + </SpeedyLink> 118 + ); 119 + };
+219
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
···
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useMemo } from "react"; 4 + import useSWRInfinite from "swr/infinite"; 5 + import { AppBskyActorProfile, AtUri } from "@atproto/api"; 6 + import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 7 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 8 + import { Avatar } from "components/Avatar"; 9 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 10 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + import { 12 + getProfileComments, 13 + type ProfileComment, 14 + type Cursor, 15 + } from "./getProfileComments"; 16 + import { timeAgo } from "src/utils/timeAgo"; 17 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 18 + 19 + export const ProfileCommentsContent = (props: { 20 + did: string; 21 + comments: ProfileComment[]; 22 + nextCursor: Cursor | null; 23 + }) => { 24 + const getKey = ( 25 + pageIndex: number, 26 + previousPageData: { 27 + comments: ProfileComment[]; 28 + nextCursor: Cursor | null; 29 + } | null, 30 + ) => { 31 + // Reached the end 32 + if (previousPageData && !previousPageData.nextCursor) return null; 33 + 34 + // First page, we don't have previousPageData 35 + if (pageIndex === 0) return ["profile-comments", props.did, null] as const; 36 + 37 + // Add the cursor to the key 38 + return [ 39 + "profile-comments", 40 + props.did, 41 + previousPageData?.nextCursor, 42 + ] as const; 43 + }; 44 + 45 + const { data, size, setSize, isValidating } = useSWRInfinite( 46 + getKey, 47 + ([_, did, cursor]) => getProfileComments(did, cursor), 48 + { 49 + fallbackData: [ 50 + { comments: props.comments, nextCursor: props.nextCursor }, 51 + ], 52 + revalidateFirstPage: false, 53 + }, 54 + ); 55 + 56 + const loadMoreRef = useRef<HTMLDivElement>(null); 57 + 58 + // Set up intersection observer to load more when trigger element is visible 59 + useEffect(() => { 60 + const observer = new IntersectionObserver( 61 + (entries) => { 62 + if (entries[0].isIntersecting && !isValidating) { 63 + const hasMore = data && data[data.length - 1]?.nextCursor; 64 + if (hasMore) { 65 + setSize(size + 1); 66 + } 67 + } 68 + }, 69 + { threshold: 0.1 }, 70 + ); 71 + 72 + if (loadMoreRef.current) { 73 + observer.observe(loadMoreRef.current); 74 + } 75 + 76 + return () => observer.disconnect(); 77 + }, [data, size, setSize, isValidating]); 78 + 79 + const allComments = data ? data.flatMap((page) => page.comments) : []; 80 + 81 + if (allComments.length === 0 && !isValidating) { 82 + return ( 83 + <div className="text-tertiary text-center py-4">No comments yet</div> 84 + ); 85 + } 86 + 87 + return ( 88 + <div className="flex flex-col gap-2 text-left relative"> 89 + {allComments.map((comment) => ( 90 + <CommentItem key={comment.uri} comment={comment} /> 91 + ))} 92 + {/* Trigger element for loading more comments */} 93 + <div 94 + ref={loadMoreRef} 95 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 96 + aria-hidden="true" 97 + /> 98 + {isValidating && ( 99 + <div className="text-center text-tertiary py-4"> 100 + Loading more comments... 101 + </div> 102 + )} 103 + </div> 104 + ); 105 + }; 106 + 107 + const CommentItem = ({ comment }: { comment: ProfileComment }) => { 108 + const record = comment.record as PubLeafletComment.Record; 109 + const profile = comment.bsky_profiles?.record as 110 + | AppBskyActorProfile.Record 111 + | undefined; 112 + const displayName = 113 + profile?.displayName || comment.bsky_profiles?.handle || "Unknown"; 114 + 115 + // Get commenter DID from comment URI 116 + const commenterDid = new AtUri(comment.uri).host; 117 + 118 + const isReply = !!record.reply; 119 + 120 + // Get document title 121 + const docData = comment.document?.data as 122 + | PubLeafletDocument.Record 123 + | undefined; 124 + const postTitle = docData?.title || "Untitled"; 125 + 126 + // Get parent comment info for replies 127 + const parentRecord = comment.parentComment?.record as 128 + | PubLeafletComment.Record 129 + | undefined; 130 + const parentProfile = comment.parentComment?.bsky_profiles?.record as 131 + | AppBskyActorProfile.Record 132 + | undefined; 133 + const parentDisplayName = 134 + parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle; 135 + 136 + // Build direct link to the comment 137 + const commentLink = useMemo(() => { 138 + if (!comment.document) return null; 139 + const docUri = new AtUri(comment.document.uri); 140 + 141 + // Get base URL using getPublicationURL if publication exists, otherwise build path 142 + let baseUrl: string; 143 + if (comment.publication) { 144 + baseUrl = getPublicationURL(comment.publication); 145 + const pubUri = new AtUri(comment.publication.uri); 146 + // If getPublicationURL returns a relative path, append the document rkey 147 + if (baseUrl.startsWith("/")) { 148 + baseUrl = `${baseUrl}/${docUri.rkey}`; 149 + } else { 150 + // For custom domains, append the document rkey 151 + baseUrl = `${baseUrl}/${docUri.rkey}`; 152 + } 153 + } else { 154 + baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`; 155 + } 156 + 157 + // Build query parameters 158 + const params = new URLSearchParams(); 159 + params.set("interactionDrawer", "comments"); 160 + if (record.onPage) { 161 + params.set("page", record.onPage); 162 + } 163 + 164 + // Use comment URI as hash for direct reference 165 + const commentId = encodeURIComponent(comment.uri); 166 + 167 + return `${baseUrl}?${params.toString()}#${commentId}`; 168 + }, [comment.document, comment.publication, comment.uri, record.onPage]); 169 + 170 + // Get avatar source 171 + const avatarSrc = profile?.avatar?.ref 172 + ? blobRefToSrc(profile.avatar.ref, commenterDid) 173 + : undefined; 174 + 175 + return ( 176 + <div id={comment.uri} className="w-full flex flex-col text-left mb-8"> 177 + <div className="flex gap-2 w-full"> 178 + <Avatar src={avatarSrc} displayName={displayName} /> 179 + <div className="flex flex-col w-full min-w-0 grow"> 180 + <div className="flex flex-row gap-2"> 181 + <div className="text-tertiary text-sm truncate"> 182 + <span className="font-bold text-secondary">{displayName}</span>{" "} 183 + {isReply ? "replied" : "commented"} on{" "} 184 + {commentLink ? ( 185 + <a 186 + href={commentLink} 187 + className="italic text-accent-contrast hover:underline" 188 + > 189 + {postTitle} 190 + </a> 191 + ) : ( 192 + <span className="italic text-accent-contrast">{postTitle}</span> 193 + )} 194 + </div> 195 + </div> 196 + {isReply && parentRecord && ( 197 + <div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center"> 198 + <ReplyTiny className="shrink-0 scale-75" /> 199 + {parentDisplayName && ( 200 + <div className="font-bold shrink-0">{parentDisplayName}</div> 201 + )} 202 + <div className="grow truncate">{parentRecord.plaintext}</div> 203 + </div> 204 + )} 205 + <pre 206 + style={{ wordBreak: "break-word" }} 207 + className="whitespace-pre-wrap text-secondary" 208 + > 209 + <BaseTextBlock 210 + index={[]} 211 + plaintext={record.plaintext} 212 + facets={record.facets} 213 + /> 214 + </pre> 215 + </div> 216 + </div> 217 + </div> 218 + ); 219 + };
+133
app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
···
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Json } from "supabase/database.types"; 5 + import { PubLeafletComment } from "lexicons/api"; 6 + 7 + export type Cursor = { 8 + indexed_at: string; 9 + uri: string; 10 + }; 11 + 12 + export type ProfileComment = { 13 + uri: string; 14 + record: Json; 15 + indexed_at: string; 16 + bsky_profiles: { record: Json; handle: string | null } | null; 17 + document: { 18 + uri: string; 19 + data: Json; 20 + } | null; 21 + publication: { 22 + uri: string; 23 + record: Json; 24 + } | null; 25 + // For replies, include the parent comment info 26 + parentComment: { 27 + uri: string; 28 + record: Json; 29 + bsky_profiles: { record: Json; handle: string | null } | null; 30 + } | null; 31 + }; 32 + 33 + export async function getProfileComments( 34 + did: string, 35 + cursor?: Cursor | null, 36 + ): Promise<{ comments: ProfileComment[]; nextCursor: Cursor | null }> { 37 + const limit = 20; 38 + 39 + let query = supabaseServerClient 40 + .from("comments_on_documents") 41 + .select( 42 + `*, 43 + bsky_profiles(record, handle), 44 + documents(uri, data, documents_in_publications(publications(*)))`, 45 + ) 46 + .eq("profile", did) 47 + .order("indexed_at", { ascending: false }) 48 + .order("uri", { ascending: false }) 49 + .limit(limit); 50 + 51 + if (cursor) { 52 + query = query.or( 53 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 54 + ); 55 + } 56 + 57 + const { data: rawComments } = await query; 58 + 59 + if (!rawComments || rawComments.length === 0) { 60 + return { comments: [], nextCursor: null }; 61 + } 62 + 63 + // Collect parent comment URIs for replies 64 + const parentUris = rawComments 65 + .map((c) => (c.record as PubLeafletComment.Record).reply?.parent) 66 + .filter((uri): uri is string => !!uri); 67 + 68 + // Fetch parent comments if there are any replies 69 + let parentCommentsMap = new Map< 70 + string, 71 + { 72 + uri: string; 73 + record: Json; 74 + bsky_profiles: { record: Json; handle: string | null } | null; 75 + } 76 + >(); 77 + 78 + if (parentUris.length > 0) { 79 + const { data: parentComments } = await supabaseServerClient 80 + .from("comments_on_documents") 81 + .select(`uri, record, bsky_profiles(record, handle)`) 82 + .in("uri", parentUris); 83 + 84 + if (parentComments) { 85 + for (const pc of parentComments) { 86 + parentCommentsMap.set(pc.uri, { 87 + uri: pc.uri, 88 + record: pc.record, 89 + bsky_profiles: pc.bsky_profiles, 90 + }); 91 + } 92 + } 93 + } 94 + 95 + // Transform to ProfileComment format 96 + const comments: ProfileComment[] = rawComments.map((comment) => { 97 + const record = comment.record as PubLeafletComment.Record; 98 + const doc = comment.documents; 99 + const pub = doc?.documents_in_publications?.[0]?.publications; 100 + 101 + return { 102 + uri: comment.uri, 103 + record: comment.record, 104 + indexed_at: comment.indexed_at, 105 + bsky_profiles: comment.bsky_profiles, 106 + document: doc 107 + ? { 108 + uri: doc.uri, 109 + data: doc.data, 110 + } 111 + : null, 112 + publication: pub 113 + ? { 114 + uri: pub.uri, 115 + record: pub.record, 116 + } 117 + : null, 118 + parentComment: record.reply?.parent 119 + ? parentCommentsMap.get(record.reply.parent) || null 120 + : null, 121 + }; 122 + }); 123 + 124 + const nextCursor = 125 + comments.length === limit 126 + ? { 127 + indexed_at: comments[comments.length - 1].indexed_at, 128 + uri: comments[comments.length - 1].uri, 129 + } 130 + : null; 131 + 132 + return { comments, nextCursor }; 133 + }
+28
app/(home-pages)/p/[didOrHandle]/comments/page.tsx
···
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getProfileComments } from "./getProfileComments"; 3 + import { ProfileCommentsContent } from "./CommentsContent"; 4 + 5 + export default async function ProfileCommentsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + let params = await props.params; 9 + let didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + let resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { comments, nextCursor } = await getProfileComments(did); 20 + 21 + return ( 22 + <ProfileCommentsContent 23 + did={did} 24 + comments={comments} 25 + nextCursor={nextCursor} 26 + /> 27 + ); 28 + }
+95
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
···
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 6 + 7 + export type Cursor = { 8 + indexed_at: string; 9 + uri: string; 10 + }; 11 + 12 + export async function getProfilePosts( 13 + did: string, 14 + cursor?: Cursor | null, 15 + ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 16 + const limit = 20; 17 + 18 + let query = supabaseServerClient 19 + .from("documents") 20 + .select( 21 + `*, 22 + comments_on_documents(count), 23 + document_mentions_in_bsky(count), 24 + documents_in_publications(publications(*))`, 25 + ) 26 + .like("uri", `at://${did}/%`) 27 + .order("indexed_at", { ascending: false }) 28 + .order("uri", { ascending: false }) 29 + .limit(limit); 30 + 31 + if (cursor) { 32 + query = query.or( 33 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 34 + ); 35 + } 36 + 37 + let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([ 38 + query, 39 + supabaseServerClient 40 + .from("publications") 41 + .select("*") 42 + .eq("identity_did", did), 43 + supabaseServerClient 44 + .from("bsky_profiles") 45 + .select("handle") 46 + .eq("did", did) 47 + .single(), 48 + ]); 49 + 50 + // Build a map of publications for quick lookup 51 + let pubMap = new Map<string, NonNullable<typeof pubs>[number]>(); 52 + for (let pub of pubs || []) { 53 + pubMap.set(pub.uri, pub); 54 + } 55 + 56 + // Transform data to Post[] format 57 + let handle = profile?.handle ? `@${profile.handle}` : null; 58 + let posts: Post[] = []; 59 + 60 + for (let doc of docs || []) { 61 + let pubFromDoc = doc.documents_in_publications?.[0]?.publications; 62 + let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; 63 + 64 + let post: Post = { 65 + author: handle, 66 + documents: { 67 + data: doc.data, 68 + uri: doc.uri, 69 + indexed_at: doc.indexed_at, 70 + comments_on_documents: doc.comments_on_documents, 71 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 72 + }, 73 + }; 74 + 75 + if (pub) { 76 + post.publication = { 77 + href: getPublicationURL(pub), 78 + pubRecord: pub.record, 79 + uri: pub.uri, 80 + }; 81 + } 82 + 83 + posts.push(post); 84 + } 85 + 86 + const nextCursor = 87 + posts.length === limit 88 + ? { 89 + indexed_at: posts[posts.length - 1].documents.indexed_at, 90 + uri: posts[posts.length - 1].documents.uri, 91 + } 92 + : null; 93 + 94 + return { posts, nextCursor }; 95 + }
+77
app/(home-pages)/p/[didOrHandle]/layout.tsx
···
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Json } from "supabase/database.types"; 5 + import { ProfileHeader } from "./ProfileHeader"; 6 + import { ProfileTabs } from "./ProfileTabs"; 7 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 8 + import { ProfileLayout } from "./ProfileLayout"; 9 + import { Agent } from "@atproto/api"; 10 + import { get_profile_data } from "app/api/rpc/[command]/get_profile_data"; 11 + 12 + export default async function ProfilePageLayout(props: { 13 + params: Promise<{ didOrHandle: string }>; 14 + children: React.ReactNode; 15 + }) { 16 + let params = await props.params; 17 + let didOrHandle = decodeURIComponent(params.didOrHandle); 18 + 19 + // Resolve handle to DID if necessary 20 + let did = didOrHandle; 21 + 22 + if (!didOrHandle.startsWith("did:")) { 23 + let resolved = await idResolver.handle.resolve(didOrHandle); 24 + if (!resolved) { 25 + return ( 26 + <NotFoundLayout> 27 + <p className="font-bold">Sorry, can&apos;t resolve handle!</p> 28 + <p> 29 + This may be a glitch on our end. If the issue persists please{" "} 30 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 31 + </p> 32 + </NotFoundLayout> 33 + ); 34 + } 35 + did = resolved; 36 + } 37 + let profileData = await get_profile_data.handler( 38 + { didOrHandle: did }, 39 + { supabase: supabaseServerClient }, 40 + ); 41 + let { publications, profile } = profileData.result; 42 + 43 + if (!profile) return null; 44 + 45 + return ( 46 + <DashboardLayout 47 + id="profile" 48 + defaultTab="default" 49 + currentPage="profile" 50 + actions={null} 51 + tabs={{ 52 + default: { 53 + controls: null, 54 + content: ( 55 + <ProfileLayout> 56 + <ProfileHeader 57 + profile={profile} 58 + publications={publications || []} 59 + /> 60 + <ProfileTabs didOrHandle={params.didOrHandle} /> 61 + <div className="h-full pt-3 pb-4 px-3 sm:px-4 flex flex-col"> 62 + {props.children} 63 + </div> 64 + </ProfileLayout> 65 + ), 66 + }, 67 + }} 68 + /> 69 + ); 70 + } 71 + 72 + export type ProfileData = { 73 + did: string; 74 + handle: string | null; 75 + indexed_at: string; 76 + record: Json; 77 + };
+24
app/(home-pages)/p/[didOrHandle]/page.tsx
···
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getProfilePosts } from "./getProfilePosts"; 3 + import { ProfilePostsContent } from "./PostsContent"; 4 + 5 + export default async function ProfilePostsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + let params = await props.params; 9 + let didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + let resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { posts, nextCursor } = await getProfilePosts(did); 20 + 21 + return ( 22 + <ProfilePostsContent did={did} posts={posts} nextCursor={nextCursor} /> 23 + ); 24 + }
+103
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
···
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef } from "react"; 4 + import useSWRInfinite from "swr/infinite"; 5 + import { PubListing } from "app/(home-pages)/discover/PubListing"; 6 + import { 7 + getSubscriptions, 8 + type PublicationSubscription, 9 + } from "app/(home-pages)/reader/getSubscriptions"; 10 + import { Cursor } from "app/(home-pages)/reader/getReaderFeed"; 11 + 12 + export const ProfileSubscriptionsContent = (props: { 13 + did: string; 14 + subscriptions: PublicationSubscription[]; 15 + nextCursor: Cursor | null; 16 + }) => { 17 + const getKey = ( 18 + pageIndex: number, 19 + previousPageData: { 20 + subscriptions: PublicationSubscription[]; 21 + nextCursor: Cursor | null; 22 + } | null, 23 + ) => { 24 + // Reached the end 25 + if (previousPageData && !previousPageData.nextCursor) return null; 26 + 27 + // First page, we don't have previousPageData 28 + if (pageIndex === 0) 29 + return ["profile-subscriptions", props.did, null] as const; 30 + 31 + // Add the cursor to the key 32 + return [ 33 + "profile-subscriptions", 34 + props.did, 35 + previousPageData?.nextCursor, 36 + ] as const; 37 + }; 38 + 39 + const { data, size, setSize, isValidating } = useSWRInfinite( 40 + getKey, 41 + ([_, did, cursor]) => getSubscriptions(did, cursor), 42 + { 43 + fallbackData: [ 44 + { subscriptions: props.subscriptions, nextCursor: props.nextCursor }, 45 + ], 46 + revalidateFirstPage: false, 47 + }, 48 + ); 49 + 50 + const loadMoreRef = useRef<HTMLDivElement>(null); 51 + 52 + // Set up intersection observer to load more when trigger element is visible 53 + useEffect(() => { 54 + const observer = new IntersectionObserver( 55 + (entries) => { 56 + if (entries[0].isIntersecting && !isValidating) { 57 + const hasMore = data && data[data.length - 1]?.nextCursor; 58 + if (hasMore) { 59 + setSize(size + 1); 60 + } 61 + } 62 + }, 63 + { threshold: 0.1 }, 64 + ); 65 + 66 + if (loadMoreRef.current) { 67 + observer.observe(loadMoreRef.current); 68 + } 69 + 70 + return () => observer.disconnect(); 71 + }, [data, size, setSize, isValidating]); 72 + 73 + const allSubscriptions = data 74 + ? data.flatMap((page) => page.subscriptions) 75 + : []; 76 + 77 + if (allSubscriptions.length === 0 && !isValidating) { 78 + return ( 79 + <div className="text-tertiary text-center py-4">No subscriptions yet</div> 80 + ); 81 + } 82 + 83 + return ( 84 + <div className="relative"> 85 + <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 86 + {allSubscriptions.map((sub) => ( 87 + <PubListing key={sub.uri} {...sub} /> 88 + ))} 89 + </div> 90 + {/* Trigger element for loading more subscriptions */} 91 + <div 92 + ref={loadMoreRef} 93 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 94 + aria-hidden="true" 95 + /> 96 + {isValidating && ( 97 + <div className="text-center text-tertiary py-4"> 98 + Loading more subscriptions... 99 + </div> 100 + )} 101 + </div> 102 + ); 103 + };
+28
app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
···
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getSubscriptions } from "app/(home-pages)/reader/getSubscriptions"; 3 + import { ProfileSubscriptionsContent } from "./SubscriptionsContent"; 4 + 5 + export default async function ProfileSubscriptionsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + const params = await props.params; 9 + const didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + const resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { subscriptions, nextCursor } = await getSubscriptions(did); 20 + 21 + return ( 22 + <ProfileSubscriptionsContent 23 + did={did} 24 + subscriptions={subscriptions} 25 + nextCursor={nextCursor} 26 + /> 27 + ); 28 + }
+7 -192
app/(home-pages)/reader/ReaderContent.tsx
··· 1 "use client"; 2 - import { AtUri } from "@atproto/api"; 3 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 - import { PubIcon } from "components/ActionBar/Publications"; 5 import { ButtonPrimary } from "components/Buttons"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 8 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 9 - import { Separator } from "components/Layout"; 10 - import { SpeedyLink } from "components/SpeedyLink"; 11 - import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 12 - import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 - import { useSmoker } from "components/Toast"; 14 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 15 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 16 - import { Json } from "supabase/database.types"; 17 import type { Cursor, Post } from "./getReaderFeed"; 18 import useSWRInfinite from "swr/infinite"; 19 import { getReaderFeed } from "./getReaderFeed"; 20 import { useEffect, useRef } from "react"; 21 - import { useRouter } from "next/navigation"; 22 import Link from "next/link"; 23 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 24 25 export const ReaderContent = (props: { 26 posts: Post[]; ··· 28 }) => { 29 const getKey = ( 30 pageIndex: number, 31 - previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 32 ) => { 33 // Reached the end 34 if (previousPageData && !previousPageData.nextCursor) return null; ··· 40 return ["reader-feed", previousPageData?.nextCursor] as const; 41 }; 42 43 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 44 getKey, 45 ([_, cursor]) => getReaderFeed(cursor), 46 { ··· 79 return ( 80 <div className="flex flex-col gap-3 relative"> 81 {allPosts.map((p) => ( 82 - <Post {...p} key={p.documents.uri} /> 83 ))} 84 {/* Trigger element for loading more posts */} 85 <div ··· 96 ); 97 }; 98 99 - const Post = (props: Post) => { 100 - let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 101 - 102 - let postRecord = props.documents.data as PubLeafletDocument.Record; 103 - let postUri = new AtUri(props.documents.uri); 104 - 105 - let theme = usePubTheme(pubRecord); 106 - let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 107 - ? blobRefToSrc( 108 - pubRecord?.theme?.backgroundImage?.image?.ref, 109 - new AtUri(props.publication.uri).host, 110 - ) 111 - : null; 112 - 113 - let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 114 - let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 115 - 116 - let showPageBackground = pubRecord.theme?.showPageBackground; 117 - 118 - let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 119 - let comments = 120 - pubRecord.preferences?.showComments === false 121 - ? 0 122 - : props.documents.comments_on_documents?.[0]?.count || 0; 123 - 124 - return ( 125 - <BaseThemeProvider {...theme} local> 126 - <div 127 - style={{ 128 - backgroundImage: `url(${backgroundImage})`, 129 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 130 - backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 131 - }} 132 - className={`no-underline! flex flex-row gap-2 w-full relative 133 - bg-bg-leaflet 134 - border border-border-light rounded-lg 135 - sm:p-2 p-2 selected-outline 136 - hover:outline-accent-contrast hover:border-accent-contrast 137 - `} 138 - > 139 - <a 140 - className="h-full w-full absolute top-0 left-0" 141 - href={`${props.publication.href}/${postUri.rkey}`} 142 - /> 143 - <div 144 - className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 145 - style={{ 146 - backgroundColor: showPageBackground 147 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 148 - : "transparent", 149 - }} 150 - > 151 - <h3 className="text-primary truncate">{postRecord.title}</h3> 152 - 153 - <p className="text-secondary">{postRecord.description}</p> 154 - <div className="flex gap-2 justify-between items-end"> 155 - <div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-start justify-start pt-1 md:pt-3"> 156 - <PubInfo 157 - href={props.publication.href} 158 - pubRecord={pubRecord} 159 - uri={props.publication.uri} 160 - /> 161 - <Separator classname="h-4 !min-h-0 md:block hidden" /> 162 - <PostInfo 163 - author={props.author || ""} 164 - publishedAt={postRecord.publishedAt} 165 - /> 166 - </div> 167 - 168 - <PostInterations 169 - postUrl={`${props.publication.href}/${postUri.rkey}`} 170 - quotesCount={quotes} 171 - commentsCount={comments} 172 - showComments={pubRecord.preferences?.showComments} 173 - /> 174 - </div> 175 - </div> 176 - </div> 177 - </BaseThemeProvider> 178 - ); 179 - }; 180 - 181 - const PubInfo = (props: { 182 - href: string; 183 - pubRecord: PubLeafletPublication.Record; 184 - uri: string; 185 - }) => { 186 - return ( 187 - <a 188 - href={props.href} 189 - className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0" 190 - > 191 - <PubIcon small record={props.pubRecord} uri={props.uri} /> 192 - {props.pubRecord.name} 193 - </a> 194 - ); 195 - }; 196 - 197 - const PostInfo = (props: { 198 - author: string; 199 - publishedAt: string | undefined; 200 - }) => { 201 - const formattedDate = useLocalizedDate( 202 - props.publishedAt || new Date().toISOString(), 203 - { 204 - year: "numeric", 205 - month: "short", 206 - day: "numeric", 207 - }, 208 - ); 209 - 210 - return ( 211 - <div className="flex flex-wrap gap-2 grow items-center shrink-0"> 212 - {props.author} 213 - {props.publishedAt && ( 214 - <> 215 - <Separator classname="h-4 !min-h-0" /> 216 - {formattedDate}{" "} 217 - </> 218 - )} 219 - </div> 220 - ); 221 - }; 222 - 223 - const PostInterations = (props: { 224 - quotesCount: number; 225 - commentsCount: number; 226 - postUrl: string; 227 - showComments: boolean | undefined; 228 - }) => { 229 - let smoker = useSmoker(); 230 - let interactionsAvailable = 231 - props.quotesCount > 0 || 232 - (props.showComments !== false && props.commentsCount > 0); 233 - 234 - return ( 235 - <div className={`flex gap-2 text-tertiary text-sm items-center`}> 236 - {props.quotesCount === 0 ? null : ( 237 - <div className={`flex gap-1 items-center `} aria-label="Post quotes"> 238 - <QuoteTiny aria-hidden /> {props.quotesCount} 239 - </div> 240 - )} 241 - {props.showComments === false || props.commentsCount === 0 ? null : ( 242 - <div className={`flex gap-1 items-center`} aria-label="Post comments"> 243 - <CommentTiny aria-hidden /> {props.commentsCount} 244 - </div> 245 - )} 246 - {interactionsAvailable && <Separator classname="h-4 !min-h-0" />} 247 - <button 248 - id={`copy-post-link-${props.postUrl}`} 249 - className="flex gap-1 items-center hover:font-bold relative" 250 - onClick={(e) => { 251 - e.stopPropagation(); 252 - e.preventDefault(); 253 - let mouseX = e.clientX; 254 - let mouseY = e.clientY; 255 - 256 - if (!props.postUrl) return; 257 - navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 258 - 259 - smoker({ 260 - text: <strong>Copied Link!</strong>, 261 - position: { 262 - y: mouseY, 263 - x: mouseX, 264 - }, 265 - }); 266 - }} 267 - > 268 - Share 269 - </button> 270 - </div> 271 - ); 272 - }; 273 export const ReaderEmpty = () => { 274 return ( 275 <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary">
··· 1 "use client"; 2 import { ButtonPrimary } from "components/Buttons"; 3 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 4 import type { Cursor, Post } from "./getReaderFeed"; 5 import useSWRInfinite from "swr/infinite"; 6 import { getReaderFeed } from "./getReaderFeed"; 7 import { useEffect, useRef } from "react"; 8 import Link from "next/link"; 9 + import { PostListing } from "components/PostListing"; 10 11 export const ReaderContent = (props: { 12 posts: Post[]; ··· 14 }) => { 15 const getKey = ( 16 pageIndex: number, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 21 ) => { 22 // Reached the end 23 if (previousPageData && !previousPageData.nextCursor) return null; ··· 29 return ["reader-feed", previousPageData?.nextCursor] as const; 30 }; 31 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 33 getKey, 34 ([_, cursor]) => getReaderFeed(cursor), 35 { ··· 68 return ( 69 <div className="flex flex-col gap-3 relative"> 70 {allPosts.map((p) => ( 71 + <PostListing {...p} key={p.documents.uri} /> 72 ))} 73 {/* Trigger element for loading more posts */} 74 <div ··· 85 ); 86 }; 87 88 export const ReaderEmpty = () => { 89 return ( 90 <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary">
+1 -1
app/(home-pages)/reader/SubscriptionsContent.tsx
··· 32 33 const { data, error, size, setSize, isValidating } = useSWRInfinite( 34 getKey, 35 - ([_, cursor]) => getSubscriptions(cursor), 36 { 37 fallbackData: [ 38 { subscriptions: props.publications, nextCursor: props.nextCursor },
··· 32 33 const { data, error, size, setSize, isValidating } = useSWRInfinite( 34 getKey, 35 + ([_, cursor]) => getSubscriptions(null, cursor), 36 { 37 fallbackData: [ 38 { subscriptions: props.publications, nextCursor: props.nextCursor },
+1 -1
app/(home-pages)/reader/getReaderFeed.ts
··· 83 84 export type Post = { 85 author: string | null; 86 - publication: { 87 href: string; 88 pubRecord: Json; 89 uri: string;
··· 83 84 export type Post = { 85 author: string | null; 86 + publication?: { 87 href: string; 88 pubRecord: Json; 89 uri: string;
+13 -4
app/(home-pages)/reader/getSubscriptions.ts
··· 8 import { idResolver } from "./idResolver"; 9 import { Cursor } from "./getReaderFeed"; 10 11 - export async function getSubscriptions(cursor?: Cursor | null): Promise<{ 12 nextCursor: null | Cursor; 13 subscriptions: PublicationSubscription[]; 14 }> { 15 - let auth_res = await getIdentityData(); 16 - if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 17 let query = supabaseServerClient 18 .from("publication_subscriptions") 19 .select(`*, publications(*, documents_in_publications(*, documents(*)))`) ··· 25 }) 26 .limit(1, { referencedTable: "publications.documents_in_publications" }) 27 .limit(25) 28 - .eq("identity", auth_res.atp_did); 29 30 if (cursor) { 31 query = query.or(
··· 8 import { idResolver } from "./idResolver"; 9 import { Cursor } from "./getReaderFeed"; 10 11 + export async function getSubscriptions( 12 + did?: string | null, 13 + cursor?: Cursor | null, 14 + ): Promise<{ 15 nextCursor: null | Cursor; 16 subscriptions: PublicationSubscription[]; 17 }> { 18 + // If no DID provided, use logged-in user's DID 19 + let identity = did; 20 + if (!identity) { 21 + const auth_res = await getIdentityData(); 22 + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 23 + identity = auth_res.atp_did; 24 + } 25 + 26 let query = supabaseServerClient 27 .from("publication_subscriptions") 28 .select(`*, publications(*, documents_in_publications(*, documents(*)))`) ··· 34 }) 35 .limit(1, { referencedTable: "publications.documents_in_publications" }) 36 .limit(25) 37 + .eq("identity", identity); 38 39 if (cursor) { 40 query = query.or(
-1
app/(home-pages)/reader/page.tsx
··· 12 return ( 13 <DashboardLayout 14 id="reader" 15 - cardBorderHidden={false} 16 currentPage="reader" 17 defaultTab="Read" 18 actions={null}
··· 12 return ( 13 <DashboardLayout 14 id="reader" 15 currentPage="reader" 16 defaultTab="Read" 17 actions={null}
+71
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
···
··· 1 + "use server"; 2 + 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + import { AtUri } from "@atproto/api"; 6 + import { Json } from "supabase/database.types"; 7 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 9 + 10 + export async function getDocumentsByTag( 11 + tag: string, 12 + ): Promise<{ posts: Post[] }> { 13 + // Normalize tag to lowercase for case-insensitive search 14 + const normalizedTag = tag.toLowerCase(); 15 + 16 + // Query documents that have this tag 17 + const { data: documents, error } = await supabaseServerClient 18 + .from("documents") 19 + .select( 20 + `*, 21 + comments_on_documents(count), 22 + document_mentions_in_bsky(count), 23 + documents_in_publications(publications(*))`, 24 + ) 25 + .contains("data->tags", `["${normalizedTag}"]`) 26 + .order("indexed_at", { ascending: false }) 27 + .limit(50); 28 + 29 + if (error) { 30 + console.error("Error fetching documents by tag:", error); 31 + return { posts: [] }; 32 + } 33 + 34 + const posts = await Promise.all( 35 + documents.map(async (doc) => { 36 + const pub = doc.documents_in_publications[0]?.publications; 37 + 38 + // Skip if document doesn't have a publication 39 + if (!pub) { 40 + return null; 41 + } 42 + 43 + const uri = new AtUri(doc.uri); 44 + const handle = await idResolver.did.resolve(uri.host); 45 + 46 + const post: Post = { 47 + publication: { 48 + href: getPublicationURL(pub), 49 + pubRecord: pub?.record || null, 50 + uri: pub?.uri || "", 51 + }, 52 + author: handle?.alsoKnownAs?.[0] 53 + ? `@${handle.alsoKnownAs[0].slice(5)}` 54 + : null, 55 + documents: { 56 + comments_on_documents: doc.comments_on_documents, 57 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 58 + data: doc.data, 59 + uri: doc.uri, 60 + indexed_at: doc.indexed_at, 61 + }, 62 + }; 63 + return post; 64 + }), 65 + ); 66 + 67 + // Filter out null entries (documents without publications) 68 + return { 69 + posts: posts.filter((p): p is Post => p !== null), 70 + }; 71 + }
+74
app/(home-pages)/tag/[tag]/page.tsx
···
··· 1 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 2 + import { Tag } from "components/Tags"; 3 + import { PostListing } from "components/PostListing"; 4 + import { getDocumentsByTag } from "./getDocumentsByTag"; 5 + import { TagTiny } from "components/Icons/TagTiny"; 6 + 7 + export default async function TagPage(props: { 8 + params: Promise<{ tag: string }>; 9 + }) { 10 + const params = await props.params; 11 + const decodedTag = decodeURIComponent(params.tag); 12 + const { posts } = await getDocumentsByTag(decodedTag); 13 + 14 + return ( 15 + <DashboardLayout 16 + id="tag" 17 + currentPage="tag" 18 + defaultTab="default" 19 + actions={null} 20 + tabs={{ 21 + default: { 22 + controls: null, 23 + content: <TagContent tag={decodedTag} posts={posts} />, 24 + }, 25 + }} 26 + /> 27 + ); 28 + } 29 + 30 + const TagContent = (props: { 31 + tag: string; 32 + posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"]; 33 + }) => { 34 + return ( 35 + <div className="max-w-prose mx-auto w-full grow shrink-0"> 36 + <div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4"> 37 + <TagHeader tag={props.tag} postCount={props.posts.length} /> 38 + </div> 39 + <div className="pt-6 flex flex-col gap-3"> 40 + {props.posts.length === 0 ? ( 41 + <EmptyState tag={props.tag} /> 42 + ) : ( 43 + props.posts.map((post) => ( 44 + <PostListing key={post.documents.uri} {...post} /> 45 + )) 46 + )} 47 + </div> 48 + </div> 49 + ); 50 + }; 51 + 52 + const TagHeader = (props: { tag: string; postCount: number }) => { 53 + return ( 54 + <div className="flex flex-col leading-tight items-center"> 55 + <div className="flex items-center gap-3 text-xl font-bold text-primary"> 56 + <TagTiny className="scale-150" /> 57 + <h1>{props.tag}</h1> 58 + </div> 59 + <div className="text-tertiary text-sm"> 60 + {props.postCount} {props.postCount === 1 ? "post" : "posts"} 61 + </div> 62 + </div> 63 + ); 64 + }; 65 + 66 + const EmptyState = (props: { tag: string }) => { 67 + return ( 68 + <div className="flex flex-col gap-2 items-center justify-center p-8 text-center"> 69 + <div className="text-tertiary"> 70 + No posts found with the tag "{props.tag}" 71 + </div> 72 + </div> 73 + ); 74 + };
-98
app/[leaflet_id]/Actions.tsx
··· 1 - import { publishToPublication } from "actions/publishToPublication"; 2 - import { 3 - getBasePublicationURL, 4 - getPublicationURL, 5 - } from "app/lish/createPub/getPublicationURL"; 6 - import { ActionButton } from "components/ActionBar/ActionButton"; 7 - import { GoBackSmall } from "components/Icons/GoBackSmall"; 8 - import { PublishSmall } from "components/Icons/PublishSmall"; 9 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 10 - import { SpeedyLink } from "components/SpeedyLink"; 11 - import { useToaster } from "components/Toast"; 12 - import { DotLoader } from "components/utils/DotLoader"; 13 - import { useParams, useRouter } from "next/navigation"; 14 - import { useState } from "react"; 15 - import { useReplicache } from "src/replicache"; 16 - import { Json } from "supabase/database.types"; 17 - 18 - export const BackToPubButton = (props: { 19 - publication: { 20 - identity_did: string; 21 - indexed_at: string; 22 - name: string; 23 - record: Json; 24 - uri: string; 25 - }; 26 - }) => { 27 - return ( 28 - <SpeedyLink 29 - href={`${getBasePublicationURL(props.publication)}/dashboard`} 30 - className="hover:no-underline!" 31 - > 32 - <ActionButton 33 - icon={<GoBackSmall className="shrink-0" />} 34 - label="To Pub" 35 - /> 36 - </SpeedyLink> 37 - ); 38 - }; 39 - 40 - export const PublishButton = () => { 41 - let { data: pub } = useLeafletPublicationData(); 42 - let params = useParams(); 43 - let router = useRouter(); 44 - if (!pub?.doc) 45 - return ( 46 - <ActionButton 47 - primary 48 - icon={<PublishSmall className="shrink-0" />} 49 - label={"Publish!"} 50 - onClick={() => { 51 - router.push(`/${params.leaflet_id}/publish`); 52 - }} 53 - /> 54 - ); 55 - 56 - return <UpdateButton />; 57 - }; 58 - 59 - const UpdateButton = () => { 60 - let [isLoading, setIsLoading] = useState(false); 61 - let { data: pub, mutate } = useLeafletPublicationData(); 62 - let { permission_token, rootEntity } = useReplicache(); 63 - let toaster = useToaster(); 64 - 65 - return ( 66 - <ActionButton 67 - primary 68 - icon={<PublishSmall className="shrink-0" />} 69 - label={isLoading ? <DotLoader /> : "Update!"} 70 - onClick={async () => { 71 - if (!pub || !pub.publications) return; 72 - setIsLoading(true); 73 - let doc = await publishToPublication({ 74 - root_entity: rootEntity, 75 - publication_uri: pub.publications.uri, 76 - leaflet_id: permission_token.id, 77 - title: pub.title, 78 - description: pub.description, 79 - }); 80 - setIsLoading(false); 81 - mutate(); 82 - toaster({ 83 - content: ( 84 - <div> 85 - {pub.doc ? "Updated! " : "Published! "} 86 - <SpeedyLink 87 - href={`${getPublicationURL(pub.publications)}/${doc?.rkey}`} 88 - > 89 - link 90 - </SpeedyLink> 91 - </div> 92 - ), 93 - type: "success", 94 - }); 95 - }} 96 - /> 97 - ); 98 - };
···
+16 -20
app/[leaflet_id]/Footer.tsx
··· 4 import { Media } from "components/Media"; 5 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 6 import { Toolbar } from "components/Toolbar"; 7 - import { ShareOptions } from "components/ShareOptions"; 8 - import { HomeButton } from "components/HomeButton"; 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 - import { HelpPopover } from "components/HelpPopover"; 11 import { Watermark } from "components/Watermark"; 12 - import { BackToPubButton, PublishButton } from "./Actions"; 13 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 14 import { useIdentityData } from "components/IdentityProvider"; 15 ··· 36 /> 37 </div> 38 ) : entity_set.permissions.write ? ( 39 - pub?.publications && 40 - identity?.atp_did && 41 - pub.publications.identity_did === identity.atp_did ? ( 42 - <ActionFooter> 43 <BackToPubButton publication={pub.publications} /> 44 - <PublishButton /> 45 - <ShareOptions /> 46 - <HelpPopover /> 47 - <ThemePopover entityID={props.entityID} /> 48 - </ActionFooter> 49 - ) : ( 50 - <ActionFooter> 51 <HomeButton /> 52 - <ShareOptions /> 53 - <HelpPopover /> 54 - <ThemePopover entityID={props.entityID} /> 55 - </ActionFooter> 56 - ) 57 ) : ( 58 <div className="pb-2 px-2 z-10 flex justify-end"> 59 <Watermark mobile />
··· 4 import { Media } from "components/Media"; 5 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 6 import { Toolbar } from "components/Toolbar"; 7 + import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 8 + import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 + import { PublishButton } from "./actions/PublishButton"; 10 import { useEntitySetContext } from "components/EntitySetProvider"; 11 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 12 import { Watermark } from "components/Watermark"; 13 + import { BackToPubButton } from "./actions/BackToPubButton"; 14 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 15 import { useIdentityData } from "components/IdentityProvider"; 16 ··· 37 /> 38 </div> 39 ) : entity_set.permissions.write ? ( 40 + <ActionFooter> 41 + {pub?.publications && 42 + identity?.atp_did && 43 + pub.publications.identity_did === identity.atp_did ? ( 44 <BackToPubButton publication={pub.publications} /> 45 + ) : ( 46 <HomeButton /> 47 + )} 48 + 49 + <PublishButton entityID={props.entityID} /> 50 + <ShareOptions /> 51 + <ThemePopover entityID={props.entityID} /> 52 + </ActionFooter> 53 ) : ( 54 <div className="pb-2 px-2 z-10 flex justify-end"> 55 <Watermark mobile />
+12 -28
app/[leaflet_id]/Sidebar.tsx
··· 1 "use client"; 2 - import { ActionButton } from "components/ActionBar/ActionButton"; 3 import { Sidebar } from "components/ActionBar/Sidebar"; 4 import { useEntitySetContext } from "components/EntitySetProvider"; 5 - import { HelpPopover } from "components/HelpPopover"; 6 - import { HomeButton } from "components/HomeButton"; 7 import { Media } from "components/Media"; 8 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 9 - import { ShareOptions } from "components/ShareOptions"; 10 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 11 import { Watermark } from "components/Watermark"; 12 - import { useUIState } from "src/useUIState"; 13 - import { BackToPubButton, PublishButton } from "./Actions"; 14 import { useIdentityData } from "components/IdentityProvider"; 15 import { useReplicache } from "src/replicache"; 16 ··· 29 <div className="sidebarContainer flex flex-col justify-end h-full w-16 relative"> 30 {entity_set.permissions.write && ( 31 <Sidebar> 32 {pub?.publications && 33 identity?.atp_did && 34 pub.publications.identity_did === identity.atp_did ? ( 35 - <> 36 - <PublishButton /> 37 - <ShareOptions /> 38 - <ThemePopover entityID={rootEntity} /> 39 - <HelpPopover /> 40 - <hr className="text-border" /> 41 - <BackToPubButton publication={pub.publications} /> 42 - </> 43 ) : ( 44 - <> 45 - <ShareOptions /> 46 - <ThemePopover entityID={rootEntity} /> 47 - <HelpPopover /> 48 - <hr className="text-border" /> 49 - <HomeButton /> 50 - </> 51 )} 52 </Sidebar> 53 )} ··· 59 </Media> 60 ); 61 } 62 - 63 - const blurPage = () => { 64 - useUIState.setState(() => ({ 65 - focusedEntity: null, 66 - selectedBlocks: [], 67 - })); 68 - };
··· 1 "use client"; 2 import { Sidebar } from "components/ActionBar/Sidebar"; 3 import { useEntitySetContext } from "components/EntitySetProvider"; 4 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 5 + import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 6 import { Media } from "components/Media"; 7 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 + import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 9 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 10 + import { PublishButton } from "./actions/PublishButton"; 11 import { Watermark } from "components/Watermark"; 12 + import { BackToPubButton } from "./actions/BackToPubButton"; 13 import { useIdentityData } from "components/IdentityProvider"; 14 import { useReplicache } from "src/replicache"; 15 ··· 28 <div className="sidebarContainer flex flex-col justify-end h-full w-16 relative"> 29 {entity_set.permissions.write && ( 30 <Sidebar> 31 + <PublishButton entityID={rootEntity} /> 32 + <ShareOptions /> 33 + <ThemePopover entityID={rootEntity} /> 34 + <HelpButton /> 35 + <hr className="text-border" /> 36 {pub?.publications && 37 identity?.atp_did && 38 pub.publications.identity_did === identity.atp_did ? ( 39 + <BackToPubButton publication={pub.publications} /> 40 ) : ( 41 + <HomeButton /> 42 )} 43 </Sidebar> 44 )} ··· 50 </Media> 51 ); 52 }
+27
app/[leaflet_id]/actions/BackToPubButton.tsx
···
··· 1 + import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; 2 + import { ActionButton } from "components/ActionBar/ActionButton"; 3 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 4 + import { SpeedyLink } from "components/SpeedyLink"; 5 + import { Json } from "supabase/database.types"; 6 + 7 + export const BackToPubButton = (props: { 8 + publication: { 9 + identity_did: string; 10 + indexed_at: string; 11 + name: string; 12 + record: Json; 13 + uri: string; 14 + }; 15 + }) => { 16 + return ( 17 + <SpeedyLink 18 + href={`${getBasePublicationURL(props.publication)}/dashboard`} 19 + className="hover:no-underline!" 20 + > 21 + <ActionButton 22 + icon={<GoBackSmall className="shrink-0" />} 23 + label="To Pub" 24 + /> 25 + </SpeedyLink> 26 + ); 27 + };
+173
app/[leaflet_id]/actions/HelpButton.tsx
···
··· 1 + "use client"; 2 + import { ShortcutKey } from "../../../components/Layout"; 3 + import { Media } from "../../../components/Media"; 4 + import { Popover } from "../../../components/Popover"; 5 + import { metaKey } from "src/utils/metaKey"; 6 + import { useEntitySetContext } from "../../../components/EntitySetProvider"; 7 + import { useState } from "react"; 8 + import { ActionButton } from "components/ActionBar/ActionButton"; 9 + import { HelpSmall } from "../../../components/Icons/HelpSmall"; 10 + import { isMac } from "src/utils/isDevice"; 11 + import { useIsMobile } from "src/hooks/isMobile"; 12 + 13 + export const HelpButton = (props: { noShortcuts?: boolean }) => { 14 + let entity_set = useEntitySetContext(); 15 + let isMobile = useIsMobile(); 16 + 17 + return entity_set.permissions.write ? ( 18 + <Popover 19 + side={isMobile ? "top" : "right"} 20 + align={isMobile ? "center" : "start"} 21 + asChild 22 + className="max-w-xs w-full" 23 + trigger={<ActionButton icon={<HelpSmall />} label="About" />} 24 + > 25 + <div className="flex flex-col text-sm gap-2 text-secondary"> 26 + {/* about links */} 27 + <HelpLink text="๐Ÿ“– Leaflet Manual" url="https://about.leaflet.pub" /> 28 + <HelpLink text="๐Ÿ’ก Make with Leaflet" url="https://make.leaflet.pub" /> 29 + <HelpLink 30 + text="โœจ Explore Publications" 31 + url="https://leaflet.pub/discover" 32 + /> 33 + <HelpLink text="๐Ÿ“ฃ Newsletter" url="https://buttondown.com/leaflet" /> 34 + {/* contact links */} 35 + <div className="columns-2 gap-2"> 36 + <HelpLink 37 + text="๐Ÿฆ‹ Bluesky" 38 + url="https://bsky.app/profile/leaflet.pub" 39 + /> 40 + <HelpLink text="๐Ÿ’Œ Email" url="mailto:contact@leaflet.pub" /> 41 + </div> 42 + {/* keyboard shortcuts: desktop only */} 43 + <Media mobile={false}> 44 + {!props.noShortcuts && ( 45 + <> 46 + <hr className="text-border my-1" /> 47 + <div className="flex flex-col gap-1"> 48 + <Label>Text Shortcuts</Label> 49 + <KeyboardShortcut name="Bold" keys={[metaKey(), "B"]} /> 50 + <KeyboardShortcut name="Italic" keys={[metaKey(), "I"]} /> 51 + <KeyboardShortcut name="Underline" keys={[metaKey(), "U"]} /> 52 + <KeyboardShortcut 53 + name="Highlight" 54 + keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "H"]} 55 + /> 56 + <KeyboardShortcut 57 + name="Strikethrough" 58 + keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]} 59 + /> 60 + <KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} /> 61 + 62 + <Label>Block Shortcuts</Label> 63 + {/* shift + up/down arrows (or click + drag): select multiple blocks */} 64 + <KeyboardShortcut 65 + name="Move Block Up" 66 + keys={["Shift", metaKey(), "โ†‘"]} 67 + /> 68 + <KeyboardShortcut 69 + name="Move Block Down" 70 + keys={["Shift", metaKey(), "โ†“"]} 71 + /> 72 + {/* cmd/ctrl-a: first selects all text in a block; again selects all blocks on page */} 73 + {/* cmd/ctrl + up/down arrows: go to beginning / end of doc */} 74 + 75 + <Label>Canvas Shortcuts</Label> 76 + <OtherShortcut name="Add Block" description="Double click" /> 77 + <OtherShortcut name="Select Block" description="Long press" /> 78 + 79 + <Label>Outliner Shortcuts</Label> 80 + <KeyboardShortcut 81 + name="Make List" 82 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "L"]} 83 + /> 84 + {/* tab / shift + tab: indent / outdent */} 85 + <KeyboardShortcut 86 + name="Toggle Checkbox" 87 + keys={[metaKey(), "Enter"]} 88 + /> 89 + <KeyboardShortcut 90 + name="Toggle Fold" 91 + keys={[metaKey(), "Shift", "Enter"]} 92 + /> 93 + <KeyboardShortcut 94 + name="Fold All" 95 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "โ†‘"]} 96 + /> 97 + <KeyboardShortcut 98 + name="Unfold All" 99 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "โ†“"]} 100 + /> 101 + </div> 102 + </> 103 + )} 104 + </Media> 105 + {/* links: terms and privacy */} 106 + <hr className="text-border my-1" /> 107 + {/* <HelpLink 108 + text="Terms and Privacy Policy" 109 + url="https://leaflet.pub/legal" 110 + /> */} 111 + <div> 112 + <a href="https://leaflet.pub/legal" target="_blank"> 113 + Terms and Privacy Policy 114 + </a> 115 + </div> 116 + </div> 117 + </Popover> 118 + ) : null; 119 + }; 120 + 121 + const KeyboardShortcut = (props: { name: string; keys: string[] }) => { 122 + return ( 123 + <div className="flex gap-2 justify-between items-center"> 124 + {props.name} 125 + <div className="flex gap-1 items-center font-bold"> 126 + {props.keys.map((key, index) => { 127 + return <ShortcutKey key={index}>{key}</ShortcutKey>; 128 + })} 129 + </div> 130 + </div> 131 + ); 132 + }; 133 + 134 + const OtherShortcut = (props: { name: string; description: string }) => { 135 + return ( 136 + <div className="flex justify-between items-center"> 137 + <span>{props.name}</span> 138 + <span> 139 + <strong>{props.description}</strong> 140 + </span> 141 + </div> 142 + ); 143 + }; 144 + 145 + const Label = (props: { children: React.ReactNode }) => { 146 + return <div className="text-tertiary font-bold pt-2 ">{props.children}</div>; 147 + }; 148 + 149 + const HelpLink = (props: { url: string; text: string }) => { 150 + const [isHovered, setIsHovered] = useState(false); 151 + const handleMouseEnter = () => { 152 + setIsHovered(true); 153 + }; 154 + const handleMouseLeave = () => { 155 + setIsHovered(false); 156 + }; 157 + return ( 158 + <a 159 + href={props.url} 160 + target="_blank" 161 + className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline" 162 + style={{ 163 + backgroundColor: isHovered 164 + ? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)" 165 + : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 166 + }} 167 + onMouseEnter={handleMouseEnter} 168 + onMouseLeave={handleMouseLeave} 169 + > 170 + <strong>{props.text}</strong> 171 + </a> 172 + ); 173 + };
+74
app/[leaflet_id]/actions/HomeButton.tsx
···
··· 1 + "use client"; 2 + import Link from "next/link"; 3 + import { useEntitySetContext } from "../../../components/EntitySetProvider"; 4 + import { ActionButton } from "components/ActionBar/ActionButton"; 5 + import { useSearchParams } from "next/navigation"; 6 + import { useIdentityData } from "../../../components/IdentityProvider"; 7 + import { useReplicache } from "src/replicache"; 8 + import { addLeafletToHome } from "actions/addLeafletToHome"; 9 + import { useSmoker } from "../../../components/Toast"; 10 + import { AddToHomeSmall } from "../../../components/Icons/AddToHomeSmall"; 11 + import { HomeSmall } from "../../../components/Icons/HomeSmall"; 12 + import { produce } from "immer"; 13 + 14 + export function HomeButton() { 15 + let { permissions } = useEntitySetContext(); 16 + let searchParams = useSearchParams(); 17 + 18 + return ( 19 + <> 20 + <Link 21 + href="/home" 22 + prefetch 23 + className="hover:no-underline" 24 + style={{ textDecorationLine: "none !important" }} 25 + > 26 + <ActionButton icon={<HomeSmall />} label="Go Home" /> 27 + </Link> 28 + {<AddToHomeButton />} 29 + </> 30 + ); 31 + } 32 + 33 + const AddToHomeButton = (props: {}) => { 34 + let { permission_token } = useReplicache(); 35 + let { identity, mutate } = useIdentityData(); 36 + let smoker = useSmoker(); 37 + if ( 38 + identity?.permission_token_on_homepage.find( 39 + (pth) => pth.permission_tokens.id === permission_token.id, 40 + ) || 41 + !identity 42 + ) 43 + return null; 44 + return ( 45 + <ActionButton 46 + onClick={async (e) => { 47 + await addLeafletToHome(permission_token.id); 48 + mutate((identity) => { 49 + if (!identity) return; 50 + return produce<typeof identity>((draft) => { 51 + draft.permission_token_on_homepage.push({ 52 + created_at: new Date().toISOString(), 53 + archived: null, 54 + permission_tokens: { 55 + ...permission_token, 56 + leaflets_to_documents: [], 57 + leaflets_in_publications: [], 58 + }, 59 + }); 60 + })(identity); 61 + }); 62 + smoker({ 63 + position: { 64 + x: e.clientX + 64, 65 + y: e.clientY, 66 + }, 67 + text: "Leaflet added to your home!", 68 + }); 69 + }} 70 + icon={<AddToHomeSmall />} 71 + label="Add to Home" 72 + /> 73 + ); 74 + };
+432
app/[leaflet_id]/actions/PublishButton.tsx
···
··· 1 + "use client"; 2 + import { publishToPublication } from "actions/publishToPublication"; 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { ActionButton } from "components/ActionBar/ActionButton"; 5 + import { 6 + PubIcon, 7 + PubListEmptyContent, 8 + PubListEmptyIllo, 9 + } from "components/ActionBar/Publications"; 10 + import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 11 + import { AddSmall } from "components/Icons/AddSmall"; 12 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 13 + import { PublishSmall } from "components/Icons/PublishSmall"; 14 + import { useIdentityData } from "components/IdentityProvider"; 15 + import { InputWithLabel } from "components/Input"; 16 + import { Menu, MenuItem } from "components/Layout"; 17 + import { 18 + useLeafletDomains, 19 + useLeafletPublicationData, 20 + } from "components/PageSWRDataProvider"; 21 + import { Popover } from "components/Popover"; 22 + import { SpeedyLink } from "components/SpeedyLink"; 23 + import { useToaster } from "components/Toast"; 24 + import { DotLoader } from "components/utils/DotLoader"; 25 + import { PubLeafletPublication } from "lexicons/api"; 26 + import { useParams, useRouter, useSearchParams } from "next/navigation"; 27 + import { useState, useMemo } from "react"; 28 + import { useIsMobile } from "src/hooks/isMobile"; 29 + import { useReplicache, useEntity } from "src/replicache"; 30 + import { useSubscribe } from "src/replicache/useSubscribe"; 31 + import { Json } from "supabase/database.types"; 32 + import { 33 + useBlocks, 34 + useCanvasBlocksWithType, 35 + } from "src/hooks/queries/useBlocks"; 36 + import * as Y from "yjs"; 37 + import * as base64 from "base64-js"; 38 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39 + import { BlueskyLogin } from "app/login/LoginForm"; 40 + import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41 + import { AddTiny } from "components/Icons/AddTiny"; 42 + 43 + export const PublishButton = (props: { entityID: string }) => { 44 + let { data: pub } = useLeafletPublicationData(); 45 + let params = useParams(); 46 + let router = useRouter(); 47 + 48 + if (!pub) return <PublishToPublicationButton entityID={props.entityID} />; 49 + if (!pub?.doc) 50 + return ( 51 + <ActionButton 52 + primary 53 + icon={<PublishSmall className="shrink-0" />} 54 + label={"Publish!"} 55 + onClick={() => { 56 + router.push(`/${params.leaflet_id}/publish`); 57 + }} 58 + /> 59 + ); 60 + 61 + return <UpdateButton />; 62 + }; 63 + 64 + const UpdateButton = () => { 65 + let [isLoading, setIsLoading] = useState(false); 66 + let { data: pub, mutate } = useLeafletPublicationData(); 67 + let { permission_token, rootEntity, rep } = useReplicache(); 68 + let { identity } = useIdentityData(); 69 + let toaster = useToaster(); 70 + 71 + // Get tags from Replicache state (same as draft editor) 72 + let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 73 + const currentTags = Array.isArray(tags) ? tags : []; 74 + 75 + return ( 76 + <ActionButton 77 + primary 78 + icon={<PublishSmall className="shrink-0" />} 79 + label={isLoading ? <DotLoader /> : "Update!"} 80 + onClick={async () => { 81 + if (!pub) return; 82 + setIsLoading(true); 83 + let doc = await publishToPublication({ 84 + root_entity: rootEntity, 85 + publication_uri: pub.publications?.uri, 86 + leaflet_id: permission_token.id, 87 + title: pub.title, 88 + description: pub.description, 89 + tags: currentTags, 90 + }); 91 + setIsLoading(false); 92 + mutate(); 93 + 94 + // Generate URL based on whether it's in a publication or standalone 95 + let docUrl = pub.publications 96 + ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` 97 + : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; 98 + 99 + toaster({ 100 + content: ( 101 + <div> 102 + {pub.doc ? "Updated! " : "Published! "} 103 + <SpeedyLink href={docUrl}>link</SpeedyLink> 104 + </div> 105 + ), 106 + type: "success", 107 + }); 108 + }} 109 + /> 110 + ); 111 + }; 112 + 113 + const PublishToPublicationButton = (props: { entityID: string }) => { 114 + let { identity } = useIdentityData(); 115 + let { permission_token } = useReplicache(); 116 + let query = useSearchParams(); 117 + let [open, setOpen] = useState(query.get("publish") !== null); 118 + 119 + let isMobile = useIsMobile(); 120 + identity && identity.atp_did && identity.publications.length > 0; 121 + let [selectedPub, setSelectedPub] = useState<string | undefined>(undefined); 122 + let router = useRouter(); 123 + let { title, entitiesToDelete } = useTitle(props.entityID); 124 + let [description, setDescription] = useState(""); 125 + 126 + return ( 127 + <Popover 128 + asChild 129 + open={open} 130 + onOpenChange={(o) => setOpen(o)} 131 + side={isMobile ? "top" : "right"} 132 + align={isMobile ? "center" : "start"} 133 + className="sm:max-w-sm w-[1000px]" 134 + trigger={ 135 + <ActionButton 136 + primary 137 + icon={<PublishSmall className="shrink-0" />} 138 + label={"Publish on ATP"} 139 + /> 140 + } 141 + > 142 + {!identity || !identity.atp_did ? ( 143 + <div className="-mx-2 -my-1"> 144 + <div 145 + className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`} 146 + > 147 + <div className="mx-auto pt-2 scale-90"> 148 + <PubListEmptyIllo /> 149 + </div> 150 + <div className="pt-1 font-bold">Publish on AT Proto</div> 151 + { 152 + <> 153 + <div className="pb-2 text-secondary text-xs"> 154 + Link a Bluesky account to start <br /> a publishing on AT 155 + Proto 156 + </div> 157 + 158 + <BlueskyLogin 159 + compact 160 + redirectRoute={`/${permission_token.id}?publish`} 161 + /> 162 + </> 163 + } 164 + </div> 165 + </div> 166 + ) : ( 167 + <div className="flex flex-col"> 168 + <PostDetailsForm 169 + title={title} 170 + description={description} 171 + setDescription={setDescription} 172 + /> 173 + <hr className="border-border-light my-3" /> 174 + <div> 175 + <PubSelector 176 + publications={identity.publications} 177 + selectedPub={selectedPub} 178 + setSelectedPub={setSelectedPub} 179 + /> 180 + </div> 181 + <hr className="border-border-light mt-3 mb-2" /> 182 + 183 + <div className="flex gap-2 items-center place-self-end"> 184 + {selectedPub !== "looseleaf" && selectedPub && ( 185 + <SaveAsDraftButton 186 + selectedPub={selectedPub} 187 + leafletId={permission_token.id} 188 + metadata={{ title: title, description }} 189 + entitiesToDelete={entitiesToDelete} 190 + /> 191 + )} 192 + <ButtonPrimary 193 + disabled={selectedPub === undefined} 194 + onClick={async (e) => { 195 + if (!selectedPub) return; 196 + e.preventDefault(); 197 + if (selectedPub === "create") return; 198 + 199 + // For looseleaf, navigate without publication_uri 200 + if (selectedPub === "looseleaf") { 201 + router.push( 202 + `${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`, 203 + ); 204 + } else { 205 + router.push( 206 + `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`, 207 + ); 208 + } 209 + }} 210 + > 211 + Next{selectedPub === "create" && ": Create Pub!"} 212 + </ButtonPrimary> 213 + </div> 214 + </div> 215 + )} 216 + </Popover> 217 + ); 218 + }; 219 + 220 + const SaveAsDraftButton = (props: { 221 + selectedPub: string | undefined; 222 + leafletId: string; 223 + metadata: { title: string; description: string }; 224 + entitiesToDelete: string[]; 225 + }) => { 226 + let { mutate } = useLeafletPublicationData(); 227 + let { rep } = useReplicache(); 228 + let [isLoading, setIsLoading] = useState(false); 229 + 230 + return ( 231 + <ButtonTertiary 232 + onClick={async (e) => { 233 + if (!props.selectedPub) return; 234 + if (props.selectedPub === "create") return; 235 + e.preventDefault(); 236 + setIsLoading(true); 237 + await moveLeafletToPublication( 238 + props.leafletId, 239 + props.selectedPub, 240 + props.metadata, 241 + props.entitiesToDelete, 242 + ); 243 + await Promise.all([rep?.pull(), mutate()]); 244 + setIsLoading(false); 245 + }} 246 + > 247 + {isLoading ? <DotLoader /> : "Save as Draft"} 248 + </ButtonTertiary> 249 + ); 250 + }; 251 + 252 + const PostDetailsForm = (props: { 253 + title: string; 254 + description: string; 255 + setDescription: (d: string) => void; 256 + }) => { 257 + return ( 258 + <div className=" flex flex-col gap-1"> 259 + <div className="text-sm text-tertiary">Post Details</div> 260 + <div className="flex flex-col gap-2"> 261 + <InputWithLabel label="Title" value={props.title} disabled /> 262 + <InputWithLabel 263 + label="Description (optional)" 264 + textarea 265 + value={props.description} 266 + className="h-[4lh]" 267 + onChange={(e) => props.setDescription(e.currentTarget.value)} 268 + /> 269 + </div> 270 + </div> 271 + ); 272 + }; 273 + 274 + const PubSelector = (props: { 275 + selectedPub: string | undefined; 276 + setSelectedPub: (s: string) => void; 277 + publications: { 278 + identity_did: string; 279 + indexed_at: string; 280 + name: string; 281 + record: Json | null; 282 + uri: string; 283 + }[]; 284 + }) => { 285 + // HEY STILL TO DO 286 + // test out logged out, logged in but no pubs, and pubbed up flows 287 + 288 + return ( 289 + <div className="flex flex-col gap-1"> 290 + <div className="text-sm text-tertiary">Publish toโ€ฆ</div> 291 + {props.publications.length === 0 || props.publications === undefined ? ( 292 + <div className="flex flex-col gap-1"> 293 + <div className="flex gap-2 menuItem"> 294 + <LooseLeafSmall className="shrink-0" /> 295 + <div className="flex flex-col leading-snug"> 296 + <div className="text-secondary font-bold"> 297 + Publish as Looseleaf 298 + </div> 299 + <div className="text-tertiary text-sm font-normal"> 300 + Publish this as a one off doc to AT Proto 301 + </div> 302 + </div> 303 + </div> 304 + <div className="flex gap-2 px-2 py-1 "> 305 + <PublishSmall className="shrink-0 text-border" /> 306 + <div className="flex flex-col leading-snug"> 307 + <div className="text-border font-bold"> 308 + Publish to Publication 309 + </div> 310 + <div className="text-border text-sm font-normal"> 311 + Publish your writing to a blog on AT Proto 312 + </div> 313 + <hr className="my-2 drashed border-border-light border-dashed" /> 314 + <div className="text-tertiary text-sm font-normal "> 315 + You don't have any Publications yet.{" "} 316 + <a target="_blank" href="/lish/createPub"> 317 + Create one 318 + </a>{" "} 319 + to get started! 320 + </div> 321 + </div> 322 + </div> 323 + </div> 324 + ) : ( 325 + <div className="flex flex-col gap-1"> 326 + <PubOption 327 + selected={props.selectedPub === "looseleaf"} 328 + onSelect={() => props.setSelectedPub("looseleaf")} 329 + > 330 + <LooseLeafSmall /> 331 + Publish as Looseleaf 332 + </PubOption> 333 + <hr className="border-border-light border-dashed " /> 334 + {props.publications.map((p) => { 335 + let pubRecord = p.record as PubLeafletPublication.Record; 336 + return ( 337 + <PubOption 338 + key={p.uri} 339 + selected={props.selectedPub === p.uri} 340 + onSelect={() => props.setSelectedPub(p.uri)} 341 + > 342 + <> 343 + <PubIcon record={pubRecord} uri={p.uri} /> 344 + {p.name} 345 + </> 346 + </PubOption> 347 + ); 348 + })} 349 + <div className="flex items-center px-2 py-1 text-accent-contrast gap-2"> 350 + <AddTiny className="m-1 shrink-0" /> 351 + 352 + <a target="_blank" href="/lish/createPub"> 353 + Start a new Publication 354 + </a> 355 + </div> 356 + </div> 357 + )} 358 + </div> 359 + ); 360 + }; 361 + 362 + const PubOption = (props: { 363 + selected: boolean; 364 + onSelect: () => void; 365 + children: React.ReactNode; 366 + }) => { 367 + return ( 368 + <button 369 + className={`flex gap-2 menuItem font-bold text-secondary ${props.selected && "bg-[var(--accent-light)]! outline! outline-offset-1! outline-accent-contrast!"}`} 370 + onClick={() => { 371 + props.onSelect(); 372 + }} 373 + > 374 + {props.children} 375 + </button> 376 + ); 377 + }; 378 + 379 + let useTitle = (entityID: string) => { 380 + let rootPage = useEntity(entityID, "root/page")[0].data.value; 381 + let canvasBlocks = useCanvasBlocksWithType(rootPage).filter( 382 + (b) => b.type === "text" || b.type === "heading", 383 + ); 384 + let blocks = useBlocks(rootPage).filter( 385 + (b) => b.type === "text" || b.type === "heading", 386 + ); 387 + let firstBlock = canvasBlocks[0] || blocks[0]; 388 + 389 + let firstBlockText = useEntity(firstBlock?.value, "block/text")?.data.value; 390 + 391 + const leafletTitle = useMemo(() => { 392 + if (!firstBlockText) return "Untitled"; 393 + let doc = new Y.Doc(); 394 + const update = base64.toByteArray(firstBlockText); 395 + Y.applyUpdate(doc, update); 396 + let nodes = doc.getXmlElement("prosemirror").toArray(); 397 + return YJSFragmentToString(nodes[0]) || "Untitled"; 398 + }, [firstBlockText]); 399 + 400 + // Only handle second block logic for linear documents, not canvas 401 + let isCanvas = canvasBlocks.length > 0; 402 + let secondBlock = !isCanvas ? blocks[1] : undefined; 403 + let secondBlockTextValue = useEntity(secondBlock?.value || null, "block/text") 404 + ?.data.value; 405 + const secondBlockText = useMemo(() => { 406 + if (!secondBlockTextValue) return ""; 407 + let doc = new Y.Doc(); 408 + const update = base64.toByteArray(secondBlockTextValue); 409 + Y.applyUpdate(doc, update); 410 + let nodes = doc.getXmlElement("prosemirror").toArray(); 411 + return YJSFragmentToString(nodes[0]) || ""; 412 + }, [secondBlockTextValue]); 413 + 414 + let entitiesToDelete = useMemo(() => { 415 + let etod: string[] = []; 416 + // Only delete first block if it's a heading type 417 + if (firstBlock?.type === "heading") { 418 + etod.push(firstBlock.value); 419 + } 420 + // Delete second block if it's empty text (only for linear documents) 421 + if ( 422 + !isCanvas && 423 + secondBlockText.trim() === "" && 424 + secondBlock?.type === "text" 425 + ) { 426 + etod.push(secondBlock.value); 427 + } 428 + return etod; 429 + }, [firstBlock, secondBlockText, secondBlock, isCanvas]); 430 + 431 + return { title: leafletTitle, entitiesToDelete }; 432 + };
+394
app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx
···
··· 1 + import { useState } from "react"; 2 + import { ButtonPrimary } from "components/Buttons"; 3 + 4 + import { useSmoker, useToaster } from "components/Toast"; 5 + import { Input, InputWithLabel } from "components/Input"; 6 + import useSWR from "swr"; 7 + import { useIdentityData } from "components/IdentityProvider"; 8 + import { addDomain } from "actions/domains/addDomain"; 9 + import { callRPC } from "app/api/rpc/client"; 10 + import { useLeafletDomains } from "components/PageSWRDataProvider"; 11 + import { useReadOnlyShareLink } from "."; 12 + import { addDomainPath } from "actions/domains/addDomainPath"; 13 + import { useReplicache } from "src/replicache"; 14 + import { deleteDomain } from "actions/domains/deleteDomain"; 15 + import { AddTiny } from "components/Icons/AddTiny"; 16 + 17 + type DomainMenuState = 18 + | { 19 + state: "default"; 20 + } 21 + | { 22 + state: "domain-settings"; 23 + domain: string; 24 + } 25 + | { 26 + state: "add-domain"; 27 + } 28 + | { 29 + state: "has-domain"; 30 + domain: string; 31 + }; 32 + export function CustomDomainMenu(props: { 33 + setShareMenuState: (s: "default") => void; 34 + }) { 35 + let { data: domains } = useLeafletDomains(); 36 + let [state, setState] = useState<DomainMenuState>( 37 + domains?.[0] 38 + ? { state: "has-domain", domain: domains[0].domain } 39 + : { state: "default" }, 40 + ); 41 + switch (state.state) { 42 + case "has-domain": 43 + case "default": 44 + return ( 45 + <DomainOptions 46 + setDomainMenuState={setState} 47 + domainConnected={false} 48 + setShareMenuState={props.setShareMenuState} 49 + /> 50 + ); 51 + case "domain-settings": 52 + return ( 53 + <DomainSettings domain={state.domain} setDomainMenuState={setState} /> 54 + ); 55 + case "add-domain": 56 + return <AddDomain setDomainMenuState={setState} />; 57 + } 58 + } 59 + 60 + export const DomainOptions = (props: { 61 + setShareMenuState: (s: "default") => void; 62 + setDomainMenuState: (state: DomainMenuState) => void; 63 + domainConnected: boolean; 64 + }) => { 65 + let { data: domains, mutate: mutateDomains } = useLeafletDomains(); 66 + let [selectedDomain, setSelectedDomain] = useState<string | undefined>( 67 + domains?.[0]?.domain, 68 + ); 69 + let [selectedRoute, setSelectedRoute] = useState( 70 + domains?.[0]?.route.slice(1) || "", 71 + ); 72 + let { identity } = useIdentityData(); 73 + let { permission_token } = useReplicache(); 74 + 75 + let toaster = useToaster(); 76 + let smoker = useSmoker(); 77 + let publishLink = useReadOnlyShareLink(); 78 + 79 + return ( 80 + <div className="px-3 py-1 flex flex-col gap-3 max-w-full w-[600px]"> 81 + <h3 className="text-secondary">Choose a Domain</h3> 82 + <div className="flex flex-col gap-1 text-secondary"> 83 + {identity?.custom_domains 84 + .filter((d) => !d.publication_domains.length) 85 + .map((domain) => { 86 + return ( 87 + <DomainOption 88 + selectedRoute={selectedRoute} 89 + setSelectedRoute={setSelectedRoute} 90 + key={domain.domain} 91 + domain={domain.domain} 92 + checked={selectedDomain === domain.domain} 93 + setChecked={setSelectedDomain} 94 + setDomainMenuState={props.setDomainMenuState} 95 + /> 96 + ); 97 + })} 98 + <button 99 + onMouseDown={() => { 100 + props.setDomainMenuState({ state: "add-domain" }); 101 + }} 102 + className="text-accent-contrast flex gap-2 items-center px-1 py-0.5" 103 + > 104 + <AddTiny /> Add a New Domain 105 + </button> 106 + </div> 107 + 108 + {/* ONLY SHOW IF A DOMAIN IS CURRENTLY CONNECTED */} 109 + <div className="flex gap-3 items-center justify-end"> 110 + {props.domainConnected && ( 111 + <button 112 + onMouseDown={() => { 113 + props.setShareMenuState("default"); 114 + toaster({ 115 + content: ( 116 + <div className="font-bold"> 117 + Unpublished from custom domain! 118 + </div> 119 + ), 120 + type: "error", 121 + }); 122 + }} 123 + > 124 + Unpublish 125 + </button> 126 + )} 127 + 128 + <ButtonPrimary 129 + id="publish-to-domain" 130 + disabled={ 131 + domains?.[0] 132 + ? domains[0].domain === selectedDomain && 133 + domains[0].route.slice(1) === selectedRoute 134 + : !selectedDomain 135 + } 136 + onClick={async () => { 137 + // let rect = document 138 + // .getElementById("publish-to-domain") 139 + // ?.getBoundingClientRect(); 140 + // smoker({ 141 + // error: true, 142 + // text: "url already in use!", 143 + // position: { 144 + // x: rect ? rect.left : 0, 145 + // y: rect ? rect.top + 26 : 0, 146 + // }, 147 + // }); 148 + if (!selectedDomain || !publishLink) return; 149 + await addDomainPath({ 150 + domain: selectedDomain, 151 + route: "/" + selectedRoute, 152 + view_permission_token: publishLink, 153 + edit_permission_token: permission_token.id, 154 + }); 155 + 156 + toaster({ 157 + content: ( 158 + <div className="font-bold"> 159 + Published to custom domain!{" "} 160 + <a 161 + className="underline text-accent-2" 162 + href={`https://${selectedDomain}/${selectedRoute}`} 163 + target="_blank" 164 + > 165 + View 166 + </a> 167 + </div> 168 + ), 169 + type: "success", 170 + }); 171 + mutateDomains(); 172 + props.setShareMenuState("default"); 173 + }} 174 + > 175 + Publish! 176 + </ButtonPrimary> 177 + </div> 178 + </div> 179 + ); 180 + }; 181 + 182 + const DomainOption = (props: { 183 + selectedRoute: string; 184 + setSelectedRoute: (s: string) => void; 185 + checked: boolean; 186 + setChecked: (checked: string) => void; 187 + domain: string; 188 + setDomainMenuState: (state: DomainMenuState) => void; 189 + }) => { 190 + let [value, setValue] = useState(""); 191 + let { data } = useSWR(props.domain, async (domain) => { 192 + return await callRPC("get_domain_status", { domain }); 193 + }); 194 + let pending = data?.config?.misconfigured || data?.error; 195 + return ( 196 + <label htmlFor={props.domain}> 197 + <input 198 + type="radio" 199 + name={props.domain} 200 + id={props.domain} 201 + value={props.domain} 202 + checked={props.checked} 203 + className="hidden appearance-none" 204 + onChange={() => { 205 + if (pending) return; 206 + props.setChecked(props.domain); 207 + }} 208 + /> 209 + <div 210 + className={` 211 + px-[6px] py-1 212 + flex 213 + border rounded-md 214 + ${ 215 + pending 216 + ? "border-border-light text-secondary justify-between gap-2 items-center " 217 + : !props.checked 218 + ? "flex-wrap border-border-light" 219 + : "flex-wrap border-accent-1 bg-accent-1 text-accent-2 font-bold" 220 + } `} 221 + > 222 + <div className={`w-max truncate ${pending && "animate-pulse"}`}> 223 + {props.domain} 224 + </div> 225 + {props.checked && ( 226 + <div className="flex gap-0 w-full"> 227 + <span 228 + className="font-normal" 229 + style={value === "" ? { opacity: "0.5" } : {}} 230 + > 231 + / 232 + </span> 233 + 234 + <Input 235 + type="text" 236 + autoFocus 237 + className="appearance-none focus:outline-hidden font-normal text-accent-2 w-full bg-transparent placeholder:text-accent-2 placeholder:opacity-50" 238 + placeholder="add-optional-path" 239 + onChange={(e) => props.setSelectedRoute(e.target.value)} 240 + value={props.selectedRoute} 241 + /> 242 + </div> 243 + )} 244 + {pending && ( 245 + <button 246 + className="text-accent-contrast text-sm" 247 + onMouseDown={() => { 248 + props.setDomainMenuState({ 249 + state: "domain-settings", 250 + domain: props.domain, 251 + }); 252 + }} 253 + > 254 + pending 255 + </button> 256 + )} 257 + </div> 258 + </label> 259 + ); 260 + }; 261 + 262 + export const AddDomain = (props: { 263 + setDomainMenuState: (state: DomainMenuState) => void; 264 + }) => { 265 + let [value, setValue] = useState(""); 266 + let { mutate } = useIdentityData(); 267 + let smoker = useSmoker(); 268 + return ( 269 + <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]"> 270 + <div> 271 + <h3 className="text-secondary">Add a New Domain</h3> 272 + <div className="text-xs italic text-secondary"> 273 + Don't include the protocol or path, just the base domain name for now 274 + </div> 275 + </div> 276 + 277 + <Input 278 + className="input-with-border text-primary" 279 + placeholder="www.example.com" 280 + value={value} 281 + onChange={(e) => setValue(e.target.value)} 282 + /> 283 + 284 + <ButtonPrimary 285 + disabled={!value} 286 + className="place-self-end mt-2" 287 + onMouseDown={async (e) => { 288 + // call the vercel api, set the thing... 289 + let { error } = await addDomain(value); 290 + if (error) { 291 + smoker({ 292 + error: true, 293 + text: 294 + error === "invalid_domain" 295 + ? "Invalid domain! Use just the base domain" 296 + : error === "domain_already_in_use" 297 + ? "That domain is already in use!" 298 + : "An unknown error occured", 299 + position: { 300 + y: e.clientY, 301 + x: e.clientX - 5, 302 + }, 303 + }); 304 + return; 305 + } 306 + mutate(); 307 + props.setDomainMenuState({ state: "domain-settings", domain: value }); 308 + }} 309 + > 310 + Verify Domain 311 + </ButtonPrimary> 312 + </div> 313 + ); 314 + }; 315 + 316 + const DomainSettings = (props: { 317 + domain: string; 318 + setDomainMenuState: (s: DomainMenuState) => void; 319 + }) => { 320 + let isSubdomain = props.domain.split(".").length > 2; 321 + return ( 322 + <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]"> 323 + <h3 className="text-secondary">Verify Domain</h3> 324 + 325 + <div className="text-secondary text-sm flex flex-col gap-3"> 326 + <div className="flex flex-col gap-[6px]"> 327 + <div> 328 + To verify this domain, add the following record to your DNS provider 329 + for <strong>{props.domain}</strong>. 330 + </div> 331 + 332 + {isSubdomain ? ( 333 + <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 334 + <div className="flex flex-col "> 335 + <div className="text-tertiary">Type</div> 336 + <div>CNAME</div> 337 + </div> 338 + <div className="flex flex-col"> 339 + <div className="text-tertiary">Name</div> 340 + <div style={{ wordBreak: "break-word" }}> 341 + {props.domain.split(".").slice(0, -2).join(".")} 342 + </div> 343 + </div> 344 + <div className="flex flex-col"> 345 + <div className="text-tertiary">Value</div> 346 + <div style={{ wordBreak: "break-word" }}> 347 + cname.vercel-dns.com 348 + </div> 349 + </div> 350 + </div> 351 + ) : ( 352 + <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 353 + <div className="flex flex-col "> 354 + <div className="text-tertiary">Type</div> 355 + <div>A</div> 356 + </div> 357 + <div className="flex flex-col"> 358 + <div className="text-tertiary">Name</div> 359 + <div>@</div> 360 + </div> 361 + <div className="flex flex-col"> 362 + <div className="text-tertiary">Value</div> 363 + <div>76.76.21.21</div> 364 + </div> 365 + </div> 366 + )} 367 + </div> 368 + <div> 369 + Once you do this, the status may be pending for up to a few hours. 370 + </div> 371 + <div>Check back later to see if verification was successful.</div> 372 + </div> 373 + 374 + <div className="flex gap-3 justify-between items-center mt-2"> 375 + <button 376 + className="text-accent-contrast font-bold " 377 + onMouseDown={async () => { 378 + await deleteDomain({ domain: props.domain }); 379 + props.setDomainMenuState({ state: "default" }); 380 + }} 381 + > 382 + Delete Domain 383 + </button> 384 + <ButtonPrimary 385 + onMouseDown={() => { 386 + props.setDomainMenuState({ state: "default" }); 387 + }} 388 + > 389 + Back to Domains 390 + </ButtonPrimary> 391 + </div> 392 + </div> 393 + ); 394 + };
+70
app/[leaflet_id]/actions/ShareOptions/getShareLink.ts
···
··· 1 + "use server"; 2 + 3 + import { eq, and } from "drizzle-orm"; 4 + import { drizzle } from "drizzle-orm/node-postgres"; 5 + import { permission_token_rights, permission_tokens } from "drizzle/schema"; 6 + import { pool } from "supabase/pool"; 7 + export async function getShareLink( 8 + token: { id: string; entity_set: string }, 9 + rootEntity: string, 10 + ) { 11 + const client = await pool.connect(); 12 + const db = drizzle(client); 13 + let link = await db.transaction(async (tx) => { 14 + // This will likely error out when if we have multiple permission 15 + // token rights associated with a single token 16 + let [tokenW] = await tx 17 + .select() 18 + .from(permission_tokens) 19 + .leftJoin( 20 + permission_token_rights, 21 + eq(permission_token_rights.token, permission_tokens.id), 22 + ) 23 + .where(eq(permission_tokens.id, token.id)); 24 + if ( 25 + !tokenW.permission_token_rights || 26 + tokenW.permission_token_rights.create_token !== true || 27 + tokenW.permission_tokens.root_entity !== rootEntity || 28 + tokenW.permission_token_rights.entity_set !== token.entity_set 29 + ) { 30 + return null; 31 + } 32 + 33 + let [existingToken] = await tx 34 + .select() 35 + .from(permission_tokens) 36 + .rightJoin( 37 + permission_token_rights, 38 + eq(permission_token_rights.token, permission_tokens.id), 39 + ) 40 + .where( 41 + and( 42 + eq(permission_token_rights.read, true), 43 + eq(permission_token_rights.write, false), 44 + eq(permission_token_rights.create_token, false), 45 + eq(permission_token_rights.change_entity_set, false), 46 + eq(permission_token_rights.entity_set, token.entity_set), 47 + eq(permission_tokens.root_entity, rootEntity), 48 + ), 49 + ); 50 + if (existingToken) { 51 + return existingToken.permission_tokens; 52 + } 53 + let [newToken] = await tx 54 + .insert(permission_tokens) 55 + .values({ root_entity: rootEntity }) 56 + .returning(); 57 + await tx.insert(permission_token_rights).values({ 58 + entity_set: token.entity_set, 59 + token: newToken.id, 60 + read: true, 61 + write: false, 62 + create_token: false, 63 + change_entity_set: false, 64 + }); 65 + return newToken; 66 + }); 67 + 68 + client.release(); 69 + return link; 70 + }
+257
app/[leaflet_id]/actions/ShareOptions/index.tsx
···
··· 1 + import { useReplicache } from "src/replicache"; 2 + import React, { useEffect, useState } from "react"; 3 + import { getShareLink } from "./getShareLink"; 4 + import { useEntitySetContext } from "components/EntitySetProvider"; 5 + import { useSmoker } from "components/Toast"; 6 + import { Menu, MenuItem } from "components/Layout"; 7 + import { ActionButton } from "components/ActionBar/ActionButton"; 8 + import useSWR from "swr"; 9 + import LoginForm from "app/login/LoginForm"; 10 + import { CustomDomainMenu } from "./DomainOptions"; 11 + import { useIdentityData } from "components/IdentityProvider"; 12 + import { 13 + useLeafletDomains, 14 + useLeafletPublicationData, 15 + } from "components/PageSWRDataProvider"; 16 + import { ShareSmall } from "components/Icons/ShareSmall"; 17 + import { PubLeafletDocument } from "lexicons/api"; 18 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 19 + import { AtUri } from "@atproto/syntax"; 20 + import { useIsMobile } from "src/hooks/isMobile"; 21 + 22 + export type ShareMenuStates = "default" | "login" | "domain"; 23 + 24 + export let useReadOnlyShareLink = () => { 25 + let { permission_token, rootEntity } = useReplicache(); 26 + let entity_set = useEntitySetContext(); 27 + let { data: publishLink } = useSWR( 28 + "publishLink-" + permission_token.id, 29 + async () => { 30 + if ( 31 + !permission_token.permission_token_rights.find( 32 + (s) => s.entity_set === entity_set.set && s.create_token, 33 + ) 34 + ) 35 + return; 36 + let shareLink = await getShareLink( 37 + { id: permission_token.id, entity_set: entity_set.set }, 38 + rootEntity, 39 + ); 40 + return shareLink?.id; 41 + }, 42 + ); 43 + return publishLink; 44 + }; 45 + 46 + export function ShareOptions() { 47 + let [menuState, setMenuState] = useState<ShareMenuStates>("default"); 48 + let { data: pub } = useLeafletPublicationData(); 49 + let isMobile = useIsMobile(); 50 + 51 + return ( 52 + <Menu 53 + asChild 54 + side={isMobile ? "top" : "right"} 55 + align={isMobile ? "center" : "start"} 56 + className="max-w-xs" 57 + onOpenChange={() => { 58 + setMenuState("default"); 59 + }} 60 + trigger={ 61 + <ActionButton 62 + icon=<ShareSmall /> 63 + secondary 64 + label={`Share ${pub ? "Draft" : ""}`} 65 + /> 66 + } 67 + > 68 + {menuState === "login" ? ( 69 + <div className="px-3 py-1"> 70 + <LoginForm text="Save your Leaflets and access them on multiple devices!" /> 71 + </div> 72 + ) : menuState === "domain" ? ( 73 + <CustomDomainMenu setShareMenuState={setMenuState} /> 74 + ) : ( 75 + <ShareMenu 76 + setMenuState={setMenuState} 77 + domainConnected={false} 78 + isPub={!!pub} 79 + /> 80 + )} 81 + </Menu> 82 + ); 83 + } 84 + 85 + const ShareMenu = (props: { 86 + setMenuState: (state: ShareMenuStates) => void; 87 + domainConnected: boolean; 88 + isPub?: boolean; 89 + }) => { 90 + let { permission_token } = useReplicache(); 91 + let { data: pub } = useLeafletPublicationData(); 92 + 93 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 94 + 95 + let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; 96 + let postLink = !docURI 97 + ? null 98 + : pub?.publications 99 + ? `${getPublicationURL(pub.publications)}/${docURI.rkey}` 100 + : `p/${docURI.host}/${docURI.rkey}`; 101 + let publishLink = useReadOnlyShareLink(); 102 + let [collabLink, setCollabLink] = useState<null | string>(null); 103 + useEffect(() => { 104 + // strip leading '/' character from pathname 105 + setCollabLink(window.location.pathname.slice(1)); 106 + }, []); 107 + let { data: domains } = useLeafletDomains(); 108 + 109 + return ( 110 + <> 111 + <ShareButton 112 + text={`Share ${postLink ? "Draft" : ""} Edit Link`} 113 + subtext="" 114 + smokerText="Edit link copied!" 115 + id="get-edit-link" 116 + link={collabLink} 117 + /> 118 + <ShareButton 119 + text={`Share ${postLink ? "Draft" : ""} View Link`} 120 + subtext=<> 121 + {domains?.[0] ? ( 122 + <> 123 + This Leaflet is published on{" "} 124 + <span className="italic underline"> 125 + {domains[0].domain} 126 + {domains[0].route} 127 + </span> 128 + </> 129 + ) : ( 130 + "" 131 + )} 132 + </> 133 + smokerText="View link copied!" 134 + id="get-view-link" 135 + fullLink={ 136 + domains?.[0] 137 + ? `https://${domains[0].domain}${domains[0].route}` 138 + : undefined 139 + } 140 + link={publishLink || ""} 141 + /> 142 + {postLink && ( 143 + <> 144 + <hr className="border-border-light" /> 145 + 146 + <ShareButton 147 + text="Share Published Link" 148 + subtext="" 149 + smokerText="Post link copied!" 150 + id="get-post-link" 151 + fullLink={postLink.includes("http") ? postLink : undefined} 152 + link={postLink} 153 + /> 154 + </> 155 + )} 156 + {!props.isPub && ( 157 + <> 158 + <hr className="border-border mt-1" /> 159 + <DomainMenuItem setMenuState={props.setMenuState} /> 160 + </> 161 + )} 162 + </> 163 + ); 164 + }; 165 + 166 + export const ShareButton = (props: { 167 + text: React.ReactNode; 168 + subtext?: React.ReactNode; 169 + smokerText: string; 170 + id: string; 171 + link: null | string; 172 + fullLink?: string; 173 + className?: string; 174 + }) => { 175 + let smoker = useSmoker(); 176 + 177 + return ( 178 + <MenuItem 179 + id={props.id} 180 + onSelect={(e) => { 181 + e.preventDefault(); 182 + let rect = document.getElementById(props.id)?.getBoundingClientRect(); 183 + if (props.link || props.fullLink) { 184 + navigator.clipboard.writeText( 185 + props.fullLink 186 + ? props.fullLink 187 + : `${location.protocol}//${location.host}/${props.link}`, 188 + ); 189 + smoker({ 190 + position: { 191 + x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 192 + y: rect ? rect.top + 26 : 0, 193 + }, 194 + text: props.smokerText, 195 + }); 196 + } 197 + }} 198 + > 199 + <div className={`group/${props.id} ${props.className} leading-snug`}> 200 + {props.text} 201 + 202 + {props.subtext && ( 203 + <div className={`text-sm font-normal text-tertiary`}> 204 + {props.subtext} 205 + </div> 206 + )} 207 + </div> 208 + </MenuItem> 209 + ); 210 + }; 211 + 212 + const DomainMenuItem = (props: { 213 + setMenuState: (state: ShareMenuStates) => void; 214 + }) => { 215 + let { identity } = useIdentityData(); 216 + let { data: domains } = useLeafletDomains(); 217 + 218 + if (identity === null) 219 + return ( 220 + <div className="text-tertiary font-normal text-sm px-3 py-1"> 221 + <button 222 + className="text-accent-contrast hover:font-bold" 223 + onClick={() => { 224 + props.setMenuState("login"); 225 + }} 226 + > 227 + Log In 228 + </button>{" "} 229 + to publish on a custom domain! 230 + </div> 231 + ); 232 + else 233 + return ( 234 + <> 235 + {domains?.[0] ? ( 236 + <button 237 + className="px-3 py-1 text-accent-contrast text-sm hover:font-bold w-fit text-left" 238 + onMouseDown={() => { 239 + props.setMenuState("domain"); 240 + }} 241 + > 242 + Edit custom domain 243 + </button> 244 + ) : ( 245 + <MenuItem 246 + className="font-normal text-tertiary text-sm" 247 + onSelect={(e) => { 248 + e.preventDefault(); 249 + props.setMenuState("domain"); 250 + }} 251 + > 252 + Publish on a custom domain 253 + </MenuItem> 254 + )} 255 + </> 256 + ); 257 + };
+4 -2
app/[leaflet_id]/icon.tsx
··· 24 process.env.SUPABASE_SERVICE_ROLE_KEY as string, 25 { cookies: {} }, 26 ); 27 - export default async function Icon(props: { params: { leaflet_id: string } }) { 28 let res = await supabase 29 .from("permission_tokens") 30 .select("*, permission_token_rights(*)") 31 - .eq("id", props.params.leaflet_id) 32 .single(); 33 let rootEntity = res.data?.root_entity; 34 let outlineColor, fillColor;
··· 24 process.env.SUPABASE_SERVICE_ROLE_KEY as string, 25 { cookies: {} }, 26 ); 27 + export default async function Icon(props: { 28 + params: Promise<{ leaflet_id: string }>; 29 + }) { 30 let res = await supabase 31 .from("permission_tokens") 32 .select("*, permission_token_rights(*)") 33 + .eq("id", (await props.params).leaflet_id) 34 .single(); 35 let rootEntity = res.data?.root_entity; 36 let outlineColor, fillColor;
+3 -2
app/[leaflet_id]/opengraph-image.tsx
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 - params: { leaflet_id: string }; 8 }) { 9 - return getMicroLinkOgImage(`/${props.params.leaflet_id}`); 10 }
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 + params: Promise<{ leaflet_id: string }>; 8 }) { 9 + let params = await props.params; 10 + return getMicroLinkOgImage(`/${params.leaflet_id}`); 11 }
+3 -6
app/[leaflet_id]/page.tsx
··· 4 5 import type { Fact } from "src/replicache"; 6 import type { Attribute } from "src/replicache/attributes"; 7 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 import { Leaflet } from "./Leaflet"; 9 import { scanIndexLocal } from "src/replicache/utils"; 10 import { getRSVPData } from "actions/getRSVPData"; ··· 13 import { supabaseServerClient } from "supabase/serverClient"; 14 import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 15 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 16 17 export const preferredRegion = ["sfo1"]; 18 export const dynamic = "force-dynamic"; ··· 70 ); 71 let rootEntity = res.data?.root_entity; 72 if (!rootEntity || !res.data) return { title: "Leaflet not found" }; 73 - let publication_data = 74 - res.data?.leaflets_in_publications?.[0] || 75 - res.data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 76 - (p) => p.leaflets_in_publications.length, 77 - )?.leaflets_in_publications?.[0]; 78 if (publication_data) { 79 return { 80 title: publication_data.title || "Untitled",
··· 4 5 import type { Fact } from "src/replicache"; 6 import type { Attribute } from "src/replicache/attributes"; 7 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 8 import { Leaflet } from "./Leaflet"; 9 import { scanIndexLocal } from "src/replicache/utils"; 10 import { getRSVPData } from "actions/getRSVPData"; ··· 13 import { supabaseServerClient } from "supabase/serverClient"; 14 import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 15 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 16 + import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 17 18 export const preferredRegion = ["sfo1"]; 19 export const dynamic = "force-dynamic"; ··· 71 ); 72 let rootEntity = res.data?.root_entity; 73 if (!rootEntity || !res.data) return { title: "Leaflet not found" }; 74 + let publication_data = getPublicationMetadataFromLeafletData(res.data); 75 if (publication_data) { 76 return { 77 title: publication_data.title || "Untitled",
+147 -295
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 1 "use client"; 2 - import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 - import { 4 - useState, 5 - useCallback, 6 - useRef, 7 - useLayoutEffect, 8 - useEffect, 9 - } from "react"; 10 - import { createPortal } from "react-dom"; 11 - import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 12 - import * as Popover from "@radix-ui/react-popover"; 13 - import { EditorState, TextSelection, Plugin } from "prosemirror-state"; 14 import { EditorView } from "prosemirror-view"; 15 import { Schema, MarkSpec, Mark } from "prosemirror-model"; 16 import { baseKeymap } from "prosemirror-commands"; ··· 19 import { inputRules, InputRule } from "prosemirror-inputrules"; 20 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21 import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; 22 23 // Schema with only links, mentions, and hashtags marks 24 const bskyPostSchema = new Schema({ ··· 134 return tr; 135 }); 136 } 137 - 138 export function BlueskyPostEditorProsemirror(props: { 139 - editorStateRef: React.MutableRefObject<EditorState | null>; 140 initialContent?: string; 141 onCharCountChange?: (count: number) => void; 142 }) { 143 const mountRef = useRef<HTMLDivElement | null>(null); 144 const viewRef = useRef<EditorView | null>(null); 145 const [editorState, setEditorState] = useState<EditorState | null>(null); 146 - const [mentionState, setMentionState] = useState<{ 147 - active: boolean; 148 - range: { from: number; to: number } | null; 149 - selectedMention: { handle: string; did: string } | null; 150 - }>({ active: false, range: null, selectedMention: null }); 151 152 const handleMentionSelect = useCallback( 153 - ( 154 - mention: { handle: string; did: string }, 155 - range: { from: number; to: number }, 156 - ) => { 157 - if (!viewRef.current) return; 158 const view = viewRef.current; 159 - const { from, to } = range; 160 const tr = view.state.tr; 161 162 - // Delete the query text (keep the @) 163 - tr.delete(from + 1, to); 164 165 - // Insert the mention text after the @ 166 - const mentionText = mention.handle; 167 - tr.insertText(mentionText, from + 1); 168 - 169 - // Apply mention mark to @ and handle 170 - tr.addMark( 171 - from, 172 - from + 1 + mentionText.length, 173 - bskyPostSchema.marks.mention.create({ did: mention.did }), 174 - ); 175 - 176 - // Add a space after the mention 177 - tr.insertText(" ", from + 1 + mentionText.length); 178 179 view.dispatch(tr); 180 view.focus(); 181 }, 182 - [], 183 ); 184 185 - const mentionStateRef = useRef(mentionState); 186 - mentionStateRef.current = mentionState; 187 188 useLayoutEffect(() => { 189 if (!mountRef.current) return; 190 191 const initialState = EditorState.create({ 192 schema: bskyPostSchema, 193 doc: props.initialContent ··· 200 }) 201 : undefined, 202 plugins: [ 203 - inputRules({ rules: [createHashtagInputRule()] }), 204 keymap({ 205 "Mod-z": undo, 206 "Mod-y": redo, 207 "Shift-Mod-z": redo, 208 - Enter: (state, dispatch) => { 209 - // Check if mention autocomplete is active 210 - const currentMentionState = mentionStateRef.current; 211 - if ( 212 - currentMentionState.active && 213 - currentMentionState.selectedMention && 214 - currentMentionState.range 215 - ) { 216 - handleMentionSelect( 217 - currentMentionState.selectedMention, 218 - currentMentionState.range, 219 - ); 220 - return true; 221 - } 222 - // Otherwise let the default Enter behavior happen (new paragraph) 223 - return false; 224 - }, 225 }), 226 keymap(baseKeymap), 227 autolink({ ··· 245 view.updateState(newState); 246 setEditorState(newState); 247 props.editorStateRef.current = newState; 248 - props.onCharCountChange?.(newState.doc.textContent.length); 249 }, 250 }, 251 ); ··· 256 view.destroy(); 257 viewRef.current = null; 258 }; 259 - }, [handleMentionSelect]); 260 261 return ( 262 <div className="relative w-full h-full group"> 263 - {editorState && ( 264 - <MentionAutocomplete 265 - editorState={editorState} 266 - view={viewRef} 267 - onSelect={handleMentionSelect} 268 - onMentionStateChange={(active, range, selectedMention) => { 269 - setMentionState({ active, range, selectedMention }); 270 - }} 271 - /> 272 - )} 273 {editorState?.doc.textContent.length === 0 && ( 274 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 275 Write a post to share your writing! ··· 277 )} 278 <div 279 ref={mountRef} 280 - className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 281 style={{ 282 wordWrap: "break-word", 283 overflowWrap: "break-word", ··· 288 ); 289 } 290 291 - function MentionAutocomplete(props: { 292 - editorState: EditorState; 293 - view: React.RefObject<EditorView | null>; 294 - onSelect: ( 295 - mention: { handle: string; did: string }, 296 - range: { from: number; to: number }, 297 - ) => void; 298 - onMentionStateChange: ( 299 - active: boolean, 300 - range: { from: number; to: number } | null, 301 - selectedMention: { handle: string; did: string } | null, 302 - ) => void; 303 - }) { 304 - const [mentionQuery, setMentionQuery] = useState<string | null>(null); 305 - const [mentionRange, setMentionRange] = useState<{ 306 - from: number; 307 - to: number; 308 - } | null>(null); 309 - const [mentionCoords, setMentionCoords] = useState<{ 310 - top: number; 311 - left: number; 312 - } | null>(null); 313 - 314 - const { suggestionIndex, setSuggestionIndex, suggestions } = 315 - useMentionSuggestions(mentionQuery); 316 - 317 - // Check for mention pattern whenever editor state changes 318 - useEffect(() => { 319 - const { $from } = props.editorState.selection; 320 - const textBefore = $from.parent.textBetween( 321 - Math.max(0, $from.parentOffset - 50), 322 - $from.parentOffset, 323 - null, 324 - "\ufffc", 325 - ); 326 - 327 - // Look for @ followed by word characters before cursor 328 - const match = textBefore.match(/@([\w.]*)$/); 329 - 330 - if (match && props.view.current) { 331 - const queryBefore = match[1]; 332 - const from = $from.pos - queryBefore.length - 1; 333 - 334 - // Get text after cursor to find the rest of the handle 335 - const textAfter = $from.parent.textBetween( 336 - $from.parentOffset, 337 - Math.min($from.parent.content.size, $from.parentOffset + 50), 338 - null, 339 - "\ufffc", 340 - ); 341 - 342 - // Match word characters after cursor until space or end 343 - const afterMatch = textAfter.match(/^([\w.]*)/); 344 - const queryAfter = afterMatch ? afterMatch[1] : ""; 345 - 346 - // Combine the full handle 347 - const query = queryBefore + queryAfter; 348 - const to = $from.pos + queryAfter.length; 349 - 350 - setMentionQuery(query); 351 - setMentionRange({ from, to }); 352 - 353 - // Get coordinates for the autocomplete popup 354 - const coords = props.view.current.coordsAtPos(from); 355 - setMentionCoords({ 356 - top: coords.bottom + window.scrollY, 357 - left: coords.left + window.scrollX, 358 - }); 359 - setSuggestionIndex(0); 360 - } else { 361 - setMentionQuery(null); 362 - setMentionRange(null); 363 - setMentionCoords(null); 364 - } 365 - }, [props.editorState, props.view, setSuggestionIndex]); 366 - 367 - // Update parent's mention state 368 - useEffect(() => { 369 - const active = mentionQuery !== null && suggestions.length > 0; 370 - const selectedMention = 371 - active && suggestions[suggestionIndex] 372 - ? suggestions[suggestionIndex] 373 - : null; 374 - props.onMentionStateChange(active, mentionRange, selectedMention); 375 - }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); 376 - 377 - // Handle keyboard navigation for arrow keys only 378 - useEffect(() => { 379 - if (!mentionQuery || !props.view.current) return; 380 - 381 - const handleKeyDown = (e: KeyboardEvent) => { 382 - if (suggestions.length === 0) return; 383 - 384 - if (e.key === "ArrowUp") { 385 - e.preventDefault(); 386 - if (suggestionIndex > 0) { 387 - setSuggestionIndex((i) => i - 1); 388 - } 389 - } else if (e.key === "ArrowDown") { 390 - e.preventDefault(); 391 - if (suggestionIndex < suggestions.length - 1) { 392 - setSuggestionIndex((i) => i + 1); 393 - } 394 - } 395 - }; 396 - 397 - const dom = props.view.current.dom; 398 - dom.addEventListener("keydown", handleKeyDown); 399 - 400 - return () => { 401 - dom.removeEventListener("keydown", handleKeyDown); 402 - }; 403 - }, [ 404 - mentionQuery, 405 - suggestions, 406 - suggestionIndex, 407 - props.view, 408 - setSuggestionIndex, 409 - ]); 410 - 411 - if (!mentionCoords || suggestions.length === 0) return null; 412 - 413 - // The styles in this component should match the Menu styles in components/Layout.tsx 414 - return ( 415 - <Popover.Root open> 416 - {createPortal( 417 - <Popover.Anchor 418 - style={{ 419 - top: mentionCoords.top, 420 - left: mentionCoords.left, 421 - position: "absolute", 422 - }} 423 - />, 424 - document.body, 425 - )} 426 - <Popover.Portal> 427 - <Popover.Content 428 - side="bottom" 429 - align="start" 430 - sideOffset={4} 431 - collisionPadding={20} 432 - onOpenAutoFocus={(e) => e.preventDefault()} 433 - className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`} 434 - > 435 - <ul className="list-none p-0 text-sm"> 436 - {suggestions.map((result, index) => { 437 - return ( 438 - <div 439 - className={` 440 - MenuItem 441 - font-bold z-10 py-1 px-3 442 - text-left text-secondary 443 - flex gap-2 444 - ${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""} 445 - hover:bg-border-light hover:text-secondary 446 - outline-none 447 - `} 448 - key={result.did} 449 - onClick={() => { 450 - if (mentionRange) { 451 - props.onSelect(result, mentionRange); 452 - setMentionQuery(null); 453 - setMentionRange(null); 454 - setMentionCoords(null); 455 - } 456 - }} 457 - onMouseDown={(e) => e.preventDefault()} 458 - > 459 - @{result.handle} 460 - </div> 461 - ); 462 - })} 463 - </ul> 464 - </Popover.Content> 465 - </Popover.Portal> 466 - </Popover.Root> 467 - ); 468 - } 469 - 470 - function useMentionSuggestions(query: string | null) { 471 - const [suggestionIndex, setSuggestionIndex] = useState(0); 472 - const [suggestions, setSuggestions] = useState< 473 - { handle: string; did: string }[] 474 - >([]); 475 - 476 - useDebouncedEffect( 477 - async () => { 478 - if (!query) { 479 - setSuggestions([]); 480 - return; 481 - } 482 - 483 - const agent = new Agent("https://public.api.bsky.app"); 484 - const result = await agent.searchActorsTypeahead({ 485 - q: query, 486 - limit: 8, 487 - }); 488 - setSuggestions( 489 - result.data.actors.map((actor) => ({ 490 - handle: actor.handle, 491 - did: actor.did, 492 - })), 493 - ); 494 - }, 495 - 300, 496 - [query], 497 - ); 498 - 499 - useEffect(() => { 500 - if (suggestionIndex > suggestions.length - 1) { 501 - setSuggestionIndex(Math.max(0, suggestions.length - 1)); 502 - } 503 - }, [suggestionIndex, suggestions.length]); 504 - 505 - return { 506 - suggestions, 507 - suggestionIndex, 508 - setSuggestionIndex, 509 - }; 510 - } 511 - 512 /** 513 * Converts a ProseMirror editor state to Bluesky post facets. 514 * Extracts mentions, links, and hashtags from the editor state and returns them ··· 593 594 return features; 595 }
··· 1 "use client"; 2 + import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 + import { useState, useCallback, useRef, useLayoutEffect } from "react"; 4 + import { EditorState } from "prosemirror-state"; 5 import { EditorView } from "prosemirror-view"; 6 import { Schema, MarkSpec, Mark } from "prosemirror-model"; 7 import { baseKeymap } from "prosemirror-commands"; ··· 10 import { inputRules, InputRule } from "prosemirror-inputrules"; 11 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 12 import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; 13 + import { schema } from "components/Blocks/TextBlock/schema"; 14 + import { Mention, MentionAutocomplete } from "components/Mention"; 15 16 // Schema with only links, mentions, and hashtags marks 17 const bskyPostSchema = new Schema({ ··· 127 return tr; 128 }); 129 } 130 export function BlueskyPostEditorProsemirror(props: { 131 + editorStateRef: React.RefObject<EditorState | null>; 132 initialContent?: string; 133 onCharCountChange?: (count: number) => void; 134 }) { 135 const mountRef = useRef<HTMLDivElement | null>(null); 136 const viewRef = useRef<EditorView | null>(null); 137 const [editorState, setEditorState] = useState<EditorState | null>(null); 138 + const [mentionOpen, setMentionOpen] = useState(false); 139 + const [mentionCoords, setMentionCoords] = useState<{ 140 + top: number; 141 + left: number; 142 + } | null>(null); 143 + const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 144 + 145 + const openMentionAutocomplete = useCallback(() => { 146 + if (!viewRef.current) return; 147 + const view = viewRef.current; 148 + const pos = view.state.selection.from; 149 + setMentionInsertPos(pos); 150 + const coords = view.coordsAtPos(pos - 1); 151 + 152 + // Get coordinates relative to the positioned parent container 153 + const editorEl = view.dom; 154 + const container = editorEl.closest(".relative") as HTMLElement | null; 155 + 156 + if (container) { 157 + const containerRect = container.getBoundingClientRect(); 158 + setMentionCoords({ 159 + top: coords.bottom - containerRect.top, 160 + left: coords.left - containerRect.left, 161 + }); 162 + } else { 163 + setMentionCoords({ 164 + top: coords.bottom, 165 + left: coords.left, 166 + }); 167 + } 168 + setMentionOpen(true); 169 + }, []); 170 171 const handleMentionSelect = useCallback( 172 + (mention: Mention) => { 173 + if (!viewRef.current || mentionInsertPos === null) return; 174 const view = viewRef.current; 175 + const from = mentionInsertPos - 1; 176 + const to = mentionInsertPos; 177 const tr = view.state.tr; 178 179 + // Delete the @ symbol 180 + tr.delete(from, to); 181 182 + if (mention.type === "did") { 183 + // Insert @handle with mention mark 184 + const mentionText = "@" + mention.handle; 185 + tr.insertText(mentionText, from); 186 + tr.addMark( 187 + from, 188 + from + mentionText.length, 189 + bskyPostSchema.marks.mention.create({ did: mention.did }), 190 + ); 191 + tr.insertText(" ", from + mentionText.length); 192 + } else if (mention.type === "publication") { 193 + // Insert publication name as a link 194 + const linkText = mention.name; 195 + tr.insertText(linkText, from); 196 + tr.addMark( 197 + from, 198 + from + linkText.length, 199 + bskyPostSchema.marks.link.create({ href: mention.url }), 200 + ); 201 + tr.insertText(" ", from + linkText.length); 202 + } else if (mention.type === "post") { 203 + // Insert post title as a link 204 + const linkText = mention.title; 205 + tr.insertText(linkText, from); 206 + tr.addMark( 207 + from, 208 + from + linkText.length, 209 + bskyPostSchema.marks.link.create({ href: mention.url }), 210 + ); 211 + tr.insertText(" ", from + linkText.length); 212 + } 213 214 view.dispatch(tr); 215 view.focus(); 216 }, 217 + [mentionInsertPos], 218 ); 219 220 + const handleMentionOpenChange = useCallback((open: boolean) => { 221 + setMentionOpen(open); 222 + if (!open) { 223 + setMentionCoords(null); 224 + setMentionInsertPos(null); 225 + } 226 + }, []); 227 228 useLayoutEffect(() => { 229 if (!mountRef.current) return; 230 231 + // Input rule to trigger mention autocomplete when @ is typed 232 + const mentionInputRule = new InputRule( 233 + /(?:^|\s)@$/, 234 + (state, match, start, end) => { 235 + setTimeout(() => openMentionAutocomplete(), 0); 236 + return null; 237 + }, 238 + ); 239 + 240 const initialState = EditorState.create({ 241 schema: bskyPostSchema, 242 doc: props.initialContent ··· 249 }) 250 : undefined, 251 plugins: [ 252 + inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }), 253 keymap({ 254 "Mod-z": undo, 255 "Mod-y": redo, 256 "Shift-Mod-z": redo, 257 }), 258 keymap(baseKeymap), 259 autolink({ ··· 277 view.updateState(newState); 278 setEditorState(newState); 279 props.editorStateRef.current = newState; 280 + props.onCharCountChange?.( 281 + newState.doc.textContent.length + newState.doc.children.length - 1, 282 + ); 283 }, 284 }, 285 ); ··· 290 view.destroy(); 291 viewRef.current = null; 292 }; 293 + }, [openMentionAutocomplete]); 294 295 return ( 296 <div className="relative w-full h-full group"> 297 + <MentionAutocomplete 298 + open={mentionOpen} 299 + onOpenChange={handleMentionOpenChange} 300 + view={viewRef} 301 + onSelect={handleMentionSelect} 302 + coords={mentionCoords} 303 + placeholder="Search people..." 304 + /> 305 {editorState?.doc.textContent.length === 0 && ( 306 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 307 Write a post to share your writing! ··· 309 )} 310 <div 311 ref={mountRef} 312 + className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm" 313 style={{ 314 wordWrap: "break-word", 315 overflowWrap: "break-word", ··· 320 ); 321 } 322 323 /** 324 * Converts a ProseMirror editor state to Bluesky post facets. 325 * Extracts mentions, links, and hashtags from the editor state and returns them ··· 404 405 return features; 406 } 407 + 408 + export const addMentionToEditor = ( 409 + mention: Mention, 410 + range: { from: number; to: number }, 411 + view: EditorView, 412 + ) => { 413 + console.log("view", view); 414 + if (!view) return; 415 + const { from, to } = range; 416 + const tr = view.state.tr; 417 + 418 + if (mention.type == "did") { 419 + // Delete the @ and any query text 420 + tr.delete(from, to); 421 + // Insert didMention inline node 422 + const mentionText = "@" + mention.handle; 423 + const didMentionNode = schema.nodes.didMention.create({ 424 + did: mention.did, 425 + text: mentionText, 426 + }); 427 + tr.insert(from, didMentionNode); 428 + } 429 + if (mention.type === "publication" || mention.type === "post") { 430 + // Delete the @ and any query text 431 + tr.delete(from, to); 432 + let name = mention.type == "post" ? mention.title : mention.name; 433 + // Insert atMention inline node 434 + const atMentionNode = schema.nodes.atMention.create({ 435 + atURI: mention.uri, 436 + text: name, 437 + }); 438 + tr.insert(from, atMentionNode); 439 + } 440 + console.log("yo", mention); 441 + 442 + // Add a space after the mention 443 + tr.insertText(" ", from + 1); 444 + 445 + view.dispatch(tr); 446 + view.focus(); 447 + };
+202 -90
app/[leaflet_id]/publish/PublishPost.tsx
··· 6 import { Radio } from "components/Checkbox"; 7 import { useParams } from "next/navigation"; 8 import Link from "next/link"; 9 - import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 10 import { PubLeafletPublication } from "lexicons/api"; 11 import { publishPostToBsky } from "./publishBskyPost"; 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 import { AtUri } from "@atproto/syntax"; 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 import { useReplicache } from "src/replicache"; 16 import { 17 BlueskyPostEditorProsemirror, 18 editorStateToFacetedText, 19 } from "./BskyPostEditorProsemirror"; 20 import { EditorState } from "prosemirror-state"; 21 22 type Props = { 23 title: string; ··· 25 root_entity: string; 26 profile: ProfileViewDetailed; 27 description: string; 28 - publication_uri: string; 29 record?: PubLeafletPublication.Record; 30 posts_in_pub?: number; 31 }; 32 33 export function PublishPost(props: Props) { ··· 35 { state: "default" } | { state: "success"; post_url: string } 36 >({ state: "default" }); 37 return ( 38 - <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 39 {publishState.state === "default" ? ( 40 <PublishPostForm setPublishState={setPublishState} {...props} /> 41 ) : ( ··· 55 setPublishState: (s: { state: "success"; post_url: string }) => void; 56 } & Props, 57 ) => { 58 - let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 59 let editorStateRef = useRef<EditorState | null>(null); 60 - let [isLoading, setIsLoading] = useState(false); 61 let [charCount, setCharCount] = useState(0); 62 let params = useParams(); 63 let { rep } = useReplicache(); 64 65 async function submit() { 66 if (isLoading) return; 67 setIsLoading(true); ··· 72 leaflet_id: props.leaflet_id, 73 title: props.title, 74 description: props.description, 75 }); 76 if (!doc) return; 77 78 - let post_url = `https://${props.record?.base_path}/${doc.rkey}`; 79 let [text, facets] = editorStateRef.current 80 ? editorStateToFacetedText(editorStateRef.current) 81 : []; ··· 95 96 return ( 97 <div className="flex flex-col gap-4 w-[640px] max-w-full sm:px-4 px-3 text-primary"> 98 - <h3>Publish Options</h3> 99 <form 100 onSubmit={(e) => { 101 e.preventDefault(); 102 submit(); 103 }} 104 > 105 - <div className="container flex flex-col gap-2 sm:p-3 p-4"> 106 - <Radio 107 - checked={shareOption === "quiet"} 108 - onChange={(e) => { 109 - if (e.target === e.currentTarget) { 110 - setShareOption("quiet"); 111 - } 112 - }} 113 - name="share-options" 114 - id="share-quietly" 115 - value="Share Quietly" 116 - > 117 - <div className="flex flex-col"> 118 - <div className="font-bold">Share Quietly</div> 119 - <div className="text-sm text-tertiary font-normal"> 120 - No one will be notified about this post 121 - </div> 122 - </div> 123 - </Radio> 124 - <Radio 125 - checked={shareOption === "bluesky"} 126 - onChange={(e) => { 127 - if (e.target === e.currentTarget) { 128 - setShareOption("bluesky"); 129 - } 130 - }} 131 - name="share-options" 132 - id="share-bsky" 133 - value="Share on Bluesky" 134 - > 135 - <div className="flex flex-col"> 136 - <div className="font-bold">Share on Bluesky</div> 137 - <div className="text-sm text-tertiary font-normal"> 138 - Pub subscribers will be updated via a custom Bluesky feed 139 - </div> 140 - </div> 141 - </Radio> 142 - 143 - <div 144 - className={`w-full pl-5 pb-4 ${shareOption !== "bluesky" ? "opacity-50" : ""}`} 145 - > 146 - <div className="opaque-container p-3 rounded-lg!"> 147 - <div className="flex gap-2"> 148 - <img 149 - className="rounded-full w-[42px] h-[42px] shrink-0" 150 - src={props.profile.avatar} 151 - /> 152 - <div className="flex flex-col w-full"> 153 - <div className="flex gap-2 pb-1"> 154 - <p className="font-bold">{props.profile.displayName}</p> 155 - <p className="text-tertiary">@{props.profile.handle}</p> 156 - </div> 157 - <div className="flex flex-col"> 158 - <BlueskyPostEditorProsemirror 159 - editorStateRef={editorStateRef} 160 - onCharCountChange={setCharCount} 161 - /> 162 - </div> 163 - <div className="opaque-container overflow-hidden flex flex-col mt-4 w-full"> 164 - <div className="flex flex-col p-2"> 165 - <div className="font-bold">{props.title}</div> 166 - <div className="text-tertiary">{props.description}</div> 167 - <hr className="border-border-light mt-2 mb-1" /> 168 - <p className="text-xs text-tertiary"> 169 - {props.record?.base_path} 170 - </p> 171 - </div> 172 - </div> 173 - <div className="text-xs text-secondary italic place-self-end pt-2"> 174 - {charCount}/300 175 - </div> 176 - </div> 177 - </div> 178 - </div> 179 </div> 180 <div className="flex justify-between"> 181 <Link 182 className="hover:no-underline! font-bold" ··· 198 ); 199 }; 200 201 const PublishPostSuccess = (props: { 202 post_url: string; 203 - publication_uri: string; 204 record: Props["record"]; 205 posts_in_pub: number; 206 }) => { 207 - let uri = new AtUri(props.publication_uri); 208 return ( 209 <div className="container p-4 m-3 sm:m-4 flex flex-col gap-1 justify-center text-center w-fit h-fit mx-auto"> 210 <PublishIllustration posts_in_pub={props.posts_in_pub} /> 211 - <h2 className="pt-2 text-primary">Published!</h2> 212 - <Link 213 - className="hover:no-underline! font-bold place-self-center pt-2" 214 - href={`/lish/${uri.host}/${encodeURIComponent(props.record?.name || "")}/dashboard`} 215 - > 216 - <ButtonPrimary>Back to Dashboard</ButtonPrimary> 217 - </Link> 218 <a href={props.post_url}>See published post</a> 219 </div> 220 );
··· 6 import { Radio } from "components/Checkbox"; 7 import { useParams } from "next/navigation"; 8 import Link from "next/link"; 9 + 10 import { PubLeafletPublication } from "lexicons/api"; 11 import { publishPostToBsky } from "./publishBskyPost"; 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 import { AtUri } from "@atproto/syntax"; 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 import { useReplicache } from "src/replicache"; 16 + import { useSubscribe } from "src/replicache/useSubscribe"; 17 import { 18 BlueskyPostEditorProsemirror, 19 editorStateToFacetedText, 20 } from "./BskyPostEditorProsemirror"; 21 import { EditorState } from "prosemirror-state"; 22 + import { TagSelector } from "../../../components/Tags"; 23 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 + import { PubIcon } from "components/ActionBar/Publications"; 25 26 type Props = { 27 title: string; ··· 29 root_entity: string; 30 profile: ProfileViewDetailed; 31 description: string; 32 + publication_uri?: string; 33 record?: PubLeafletPublication.Record; 34 posts_in_pub?: number; 35 + entitiesToDelete?: string[]; 36 + hasDraft: boolean; 37 }; 38 39 export function PublishPost(props: Props) { ··· 41 { state: "default" } | { state: "success"; post_url: string } 42 >({ state: "default" }); 43 return ( 44 + <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center text-primary"> 45 {publishState.state === "default" ? ( 46 <PublishPostForm setPublishState={setPublishState} {...props} /> 47 ) : ( ··· 61 setPublishState: (s: { state: "success"; post_url: string }) => void; 62 } & Props, 63 ) => { 64 let editorStateRef = useRef<EditorState | null>(null); 65 let [charCount, setCharCount] = useState(0); 66 + let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 67 + let [isLoading, setIsLoading] = useState(false); 68 let params = useParams(); 69 let { rep } = useReplicache(); 70 71 + // For publications with drafts, use Replicache; otherwise use local state 72 + let replicacheTags = useSubscribe(rep, (tx) => 73 + tx.get<string[]>("publication_tags"), 74 + ); 75 + let [localTags, setLocalTags] = useState<string[]>([]); 76 + 77 + // Use Replicache tags only when we have a draft 78 + const hasDraft = props.hasDraft; 79 + const currentTags = hasDraft 80 + ? Array.isArray(replicacheTags) 81 + ? replicacheTags 82 + : [] 83 + : localTags; 84 + 85 + // Update tags via Replicache mutation or local state depending on context 86 + const handleTagsChange = async (newTags: string[]) => { 87 + if (hasDraft) { 88 + await rep?.mutate.updatePublicationDraft({ 89 + tags: newTags, 90 + }); 91 + } else { 92 + setLocalTags(newTags); 93 + } 94 + }; 95 + 96 async function submit() { 97 if (isLoading) return; 98 setIsLoading(true); ··· 103 leaflet_id: props.leaflet_id, 104 title: props.title, 105 description: props.description, 106 + tags: currentTags, 107 + entitiesToDelete: props.entitiesToDelete, 108 }); 109 if (!doc) return; 110 111 + // Generate post URL based on whether it's in a publication or standalone 112 + let post_url = props.record?.base_path 113 + ? `https://${props.record.base_path}/${doc.rkey}` 114 + : `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`; 115 + 116 let [text, facets] = editorStateRef.current 117 ? editorStateToFacetedText(editorStateRef.current) 118 : []; ··· 132 133 return ( 134 <div className="flex flex-col gap-4 w-[640px] max-w-full sm:px-4 px-3 text-primary"> 135 <form 136 onSubmit={(e) => { 137 e.preventDefault(); 138 submit(); 139 }} 140 > 141 + <div className="container flex flex-col gap-3 sm:p-3 p-4"> 142 + <PublishingTo 143 + publication_uri={props.publication_uri} 144 + record={props.record} 145 + /> 146 + <hr className="border-border" /> 147 + <ShareOptions 148 + setShareOption={setShareOption} 149 + shareOption={shareOption} 150 + charCount={charCount} 151 + setCharCount={setCharCount} 152 + editorStateRef={editorStateRef} 153 + {...props} 154 + /> 155 + <hr className="border-border " /> 156 + <div className="flex flex-col gap-2"> 157 + <h4>Tags</h4> 158 + <TagSelector 159 + selectedTags={currentTags} 160 + setSelectedTags={handleTagsChange} 161 + /> 162 </div> 163 + <hr className="border-border mb-2" /> 164 + 165 <div className="flex justify-between"> 166 <Link 167 className="hover:no-underline! font-bold" ··· 183 ); 184 }; 185 186 + const ShareOptions = (props: { 187 + shareOption: "quiet" | "bluesky"; 188 + setShareOption: (option: typeof props.shareOption) => void; 189 + charCount: number; 190 + setCharCount: (c: number) => void; 191 + editorStateRef: React.MutableRefObject<EditorState | null>; 192 + title: string; 193 + profile: ProfileViewDetailed; 194 + description: string; 195 + record?: PubLeafletPublication.Record; 196 + }) => { 197 + return ( 198 + <div className="flex flex-col gap-2"> 199 + <h4>Notifications</h4> 200 + <Radio 201 + checked={props.shareOption === "quiet"} 202 + onChange={(e) => { 203 + if (e.target === e.currentTarget) { 204 + props.setShareOption("quiet"); 205 + } 206 + }} 207 + name="share-options" 208 + id="share-quietly" 209 + value="Share Quietly" 210 + > 211 + <div className="flex flex-col"> 212 + <div className="font-bold">Share Quietly</div> 213 + <div className="text-sm text-tertiary font-normal"> 214 + No one will be notified about this post 215 + </div> 216 + </div> 217 + </Radio> 218 + <Radio 219 + checked={props.shareOption === "bluesky"} 220 + onChange={(e) => { 221 + if (e.target === e.currentTarget) { 222 + props.setShareOption("bluesky"); 223 + } 224 + }} 225 + name="share-options" 226 + id="share-bsky" 227 + value="Share on Bluesky" 228 + > 229 + <div className="flex flex-col"> 230 + <div className="font-bold">Share on Bluesky</div> 231 + <div className="text-sm text-tertiary font-normal"> 232 + Pub subscribers will be updated via a custom Bluesky feed 233 + </div> 234 + </div> 235 + </Radio> 236 + <div 237 + className={`w-full pl-5 pb-4 ${props.shareOption !== "bluesky" ? "opacity-50" : ""}`} 238 + > 239 + <div className="opaque-container py-2 px-3 text-sm rounded-lg!"> 240 + <div className="flex gap-2"> 241 + <img 242 + className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0" 243 + src={props.profile.avatar} 244 + /> 245 + <div className="flex flex-col w-full"> 246 + <div className="flex gap-2 "> 247 + <p className="font-bold">{props.profile.displayName}</p> 248 + <p className="text-tertiary">@{props.profile.handle}</p> 249 + </div> 250 + <div className="flex flex-col"> 251 + <BlueskyPostEditorProsemirror 252 + editorStateRef={props.editorStateRef} 253 + onCharCountChange={props.setCharCount} 254 + /> 255 + </div> 256 + <div className="opaque-container !border-border overflow-hidden flex flex-col mt-4 w-full"> 257 + <div className="flex flex-col p-2"> 258 + <div className="font-bold">{props.title}</div> 259 + <div className="text-tertiary">{props.description}</div> 260 + <hr className="border-border mt-2 mb-1" /> 261 + <p className="text-xs text-tertiary"> 262 + {props.record?.base_path} 263 + </p> 264 + </div> 265 + </div> 266 + <div className="text-xs text-secondary italic place-self-end pt-2"> 267 + {props.charCount}/300 268 + </div> 269 + </div> 270 + </div> 271 + </div> 272 + </div> 273 + </div> 274 + ); 275 + }; 276 + 277 + const PublishingTo = (props: { 278 + publication_uri?: string; 279 + record?: PubLeafletPublication.Record; 280 + }) => { 281 + if (props.publication_uri && props.record) { 282 + return ( 283 + <div className="flex flex-col gap-1"> 284 + <h3>Publishing to</h3> 285 + <div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]"> 286 + <PubIcon record={props.record} uri={props.publication_uri} /> 287 + <div className="font-bold text-secondary">{props.record.name}</div> 288 + </div> 289 + </div> 290 + ); 291 + } 292 + 293 + return ( 294 + <div className="flex flex-col gap-1"> 295 + <h3>Publishing as</h3> 296 + <div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]"> 297 + <LooseLeafSmall className="shrink-0" /> 298 + <div className="font-bold text-secondary">Looseleaf</div> 299 + </div> 300 + </div> 301 + ); 302 + }; 303 + 304 const PublishPostSuccess = (props: { 305 post_url: string; 306 + publication_uri?: string; 307 record: Props["record"]; 308 posts_in_pub: number; 309 }) => { 310 + let uri = props.publication_uri ? new AtUri(props.publication_uri) : null; 311 return ( 312 <div className="container p-4 m-3 sm:m-4 flex flex-col gap-1 justify-center text-center w-fit h-fit mx-auto"> 313 <PublishIllustration posts_in_pub={props.posts_in_pub} /> 314 + <h2 className="pt-2">Published!</h2> 315 + {uri && props.record ? ( 316 + <Link 317 + className="hover:no-underline! font-bold place-self-center pt-2" 318 + href={`/lish/${uri.host}/${encodeURIComponent(props.record.name || "")}/dashboard`} 319 + > 320 + <ButtonPrimary>Back to Dashboard</ButtonPrimary> 321 + </Link> 322 + ) : ( 323 + <Link 324 + className="hover:no-underline! font-bold place-self-center pt-2" 325 + href="/" 326 + > 327 + <ButtonPrimary>Back to Home</ButtonPrimary> 328 + </Link> 329 + )} 330 <a href={props.post_url}>See published post</a> 331 </div> 332 );
+69 -9
app/[leaflet_id]/publish/page.tsx
··· 13 type Props = { 14 // this is now a token id not leaflet! Should probs rename 15 params: Promise<{ leaflet_id: string }>; 16 }; 17 export default async function PublishLeafletPage(props: Props) { 18 let leaflet_id = (await props.params).leaflet_id; ··· 27 *, 28 documents_in_publications(count) 29 ), 30 - documents(*))`, 31 ) 32 .eq("id", leaflet_id) 33 .single(); 34 let rootEntity = data?.root_entity; 35 - if (!data || !rootEntity || !data.leaflets_in_publications[0]) 36 return ( 37 <div> 38 missin something ··· 42 43 let identity = await getIdentityData(); 44 if (!identity || !identity.atp_did) return null; 45 - let pub = data.leaflets_in_publications[0]; 46 - let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 47 48 let profile = await agent.getProfile({ actor: identity.atp_did }); 49 return ( 50 <ReplicacheProvider 51 rootEntity={rootEntity} ··· 57 leaflet_id={leaflet_id} 58 root_entity={rootEntity} 59 profile={profile.data} 60 - title={pub.title} 61 - publication_uri={pub.publication} 62 - description={pub.description} 63 - record={pub.publications?.record as PubLeafletPublication.Record} 64 - posts_in_pub={pub.publications?.documents_in_publications[0].count} 65 /> 66 </ReplicacheProvider> 67 );
··· 13 type Props = { 14 // this is now a token id not leaflet! Should probs rename 15 params: Promise<{ leaflet_id: string }>; 16 + searchParams: Promise<{ 17 + publication_uri: string; 18 + title: string; 19 + description: string; 20 + entitiesToDelete: string; 21 + }>; 22 }; 23 export default async function PublishLeafletPage(props: Props) { 24 let leaflet_id = (await props.params).leaflet_id; ··· 33 *, 34 documents_in_publications(count) 35 ), 36 + documents(*)), 37 + leaflets_to_documents( 38 + *, 39 + documents(*) 40 + )`, 41 ) 42 .eq("id", leaflet_id) 43 .single(); 44 let rootEntity = data?.root_entity; 45 + 46 + // Try to find publication from leaflets_in_publications first 47 + let publication = data?.leaflets_in_publications[0]?.publications; 48 + 49 + // If not found, check if publication_uri is in searchParams 50 + if (!publication) { 51 + let pub_uri = (await props.searchParams).publication_uri; 52 + if (pub_uri) { 53 + console.log(decodeURIComponent(pub_uri)); 54 + let { data: pubData, error } = await supabaseServerClient 55 + .from("publications") 56 + .select("*, documents_in_publications(count)") 57 + .eq("uri", decodeURIComponent(pub_uri)) 58 + .single(); 59 + console.log(error); 60 + publication = pubData; 61 + } 62 + } 63 + 64 + // Check basic data requirements 65 + if (!data || !rootEntity) 66 return ( 67 <div> 68 missin something ··· 72 73 let identity = await getIdentityData(); 74 if (!identity || !identity.atp_did) return null; 75 76 + // Get title and description from either source 77 + let title = 78 + data.leaflets_in_publications[0]?.title || 79 + data.leaflets_to_documents[0]?.title || 80 + decodeURIComponent((await props.searchParams).title || ""); 81 + let description = 82 + data.leaflets_in_publications[0]?.description || 83 + data.leaflets_to_documents[0]?.description || 84 + decodeURIComponent((await props.searchParams).description || ""); 85 + 86 + let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 87 let profile = await agent.getProfile({ actor: identity.atp_did }); 88 + 89 + // Parse entitiesToDelete from URL params 90 + let searchParams = await props.searchParams; 91 + let entitiesToDelete: string[] = []; 92 + try { 93 + if (searchParams.entitiesToDelete) { 94 + entitiesToDelete = JSON.parse( 95 + decodeURIComponent(searchParams.entitiesToDelete), 96 + ); 97 + } 98 + } catch (e) { 99 + // If parsing fails, just use empty array 100 + } 101 + 102 + // Check if a draft record exists (either in a publication or standalone) 103 + let hasDraft = 104 + data.leaflets_in_publications.length > 0 || 105 + data.leaflets_to_documents.length > 0; 106 + 107 return ( 108 <ReplicacheProvider 109 rootEntity={rootEntity} ··· 115 leaflet_id={leaflet_id} 116 root_entity={rootEntity} 117 profile={profile.data} 118 + title={title} 119 + description={description} 120 + publication_uri={publication?.uri} 121 + record={publication?.record as PubLeafletPublication.Record | undefined} 122 + posts_in_pub={publication?.documents_in_publications[0]?.count} 123 + entitiesToDelete={entitiesToDelete} 124 + hasDraft={hasDraft} 125 /> 126 </ReplicacheProvider> 127 );
+80 -17
app/api/inngest/functions/index_post_mention.ts
··· 3 import { AtpAgent, AtUri } from "@atproto/api"; 4 import { Json } from "supabase/database.types"; 5 import { ids } from "lexicons/api/lexicons"; 6 7 export const index_post_mention = inngest.createFunction( 8 { id: "index_post_mention" }, ··· 11 let url = new URL(event.data.document_link); 12 let path = url.pathname.split("/").filter(Boolean); 13 14 - let { data: pub, error } = await supabaseServerClient 15 - .from("publications") 16 - .select("*") 17 - .eq("record->>base_path", url.host) 18 - .single(); 19 20 - if (!pub) { 21 - return { 22 - message: `No publication found for ${url.host}/${path[0]}`, 23 - error, 24 - }; 25 } 26 27 let bsky_post = await step.run("get-bsky-post-data", async () => { ··· 38 } 39 40 await step.run("index-bsky-post", async () => { 41 - await supabaseServerClient.from("bsky_posts").insert({ 42 uri: bsky_post.uri, 43 cid: bsky_post.cid, 44 post_view: bsky_post as Json, 45 }); 46 - await supabaseServerClient.from("document_mentions_in_bsky").insert({ 47 uri: bsky_post.uri, 48 - document: AtUri.make( 49 - pub.identity_did, 50 - ids.PubLeafletDocument, 51 - path[0], 52 - ).toString(), 53 link: event.data.document_link, 54 }); 55 }); 56 }, 57 );
··· 3 import { AtpAgent, AtUri } from "@atproto/api"; 4 import { Json } from "supabase/database.types"; 5 import { ids } from "lexicons/api/lexicons"; 6 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 7 + import { v7 } from "uuid"; 8 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 9 10 export const index_post_mention = inngest.createFunction( 11 { id: "index_post_mention" }, ··· 14 let url = new URL(event.data.document_link); 15 let path = url.pathname.split("/").filter(Boolean); 16 17 + // Check if this is a standalone document URL (/p/didOrHandle/rkey/...) 18 + const isStandaloneDoc = path[0] === "p" && path.length >= 3; 19 + 20 + let documentUri: string; 21 + let authorDid: string; 22 + 23 + if (isStandaloneDoc) { 24 + // Standalone doc: /p/didOrHandle/rkey/l-quote/... 25 + const didOrHandle = decodeURIComponent(path[1]); 26 + const rkey = path[2]; 27 + 28 + // Resolve handle to DID if necessary 29 + let did = didOrHandle; 30 + if (!didOrHandle.startsWith("did:")) { 31 + const resolved = await step.run("resolve-handle", async () => { 32 + return idResolver.handle.resolve(didOrHandle); 33 + }); 34 + if (!resolved) { 35 + return { message: `Could not resolve handle: ${didOrHandle}` }; 36 + } 37 + did = resolved; 38 + } 39 40 + documentUri = AtUri.make(did, ids.PubLeafletDocument, rkey).toString(); 41 + authorDid = did; 42 + } else { 43 + // Publication post: look up by custom domain 44 + let { data: pub, error } = await supabaseServerClient 45 + .from("publications") 46 + .select("*") 47 + .eq("record->>base_path", url.host) 48 + .single(); 49 + 50 + if (!pub) { 51 + return { 52 + message: `No publication found for ${url.host}/${path[0]}`, 53 + error, 54 + }; 55 + } 56 + 57 + documentUri = AtUri.make( 58 + pub.identity_did, 59 + ids.PubLeafletDocument, 60 + path[0], 61 + ).toString(); 62 + authorDid = pub.identity_did; 63 } 64 65 let bsky_post = await step.run("get-bsky-post-data", async () => { ··· 76 } 77 78 await step.run("index-bsky-post", async () => { 79 + await supabaseServerClient.from("bsky_posts").upsert({ 80 uri: bsky_post.uri, 81 cid: bsky_post.cid, 82 post_view: bsky_post as Json, 83 }); 84 + await supabaseServerClient.from("document_mentions_in_bsky").upsert({ 85 uri: bsky_post.uri, 86 + document: documentUri, 87 link: event.data.document_link, 88 }); 89 + }); 90 + 91 + await step.run("create-notification", async () => { 92 + // Only create notification if the quote is from someone other than the author 93 + if (bsky_post.author.did !== authorDid) { 94 + // Check if a notification already exists for this post and recipient 95 + const { data: existingNotification } = await supabaseServerClient 96 + .from("notifications") 97 + .select("id") 98 + .eq("recipient", authorDid) 99 + .eq("data->>type", "quote") 100 + .eq("data->>bsky_post_uri", bsky_post.uri) 101 + .eq("data->>document_uri", documentUri) 102 + .single(); 103 + 104 + if (!existingNotification) { 105 + const notification: Notification = { 106 + id: v7(), 107 + recipient: authorDid, 108 + data: { 109 + type: "quote", 110 + bsky_post_uri: bsky_post.uri, 111 + document_uri: documentUri, 112 + }, 113 + }; 114 + await supabaseServerClient.from("notifications").insert(notification); 115 + await pingIdentityToUpdateNotification(authorDid); 116 + } 117 + } 118 }); 119 }, 120 );
+145
app/api/pub_icon/route.ts
···
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { IdResolver } from "@atproto/identity"; 3 + import { NextRequest, NextResponse } from "next/server"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import sharp from "sharp"; 7 + 8 + const idResolver = new IdResolver(); 9 + 10 + export const runtime = "nodejs"; 11 + 12 + export async function GET(req: NextRequest) { 13 + const searchParams = req.nextUrl.searchParams; 14 + const bgColor = searchParams.get("bg") || "#0000E1"; 15 + const fgColor = searchParams.get("fg") || "#FFFFFF"; 16 + 17 + try { 18 + const at_uri = searchParams.get("at_uri"); 19 + 20 + if (!at_uri) { 21 + return new NextResponse(null, { status: 400 }); 22 + } 23 + 24 + // Parse the AT URI 25 + let uri: AtUri; 26 + try { 27 + uri = new AtUri(at_uri); 28 + } catch (e) { 29 + return new NextResponse(null, { status: 400 }); 30 + } 31 + 32 + let publicationRecord: PubLeafletPublication.Record | null = null; 33 + let publicationUri: string; 34 + 35 + // Check if it's a document or publication 36 + if (uri.collection === "pub.leaflet.document") { 37 + // Query the documents_in_publications table to get the publication 38 + const { data: docInPub } = await supabaseServerClient 39 + .from("documents_in_publications") 40 + .select("publication, publications(record)") 41 + .eq("document", at_uri) 42 + .single(); 43 + 44 + if (!docInPub || !docInPub.publications) { 45 + return new NextResponse(null, { status: 404 }); 46 + } 47 + 48 + publicationUri = docInPub.publication; 49 + publicationRecord = docInPub.publications 50 + .record as PubLeafletPublication.Record; 51 + } else if (uri.collection === "pub.leaflet.publication") { 52 + // Query the publications table directly 53 + const { data: publication } = await supabaseServerClient 54 + .from("publications") 55 + .select("record, uri") 56 + .eq("uri", at_uri) 57 + .single(); 58 + 59 + if (!publication || !publication.record) { 60 + return new NextResponse(null, { status: 404 }); 61 + } 62 + 63 + publicationUri = publication.uri; 64 + publicationRecord = publication.record as PubLeafletPublication.Record; 65 + } else { 66 + // Not a supported collection 67 + return new NextResponse(null, { status: 404 }); 68 + } 69 + 70 + // Check if the publication has an icon 71 + if (!publicationRecord?.icon) { 72 + // Generate a placeholder with the first letter of the publication name 73 + const firstLetter = (publicationRecord?.name || "?") 74 + .slice(0, 1) 75 + .toUpperCase(); 76 + 77 + // Create a simple SVG placeholder with theme colors 78 + const svg = `<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg"> 79 + <rect width="96" height="96" rx="48" ry="48" fill="${bgColor}"/> 80 + <text x="50%" y="50%" font-size="64" font-weight="bold" font-family="Arial, Helvetica, sans-serif" fill="${fgColor}" text-anchor="middle" dominant-baseline="central">${firstLetter}</text> 81 + </svg>`; 82 + 83 + return new NextResponse(svg, { 84 + headers: { 85 + "Content-Type": "image/svg+xml", 86 + "Cache-Control": 87 + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", 88 + "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", 89 + }, 90 + }); 91 + } 92 + 93 + // Parse the publication URI to get the DID 94 + const pubUri = new AtUri(publicationUri); 95 + 96 + // Get the CID from the icon blob 97 + const cid = (publicationRecord.icon.ref as unknown as { $link: string })[ 98 + "$link" 99 + ]; 100 + 101 + // Fetch the blob from the PDS 102 + const identity = await idResolver.did.resolve(pubUri.host); 103 + const service = identity?.service?.find((f) => f.id === "#atproto_pds"); 104 + if (!service) return new NextResponse(null, { status: 404 }); 105 + 106 + const blobResponse = await fetch( 107 + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${pubUri.host}&cid=${cid}`, 108 + { 109 + headers: { 110 + "Accept-Encoding": "gzip, deflate, br, zstd", 111 + }, 112 + }, 113 + ); 114 + 115 + if (!blobResponse.ok) { 116 + return new NextResponse(null, { status: 404 }); 117 + } 118 + 119 + // Get the image buffer 120 + const imageBuffer = await blobResponse.arrayBuffer(); 121 + 122 + // Resize to 96x96 using Sharp 123 + const resizedImage = await sharp(Buffer.from(imageBuffer)) 124 + .resize(96, 96, { 125 + fit: "cover", 126 + position: "center", 127 + }) 128 + .webp({ quality: 90 }) 129 + .toBuffer(); 130 + 131 + // Return with caching headers 132 + return new NextResponse(resizedImage, { 133 + headers: { 134 + "Content-Type": "image/webp", 135 + // Cache for 1 hour, but serve stale for much longer while revalidating 136 + "Cache-Control": 137 + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000", 138 + "CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000", 139 + }, 140 + }); 141 + } catch (error) { 142 + console.error("Error fetching publication icon:", error); 143 + return new NextResponse(null, { status: 500 }); 144 + } 145 + }
+1 -1
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
··· 5 import type { Env } from "./route"; 6 import { scanIndexLocal } from "src/replicache/utils"; 7 import * as base64 from "base64-js"; 8 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 9 import { applyUpdate, Doc } from "yjs"; 10 11 export const getFactsFromHomeLeaflets = makeRoute({
··· 5 import type { Env } from "./route"; 6 import { scanIndexLocal } from "src/replicache/utils"; 7 import * as base64 from "base64-js"; 8 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 9 import { applyUpdate, Doc } from "yjs"; 10 11 export const getFactsFromHomeLeaflets = makeRoute({
+4 -2
app/api/rpc/[command]/get_leaflet_data.ts
··· 7 >; 8 9 const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`; 10 export const get_leaflet_data = makeRoute({ 11 route: "get_leaflet_data", 12 input: z.object({ ··· 18 .from("permission_tokens") 19 .select( 20 `*, 21 - permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}))), 22 custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), 23 - ${leaflets_in_publications_query}`, 24 ) 25 .eq("id", token_id) 26 .single();
··· 7 >; 8 9 const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`; 10 + const leaflets_to_documents_query = `leaflets_to_documents(*, documents(*))`; 11 export const get_leaflet_data = makeRoute({ 12 route: "get_leaflet_data", 13 input: z.object({ ··· 19 .from("permission_tokens") 20 .select( 21 `*, 22 + permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}, ${leaflets_to_documents_query}))), 23 custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), 24 + ${leaflets_in_publications_query}, 25 + ${leaflets_to_documents_query}`, 26 ) 27 .eq("id", token_id) 28 .single();
+69
app/api/rpc/[command]/get_profile_data.ts
···
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { Agent } from "@atproto/api"; 7 + import { getIdentityData } from "actions/getIdentityData"; 8 + import { createOauthClient } from "src/atproto-oauth"; 9 + 10 + export type GetProfileDataReturnType = Awaited< 11 + ReturnType<(typeof get_profile_data)["handler"]> 12 + >; 13 + 14 + export const get_profile_data = makeRoute({ 15 + route: "get_profile_data", 16 + input: z.object({ 17 + didOrHandle: z.string(), 18 + }), 19 + handler: async ({ didOrHandle }, { supabase }: Pick<Env, "supabase">) => { 20 + // Resolve handle to DID if necessary 21 + let did = didOrHandle; 22 + 23 + if (!didOrHandle.startsWith("did:")) { 24 + const resolved = await idResolver.handle.resolve(didOrHandle); 25 + if (!resolved) { 26 + throw new Error("Could not resolve handle to DID"); 27 + } 28 + did = resolved; 29 + } 30 + let agent; 31 + let authed_identity = await getIdentityData(); 32 + if (authed_identity?.atp_did) { 33 + try { 34 + const oauthClient = await createOauthClient(); 35 + let credentialSession = await oauthClient.restore( 36 + authed_identity.atp_did, 37 + ); 38 + agent = new Agent(credentialSession); 39 + } catch (e) { 40 + agent = new Agent({ 41 + service: "https://public.api.bsky.app", 42 + }); 43 + } 44 + } else { 45 + agent = new Agent({ 46 + service: "https://public.api.bsky.app", 47 + }); 48 + } 49 + 50 + let profileReq = agent.app.bsky.actor.getProfile({ actor: did }); 51 + 52 + let publicationsReq = supabase 53 + .from("publications") 54 + .select("*") 55 + .eq("identity_did", did); 56 + 57 + let [{ data: profile }, { data: publications }] = await Promise.all([ 58 + profileReq, 59 + publicationsReq, 60 + ]); 61 + 62 + return { 63 + result: { 64 + profile, 65 + publications: publications || [], 66 + }, 67 + }; 68 + }, 69 + });
+6
app/api/rpc/[command]/pull.ts
··· 73 let publication_data = data.publications as { 74 description: string; 75 title: string; 76 }[]; 77 let pub_patch = publication_data?.[0] 78 ? [ ··· 85 op: "put", 86 key: "publication_title", 87 value: publication_data[0].title, 88 }, 89 ] 90 : [];
··· 73 let publication_data = data.publications as { 74 description: string; 75 title: string; 76 + tags: string[]; 77 }[]; 78 let pub_patch = publication_data?.[0] 79 ? [ ··· 86 op: "put", 87 key: "publication_title", 88 value: publication_data[0].title, 89 + }, 90 + { 91 + op: "put", 92 + key: "publication_tags", 93 + value: publication_data[0].tags || [], 94 }, 95 ] 96 : [];
+6
app/api/rpc/[command]/route.ts
··· 11 } from "./domain_routes"; 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 import { get_publication_data } from "./get_publication_data"; 14 15 let supabase = createClient<Database>( 16 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 35 get_leaflet_subdomain_status, 36 get_leaflet_data, 37 get_publication_data, 38 ]; 39 export async function POST( 40 req: Request,
··· 11 } from "./domain_routes"; 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 import { get_publication_data } from "./get_publication_data"; 14 + import { search_publication_names } from "./search_publication_names"; 15 + import { search_publication_documents } from "./search_publication_documents"; 16 + import { get_profile_data } from "./get_profile_data"; 17 18 let supabase = createClient<Database>( 19 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 38 get_leaflet_subdomain_status, 39 get_leaflet_data, 40 get_publication_data, 41 + search_publication_names, 42 + search_publication_documents, 43 + get_profile_data, 44 ]; 45 export async function POST( 46 req: Request,
+52
app/api/rpc/[command]/search_publication_documents.ts
···
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { z } from "zod"; 3 + import { makeRoute } from "../lib"; 4 + import type { Env } from "./route"; 5 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 + 7 + export type SearchPublicationDocumentsReturnType = Awaited< 8 + ReturnType<(typeof search_publication_documents)["handler"]> 9 + >; 10 + 11 + export const search_publication_documents = makeRoute({ 12 + route: "search_publication_documents", 13 + input: z.object({ 14 + publication_uri: z.string(), 15 + query: z.string(), 16 + limit: z.number().optional().default(10), 17 + }), 18 + handler: async ( 19 + { publication_uri, query, limit }, 20 + { supabase }: Pick<Env, "supabase">, 21 + ) => { 22 + // Get documents in the publication, filtering by title using JSON operator 23 + // Also join with publications to get the record for URL construction 24 + const { data: documents, error } = await supabase 25 + .from("documents_in_publications") 26 + .select( 27 + "document, documents!inner(uri, data), publications!inner(uri, record)", 28 + ) 29 + .eq("publication", publication_uri) 30 + .ilike("documents.data->>title", `%${query}%`) 31 + .limit(limit); 32 + 33 + if (error) { 34 + throw new Error( 35 + `Failed to search publication documents: ${error.message}`, 36 + ); 37 + } 38 + 39 + const result = documents.map((d) => { 40 + const docUri = new AtUri(d.documents.uri); 41 + const pubUrl = getPublicationURL(d.publications); 42 + 43 + return { 44 + uri: d.documents.uri, 45 + title: (d.documents.data as { title?: string })?.title || "Untitled", 46 + url: `${pubUrl}/${docUri.rkey}`, 47 + }; 48 + }); 49 + 50 + return { result: { documents: result } }; 51 + }, 52 + });
+39
app/api/rpc/[command]/search_publication_names.ts
···
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + 6 + export type SearchPublicationNamesReturnType = Awaited< 7 + ReturnType<(typeof search_publication_names)["handler"]> 8 + >; 9 + 10 + export const search_publication_names = makeRoute({ 11 + route: "search_publication_names", 12 + input: z.object({ 13 + query: z.string(), 14 + limit: z.number().optional().default(10), 15 + }), 16 + handler: async ({ query, limit }, { supabase }: Pick<Env, "supabase">) => { 17 + // Search publications by name in record (case-insensitive partial match) 18 + const { data: publications, error } = await supabase 19 + .from("publications") 20 + .select("uri, record") 21 + .ilike("record->>name", `%${query}%`) 22 + .limit(limit); 23 + 24 + if (error) { 25 + throw new Error(`Failed to search publications: ${error.message}`); 26 + } 27 + 28 + const result = publications.map((p) => { 29 + const record = p.record as { name?: string }; 30 + return { 31 + uri: p.uri, 32 + name: record.name || "Untitled", 33 + url: getPublicationURL(p), 34 + }; 35 + }); 36 + 37 + return { result: { publications: result } }; 38 + }, 39 + });
+32
app/globals.css
··· 211 212 /* END GLOBAL STYLING */ 213 } 214 button:hover { 215 cursor: pointer; 216 } ··· 295 background-color: transparent; 296 } 297 298 .multiselected:focus-within .selection-highlight { 299 background-color: transparent; 300 } ··· 339 @apply focus-within:outline-offset-1; 340 341 @apply disabled:border-border-light; 342 @apply disabled:bg-border-light; 343 @apply disabled:text-tertiary; 344 } ··· 413 outline: none !important; 414 cursor: pointer; 415 background-color: transparent; 416 417 :hover { 418 text-decoration: none !important;
··· 211 212 /* END GLOBAL STYLING */ 213 } 214 + 215 + img { 216 + font-size: 0; 217 + } 218 + 219 button:hover { 220 cursor: pointer; 221 } ··· 300 background-color: transparent; 301 } 302 303 + .ProseMirror .atMention.ProseMirror-selectednode, 304 + .ProseMirror .didMention.ProseMirror-selectednode { 305 + @apply text-accent-contrast; 306 + @apply px-0.5; 307 + @apply -mx-[3px]; /* extra px to account for the border*/ 308 + @apply -my-px; /*to account for the border*/ 309 + @apply rounded-[4px]; 310 + @apply box-decoration-clone; 311 + background-color: rgba(var(--accent-contrast), 0.2); 312 + border: 1px solid rgba(var(--accent-contrast), 1); 313 + } 314 + 315 + .mention { 316 + @apply cursor-pointer; 317 + @apply text-accent-contrast; 318 + @apply px-0.5; 319 + @apply -mx-[3px]; 320 + @apply -my-px; /*to account for the border*/ 321 + @apply rounded-[4px]; 322 + @apply box-decoration-clone; 323 + background-color: rgba(var(--accent-contrast), 0.2); 324 + border: 1px solid transparent; 325 + } 326 + 327 .multiselected:focus-within .selection-highlight { 328 background-color: transparent; 329 } ··· 368 @apply focus-within:outline-offset-1; 369 370 @apply disabled:border-border-light; 371 + @apply disabled:hover:border-border-light; 372 @apply disabled:bg-border-light; 373 @apply disabled:text-tertiary; 374 } ··· 443 outline: none !important; 444 cursor: pointer; 445 background-color: transparent; 446 + display: flex; 447 + gap: 0.5rem; 448 449 :hover { 450 text-decoration: none !important;
+36 -206
app/lish/Subscribe.tsx
··· 23 import { useSearchParams } from "next/navigation"; 24 import LoginForm from "app/login/LoginForm"; 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 - import { SpeedyLink } from "components/SpeedyLink"; 27 - 28 - type State = 29 - | { state: "email" } 30 - | { state: "code"; token: string } 31 - | { state: "success" }; 32 - export const SubscribeButton = (props: { 33 - compact?: boolean; 34 - publication: string; 35 - }) => { 36 - let { identity, mutate } = useIdentityData(); 37 - let [emailInputValue, setEmailInputValue] = useState(""); 38 - let [codeInputValue, setCodeInputValue] = useState(""); 39 - let [state, setState] = useState<State>({ state: "email" }); 40 - 41 - if (state.state === "email") { 42 - return ( 43 - <div className="flex gap-2"> 44 - <div className="flex relative w-full max-w-sm"> 45 - <Input 46 - type="email" 47 - className="input-with-border pr-[104px]! py-1! grow w-full" 48 - placeholder={ 49 - props.compact ? "subscribe with email..." : "email here..." 50 - } 51 - disabled={!!identity?.email} 52 - value={identity?.email ? identity.email : emailInputValue} 53 - onChange={(e) => { 54 - setEmailInputValue(e.currentTarget.value); 55 - }} 56 - /> 57 - <ButtonPrimary 58 - compact 59 - className="absolute right-1 top-1 outline-0!" 60 - onClick={async () => { 61 - if (identity?.email) { 62 - await subscribeToPublicationWithEmail(props.publication); 63 - //optimistically could add! 64 - await mutate(); 65 - return; 66 - } 67 - let tokenID = await requestAuthEmailToken(emailInputValue); 68 - setState({ state: "code", token: tokenID }); 69 - }} 70 - > 71 - {props.compact ? ( 72 - <ArrowRightTiny className="w-4 h-6" /> 73 - ) : ( 74 - "Subscribe" 75 - )} 76 - </ButtonPrimary> 77 - </div> 78 - {/* <ShareButton /> */} 79 - </div> 80 - ); 81 - } 82 - if (state.state === "code") { 83 - return ( 84 - <div 85 - className="w-full flex flex-col justify-center place-items-center p-4 rounded-md" 86 - style={{ 87 - background: 88 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 89 - }} 90 - > 91 - <div className="flex flex-col leading-snug text-secondary"> 92 - <div>Please enter the code we sent to </div> 93 - <div className="italic font-bold">{emailInputValue}</div> 94 - </div> 95 - 96 - <ConfirmCodeInput 97 - publication={props.publication} 98 - token={state.token} 99 - codeInputValue={codeInputValue} 100 - setCodeInputValue={setCodeInputValue} 101 - setState={setState} 102 - /> 103 - 104 - <button 105 - className="text-accent-contrast text-sm mt-1" 106 - onClick={() => { 107 - setState({ state: "email" }); 108 - }} 109 - > 110 - Re-enter Email 111 - </button> 112 - </div> 113 - ); 114 - } 115 - 116 - if (state.state === "success") { 117 - return ( 118 - <div 119 - className={`w-full flex flex-col gap-2 justify-center place-items-center p-4 rounded-md text-secondary ${props.compact ? "py-1 animate-bounce" : "p-4"}`} 120 - style={{ 121 - background: 122 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 123 - }} 124 - > 125 - <div className="flex gap-2 leading-snug font-bold italic"> 126 - <div>You're subscribed!</div> 127 - {/* <ShareButton /> */} 128 - </div> 129 - </div> 130 - ); 131 - } 132 - }; 133 - 134 - export const ShareButton = () => { 135 - return ( 136 - <button className="text-accent-contrast"> 137 - <ShareSmall /> 138 - </button> 139 - ); 140 - }; 141 - 142 - const ConfirmCodeInput = (props: { 143 - codeInputValue: string; 144 - token: string; 145 - setCodeInputValue: (value: string) => void; 146 - setState: (state: State) => void; 147 - publication: string; 148 - }) => { 149 - let { mutate } = useIdentityData(); 150 - return ( 151 - <div className="relative w-fit mt-2"> 152 - <Input 153 - type="text" 154 - pattern="[0-9]" 155 - className="input-with-border pr-[88px]! py-1! max-w-[156px]" 156 - placeholder="000000" 157 - value={props.codeInputValue} 158 - onChange={(e) => { 159 - props.setCodeInputValue(e.currentTarget.value); 160 - }} 161 - /> 162 - <ButtonPrimary 163 - compact 164 - className="absolute right-1 top-1 outline-0!" 165 - onClick={async () => { 166 - console.log( 167 - await confirmEmailAuthToken(props.token, props.codeInputValue), 168 - ); 169 - 170 - await subscribeToPublicationWithEmail(props.publication); 171 - //optimistically could add! 172 - await mutate(); 173 - props.setState({ state: "success" }); 174 - return; 175 - }} 176 - > 177 - Confirm 178 - </ButtonPrimary> 179 - </div> 180 - ); 181 - }; 182 183 export const SubscribeWithBluesky = (props: { 184 - isPost?: boolean; 185 pubName: string; 186 pub_uri: string; 187 base_url: string; ··· 208 } 209 return ( 210 <div className="flex flex-col gap-2 text-center justify-center"> 211 - {props.isPost && ( 212 - <div className="text-sm text-tertiary font-bold"> 213 - Get updates from {props.pubName}! 214 - </div> 215 - )} 216 <div className="flex flex-row gap-2 place-self-center"> 217 <BlueskySubscribeButton 218 pub_uri={props.pub_uri} ··· 231 ); 232 }; 233 234 - const ManageSubscription = (props: { 235 - isPost?: boolean; 236 - pubName: string; 237 pub_uri: string; 238 subscribers: { identity: string }[]; 239 base_url: string; ··· 248 }); 249 }, null); 250 return ( 251 - <div 252 - className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 253 > 254 - <div className="font-bold text-tertiary text-sm"> 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 - )} 261 - </div> 262 - <Popover 263 - trigger={<div className="text-accent-contrast text-sm">Manage</div>} 264 - > 265 - <div className="max-w-sm flex flex-col gap-1"> 266 - <h4>Update Options</h4> 267 268 - {!hasFeed && ( 269 - <a 270 - href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 271 - target="_blank" 272 - className=" place-self-center" 273 - > 274 - <ButtonPrimary fullWidth compact className="!px-4"> 275 - View Bluesky Custom Feed 276 - </ButtonPrimary> 277 - </a> 278 - )} 279 - 280 <a 281 - href={`${props.base_url}/rss`} 282 - className="flex" 283 target="_blank" 284 - aria-label="Subscribe to RSS" 285 > 286 - <ButtonPrimary fullWidth compact> 287 - Get RSS 288 </ButtonPrimary> 289 </a> 290 291 - <hr className="border-border-light my-1" /> 292 293 - <form action={unsubscribe}> 294 - <button className="font-bold text-accent-contrast w-max place-self-center"> 295 - {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 296 - </button> 297 - </form> 298 - </div>{" "} 299 - </Popover> 300 - </div> 301 ); 302 }; 303 ··· 430 </Dialog.Root> 431 ); 432 };
··· 23 import { useSearchParams } from "next/navigation"; 24 import LoginForm from "app/login/LoginForm"; 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 27 export const SubscribeWithBluesky = (props: { 28 pubName: string; 29 pub_uri: string; 30 base_url: string; ··· 51 } 52 return ( 53 <div className="flex flex-col gap-2 text-center justify-center"> 54 <div className="flex flex-row gap-2 place-self-center"> 55 <BlueskySubscribeButton 56 pub_uri={props.pub_uri} ··· 69 ); 70 }; 71 72 + export const ManageSubscription = (props: { 73 pub_uri: string; 74 subscribers: { identity: string }[]; 75 base_url: string; ··· 84 }); 85 }, null); 86 return ( 87 + <Popover 88 + trigger={ 89 + <div className="text-accent-contrast text-sm">Manage Subscription</div> 90 + } 91 > 92 + <div className="max-w-sm flex flex-col gap-1"> 93 + <h4>Update Options</h4> 94 95 + {!hasFeed && ( 96 <a 97 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 98 target="_blank" 99 + className=" place-self-center" 100 > 101 + <ButtonPrimary fullWidth compact className="!px-4"> 102 + View Bluesky Custom Feed 103 </ButtonPrimary> 104 </a> 105 + )} 106 107 + <a 108 + href={`${props.base_url}/rss`} 109 + className="flex" 110 + target="_blank" 111 + aria-label="Subscribe to RSS" 112 + > 113 + <ButtonPrimary fullWidth compact> 114 + Get RSS 115 + </ButtonPrimary> 116 + </a> 117 118 + <hr className="border-border-light my-1" /> 119 + 120 + <form action={unsubscribe}> 121 + <button className="font-bold text-accent-contrast w-max place-self-center"> 122 + {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 123 + </button> 124 + </form> 125 + </div> 126 + </Popover> 127 ); 128 }; 129 ··· 256 </Dialog.Root> 257 ); 258 }; 259 + 260 + export const SubscribeOnPost = () => { 261 + return <div></div>; 262 + };
+24
app/lish/[did]/[publication]/PublicationHomeLayout.tsx
···
··· 1 + "use client"; 2 + 3 + import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 4 + 5 + export function PublicationHomeLayout(props: { 6 + uri: string; 7 + showPageBackground: boolean; 8 + children: React.ReactNode; 9 + }) { 10 + let { ref } = usePreserveScroll<HTMLDivElement>(props.uri); 11 + return ( 12 + <div 13 + ref={props.showPageBackground ? null : ref} 14 + className={`pubWrapper flex flex-col sm:py-6 h-full ${props.showPageBackground ? "max-w-prose mx-auto sm:px-0 px-[6px] py-2" : "w-full overflow-y-scroll"}`} 15 + > 16 + <div 17 + ref={!props.showPageBackground ? null : ref} 18 + className={`pub sm:max-w-prose max-w-(--page-width-units) w-[1000px] mx-auto px-3 sm:px-4 py-5 ${props.showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`} 19 + > 20 + {props.children} 21 + </div> 22 + </div> 23 + ); 24 + }
+36 -3
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 import { UnicodeString } from "@atproto/api"; 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 4 type Facet = PubLeafletRichtextFacet.Main; 5 export function BaseTextBlock(props: { ··· 22 let isStrikethrough = segment.facet?.find( 23 PubLeafletRichtextFacet.isStrikethrough, 24 ); 25 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 26 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 27 let isHighlighted = segment.facet?.find( ··· 36 ${isStrikethrough ? "line-through decoration-tertiary" : ""} 37 ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 38 39 if (isCode) { 40 children.push( 41 <code key={counter} className={className} id={id?.id}> 42 - {segment.text} 43 </code>, 44 ); 45 } else if (link) { 46 children.push( 47 <a ··· 50 className={`text-accent-contrast hover:underline ${className}`} 51 target="_blank" 52 > 53 - {segment.text} 54 </a>, 55 ); 56 } else { 57 children.push( 58 <span key={counter} className={className} id={id?.id}> 59 - {segment.text} 60 </span>, 61 ); 62 }
··· 1 import { UnicodeString } from "@atproto/api"; 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 4 + import { AtMentionLink } from "components/AtMentionLink"; 5 + import { ProfilePopover } from "components/ProfilePopover"; 6 7 type Facet = PubLeafletRichtextFacet.Main; 8 export function BaseTextBlock(props: { ··· 25 let isStrikethrough = segment.facet?.find( 26 PubLeafletRichtextFacet.isStrikethrough, 27 ); 28 + let isDidMention = segment.facet?.find( 29 + PubLeafletRichtextFacet.isDidMention, 30 + ); 31 + let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); 32 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 33 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 34 let isHighlighted = segment.facet?.find( ··· 43 ${isStrikethrough ? "line-through decoration-tertiary" : ""} 44 ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 45 46 + // Split text by newlines and insert <br> tags 47 + const textParts = segment.text.split("\n"); 48 + const renderedText = textParts.flatMap((part, i) => 49 + i < textParts.length - 1 50 + ? [part, <br key={`br-${counter}-${i}`} />] 51 + : [part], 52 + ); 53 + 54 if (isCode) { 55 children.push( 56 <code key={counter} className={className} id={id?.id}> 57 + {renderedText} 58 </code>, 59 ); 60 + } else if (isDidMention) { 61 + children.push( 62 + <ProfilePopover 63 + key={counter} 64 + didOrHandle={isDidMention.did} 65 + trigger={<span className="mention">{renderedText}</span>} 66 + />, 67 + ); 68 + } else if (isAtMention) { 69 + children.push( 70 + <AtMentionLink 71 + key={counter} 72 + atURI={isAtMention.atURI} 73 + className={className} 74 + > 75 + {renderedText} 76 + </AtMentionLink>, 77 + ); 78 } else if (link) { 79 children.push( 80 <a ··· 83 className={`text-accent-contrast hover:underline ${className}`} 84 target="_blank" 85 > 86 + {renderedText} 87 </a>, 88 ); 89 } else { 90 children.push( 91 <span key={counter} className={className} id={id?.id}> 92 + {renderedText} 93 </span>, 94 ); 95 }
+25 -32
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 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 if (!document) return null; 57 58 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 59 let isSubpage = !!pageId; 60 let drawer = useDrawerOpen(document_uri); 61 ··· 63 <PageWrapper 64 pageType="canvas" 65 fullPageScroll={fullPageScroll} 66 - cardBorderHidden={!hasPageBackground} 67 id={pageId ? `post-page-${pageId}` : "post-page"} 68 drawerOpen={ 69 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) ··· 213 quotesCount: number | undefined; 214 commentsCount: number | undefined; 215 }) => { 216 return ( 217 - <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"> 218 <Interactions 219 quotesCount={props.quotesCount || 0} 220 commentsCount={props.commentsCount || 0} 221 - compact 222 showComments={props.preferences.showComments} 223 pageId={props.pageId} 224 /> ··· 226 <> 227 <Separator classname="h-5" /> 228 <Popover 229 - side="left" 230 - align="start" 231 - className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 232 trigger={<InfoSmall />} 233 > 234 <PostHeader
··· 21 import { PostHeader } from "./PostHeader/PostHeader"; 22 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 import { PollData } from "./fetchPollData"; 24 + import { SharedPageProps } from "./PostPages"; 25 + import { useIsMobile } from "src/hooks/isMobile"; 26 27 export function CanvasPage({ 28 blocks, 29 pages, 30 + ...props 31 + }: Omit<SharedPageProps, "allPages"> & { 32 blocks: PubLeafletPagesCanvas.Block[]; 33 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 34 }) { 35 + const { 36 + document, 37 + did, 38 + profile, 39 + preferences, 40 + pubRecord, 41 + theme, 42 + prerenderedCodeBlocks, 43 + bskyPostData, 44 + pollData, 45 + document_uri, 46 + pageId, 47 + pageOptions, 48 + fullPageScroll, 49 + hasPageBackground, 50 + } = props; 51 if (!document) return null; 52 53 let isSubpage = !!pageId; 54 let drawer = useDrawerOpen(document_uri); 55 ··· 57 <PageWrapper 58 pageType="canvas" 59 fullPageScroll={fullPageScroll} 60 id={pageId ? `post-page-${pageId}` : "post-page"} 61 drawerOpen={ 62 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) ··· 206 quotesCount: number | undefined; 207 commentsCount: number | undefined; 208 }) => { 209 + let isMobile = useIsMobile(); 210 return ( 211 + <div className="flex flex-row gap-3 items-center absolute top-3 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 212 <Interactions 213 quotesCount={props.quotesCount || 0} 214 commentsCount={props.commentsCount || 0} 215 showComments={props.preferences.showComments} 216 pageId={props.pageId} 217 /> ··· 219 <> 220 <Separator classname="h-5" /> 221 <Popover 222 + side="bottom" 223 + align="end" 224 + className={`flex flex-col gap-2 p-0! text-primary ${isMobile ? "w-full" : "max-w-sm w-[1000px] t"}`} 225 trigger={<InfoSmall />} 226 > 227 <PostHeader
+146
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
···
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { ids } from "lexicons/api/lexicons"; 4 + import { 5 + PubLeafletBlocksBskyPost, 6 + PubLeafletDocument, 7 + PubLeafletPagesLinearDocument, 8 + PubLeafletPagesCanvas, 9 + PubLeafletPublication, 10 + } from "lexicons/api"; 11 + import { QuoteHandler } from "./QuoteHandler"; 12 + import { 13 + PublicationBackgroundProvider, 14 + PublicationThemeProvider, 15 + } from "components/ThemeManager/PublicationThemeProvider"; 16 + import { getPostPageData } from "./getPostPageData"; 17 + import { PostPageContextProvider } from "./PostPageContext"; 18 + import { PostPages } from "./PostPages"; 19 + import { extractCodeBlocks } from "./extractCodeBlocks"; 20 + import { LeafletLayout } from "components/LeafletLayout"; 21 + import { fetchPollData } from "./fetchPollData"; 22 + 23 + export async function DocumentPageRenderer({ 24 + did, 25 + rkey, 26 + }: { 27 + did: string; 28 + rkey: string; 29 + }) { 30 + let agent = new AtpAgent({ 31 + service: "https://public.api.bsky.app", 32 + fetch: (...args) => 33 + fetch(args[0], { 34 + ...args[1], 35 + next: { revalidate: 3600 }, 36 + }), 37 + }); 38 + 39 + let [document, profile] = await Promise.all([ 40 + getPostPageData(AtUri.make(did, ids.PubLeafletDocument, rkey).toString()), 41 + agent.getProfile({ actor: did }), 42 + ]); 43 + 44 + if (!document?.data) 45 + return ( 46 + <div className="bg-bg-leaflet h-full p-3 text-center relative"> 47 + <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full"> 48 + <div className=" px-3 py-4 opaque-container flex flex-col gap-1 mx-2 "> 49 + <h3>Sorry, post not found!</h3> 50 + <p> 51 + This may be a glitch on our end. If the issue persists please{" "} 52 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 53 + </p> 54 + </div> 55 + </div> 56 + </div> 57 + ); 58 + 59 + let record = document.data as PubLeafletDocument.Record; 60 + let bskyPosts = 61 + record.pages.flatMap((p) => { 62 + let page = p as PubLeafletPagesLinearDocument.Main; 63 + return page.blocks?.filter( 64 + (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, 65 + ); 66 + }) || []; 67 + 68 + // Batch bsky posts into groups of 25 and fetch in parallel 69 + let bskyPostBatches = []; 70 + for (let i = 0; i < bskyPosts.length; i += 25) { 71 + bskyPostBatches.push(bskyPosts.slice(i, i + 25)); 72 + } 73 + 74 + let bskyPostResponses = await Promise.all( 75 + bskyPostBatches.map((batch) => 76 + agent.getPosts( 77 + { 78 + uris: batch.map((p) => { 79 + let block = p?.block as PubLeafletBlocksBskyPost.Main; 80 + return block.postRef.uri; 81 + }), 82 + }, 83 + { headers: {} }, 84 + ), 85 + ), 86 + ); 87 + 88 + let bskyPostData = 89 + bskyPostResponses.length > 0 90 + ? bskyPostResponses.flatMap((response) => response.data.posts) 91 + : []; 92 + 93 + // Extract poll blocks and fetch vote data 94 + let pollBlocks = record.pages.flatMap((p) => { 95 + let page = p as PubLeafletPagesLinearDocument.Main; 96 + return ( 97 + page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || 98 + [] 99 + ); 100 + }); 101 + let pollData = await fetchPollData( 102 + pollBlocks.map((b) => (b.block as any).pollRef.uri), 103 + ); 104 + 105 + // Get theme from publication or document (for standalone docs) 106 + let pubRecord = document.documents_in_publications[0]?.publications 107 + ?.record as PubLeafletPublication.Record | undefined; 108 + let theme = pubRecord?.theme || record.theme || null; 109 + let pub_creator = 110 + document.documents_in_publications[0]?.publications?.identity_did || did; 111 + let isStandalone = !pubRecord; 112 + 113 + let firstPage = record.pages[0]; 114 + 115 + let firstPageBlocks = 116 + ( 117 + firstPage as 118 + | PubLeafletPagesLinearDocument.Main 119 + | PubLeafletPagesCanvas.Main 120 + ).blocks || []; 121 + let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); 122 + 123 + return ( 124 + <PostPageContextProvider value={document}> 125 + <PublicationThemeProvider theme={theme} pub_creator={pub_creator} isStandalone={isStandalone}> 126 + <PublicationBackgroundProvider theme={theme} pub_creator={pub_creator}> 127 + <LeafletLayout> 128 + <PostPages 129 + document_uri={document.uri} 130 + preferences={pubRecord?.preferences || {}} 131 + pubRecord={pubRecord} 132 + profile={JSON.parse(JSON.stringify(profile.data))} 133 + document={document} 134 + bskyPostData={bskyPostData} 135 + did={did} 136 + prerenderedCodeBlocks={prerenderedCodeBlocks} 137 + pollData={pollData} 138 + /> 139 + </LeafletLayout> 140 + 141 + <QuoteHandler /> 142 + </PublicationBackgroundProvider> 143 + </PublicationThemeProvider> 144 + </PostPageContextProvider> 145 + ); 146 + }
+228 -13
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 1 - import { UnicodeString } from "@atproto/api"; 2 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 3 import { multiBlockSchema } from "components/Blocks/TextBlock/schema"; 4 import { PubLeafletRichtextFacet } from "lexicons/api"; ··· 8 import { EditorState, TextSelection } from "prosemirror-state"; 9 import { EditorView } from "prosemirror-view"; 10 import { history, redo, undo } from "prosemirror-history"; 11 import { 12 MutableRefObject, 13 RefObject, 14 useEffect, 15 useLayoutEffect, 16 useRef, ··· 36 import { CloseTiny } from "components/Icons/CloseTiny"; 37 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 38 import { betterIsUrl } from "src/utils/isURL"; 39 40 export function CommentBox(props: { 41 doc_uri: string; ··· 50 commentBox: { quote }, 51 } = useInteractionState(props.doc_uri); 52 let [loading, setLoading] = useState(false); 53 54 - const handleSubmit = async () => { 55 if (loading || !view.current) return; 56 57 setLoading(true); ··· 92 { 93 record: comment.record, 94 uri: comment.uri, 95 - bsky_profiles: { record: comment.profile as Json }, 96 }, 97 ], 98 })); ··· 114 "Mod-y": redo, 115 "Shift-Mod-z": redo, 116 "Ctrl-Enter": () => { 117 - handleSubmit(); 118 return true; 119 }, 120 "Meta-Enter": () => { 121 - handleSubmit(); 122 return true; 123 }, 124 }), ··· 128 shouldAutoLink: () => true, 129 defaultProtocol: "https", 130 }), 131 history(), 132 ], 133 }), 134 ); 135 - let view = useRef<null | EditorView>(null); 136 useLayoutEffect(() => { 137 if (!mountRef.current) return; 138 view.current = new EditorView( ··· 187 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 188 if (!direct) return; 189 if (node.nodeSize - 2 <= _pos) return; 190 let mark = 191 - node 192 - .nodeAt(_pos - 1) 193 - ?.marks.find((f) => f.type === multiBlockSchema.marks.link) || 194 - node 195 - .nodeAt(Math.max(_pos - 2, 0)) 196 - ?.marks.find((f) => f.type === multiBlockSchema.marks.link); 197 if (mark) { 198 window.open(mark.attrs.href, "_blank"); 199 } 200 }, 201 dispatchTransaction(tr) { ··· 236 <div className="w-full relative group"> 237 <pre 238 ref={mountRef} 239 className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`} 240 /> 241 <IOSBS view={view} /> 242 </div> 243 <div className="flex justify-between pt-1"> 244 <div className="flex gap-1"> ··· 261 view={view} 262 /> 263 </div> 264 - <ButtonPrimary compact onClick={handleSubmit}> 265 {loading ? <DotLoader /> : <ShareSmall />} 266 </ButtonPrimary> 267 </div> ··· 328 facets.push(facet); 329 } 330 } 331 332 fullText += text; 333 byteOffset += unicodeString.length;
··· 1 + import { AtUri, UnicodeString } from "@atproto/api"; 2 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 3 import { multiBlockSchema } from "components/Blocks/TextBlock/schema"; 4 import { PubLeafletRichtextFacet } from "lexicons/api"; ··· 8 import { EditorState, TextSelection } from "prosemirror-state"; 9 import { EditorView } from "prosemirror-view"; 10 import { history, redo, undo } from "prosemirror-history"; 11 + import { InputRule, inputRules } from "prosemirror-inputrules"; 12 import { 13 MutableRefObject, 14 RefObject, 15 + useCallback, 16 useEffect, 17 useLayoutEffect, 18 useRef, ··· 38 import { CloseTiny } from "components/Icons/CloseTiny"; 39 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 40 import { betterIsUrl } from "src/utils/isURL"; 41 + import { Mention, MentionAutocomplete } from "components/Mention"; 42 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 43 + 44 + const addMentionToEditor = ( 45 + mention: Mention, 46 + range: { from: number; to: number }, 47 + view: EditorView, 48 + ) => { 49 + if (!view) return; 50 + const { from, to } = range; 51 + const tr = view.state.tr; 52 + 53 + if (mention.type === "did") { 54 + // Delete the @ and any query text 55 + tr.delete(from, to); 56 + // Insert didMention inline node 57 + const mentionText = "@" + mention.handle; 58 + const didMentionNode = multiBlockSchema.nodes.didMention.create({ 59 + did: mention.did, 60 + text: mentionText, 61 + }); 62 + tr.insert(from, didMentionNode); 63 + // Add a space after the mention 64 + tr.insertText(" ", from + 1); 65 + } 66 + if (mention.type === "publication" || mention.type === "post") { 67 + // Delete the @ and any query text 68 + tr.delete(from, to); 69 + let name = mention.type === "post" ? mention.title : mention.name; 70 + // Insert atMention inline node 71 + const atMentionNode = multiBlockSchema.nodes.atMention.create({ 72 + atURI: mention.uri, 73 + text: name, 74 + }); 75 + tr.insert(from, atMentionNode); 76 + // Add a space after the mention 77 + tr.insertText(" ", from + 1); 78 + } 79 + 80 + view.dispatch(tr); 81 + view.focus(); 82 + }; 83 84 export function CommentBox(props: { 85 doc_uri: string; ··· 94 commentBox: { quote }, 95 } = useInteractionState(props.doc_uri); 96 let [loading, setLoading] = useState(false); 97 + let view = useRef<null | EditorView>(null); 98 + 99 + // Mention autocomplete state 100 + const [mentionOpen, setMentionOpen] = useState(false); 101 + const [mentionCoords, setMentionCoords] = useState<{ 102 + top: number; 103 + left: number; 104 + } | null>(null); 105 + // Use a ref for insert position to avoid stale closure issues 106 + const mentionInsertPosRef = useRef<number | null>(null); 107 + 108 + // Use a ref for the callback so input rules can access it 109 + const openMentionAutocompleteRef = useRef<() => void>(() => {}); 110 + openMentionAutocompleteRef.current = () => { 111 + if (!view.current) return; 112 113 + const pos = view.current.state.selection.from; 114 + mentionInsertPosRef.current = pos; 115 + 116 + // Get coordinates for the popup relative to the positioned parent 117 + const coords = view.current.coordsAtPos(pos - 1); 118 + 119 + // Find the relative positioned parent container 120 + const editorEl = view.current.dom; 121 + const container = editorEl.closest(".relative") as HTMLElement | null; 122 + 123 + if (container) { 124 + const containerRect = container.getBoundingClientRect(); 125 + setMentionCoords({ 126 + top: coords.bottom - containerRect.top, 127 + left: coords.left - containerRect.left, 128 + }); 129 + } else { 130 + setMentionCoords({ 131 + top: coords.bottom, 132 + left: coords.left, 133 + }); 134 + } 135 + setMentionOpen(true); 136 + }; 137 + 138 + const handleMentionSelect = useCallback((mention: Mention) => { 139 + if (!view.current || mentionInsertPosRef.current === null) return; 140 + 141 + const from = mentionInsertPosRef.current - 1; 142 + const to = mentionInsertPosRef.current; 143 + 144 + addMentionToEditor(mention, { from, to }, view.current); 145 + view.current.focus(); 146 + }, []); 147 + 148 + const handleMentionOpenChange = useCallback((open: boolean) => { 149 + setMentionOpen(open); 150 + if (!open) { 151 + setMentionCoords(null); 152 + mentionInsertPosRef.current = null; 153 + } 154 + }, []); 155 + 156 + // Use a ref for handleSubmit so keyboard shortcuts can access it 157 + const handleSubmitRef = useRef<() => Promise<void>>(async () => {}); 158 + handleSubmitRef.current = async () => { 159 if (loading || !view.current) return; 160 161 setLoading(true); ··· 196 { 197 record: comment.record, 198 uri: comment.uri, 199 + bsky_profiles: { 200 + record: comment.profile as Json, 201 + did: new AtUri(comment.uri).host, 202 + }, 203 }, 204 ], 205 })); ··· 221 "Mod-y": redo, 222 "Shift-Mod-z": redo, 223 "Ctrl-Enter": () => { 224 + handleSubmitRef.current(); 225 return true; 226 }, 227 "Meta-Enter": () => { 228 + handleSubmitRef.current(); 229 return true; 230 }, 231 }), ··· 235 shouldAutoLink: () => true, 236 defaultProtocol: "https", 237 }), 238 + // Input rules for @ mentions 239 + inputRules({ 240 + rules: [ 241 + // @ at start of line or after space 242 + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 243 + setTimeout(() => openMentionAutocompleteRef.current(), 0); 244 + return null; 245 + }), 246 + ], 247 + }), 248 history(), 249 ], 250 }), 251 ); 252 useLayoutEffect(() => { 253 if (!mountRef.current) return; 254 view.current = new EditorView( ··· 303 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 304 if (!direct) return; 305 if (node.nodeSize - 2 <= _pos) return; 306 + 307 + const nodeAt1 = node.nodeAt(_pos - 1); 308 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 309 + 310 + // Check for link marks 311 let mark = 312 + nodeAt1?.marks.find( 313 + (f) => f.type === multiBlockSchema.marks.link, 314 + ) || 315 + nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link); 316 if (mark) { 317 window.open(mark.attrs.href, "_blank"); 318 + return; 319 + } 320 + 321 + // Check for didMention inline nodes 322 + if (nodeAt1?.type === multiBlockSchema.nodes.didMention) { 323 + window.open( 324 + didToBlueskyUrl(nodeAt1.attrs.did), 325 + "_blank", 326 + "noopener,noreferrer", 327 + ); 328 + return; 329 + } 330 + if (nodeAt2?.type === multiBlockSchema.nodes.didMention) { 331 + window.open( 332 + didToBlueskyUrl(nodeAt2.attrs.did), 333 + "_blank", 334 + "noopener,noreferrer", 335 + ); 336 + return; 337 + } 338 + 339 + // Check for atMention inline nodes (publications/documents) 340 + if (nodeAt1?.type === multiBlockSchema.nodes.atMention) { 341 + window.open( 342 + atUriToUrl(nodeAt1.attrs.atURI), 343 + "_blank", 344 + "noopener,noreferrer", 345 + ); 346 + return; 347 + } 348 + if (nodeAt2?.type === multiBlockSchema.nodes.atMention) { 349 + window.open( 350 + atUriToUrl(nodeAt2.attrs.atURI), 351 + "_blank", 352 + "noopener,noreferrer", 353 + ); 354 + return; 355 } 356 }, 357 dispatchTransaction(tr) { ··· 392 <div className="w-full relative group"> 393 <pre 394 ref={mountRef} 395 + onFocus={() => { 396 + // Close mention dropdown when editor gains focus (reset stale state) 397 + handleMentionOpenChange(false); 398 + }} 399 + onBlur={(e) => { 400 + // Close mention dropdown when editor loses focus 401 + // But not if focus moved to the mention autocomplete 402 + const relatedTarget = e.relatedTarget as HTMLElement | null; 403 + if (!relatedTarget?.closest(".dropdownMenu")) { 404 + handleMentionOpenChange(false); 405 + } 406 + }} 407 className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`} 408 /> 409 <IOSBS view={view} /> 410 + <MentionAutocomplete 411 + open={mentionOpen} 412 + onOpenChange={handleMentionOpenChange} 413 + view={view} 414 + onSelect={handleMentionSelect} 415 + coords={mentionCoords} 416 + /> 417 </div> 418 <div className="flex justify-between pt-1"> 419 <div className="flex gap-1"> ··· 436 view={view} 437 /> 438 </div> 439 + <ButtonPrimary compact onClick={() => handleSubmitRef.current()}> 440 {loading ? <DotLoader /> : <ShareSmall />} 441 </ButtonPrimary> 442 </div> ··· 503 facets.push(facet); 504 } 505 } 506 + 507 + fullText += text; 508 + byteOffset += unicodeString.length; 509 + } else if (node.type.name === "didMention") { 510 + // Handle DID mention nodes 511 + const text = node.attrs.text || ""; 512 + const unicodeString = new UnicodeString(text); 513 + 514 + facets.push({ 515 + index: { 516 + byteStart: byteOffset, 517 + byteEnd: byteOffset + unicodeString.length, 518 + }, 519 + features: [ 520 + { 521 + $type: "pub.leaflet.richtext.facet#didMention", 522 + did: node.attrs.did, 523 + }, 524 + ], 525 + }); 526 + 527 + fullText += text; 528 + byteOffset += unicodeString.length; 529 + } else if (node.type.name === "atMention") { 530 + // Handle AT-URI mention nodes (publications and documents) 531 + const text = node.attrs.text || ""; 532 + const unicodeString = new UnicodeString(text); 533 + 534 + facets.push({ 535 + index: { 536 + byteStart: byteOffset, 537 + byteEnd: byteOffset + unicodeString.length, 538 + }, 539 + features: [ 540 + { 541 + $type: "pub.leaflet.richtext.facet#atMention", 542 + atURI: node.attrs.atURI, 543 + }, 544 + ], 545 + }); 546 547 fullText += text; 548 byteOffset += unicodeString.length;
+98 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 10 import { Json } from "supabase/database.types"; 11 import { 12 Notification, 13 pingIdentityToUpdateNotification, 14 } from "src/notifications"; 15 import { v7 } from "uuid"; ··· 84 parent_uri: args.comment.replyTo, 85 }, 86 }); 87 // SOMEDAY: move this out the action with inngest or workflows 88 await supabaseServerClient.from("notifications").insert(notifications); 89 - await pingIdentityToUpdateNotification(recipient); 90 } 91 92 return { ··· 95 uri: uri.toString(), 96 }; 97 }
··· 10 import { Json } from "supabase/database.types"; 11 import { 12 Notification, 13 + NotificationData, 14 pingIdentityToUpdateNotification, 15 } from "src/notifications"; 16 import { v7 } from "uuid"; ··· 85 parent_uri: args.comment.replyTo, 86 }, 87 }); 88 + } 89 + 90 + // Create mention notifications from comment facets 91 + const mentionNotifications = createCommentMentionNotifications( 92 + args.comment.facets, 93 + uri.toString(), 94 + credentialSession.did!, 95 + ); 96 + notifications.push(...mentionNotifications); 97 + 98 + // Insert all notifications and ping recipients 99 + if (notifications.length > 0) { 100 // SOMEDAY: move this out the action with inngest or workflows 101 await supabaseServerClient.from("notifications").insert(notifications); 102 + 103 + // Ping all unique recipients 104 + const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))]; 105 + await Promise.all( 106 + uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)), 107 + ); 108 } 109 110 return { ··· 113 uri: uri.toString(), 114 }; 115 } 116 + 117 + /** 118 + * Creates mention notifications from comment facets 119 + * Handles didMention (people) and atMention (publications/documents) 120 + */ 121 + function createCommentMentionNotifications( 122 + facets: PubLeafletRichtextFacet.Main[], 123 + commentUri: string, 124 + commenterDid: string, 125 + ): Notification[] { 126 + const notifications: Notification[] = []; 127 + const notifiedRecipients = new Set<string>(); // Avoid duplicate notifications 128 + 129 + for (const facet of facets) { 130 + for (const feature of facet.features) { 131 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 132 + // DID mention - notify the mentioned person directly 133 + const recipientDid = feature.did; 134 + 135 + // Don't notify yourself 136 + if (recipientDid === commenterDid) continue; 137 + // Avoid duplicate notifications to the same person 138 + if (notifiedRecipients.has(recipientDid)) continue; 139 + notifiedRecipients.add(recipientDid); 140 + 141 + notifications.push({ 142 + id: v7(), 143 + recipient: recipientDid, 144 + data: { 145 + type: "comment_mention", 146 + comment_uri: commentUri, 147 + mention_type: "did", 148 + }, 149 + }); 150 + } else if (PubLeafletRichtextFacet.isAtMention(feature)) { 151 + // AT-URI mention - notify the owner of the publication/document 152 + try { 153 + const mentionedUri = new AtUri(feature.atURI); 154 + const recipientDid = mentionedUri.host; 155 + 156 + // Don't notify yourself 157 + if (recipientDid === commenterDid) continue; 158 + // Avoid duplicate notifications to the same person for the same mentioned item 159 + const dedupeKey = `${recipientDid}:${feature.atURI}`; 160 + if (notifiedRecipients.has(dedupeKey)) continue; 161 + notifiedRecipients.add(dedupeKey); 162 + 163 + if (mentionedUri.collection === "pub.leaflet.publication") { 164 + notifications.push({ 165 + id: v7(), 166 + recipient: recipientDid, 167 + data: { 168 + type: "comment_mention", 169 + comment_uri: commentUri, 170 + mention_type: "publication", 171 + mentioned_uri: feature.atURI, 172 + }, 173 + }); 174 + } else if (mentionedUri.collection === "pub.leaflet.document") { 175 + notifications.push({ 176 + id: v7(), 177 + recipient: recipientDid, 178 + data: { 179 + type: "comment_mention", 180 + comment_uri: commentUri, 181 + mention_type: "document", 182 + mentioned_uri: feature.atURI, 183 + }, 184 + }); 185 + } 186 + } catch (error) { 187 + console.error("Failed to parse AT-URI for mention:", feature.atURI, error); 188 + } 189 + } 190 + } 191 + } 192 + 193 + return notifications; 194 + }
+15 -99
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 18 import { QuoteContent } from "../Quotes"; 19 import { timeAgo } from "src/utils/timeAgo"; 20 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 22 export type Comment = { 23 record: Json; 24 uri: string; 25 - bsky_profiles: { record: Json } | null; 26 }; 27 export function Comments(props: { 28 document_uri: string; ··· 109 document: string; 110 comment: Comment; 111 comments: Comment[]; 112 - profile?: AppBskyActorProfile.Record; 113 record: PubLeafletComment.Record; 114 pageId?: string; 115 }) => { 116 return ( 117 - <div className="comment"> 118 <div className="flex gap-2"> 119 - {props.profile && ( 120 - <ProfilePopover profile={props.profile} comment={props.comment.uri} /> 121 )} 122 - <DatePopover date={props.record.createdAt} /> 123 </div> 124 {props.record.attachment && 125 PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( ··· 291 </Popover> 292 ); 293 }; 294 - 295 - const ProfilePopover = (props: { 296 - profile: AppBskyActorProfile.Record; 297 - comment: string; 298 - }) => { 299 - let commenterId = new AtUri(props.comment).host; 300 - 301 - return ( 302 - <> 303 - <a 304 - className="font-bold text-tertiary text-sm hover:underline" 305 - href={`https://bsky.app/profile/${commenterId}`} 306 - > 307 - {props.profile.displayName} 308 - </a> 309 - {/*<Media mobile={false}> 310 - <Popover 311 - align="start" 312 - trigger={ 313 - <div 314 - onMouseOver={() => { 315 - setHovering(true); 316 - hoverTimeout.current = window.setTimeout(() => { 317 - setLoadProfile(true); 318 - }, 500); 319 - }} 320 - onMouseOut={() => { 321 - setHovering(false); 322 - clearTimeout(hoverTimeout.current); 323 - }} 324 - className="font-bold text-tertiary text-sm hover:underline" 325 - > 326 - {props.profile.displayName} 327 - </div> 328 - } 329 - className="max-w-sm" 330 - > 331 - {profile && ( 332 - <> 333 - <div className="profilePopover text-sm flex gap-2"> 334 - <div className="w-5 h-5 bg-test rounded-full shrink-0 mt-[2px]" /> 335 - <div className="flex flex-col"> 336 - <div className="flex justify-between"> 337 - <div className="profileHeader flex gap-2 items-center"> 338 - <div className="font-bold">celine</div> 339 - <a className="text-tertiary" href="/"> 340 - @{profile.handle} 341 - </a> 342 - </div> 343 - </div> 344 - 345 - <div className="profileBio text-secondary "> 346 - {profile.description} 347 - </div> 348 - <div className="flex flex-row gap-2 items-center pt-2 font-bold"> 349 - {!profile.viewer?.following ? ( 350 - <div className="text-tertiary bg-border-light rounded-md px-1 py-0"> 351 - Following 352 - </div> 353 - ) : ( 354 - <ButtonPrimary compact className="text-sm"> 355 - Follow <BlueskyTiny /> 356 - </ButtonPrimary> 357 - )} 358 - {profile.viewer?.followedBy && ( 359 - <div className="text-tertiary">Follows You</div> 360 - )} 361 - </div> 362 - </div> 363 - </div> 364 - 365 - <hr className="my-2 border-border-light" /> 366 - <div className="flex gap-2 leading-tight items-center text-tertiary text-sm"> 367 - <div className="flex flex-col w-6 justify-center"> 368 - {profile.viewer?.knownFollowers?.followers.map((follower) => { 369 - return ( 370 - <div 371 - className="w-[18px] h-[18px] bg-test rounded-full border-2 border-bg-page" 372 - key={follower.did} 373 - /> 374 - ); 375 - })} 376 - <div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" /> 377 - <div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" /> 378 - </div> 379 - </div> 380 - </> 381 - )} 382 - </Popover> 383 - </Media>*/} 384 - </> 385 - ); 386 - };
··· 18 import { QuoteContent } from "../Quotes"; 19 import { timeAgo } from "src/utils/timeAgo"; 20 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 + import { ProfilePopover } from "components/ProfilePopover"; 22 23 export type Comment = { 24 record: Json; 25 uri: string; 26 + bsky_profiles: { record: Json; did: string } | null; 27 }; 28 export function Comments(props: { 29 document_uri: string; ··· 110 document: string; 111 comment: Comment; 112 comments: Comment[]; 113 + profile: AppBskyActorProfile.Record; 114 record: PubLeafletComment.Record; 115 pageId?: string; 116 }) => { 117 + const did = props.comment.bsky_profiles?.did; 118 + 119 return ( 120 + <div id={props.comment.uri} className="comment"> 121 <div className="flex gap-2"> 122 + {did && ( 123 + <ProfilePopover 124 + didOrHandle={did} 125 + trigger={ 126 + <div className="text-sm text-tertiary font-bold hover:underline"> 127 + {props.profile.displayName} 128 + </div> 129 + } 130 + /> 131 )} 132 </div> 133 {props.record.attachment && 134 PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( ··· 300 </Popover> 301 ); 302 };
+4 -1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 58 export const useDrawerOpen = (uri: string) => { 59 let params = useSearchParams(); 60 let interactionDrawerSearchParam = params.get("interactionDrawer"); 61 let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 62 if (open === false || (open === undefined && !interactionDrawerSearchParam)) 63 return null; 64 drawer = 65 drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 66 - return { drawer, pageId }; 67 };
··· 58 export const useDrawerOpen = (uri: string) => { 59 let params = useSearchParams(); 60 let interactionDrawerSearchParam = params.get("interactionDrawer"); 61 + let pageParam = params.get("page"); 62 let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 63 if (open === false || (open === undefined && !interactionDrawerSearchParam)) 64 return null; 65 drawer = 66 drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 67 + // Use pageId from state, or fall back to page search param 68 + const resolvedPageId = pageId ?? pageParam ?? undefined; 69 + return { drawer, pageId: resolvedPageId }; 70 };
+208 -30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 9 import { useContext } from "react"; 10 import { PostPageContext } from "../PostPageContext"; 11 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 import { PostPageData } from "../getPostPageData"; 13 - import { PubLeafletComment } from "lexicons/api"; 14 import { prefetchQuotesData } from "./Quotes"; 15 16 export type InteractionState = { 17 drawerOpen: undefined | boolean; ··· 99 export const Interactions = (props: { 100 quotesCount: number; 101 commentsCount: number; 102 - compact?: boolean; 103 className?: string; 104 showComments?: boolean; 105 pageId?: string; 106 }) => { 107 const data = useContext(PostPageContext); 108 const document_uri = data?.uri; 109 if (!document_uri) 110 throw new Error("document_uri not available in PostPageContext"); 111 ··· 117 } 118 }; 119 120 return ( 121 - <div 122 - className={`flex gap-2 text-tertiary ${props.compact ? "text-sm" : "px-3 sm:px-4"} ${props.className}`} 123 - > 124 - <button 125 - className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 126 - onClick={() => { 127 - if (!drawerOpen || drawer !== "quotes") 128 - openInteractionDrawer("quotes", document_uri, props.pageId); 129 - else setInteractionState(document_uri, { drawerOpen: false }); 130 - }} 131 - onMouseEnter={handleQuotePrefetch} 132 - onTouchStart={handleQuotePrefetch} 133 - aria-label="Post quotes" 134 - > 135 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 136 - {!props.compact && ( 137 - <span 138 - aria-hidden 139 - >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 140 - )} 141 - </button> 142 {props.showComments === false ? null : ( 143 <button 144 - className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 145 onClick={() => { 146 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 147 openInteractionDrawer("comments", document_uri, props.pageId); ··· 149 }} 150 aria-label="Post comments" 151 > 152 - <CommentTiny aria-hidden /> {props.commentsCount}{" "} 153 - {!props.compact && ( 154 - <span 155 - aria-hidden 156 - >{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span> 157 - )} 158 </button> 159 )} 160 </div> 161 ); 162 }; 163 164 export function getQuoteCount(document: PostPageData, pageId?: string) { 165 if (!document) return; 166 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 198 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 199 ).length; 200 }
··· 9 import { useContext } from "react"; 10 import { PostPageContext } from "../PostPageContext"; 11 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 + import { TagTiny } from "components/Icons/TagTiny"; 13 + import { Tag } from "components/Tags"; 14 + import { Popover } from "components/Popover"; 15 import { PostPageData } from "../getPostPageData"; 16 + import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 17 import { prefetchQuotesData } from "./Quotes"; 18 + import { useIdentityData } from "components/IdentityProvider"; 19 + import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 20 + import { EditTiny } from "components/Icons/EditTiny"; 21 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 22 23 export type InteractionState = { 24 drawerOpen: undefined | boolean; ··· 106 export const Interactions = (props: { 107 quotesCount: number; 108 commentsCount: number; 109 className?: string; 110 showComments?: boolean; 111 pageId?: string; 112 }) => { 113 const data = useContext(PostPageContext); 114 const document_uri = data?.uri; 115 + let { identity } = useIdentityData(); 116 if (!document_uri) 117 throw new Error("document_uri not available in PostPageContext"); 118 ··· 124 } 125 }; 126 127 + const tags = (data?.data as any)?.tags as string[] | undefined; 128 + const tagCount = tags?.length || 0; 129 + 130 return ( 131 + <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 132 + {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 133 + 134 + {props.quotesCount > 0 && ( 135 + <button 136 + className="flex w-fit gap-2 items-center" 137 + onClick={() => { 138 + if (!drawerOpen || drawer !== "quotes") 139 + openInteractionDrawer("quotes", document_uri, props.pageId); 140 + else setInteractionState(document_uri, { drawerOpen: false }); 141 + }} 142 + onMouseEnter={handleQuotePrefetch} 143 + onTouchStart={handleQuotePrefetch} 144 + aria-label="Post quotes" 145 + > 146 + <QuoteTiny aria-hidden /> {props.quotesCount} 147 + </button> 148 + )} 149 {props.showComments === false ? null : ( 150 <button 151 + className="flex gap-2 items-center w-fit" 152 onClick={() => { 153 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 154 openInteractionDrawer("comments", document_uri, props.pageId); ··· 156 }} 157 aria-label="Post comments" 158 > 159 + <CommentTiny aria-hidden /> {props.commentsCount} 160 </button> 161 )} 162 </div> 163 ); 164 }; 165 166 + export const ExpandedInteractions = (props: { 167 + quotesCount: number; 168 + commentsCount: number; 169 + className?: string; 170 + showComments?: boolean; 171 + pageId?: string; 172 + }) => { 173 + const data = useContext(PostPageContext); 174 + let { identity } = useIdentityData(); 175 + 176 + const document_uri = data?.uri; 177 + if (!document_uri) 178 + throw new Error("document_uri not available in PostPageContext"); 179 + 180 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 181 + 182 + const handleQuotePrefetch = () => { 183 + if (data?.quotesAndMentions) { 184 + prefetchQuotesData(data.quotesAndMentions); 185 + } 186 + }; 187 + let publication = data?.documents_in_publications[0]?.publications; 188 + 189 + const tags = (data?.data as any)?.tags as string[] | undefined; 190 + const tagCount = tags?.length || 0; 191 + 192 + let subscribed = 193 + identity?.atp_did && 194 + publication?.publication_subscriptions && 195 + publication?.publication_subscriptions.find( 196 + (s) => s.identity === identity.atp_did, 197 + ); 198 + 199 + let isAuthor = 200 + identity && 201 + identity.atp_did === 202 + data.documents_in_publications[0]?.publications?.identity_did && 203 + data.leaflets_in_publications[0]; 204 + 205 + return ( 206 + <div 207 + className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} 208 + > 209 + {!subscribed && !isAuthor && publication && publication.record && ( 210 + <div className="text-center flex flex-col accent-container rounded-md mb-3"> 211 + <div className="flex flex-col py-4"> 212 + <div className="leading-snug flex flex-col pb-2 text-sm"> 213 + <div className="font-bold">Subscribe to {publication.name}</div>{" "} 214 + to get updates in Reader, RSS, or via Bluesky Feed 215 + </div> 216 + <SubscribeWithBluesky 217 + pubName={publication.name} 218 + pub_uri={publication.uri} 219 + base_url={ 220 + (publication.record as PubLeafletPublication.Record) 221 + .base_path || "" 222 + } 223 + subscribers={publication?.publication_subscriptions} 224 + /> 225 + </div> 226 + </div> 227 + )} 228 + {tagCount > 0 && ( 229 + <> 230 + <hr className="border-border-light mb-3" /> 231 + 232 + <TagList tags={tags} className="mb-3" /> 233 + </> 234 + )} 235 + <hr className="border-border-light mb-3 " /> 236 + <div className="flex gap-2 justify-between"> 237 + <div className="flex gap-2"> 238 + {props.quotesCount > 0 && ( 239 + <button 240 + className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 241 + onClick={() => { 242 + if (!drawerOpen || drawer !== "quotes") 243 + openInteractionDrawer("quotes", document_uri, props.pageId); 244 + else setInteractionState(document_uri, { drawerOpen: false }); 245 + }} 246 + onMouseEnter={handleQuotePrefetch} 247 + onTouchStart={handleQuotePrefetch} 248 + aria-label="Post quotes" 249 + > 250 + <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 251 + <span 252 + aria-hidden 253 + >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 254 + </button> 255 + )} 256 + {props.showComments === false ? null : ( 257 + <button 258 + className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 259 + onClick={() => { 260 + if ( 261 + !drawerOpen || 262 + drawer !== "comments" || 263 + pageId !== props.pageId 264 + ) 265 + openInteractionDrawer("comments", document_uri, props.pageId); 266 + else setInteractionState(document_uri, { drawerOpen: false }); 267 + }} 268 + aria-label="Post comments" 269 + > 270 + <CommentTiny aria-hidden />{" "} 271 + {props.commentsCount > 0 ? ( 272 + <span aria-hidden> 273 + {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 274 + </span> 275 + ) : ( 276 + "Comment" 277 + )} 278 + </button> 279 + )} 280 + </div> 281 + <EditButton document={data} /> 282 + {subscribed && publication && ( 283 + <ManageSubscription 284 + base_url={getPublicationURL(publication)} 285 + pub_uri={publication.uri} 286 + subscribers={publication.publication_subscriptions} 287 + /> 288 + )} 289 + </div> 290 + </div> 291 + ); 292 + }; 293 + 294 + const TagPopover = (props: { 295 + tagCount: number; 296 + tags: string[] | undefined; 297 + }) => { 298 + return ( 299 + <Popover 300 + className="p-2! max-w-xs" 301 + trigger={ 302 + <div className="tags flex gap-1 items-center "> 303 + <TagTiny /> {props.tagCount} 304 + </div> 305 + } 306 + > 307 + <TagList tags={props.tags} className="text-secondary!" /> 308 + </Popover> 309 + ); 310 + }; 311 + 312 + const TagList = (props: { className?: string; tags: string[] | undefined }) => { 313 + if (!props.tags) return; 314 + return ( 315 + <div className="flex gap-1 flex-wrap"> 316 + {props.tags.map((tag, index) => ( 317 + <Tag name={tag} key={index} className={props.className} /> 318 + ))} 319 + </div> 320 + ); 321 + }; 322 export function getQuoteCount(document: PostPageData, pageId?: string) { 323 if (!document) return; 324 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 356 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 357 ).length; 358 } 359 + 360 + const EditButton = (props: { document: PostPageData }) => { 361 + let { identity } = useIdentityData(); 362 + if (!props.document) return; 363 + if ( 364 + identity && 365 + identity.atp_did === 366 + props.document.documents_in_publications[0]?.publications?.identity_did && 367 + props.document.leaflets_in_publications[0] 368 + ) 369 + return ( 370 + <a 371 + href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`} 372 + 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" 373 + > 374 + <EditTiny /> Edit Post 375 + </a> 376 + ); 377 + return; 378 + };
+24 -67
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 11 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 import { EditTiny } from "components/Icons/EditTiny"; 13 import { 14 getCommentCount, 15 getQuoteCount, 16 Interactions, ··· 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; ··· 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) ··· 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 );
··· 11 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 import { EditTiny } from "components/Icons/EditTiny"; 13 import { 14 + ExpandedInteractions, 15 getCommentCount, 16 getQuoteCount, 17 Interactions, ··· 24 import { PageWrapper } from "components/Pages/Page"; 25 import { decodeQuotePosition } from "./quotePosition"; 26 import { PollData } from "./fetchPollData"; 27 + import { SharedPageProps } from "./PostPages"; 28 29 export function LinearDocumentPage({ 30 blocks, 31 + ...props 32 + }: Omit<SharedPageProps, "allPages"> & { 33 blocks: PubLeafletPagesLinearDocument.Block[]; 34 }) { 35 + const { 36 + document, 37 + did, 38 + profile, 39 + preferences, 40 + pubRecord, 41 + theme, 42 + prerenderedCodeBlocks, 43 + bskyPostData, 44 + pollData, 45 + document_uri, 46 + pageId, 47 + pageOptions, 48 + fullPageScroll, 49 + hasPageBackground, 50 + } = props; 51 let drawer = useDrawerOpen(document_uri); 52 53 + if (!document) return null; 54 55 let record = document.data as PubLeafletDocument.Record; 56 57 const isSubpage = !!pageId; ··· 61 <PageWrapper 62 pageType="doc" 63 fullPageScroll={fullPageScroll} 64 id={pageId ? `post-page-${pageId}` : "post-page"} 65 drawerOpen={ 66 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) ··· 83 did={did} 84 prerenderedCodeBlocks={prerenderedCodeBlocks} 85 /> 86 + 87 + <ExpandedInteractions 88 pageId={pageId} 89 showComments={preferences.showComments} 90 commentsCount={getCommentCount(document, pageId) || 0} 91 quotesCount={getQuoteCount(document, pageId) || 0} 92 /> 93 + {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 94 </PageWrapper> 95 </> 96 );
+11 -8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 59 return ( 60 <div 61 //The postContent class is important for QuoteHandler 62 - className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-6 ${className}`} 63 > 64 {blocks.map((b, index) => { 65 return ( ··· 293 } 294 case PubLeafletBlocksImage.isMain(b.block): { 295 return ( 296 - <div className={`relative flex ${alignment}`} {...blockProps}> 297 <img 298 alt={b.block.alt} 299 height={b.block.aspectRatio?.height} ··· 321 return ( 322 // 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. 323 <blockquote 324 - className={` blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 325 {...blockProps} 326 > 327 <TextBlock ··· 336 } 337 case PubLeafletBlocksText.isMain(b.block): 338 return ( 339 - <p className={` ${className}`} {...blockProps}> 340 <TextBlock 341 facets={b.block.facets} 342 plaintext={b.block.plaintext} ··· 349 case PubLeafletBlocksHeader.isMain(b.block): { 350 if (b.block.level === 1) 351 return ( 352 - <h2 className={`${className}`} {...blockProps}> 353 <TextBlock 354 {...b.block} 355 index={index} ··· 360 ); 361 if (b.block.level === 2) 362 return ( 363 - <h3 className={`${className}`} {...blockProps}> 364 <TextBlock 365 {...b.block} 366 index={index} ··· 371 ); 372 if (b.block.level === 3) 373 return ( 374 - <h4 className={`${className}`} {...blockProps}> 375 <TextBlock 376 {...b.block} 377 index={index} ··· 383 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 384 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 385 return ( 386 - <h6 className={`${className}`} {...blockProps}> 387 <TextBlock 388 {...b.block} 389 index={index}
··· 59 return ( 60 <div 61 //The postContent class is important for QuoteHandler 62 + className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`} 63 > 64 {blocks.map((b, index) => { 65 return ( ··· 293 } 294 case PubLeafletBlocksImage.isMain(b.block): { 295 return ( 296 + <div 297 + className={`imageBlock relative flex ${alignment}`} 298 + {...blockProps} 299 + > 300 <img 301 alt={b.block.alt} 302 height={b.block.aspectRatio?.height} ··· 324 return ( 325 // 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. 326 <blockquote 327 + className={`blockquoteBlock py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 328 {...blockProps} 329 > 330 <TextBlock ··· 339 } 340 case PubLeafletBlocksText.isMain(b.block): 341 return ( 342 + <p className={`textBlock ${className}`} {...blockProps}> 343 <TextBlock 344 facets={b.block.facets} 345 plaintext={b.block.plaintext} ··· 352 case PubLeafletBlocksHeader.isMain(b.block): { 353 if (b.block.level === 1) 354 return ( 355 + <h2 className={`h1Block ${className}`} {...blockProps}> 356 <TextBlock 357 {...b.block} 358 index={index} ··· 363 ); 364 if (b.block.level === 2) 365 return ( 366 + <h3 className={`h2Block ${className}`} {...blockProps}> 367 <TextBlock 368 {...b.block} 369 index={index} ··· 374 ); 375 if (b.block.level === 3) 376 return ( 377 + <h4 className={`h3Block ${className}`} {...blockProps}> 378 <TextBlock 379 {...b.block} 380 index={index} ··· 386 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 387 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 388 return ( 389 + <h6 className={`h6Block ${className}`} {...blockProps}> 390 <TextBlock 391 {...b.block} 392 index={index}
-63
app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx
··· 1 - "use client"; 2 - 3 - import { Media } from "components/Media"; 4 - import { 5 - Interactions, 6 - useInteractionState, 7 - } from "../Interactions/Interactions"; 8 - import { useState, useEffect } from "react"; 9 - import { Json } from "supabase/database.types"; 10 - 11 - // export const CollapsedPostHeader = (props: { 12 - // title: string; 13 - // pubIcon?: string; 14 - // quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 15 - // }) => { 16 - // let [headerVisible, setHeaderVisible] = useState(false); 17 - // let { drawerOpen: open } = useInteractionState(); 18 - 19 - // useEffect(() => { 20 - // let post = window.document.getElementById("post-page"); 21 - 22 - // function handleScroll() { 23 - // let postHeader = window.document 24 - // .getElementById("post-header") 25 - // ?.getBoundingClientRect(); 26 - // if (postHeader && postHeader.bottom <= 0) { 27 - // setHeaderVisible(true); 28 - // } else { 29 - // setHeaderVisible(false); 30 - // } 31 - // } 32 - // post?.addEventListener("scroll", handleScroll); 33 - // return () => { 34 - // post?.removeEventListener("scroll", handleScroll); 35 - // }; 36 - // }, []); 37 - // if (!headerVisible) return; 38 - // if (open) return; 39 - // return ( 40 - // <Media 41 - // mobile 42 - // className="sticky top-0 left-0 right-0 w-full bg-bg-page border-b border-border-light -mx-3" 43 - // > 44 - // <div className="flex gap-2 items-center justify-between px-3 pt-2 pb-0.5 "> 45 - // <div className="text-tertiary font-bold text-sm truncate pr-1 grow"> 46 - // {props.title} 47 - // </div> 48 - // <div className="flex gap-2 "> 49 - // <Interactions compact quotes={props.quotes.length} /> 50 - // <div 51 - // style={{ 52 - // backgroundRepeat: "no-repeat", 53 - // backgroundPosition: "center", 54 - // backgroundSize: "cover", 55 - // backgroundImage: `url(${props.pubIcon})`, 56 - // }} 57 - // className="shrink-0 w-4 h-4 rounded-full mt-[2px]" 58 - // /> 59 - // </div> 60 - // </div> 61 - // </Media> 62 - // ); 63 - // };
···
+74 -51
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 16 import { EditTiny } from "components/Icons/EditTiny"; 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 20 export function PostHeader(props: { 21 data: PostPageData; ··· 27 28 let record = document?.data as PubLeafletDocument.Record; 29 let profile = props.profile; 30 - let pub = props.data?.documents_in_publications[0].publications; 31 - let pubRecord = pub?.record as PubLeafletPublication.Record; 32 33 const formattedDate = useLocalizedDate( 34 record.publishedAt || new Date().toISOString(), ··· 36 year: "numeric", 37 month: "long", 38 day: "2-digit", 39 - } 40 ); 41 42 - if (!document?.data || !document.documents_in_publications[0].publications) 43 - return; 44 return ( 45 - <div 46 - className="max-w-prose w-full mx-auto px-3 sm:px-4 sm:pt-3 pt-2" 47 - id="post-header" 48 - > 49 - <div className="pubHeader flex flex-col pb-5"> 50 - <div className="flex justify-between w-full"> 51 - <SpeedyLink 52 - className="font-bold hover:no-underline text-accent-contrast" 53 - href={ 54 - document && 55 - getPublicationURL( 56 - document.documents_in_publications[0].publications, 57 - ) 58 - } 59 - > 60 - {pub?.name} 61 - </SpeedyLink> 62 {identity && 63 - identity.atp_did === 64 - document.documents_in_publications[0]?.publications 65 - .identity_did && 66 document.leaflets_in_publications[0] && ( 67 <a 68 className=" rounded-full flex place-items-center" ··· 71 <EditTiny className="shrink-0" /> 72 </a> 73 )} 74 - </div> 75 - <h2 className="">{record.title}</h2> 76 - {record.description ? ( 77 - <p className="italic text-secondary">{record.description}</p> 78 - ) : null} 79 - 80 - <div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap"> 81 - {profile ? ( 82 - <> 83 - <a 84 - className="text-tertiary" 85 - href={`https://bsky.app/profile/${profile.handle}`} 86 - > 87 - by {profile.displayName || profile.handle} 88 - </a> 89 - </> 90 - ) : null} 91 - {record.publishedAt ? ( 92 - <> 93 - | 94 - <p>{formattedDate}</p> 95 - </> 96 - ) : null} 97 - |{" "} 98 <Interactions 99 showComments={props.preferences.showComments} 100 - compact 101 quotesCount={getQuoteCount(document) || 0} 102 commentsCount={getCommentCount(document) || 0} 103 /> 104 </div> 105 </div> 106 </div> 107 ); 108 - }
··· 16 import { EditTiny } from "components/Icons/EditTiny"; 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 + import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page"; 20 + import { Separator } from "components/Layout"; 21 22 export function PostHeader(props: { 23 data: PostPageData; ··· 29 30 let record = document?.data as PubLeafletDocument.Record; 31 let profile = props.profile; 32 + let pub = props.data?.documents_in_publications[0]?.publications; 33 34 const formattedDate = useLocalizedDate( 35 record.publishedAt || new Date().toISOString(), ··· 37 year: "numeric", 38 month: "long", 39 day: "2-digit", 40 + }, 41 ); 42 43 + if (!document?.data) return; 44 return ( 45 + <PostHeaderLayout 46 + pubLink={ 47 + <> 48 + {pub && ( 49 + <SpeedyLink 50 + className="font-bold hover:no-underline text-accent-contrast" 51 + href={document && getPublicationURL(pub)} 52 + > 53 + {pub?.name} 54 + </SpeedyLink> 55 + )} 56 {identity && 57 + pub && 58 + identity.atp_did === pub.identity_did && 59 document.leaflets_in_publications[0] && ( 60 <a 61 className=" rounded-full flex place-items-center" ··· 64 <EditTiny className="shrink-0" /> 65 </a> 66 )} 67 + </> 68 + } 69 + postTitle={record.title} 70 + postDescription={record.description} 71 + postInfo={ 72 + <> 73 + <div className="flex flex-row gap-2 items-center"> 74 + {profile ? ( 75 + <> 76 + <a 77 + className="text-tertiary" 78 + href={`https://bsky.app/profile/${profile.handle}`} 79 + > 80 + {profile.displayName || profile.handle} 81 + </a> 82 + </> 83 + ) : null} 84 + {record.publishedAt ? ( 85 + <> 86 + <Separator classname="h-4!" /> 87 + <p>{formattedDate}</p> 88 + </> 89 + ) : null} 90 + </div> 91 <Interactions 92 showComments={props.preferences.showComments} 93 quotesCount={getQuoteCount(document) || 0} 94 commentsCount={getCommentCount(document) || 0} 95 /> 96 + </> 97 + } 98 + /> 99 + ); 100 + } 101 + 102 + export const PostHeaderLayout = (props: { 103 + pubLink: React.ReactNode; 104 + postTitle: React.ReactNode | undefined; 105 + postDescription: React.ReactNode | undefined; 106 + postInfo: React.ReactNode; 107 + }) => { 108 + return ( 109 + <div 110 + className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" 111 + id="post-header" 112 + > 113 + <div className="pubInfo flex text-accent-contrast font-bold justify-between w-full"> 114 + {props.pubLink} 115 + </div> 116 + <h2 117 + className={`postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent ${!props.postTitle && "text-tertiary italic"}`} 118 + > 119 + {props.postTitle ? props.postTitle : "Untitled"} 120 + </h2> 121 + {props.postDescription ? ( 122 + <div className="postDescription italic text-secondary outline-hidden bg-transparent pt-1"> 123 + {props.postDescription} 124 </div> 125 + ) : null} 126 + <div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between"> 127 + {props.postInfo} 128 </div> 129 </div> 130 ); 131 + };
+137 -85
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 19 import { Fragment, useEffect } from "react"; 20 import { flushSync } from "react-dom"; 21 import { scrollIntoView } from "src/utils/scrollIntoView"; 22 - import { useParams } from "next/navigation"; 23 import { decodeQuotePosition } from "./quotePosition"; 24 import { PollData } from "./fetchPollData"; 25 import { LinearDocumentPage } from "./LinearDocumentPage"; ··· 32 33 export const useOpenPages = () => { 34 const { quote } = useParams(); 35 const state = usePostPageUIState((s) => s); 36 37 - if (!state.initialized && quote) { 38 - const decodedQuote = decodeQuotePosition(quote as string); 39 - if (decodedQuote?.pageId) { 40 - return [decodedQuote.pageId]; 41 } 42 } 43 ··· 46 47 export const useInitializeOpenPages = () => { 48 const { quote } = useParams(); 49 50 useEffect(() => { 51 const state = usePostPageUIState.getState(); 52 if (!state.initialized) { 53 if (quote) { 54 const decodedQuote = decodeQuotePosition(quote as string); 55 if (decodedQuote?.pageId) { ··· 63 // Mark as initialized even if no pageId found 64 usePostPageUIState.setState({ initialized: true }); 65 } 66 - }, [quote]); 67 }; 68 69 export const openPage = ( ··· 98 }; 99 }); 100 101 export function PostPages({ 102 document, 103 - blocks, 104 did, 105 profile, 106 preferences, ··· 112 }: { 113 document_uri: string; 114 document: PostPageData; 115 - blocks: PubLeafletPagesLinearDocument.Block[]; 116 profile: ProfileViewDetailed; 117 - pubRecord: PubLeafletPublication.Record; 118 did: string; 119 prerenderedCodeBlocks?: Map<string, string>; 120 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 123 }) { 124 let drawer = useDrawerOpen(document_uri); 125 useInitializeOpenPages(); 126 - let pages = useOpenPages(); 127 - if (!document || !document.documents_in_publications[0].publications) 128 - return null; 129 130 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 131 let record = document.data as PubLeafletDocument.Record; 132 let quotesAndMentions = document.quotesAndMentions; 133 134 - let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; 135 return ( 136 <> 137 - {!fullPageScroll && <BookendSpacer />} 138 - <LinearDocumentPage 139 - document={document} 140 - blocks={blocks} 141 - did={did} 142 - profile={profile} 143 - fullPageScroll={fullPageScroll} 144 - pollData={pollData} 145 - preferences={preferences} 146 - pubRecord={pubRecord} 147 - prerenderedCodeBlocks={prerenderedCodeBlocks} 148 - bskyPostData={bskyPostData} 149 - document_uri={document_uri} 150 - /> 151 152 {drawer && !drawer.pageId && ( 153 <InteractionDrawer 154 document_uri={document.uri} 155 comments={ 156 - pubRecord.preferences?.showComments === false 157 ? [] 158 : document.comments_on_documents 159 } ··· 162 /> 163 )} 164 165 - {pages.map((p) => { 166 let page = record.pages.find( 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; 177 - if (!page) return null; 178 179 - const isCanvas = PubLeafletPagesCanvas.isMain(page); 180 181 return ( 182 - <Fragment key={p}> 183 <SandwichSpacer /> 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} 198 - pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 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} 211 - did={did} 212 - preferences={preferences} 213 - pubRecord={pubRecord} 214 - pollData={pollData} 215 - prerenderedCodeBlocks={prerenderedCodeBlocks} 216 - bskyPostData={bskyPostData} 217 - document_uri={document_uri} 218 - pageId={page.id} 219 - pageOptions={ 220 - <PageOptions 221 - onClick={() => closePage(page?.id!)} 222 - hasPageBackground={hasPageBackground} 223 - /> 224 - } 225 - /> 226 - )} 227 {drawer && drawer.pageId === page.id && ( 228 <InteractionDrawer 229 pageId={page.id} 230 document_uri={document.uri} 231 comments={ 232 - pubRecord.preferences?.showComments === false 233 ? [] 234 : document.comments_on_documents 235 } ··· 240 </Fragment> 241 ); 242 })} 243 - {!fullPageScroll && <BookendSpacer />} 244 </> 245 ); 246 } ··· 255 absolute sm:-right-[20px] right-3 sm:top-3 top-0 256 flex sm:flex-col flex-row-reverse gap-1 items-start`} 257 > 258 - <PageOptionButton 259 - cardBorderHidden={!props.hasPageBackground} 260 - onClick={props.onClick} 261 - > 262 <CloseTiny /> 263 </PageOptionButton> 264 </div>
··· 19 import { Fragment, useEffect } from "react"; 20 import { flushSync } from "react-dom"; 21 import { scrollIntoView } from "src/utils/scrollIntoView"; 22 + import { useParams, useSearchParams } from "next/navigation"; 23 import { decodeQuotePosition } from "./quotePosition"; 24 import { PollData } from "./fetchPollData"; 25 import { LinearDocumentPage } from "./LinearDocumentPage"; ··· 32 33 export const useOpenPages = () => { 34 const { quote } = useParams(); 35 + const searchParams = useSearchParams(); 36 + const pageParam = searchParams.get("page"); 37 const state = usePostPageUIState((s) => s); 38 39 + if (!state.initialized) { 40 + // Check for page search param first (for comment links) 41 + if (pageParam) { 42 + return [pageParam]; 43 + } 44 + // Then check for quote param 45 + if (quote) { 46 + const decodedQuote = decodeQuotePosition(quote as string); 47 + if (decodedQuote?.pageId) { 48 + return [decodedQuote.pageId]; 49 + } 50 } 51 } 52 ··· 55 56 export const useInitializeOpenPages = () => { 57 const { quote } = useParams(); 58 + const searchParams = useSearchParams(); 59 + const pageParam = searchParams.get("page"); 60 61 useEffect(() => { 62 const state = usePostPageUIState.getState(); 63 if (!state.initialized) { 64 + // Check for page search param first (for comment links) 65 + if (pageParam) { 66 + usePostPageUIState.setState({ 67 + pages: [pageParam], 68 + initialized: true, 69 + }); 70 + return; 71 + } 72 + // Then check for quote param 73 if (quote) { 74 const decodedQuote = decodeQuotePosition(quote as string); 75 if (decodedQuote?.pageId) { ··· 83 // Mark as initialized even if no pageId found 84 usePostPageUIState.setState({ initialized: true }); 85 } 86 + }, [quote, pageParam]); 87 }; 88 89 export const openPage = ( ··· 118 }; 119 }); 120 121 + // Shared props type for both page components 122 + export type SharedPageProps = { 123 + document: PostPageData; 124 + did: string; 125 + profile: ProfileViewDetailed; 126 + preferences: { showComments?: boolean }; 127 + pubRecord?: PubLeafletPublication.Record; 128 + theme?: PubLeafletPublication.Theme | null; 129 + prerenderedCodeBlocks?: Map<string, string>; 130 + bskyPostData: AppBskyFeedDefs.PostView[]; 131 + pollData: PollData[]; 132 + document_uri: string; 133 + fullPageScroll: boolean; 134 + hasPageBackground: boolean; 135 + pageId?: string; 136 + pageOptions?: React.ReactNode; 137 + allPages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 138 + }; 139 + 140 + // Component that renders either Canvas or Linear page based on page type 141 + function PageRenderer({ 142 + page, 143 + ...sharedProps 144 + }: { 145 + page: PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main; 146 + } & SharedPageProps) { 147 + const isCanvas = PubLeafletPagesCanvas.isMain(page); 148 + 149 + if (isCanvas) { 150 + return ( 151 + <CanvasPage 152 + {...sharedProps} 153 + blocks={(page as PubLeafletPagesCanvas.Main).blocks || []} 154 + pages={sharedProps.allPages} 155 + /> 156 + ); 157 + } 158 + 159 + return ( 160 + <LinearDocumentPage 161 + {...sharedProps} 162 + blocks={(page as PubLeafletPagesLinearDocument.Main).blocks || []} 163 + /> 164 + ); 165 + } 166 + 167 export function PostPages({ 168 document, 169 did, 170 profile, 171 preferences, ··· 177 }: { 178 document_uri: string; 179 document: PostPageData; 180 profile: ProfileViewDetailed; 181 + pubRecord?: PubLeafletPublication.Record; 182 did: string; 183 prerenderedCodeBlocks?: Map<string, string>; 184 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 187 }) { 188 let drawer = useDrawerOpen(document_uri); 189 useInitializeOpenPages(); 190 + let openPageIds = useOpenPages(); 191 + if (!document) return null; 192 193 let record = document.data as PubLeafletDocument.Record; 194 + let theme = pubRecord?.theme || record.theme || null; 195 + // For publication posts, respect the publication's showPageBackground setting 196 + // For standalone documents, default to showing page background 197 + let isInPublication = !!pubRecord; 198 + let hasPageBackground = isInPublication ? !!theme?.showPageBackground : true; 199 let quotesAndMentions = document.quotesAndMentions; 200 201 + let firstPage = record.pages[0] as 202 + | PubLeafletPagesLinearDocument.Main 203 + | PubLeafletPagesCanvas.Main; 204 + 205 + // Canvas pages don't support fullPageScroll well due to fixed 1272px width 206 + let firstPageIsCanvas = PubLeafletPagesCanvas.isMain(firstPage); 207 + 208 + // Shared props used for all pages 209 + const sharedProps: SharedPageProps = { 210 + document, 211 + did, 212 + profile, 213 + preferences, 214 + pubRecord, 215 + theme, 216 + prerenderedCodeBlocks, 217 + bskyPostData, 218 + pollData, 219 + document_uri, 220 + hasPageBackground, 221 + allPages: record.pages as ( 222 + | PubLeafletPagesLinearDocument.Main 223 + | PubLeafletPagesCanvas.Main 224 + )[], 225 + fullPageScroll: 226 + !hasPageBackground && 227 + !drawer && 228 + openPageIds.length === 0 && 229 + !firstPageIsCanvas, 230 + }; 231 + 232 return ( 233 <> 234 + {!sharedProps.fullPageScroll && <BookendSpacer />} 235 + 236 + <PageRenderer page={firstPage} {...sharedProps} /> 237 238 {drawer && !drawer.pageId && ( 239 <InteractionDrawer 240 document_uri={document.uri} 241 comments={ 242 + pubRecord?.preferences?.showComments === false 243 ? [] 244 : document.comments_on_documents 245 } ··· 248 /> 249 )} 250 251 + {openPageIds.map((pageId) => { 252 let page = record.pages.find( 253 + (p) => 254 ( 255 + p as 256 | PubLeafletPagesLinearDocument.Main 257 | PubLeafletPagesCanvas.Main 258 + ).id === pageId, 259 ) as 260 | PubLeafletPagesLinearDocument.Main 261 | PubLeafletPagesCanvas.Main 262 | undefined; 263 264 + if (!page) return null; 265 266 return ( 267 + <Fragment key={pageId}> 268 <SandwichSpacer /> 269 + <PageRenderer 270 + page={page} 271 + {...sharedProps} 272 + fullPageScroll={false} 273 + pageId={page.id} 274 + pageOptions={ 275 + <PageOptions 276 + onClick={() => closePage(page.id!)} 277 + hasPageBackground={hasPageBackground} 278 + /> 279 + } 280 + /> 281 {drawer && drawer.pageId === page.id && ( 282 <InteractionDrawer 283 pageId={page.id} 284 document_uri={document.uri} 285 comments={ 286 + pubRecord?.preferences?.showComments === false 287 ? [] 288 : document.comments_on_documents 289 } ··· 294 </Fragment> 295 ); 296 })} 297 + 298 + {!sharedProps.fullPageScroll && <BookendSpacer />} 299 </> 300 ); 301 } ··· 310 absolute sm:-right-[20px] right-3 sm:top-3 top-0 311 flex sm:flex-col flex-row-reverse gap-1 items-start`} 312 > 313 + <PageOptionButton onClick={props.onClick}> 314 <CloseTiny /> 315 </PageOptionButton> 316 </div>
+4 -5
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 106 <div className="grow"> 107 {title && ( 108 <div 109 - className={`pageBlockOne outline-none resize-none align-top flex gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 110 > 111 <TextBlock 112 facets={title.facets} ··· 118 )} 119 {description && ( 120 <div 121 - className={`pageBlockLineTwo outline-none resize-none align-top flex gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 122 > 123 <TextBlock 124 facets={description.facets} ··· 151 let previewRef = useRef<HTMLDivElement | null>(null); 152 let { rootEntity } = useReplicache(); 153 let data = useContext(PostPageContext); 154 - let theme = data?.documents_in_publications[0]?.publications 155 - ?.record as PubLeafletPublication.Record; 156 let pageWidth = `var(--page-width-unitless)`; 157 - let cardBorderHidden = !theme.theme?.showPageBackground; 158 return ( 159 <div 160 ref={previewRef}
··· 106 <div className="grow"> 107 {title && ( 108 <div 109 + className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 110 > 111 <TextBlock 112 facets={title.facets} ··· 118 )} 119 {description && ( 120 <div 121 + className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 122 > 123 <TextBlock 124 facets={description.facets} ··· 151 let previewRef = useRef<HTMLDivElement | null>(null); 152 let { rootEntity } = useReplicache(); 153 let data = useContext(PostPageContext); 154 + let theme = data?.theme; 155 let pageWidth = `var(--page-width-unitless)`; 156 + let cardBorderHidden = !theme?.showPageBackground; 157 return ( 158 <div 159 ref={previewRef}
+3 -2
app/lish/[did]/[publication]/[rkey]/extractCodeBlocks.ts
··· 1 import { 2 PubLeafletDocument, 3 PubLeafletPagesLinearDocument, 4 PubLeafletBlocksCode, 5 } from "lexicons/api"; 6 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 7 8 export async function extractCodeBlocks( 9 - blocks: PubLeafletPagesLinearDocument.Block[], 10 ): Promise<Map<string, string>> { 11 const codeBlocks = new Map<string, string>(); 12 13 - // Process all pages in the document 14 for (let i = 0; i < blocks.length; i++) { 15 const block = blocks[i]; 16 const currentIndex = [i];
··· 1 import { 2 PubLeafletDocument, 3 PubLeafletPagesLinearDocument, 4 + PubLeafletPagesCanvas, 5 PubLeafletBlocksCode, 6 } from "lexicons/api"; 7 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 8 9 export async function extractCodeBlocks( 10 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[], 11 ): Promise<Map<string, string>> { 12 const codeBlocks = new Map<string, string>(); 13 14 + // Process all blocks (works for both linear and canvas) 15 for (let i = 0; i < blocks.length; i++) { 16 const block = blocks[i]; 17 const currentIndex = [i];
+12 -3
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletPublication } from "lexicons/api"; 4 5 export async function getPostPageData(uri: string) { 6 let { data: document } = await supabaseServerClient ··· 23 // Fetch constellation backlinks for mentions 24 const pubRecord = document.documents_in_publications[0]?.publications 25 ?.record as PubLeafletPublication.Record; 26 - const rkey = new AtUri(uri).rkey; 27 - const postUrl = `https://${pubRecord?.base_path}/${rkey}`; 28 const constellationBacklinks = await getConstellationBacklinks(postUrl); 29 30 // Deduplicate constellation backlinks (same post could appear in both links and embeds) ··· 43 ...uniqueBacklinks, 44 ]; 45 46 return { 47 ...document, 48 quotesAndMentions, 49 }; 50 } 51
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 import { AtUri } from "@atproto/syntax"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 5 export async function getPostPageData(uri: string) { 6 let { data: document } = await supabaseServerClient ··· 23 // Fetch constellation backlinks for mentions 24 const pubRecord = document.documents_in_publications[0]?.publications 25 ?.record as PubLeafletPublication.Record; 26 + let aturi = new AtUri(uri); 27 + const postUrl = pubRecord 28 + ? `https://${pubRecord?.base_path}/${aturi.rkey}` 29 + : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; 30 const constellationBacklinks = await getConstellationBacklinks(postUrl); 31 32 // Deduplicate constellation backlinks (same post could appear in both links and embeds) ··· 45 ...uniqueBacklinks, 46 ]; 47 48 + let theme = 49 + ( 50 + document?.documents_in_publications[0]?.publications 51 + ?.record as PubLeafletPublication.Record 52 + )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 53 + 54 return { 55 ...document, 56 quotesAndMentions, 57 + theme, 58 }; 59 } 60
+4 -3
app/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts
··· 5 export const revalidate = 60; 6 7 export default async function OpenGraphImage(props: { 8 - params: { publication: string; did: string; rkey: string; quote: string }; 9 }) { 10 - let quotePosition = decodeQuotePosition(props.params.quote); 11 return getMicroLinkOgImage( 12 - `/lish/${decodeURIComponent(props.params.did)}/${decodeURIComponent(props.params.publication)}/${props.params.rkey}/l-quote/${props.params.quote}#${quotePosition?.pageId ? `${quotePosition.pageId}~` : ""}${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`, 13 { 14 width: 620, 15 height: 324,
··· 5 export const revalidate = 60; 6 7 export default async function OpenGraphImage(props: { 8 + params: Promise<{ publication: string; did: string; rkey: string; quote: string }>; 9 }) { 10 + let params = await props.params; 11 + let quotePosition = decodeQuotePosition(params.quote); 12 return getMicroLinkOgImage( 13 + `/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/l-quote/${params.quote}#${quotePosition?.pageId ? `${quotePosition.pageId}~` : ""}${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`, 14 { 15 width: 620, 16 height: 324,
+3 -2
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 - params: { publication: string; did: string; rkey: string }; 8 }) { 9 return getMicroLinkOgImage( 10 - `/lish/${decodeURIComponent(props.params.did)}/${decodeURIComponent(props.params.publication)}/${props.params.rkey}/`, 11 ); 12 }
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 + params: Promise<{ publication: string; did: string; rkey: string }>; 8 }) { 9 + let params = await props.params; 10 return getMicroLinkOgImage( 11 + `/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`, 12 ); 13 }
+14 -156
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 import { AtUri } from "@atproto/syntax"; 3 import { ids } from "lexicons/api/lexicons"; 4 - import { 5 - PubLeafletBlocksBskyPost, 6 - PubLeafletDocument, 7 - PubLeafletPagesLinearDocument, 8 - PubLeafletPublication, 9 - } from "lexicons/api"; 10 import { Metadata } from "next"; 11 - import { AtpAgent } from "@atproto/api"; 12 - import { QuoteHandler } from "./QuoteHandler"; 13 - import { InteractionDrawer } from "./Interactions/InteractionDrawer"; 14 - import { 15 - PublicationBackgroundProvider, 16 - PublicationThemeProvider, 17 - } from "components/ThemeManager/PublicationThemeProvider"; 18 - import { getPostPageData } from "./getPostPageData"; 19 - import { PostPageContextProvider } from "./PostPageContext"; 20 - import { PostPages } from "./PostPages"; 21 - import { extractCodeBlocks } from "./extractCodeBlocks"; 22 - import { LeafletLayout } from "components/LeafletLayout"; 23 - import { fetchPollData } from "./fetchPollData"; 24 25 export async function generateMetadata(props: { 26 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 42 43 return { 44 icons: { 45 other: { 46 rel: "alternate", 47 url: document.uri, ··· 57 export default async function Post(props: { 58 params: Promise<{ publication: string; did: string; rkey: string }>; 59 }) { 60 - let did = decodeURIComponent((await props.params).did); 61 if (!did) 62 return ( 63 <div className="p-4 text-lg text-center flex flex-col gap-4"> ··· 68 </p> 69 </div> 70 ); 71 - let agent = new AtpAgent({ 72 - service: "https://public.api.bsky.app", 73 - fetch: (...args) => 74 - fetch(args[0], { 75 - ...args[1], 76 - next: { revalidate: 3600 }, 77 - }), 78 - }); 79 - let [document, profile] = await Promise.all([ 80 - getPostPageData( 81 - AtUri.make( 82 - did, 83 - ids.PubLeafletDocument, 84 - (await props.params).rkey, 85 - ).toString(), 86 - ), 87 - agent.getProfile({ actor: did }), 88 - ]); 89 - if (!document?.data || !document.documents_in_publications[0].publications) 90 - return ( 91 - <div className="bg-bg-leaflet h-full p-3 text-center relative"> 92 - <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full"> 93 - <div className=" px-3 py-4 opaque-container flex flex-col gap-1 mx-2 "> 94 - <h3>Sorry, post not found!</h3> 95 - <p> 96 - This may be a glitch on our end. If the issue persists please{" "} 97 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 98 - </p> 99 - </div> 100 - </div> 101 - </div> 102 - ); 103 - let record = document.data as PubLeafletDocument.Record; 104 - let bskyPosts = 105 - record.pages.flatMap((p) => { 106 - let page = p as PubLeafletPagesLinearDocument.Main; 107 - return page.blocks?.filter( 108 - (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, 109 - ); 110 - }) || []; 111 112 - // Batch bsky posts into groups of 25 and fetch in parallel 113 - let bskyPostBatches = []; 114 - for (let i = 0; i < bskyPosts.length; i += 25) { 115 - bskyPostBatches.push(bskyPosts.slice(i, i + 25)); 116 - } 117 - 118 - let bskyPostResponses = await Promise.all( 119 - bskyPostBatches.map((batch) => 120 - agent.getPosts( 121 - { 122 - uris: batch.map((p) => { 123 - let block = p?.block as PubLeafletBlocksBskyPost.Main; 124 - return block.postRef.uri; 125 - }), 126 - }, 127 - { headers: {} }, 128 - ), 129 - ), 130 - ); 131 - 132 - let bskyPostData = 133 - bskyPostResponses.length > 0 134 - ? bskyPostResponses.flatMap((response) => response.data.posts) 135 - : []; 136 - 137 - // Extract poll blocks and fetch vote data 138 - let pollBlocks = record.pages.flatMap((p) => { 139 - let page = p as PubLeafletPagesLinearDocument.Main; 140 - return ( 141 - page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || 142 - [] 143 - ); 144 - }); 145 - let pollData = await fetchPollData( 146 - pollBlocks.map((b) => (b.block as any).pollRef.uri), 147 - ); 148 - 149 - let pubRecord = document.documents_in_publications[0]?.publications 150 - .record as PubLeafletPublication.Record; 151 - 152 - let firstPage = record.pages[0]; 153 - let blocks: PubLeafletPagesLinearDocument.Block[] = []; 154 - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 155 - blocks = firstPage.blocks || []; 156 - } 157 - 158 - let prerenderedCodeBlocks = await extractCodeBlocks(blocks); 159 - 160 - return ( 161 - <PostPageContextProvider value={document}> 162 - <PublicationThemeProvider 163 - record={pubRecord} 164 - pub_creator={ 165 - document.documents_in_publications[0].publications.identity_did 166 - } 167 - > 168 - <PublicationBackgroundProvider 169 - record={pubRecord} 170 - pub_creator={ 171 - document.documents_in_publications[0].publications.identity_did 172 - } 173 - > 174 - {/* 175 - TODO: SCROLL PAGE TO FIT DRAWER 176 - If the drawer fits without scrolling, dont scroll 177 - If both drawer and page fit if you scrolled it, scroll it all into the center 178 - If the drawer and pafe doesn't all fit, scroll to drawer 179 - 180 - TODO: SROLL BAR 181 - If there is no drawer && there is no page bg, scroll the entire page 182 - If there is either a drawer open OR a page background, scroll just the post content 183 - 184 - TODO: HIGHLIGHTING BORKED 185 - on chrome, if you scroll backward, things stop working 186 - seems like if you use an older browser, sel direction is not a thing yet 187 - */} 188 - <LeafletLayout> 189 - <PostPages 190 - document_uri={document.uri} 191 - preferences={pubRecord.preferences || {}} 192 - pubRecord={pubRecord} 193 - profile={JSON.parse(JSON.stringify(profile.data))} 194 - document={document} 195 - bskyPostData={bskyPostData} 196 - did={did} 197 - blocks={blocks} 198 - prerenderedCodeBlocks={prerenderedCodeBlocks} 199 - pollData={pollData} 200 - /> 201 - </LeafletLayout> 202 - 203 - <QuoteHandler /> 204 - </PublicationBackgroundProvider> 205 - </PublicationThemeProvider> 206 - </PostPageContextProvider> 207 - ); 208 }
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 import { AtUri } from "@atproto/syntax"; 3 import { ids } from "lexicons/api/lexicons"; 4 + import { PubLeafletDocument } from "lexicons/api"; 5 import { Metadata } from "next"; 6 + import { DocumentPageRenderer } from "./DocumentPageRenderer"; 7 8 export async function generateMetadata(props: { 9 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 25 26 return { 27 icons: { 28 + icon: { 29 + url: 30 + process.env.NODE_ENV === "development" 31 + ? `/lish/${did}/${params.publication}/icon` 32 + : "/icon", 33 + sizes: "32x32", 34 + type: "image/png", 35 + }, 36 other: { 37 rel: "alternate", 38 url: document.uri, ··· 48 export default async function Post(props: { 49 params: Promise<{ publication: string; did: string; rkey: string }>; 50 }) { 51 + let params = await props.params; 52 + let did = decodeURIComponent(params.did); 53 + 54 if (!did) 55 return ( 56 <div className="p-4 text-lg text-center flex flex-col gap-4"> ··· 61 </p> 62 </div> 63 ); 64 65 + return <DocumentPageRenderer did={did} rkey={params.rkey} />; 66 }
-1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 23 searchValue={props.searchValue} 24 showPreview={false} 25 defaultDisplay="list" 26 - cardBorderHidden={!props.showPageBackground} 27 leaflets={leaflets_in_publications 28 .filter((l) => !l.documents) 29 .filter((l) => !l.archived)
··· 23 searchValue={props.searchValue} 24 showPreview={false} 25 defaultDisplay="list" 26 leaflets={leaflets_in_publications 27 .filter((l) => !l.documents) 28 .filter((l) => !l.archived)
-1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 39 return ( 40 <DashboardLayout 41 id={publication.uri} 42 - cardBorderHidden={!!record.theme?.showPageBackground} 43 defaultTab="Drafts" 44 tabs={{ 45 Drafts: {
··· 39 return ( 40 <DashboardLayout 41 id={publication.uri} 42 defaultTab="Drafts" 43 tabs={{ 44 Drafts: {
+45 -36
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument } from "lexicons/api"; 4 import { EditTiny } from "components/Icons/EditTiny"; 5 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 14 import { DeleteSmall } from "components/Icons/DeleteSmall"; 15 import { ShareSmall } from "components/Icons/ShareSmall"; 16 - import { ShareButton } from "components/ShareOptions"; 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 22 23 export function PublishedPostsList(props: { 24 searchValue: string; ··· 27 let { data } = usePublicationData(); 28 let params = useParams(); 29 let { publication } = data!; 30 if (!publication) return null; 31 if (publication.documents_in_publications.length === 0) 32 return ( ··· 54 (l) => doc.documents && l.doc === doc.documents.uri, 55 ); 56 let uri = new AtUri(doc.documents.uri); 57 - let record = doc.documents.data as PubLeafletDocument.Record; 58 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 59 let comments = doc.documents.comments_on_documents[0]?.count || 0; 60 61 let postLink = data?.publication 62 ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` ··· 80 href={`${getPublicationURL(publication)}/${uri.rkey}`} 81 > 82 <h3 className="text-primary grow leading-snug"> 83 - {record.title} 84 </h3> 85 </a> 86 <div className="flex justify-start align-top flex-row gap-1"> 87 - {leaflet && ( 88 <> 89 <SpeedyLink 90 className="pt-[6px]" ··· 93 <EditTiny /> 94 </SpeedyLink> 95 96 - <LeafletOptions 97 - leaflet={leaflet?.permission_tokens!} 98 - document_uri={doc.documents.uri} 99 - shareLink={postLink} 100 - loggedIn={true} 101 - /> 102 </> 103 )} 104 </div> 105 </div> 106 107 - {record.description ? ( 108 <p className="italic text-secondary"> 109 - {record.description} 110 </p> 111 ) : null} 112 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3"> 113 - {record.publishedAt ? ( 114 - <PublishedDate dateString={record.publishedAt} /> 115 ) : null} 116 - {(comments > 0 || quotes > 0) && record.publishedAt 117 - ? " | " 118 - : ""} 119 - {quotes > 0 && ( 120 - <SpeedyLink 121 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 122 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 123 - > 124 - <QuoteTiny /> {quotes} 125 - </SpeedyLink> 126 - )} 127 - {comments > 0 && quotes > 0 ? " " : ""} 128 - {comments > 0 && ( 129 - <SpeedyLink 130 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 131 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 132 - > 133 - <CommentTiny /> {comments} 134 - </SpeedyLink> 135 - )} 136 </div> 137 </div> 138 </div>
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 import { EditTiny } from "components/Icons/EditTiny"; 5 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 14 import { DeleteSmall } from "components/Icons/DeleteSmall"; 15 import { ShareSmall } from "components/Icons/ShareSmall"; 16 + import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 + import { InteractionPreview } from "components/InteractionsPreview"; 21 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 22 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 23 + import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 24 25 export function PublishedPostsList(props: { 26 searchValue: string; ··· 29 let { data } = usePublicationData(); 30 let params = useParams(); 31 let { publication } = data!; 32 + let pubRecord = publication?.record as PubLeafletPublication.Record; 33 + 34 if (!publication) return null; 35 if (publication.documents_in_publications.length === 0) 36 return ( ··· 58 (l) => doc.documents && l.doc === doc.documents.uri, 59 ); 60 let uri = new AtUri(doc.documents.uri); 61 + let postRecord = doc.documents.data as PubLeafletDocument.Record; 62 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 63 let comments = doc.documents.comments_on_documents[0]?.count || 0; 64 + let tags = (postRecord?.tags as string[] | undefined) || []; 65 66 let postLink = data?.publication 67 ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` ··· 85 href={`${getPublicationURL(publication)}/${uri.rkey}`} 86 > 87 <h3 className="text-primary grow leading-snug"> 88 + {postRecord.title} 89 </h3> 90 </a> 91 <div className="flex justify-start align-top flex-row gap-1"> 92 + {leaflet && leaflet.permission_tokens && ( 93 <> 94 <SpeedyLink 95 className="pt-[6px]" ··· 98 <EditTiny /> 99 </SpeedyLink> 100 101 + <StaticLeafletDataContext 102 + value={{ 103 + ...leaflet.permission_tokens, 104 + leaflets_in_publications: [ 105 + { 106 + ...leaflet, 107 + publications: publication, 108 + documents: doc.documents 109 + ? { 110 + uri: doc.documents.uri, 111 + indexed_at: doc.documents.indexed_at, 112 + data: doc.documents.data, 113 + } 114 + : null, 115 + }, 116 + ], 117 + leaflets_to_documents: [], 118 + blocked_by_admin: null, 119 + custom_domain_routes: [], 120 + }} 121 + > 122 + <LeafletOptions loggedIn={true} /> 123 + </StaticLeafletDataContext> 124 </> 125 )} 126 </div> 127 </div> 128 129 + {postRecord.description ? ( 130 <p className="italic text-secondary"> 131 + {postRecord.description} 132 </p> 133 ) : null} 134 + <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 135 + {postRecord.publishedAt ? ( 136 + <PublishedDate dateString={postRecord.publishedAt} /> 137 ) : null} 138 + <InteractionPreview 139 + quotesCount={quotes} 140 + commentsCount={comments} 141 + tags={tags} 142 + showComments={pubRecord?.preferences?.showComments} 143 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 + /> 145 </div> 146 </div> 147 </div>
+67
app/lish/[did]/[publication]/icon/route.ts
···
··· 1 + import { NextRequest } from "next/server"; 2 + import { IdResolver } from "@atproto/identity"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import sharp from "sharp"; 7 + import { redirect } from "next/navigation"; 8 + 9 + let idResolver = new IdResolver(); 10 + 11 + export const dynamic = "force-dynamic"; 12 + 13 + export async function GET( 14 + request: NextRequest, 15 + props: { params: Promise<{ did: string; publication: string }> }, 16 + ) { 17 + console.log("are we getting here?"); 18 + const params = await props.params; 19 + try { 20 + let did = decodeURIComponent(params.did); 21 + let uri; 22 + if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) { 23 + uri = AtUri.make( 24 + did, 25 + "pub.leaflet.publication", 26 + params.publication, 27 + ).toString(); 28 + } 29 + let { data: publication } = await supabaseServerClient 30 + .from("publications") 31 + .select( 32 + `*, 33 + publication_subscriptions(*), 34 + documents_in_publications(documents(*)) 35 + `, 36 + ) 37 + .eq("identity_did", did) 38 + .or(`name.eq."${params.publication}", uri.eq."${uri}"`) 39 + .single(); 40 + 41 + let record = publication?.record as PubLeafletPublication.Record | null; 42 + if (!record?.icon) return redirect("/icon.png"); 43 + 44 + let identity = await idResolver.did.resolve(did); 45 + let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 46 + if (!service) return redirect("/icon.png"); 47 + let cid = (record.icon.ref as unknown as { $link: string })["$link"]; 48 + const response = await fetch( 49 + `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, 50 + ); 51 + let blob = await response.blob(); 52 + let resizedImage = await sharp(await blob.arrayBuffer()) 53 + .resize({ width: 32, height: 32 }) 54 + .toBuffer(); 55 + return new Response(new Uint8Array(resizedImage), { 56 + headers: { 57 + "Content-Type": "image/png", 58 + "CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400", 59 + "Cache-Control": 60 + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 61 + }, 62 + }); 63 + } catch (e) { 64 + console.log(e); 65 + return redirect("/icon.png"); 66 + } 67 + }
-69
app/lish/[did]/[publication]/icon.ts
··· 1 - import { NextRequest } from "next/server"; 2 - import { IdResolver } from "@atproto/identity"; 3 - import { AtUri } from "@atproto/syntax"; 4 - import { PubLeafletPublication } from "lexicons/api"; 5 - import { supabaseServerClient } from "supabase/serverClient"; 6 - import sharp from "sharp"; 7 - import { redirect } from "next/navigation"; 8 - 9 - let idResolver = new IdResolver(); 10 - 11 - export const size = { 12 - width: 32, 13 - height: 32, 14 - }; 15 - 16 - export const contentType = "image/png"; 17 - export default async function Icon({ 18 - params, 19 - }: { 20 - params: { did: string; publication: string }; 21 - }) { 22 - try { 23 - let did = decodeURIComponent(params.did); 24 - let uri; 25 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) { 26 - uri = AtUri.make( 27 - did, 28 - "pub.leaflet.publication", 29 - params.publication, 30 - ).toString(); 31 - } 32 - let { data: publication } = await supabaseServerClient 33 - .from("publications") 34 - .select( 35 - `*, 36 - publication_subscriptions(*), 37 - documents_in_publications(documents(*)) 38 - `, 39 - ) 40 - .eq("identity_did", did) 41 - .or(`name.eq."${params.publication}", uri.eq."${uri}"`) 42 - .single(); 43 - 44 - let record = publication?.record as PubLeafletPublication.Record | null; 45 - if (!record?.icon) return redirect("/icon.png"); 46 - 47 - let identity = await idResolver.did.resolve(did); 48 - let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 49 - if (!service) return null; 50 - let cid = (record.icon.ref as unknown as { $link: string })["$link"]; 51 - const response = await fetch( 52 - `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, 53 - ); 54 - let blob = await response.blob(); 55 - let resizedImage = await sharp(await blob.arrayBuffer()) 56 - .resize({ width: 32, height: 32 }) 57 - .toBuffer(); 58 - return new Response(new Uint8Array(resizedImage), { 59 - headers: { 60 - "Content-Type": "image/png", 61 - "CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400", 62 - "Cache-Control": 63 - "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 64 - }, 65 - }); 66 - } catch (e) { 67 - return redirect("/icon.png"); 68 - } 69 - }
···
+8
app/lish/[did]/[publication]/layout.tsx
··· 47 title: pubRecord?.name || "Untitled Publication", 48 description: pubRecord?.description || "", 49 icons: { 50 other: { 51 rel: "alternate", 52 url: publication.uri,
··· 47 title: pubRecord?.name || "Untitled Publication", 48 description: pubRecord?.description || "", 49 icons: { 50 + icon: { 51 + url: 52 + process.env.NODE_ENV === "development" 53 + ? `/lish/${did}/${publication_name}/icon` 54 + : "/icon", 55 + sizes: "32x32", 56 + type: "image/png", 57 + }, 58 other: { 59 rel: "alternate", 60 url: publication.uri,
+3 -2
app/lish/[did]/[publication]/opengraph-image.ts
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 - params: { publication: string; did: string }; 8 }) { 9 return getMicroLinkOgImage( 10 - `/lish/${encodeURIComponent(props.params.did)}/${encodeURIComponent(props.params.publication)}/`, 11 ); 12 }
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 + params: Promise<{ publication: string; did: string }>; 8 }) { 9 + let params = await props.params; 10 return getMicroLinkOgImage( 11 + `/lish/${encodeURIComponent(params.did)}/${encodeURIComponent(params.publication)}/`, 12 ); 13 }
+108 -120
app/lish/[did]/[publication]/page.tsx
··· 14 import { SpeedyLink } from "components/SpeedyLink"; 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 import { LocalizedDate } from "./LocalizedDate"; 18 19 export default async function Publication(props: { 20 params: Promise<{ publication: string; did: string }>; ··· 59 try { 60 return ( 61 <PublicationThemeProvider 62 - record={record} 63 pub_creator={publication.identity_did} 64 > 65 <PublicationBackgroundProvider 66 - record={record} 67 pub_creator={publication.identity_did} 68 > 69 - <div 70 - className={`pubWrapper flex flex-col sm:py-6 h-full ${showPageBackground ? "max-w-prose mx-auto sm:px-0 px-[6px] py-2" : "w-full overflow-y-scroll"}`} 71 > 72 - <div 73 - className={`pub sm:max-w-prose max-w-(--page-width-units) w-[1000px] mx-auto px-3 sm:px-4 py-5 ${showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`} 74 - > 75 - <div className="pubHeader flex flex-col pb-8 w-full text-center justify-center "> 76 - {record?.icon && ( 77 - <div 78 - className="shrink-0 w-10 h-10 rounded-full mx-auto" 79 - style={{ 80 - backgroundImage: `url(/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 81 - backgroundRepeat: "no-repeat", 82 - backgroundPosition: "center", 83 - backgroundSize: "cover", 84 - }} 85 - /> 86 - )} 87 - <h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 "> 88 - {publication.name} 89 - </h2> 90 - <p className="sm:text-lg text-secondary"> 91 - {record?.description}{" "} 92 </p> 93 - {profile && ( 94 - <p className="italic text-tertiary sm:text-base text-sm"> 95 - <strong className="">by {profile.displayName}</strong>{" "} 96 - <a 97 - className="text-tertiary" 98 - href={`https://bsky.app/profile/${profile.handle}`} 99 - > 100 - @{profile.handle} 101 - </a> 102 - </p> 103 - )} 104 - <div className="sm:pt-4 pt-4"> 105 - <SubscribeWithBluesky 106 - base_url={getPublicationURL(publication)} 107 - pubName={publication.name} 108 - pub_uri={publication.uri} 109 - subscribers={publication.publication_subscriptions} 110 - /> 111 - </div> 112 </div> 113 - <div className="publicationPostList w-full flex flex-col gap-4"> 114 - {publication.documents_in_publications 115 - .filter((d) => !!d?.documents) 116 - .sort((a, b) => { 117 - let aRecord = a.documents 118 - ?.data! as PubLeafletDocument.Record; 119 - let bRecord = b.documents 120 - ?.data! as PubLeafletDocument.Record; 121 - const aDate = aRecord.publishedAt 122 - ? new Date(aRecord.publishedAt) 123 - : new Date(0); 124 - const bDate = bRecord.publishedAt 125 - ? new Date(bRecord.publishedAt) 126 - : new Date(0); 127 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 128 - }) 129 - .map((doc) => { 130 - if (!doc.documents) return null; 131 - let uri = new AtUri(doc.documents.uri); 132 - let doc_record = doc.documents 133 - .data as PubLeafletDocument.Record; 134 - let quotes = 135 - doc.documents.document_mentions_in_bsky[0].count || 0; 136 - let comments = 137 - record?.preferences?.showComments === false 138 - ? 0 139 - : doc.documents.comments_on_documents[0].count || 0; 140 141 - return ( 142 - <React.Fragment key={doc.documents?.uri}> 143 - <div className="flex w-full grow flex-col "> 144 - <SpeedyLink 145 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 146 - className="publishedPost hover:no-underline! flex flex-col" 147 - > 148 - <h3 className="text-primary">{doc_record.title}</h3> 149 - <p className="italic text-secondary"> 150 - {doc_record.description} 151 - </p> 152 - </SpeedyLink> 153 154 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2"> 155 - <p className="text-sm text-tertiary "> 156 - {doc_record.publishedAt && ( 157 - <LocalizedDate 158 - dateString={doc_record.publishedAt} 159 - options={{ 160 - year: "numeric", 161 - month: "long", 162 - day: "2-digit", 163 - }} 164 - /> 165 - )}{" "} 166 - </p> 167 - {comments > 0 || quotes > 0 ? "| " : ""} 168 - {quotes > 0 && ( 169 - <SpeedyLink 170 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 171 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 172 - > 173 - <QuoteTiny /> {quotes} 174 - </SpeedyLink> 175 - )} 176 - {comments > 0 && 177 - record?.preferences?.showComments !== false && ( 178 - <SpeedyLink 179 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 180 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 181 - > 182 - <CommentTiny /> {comments} 183 - </SpeedyLink> 184 - )} 185 - </div> 186 </div> 187 - <hr className="last:hidden border-border-light" /> 188 - </React.Fragment> 189 - ); 190 - })} 191 - </div> 192 </div> 193 - </div> 194 </PublicationBackgroundProvider> 195 </PublicationThemeProvider> 196 );
··· 14 import { SpeedyLink } from "components/SpeedyLink"; 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { InteractionPreview } from "components/InteractionsPreview"; 18 import { LocalizedDate } from "./LocalizedDate"; 19 + import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 21 export default async function Publication(props: { 22 params: Promise<{ publication: string; did: string }>; ··· 61 try { 62 return ( 63 <PublicationThemeProvider 64 + theme={record?.theme} 65 pub_creator={publication.identity_did} 66 > 67 <PublicationBackgroundProvider 68 + theme={record?.theme} 69 pub_creator={publication.identity_did} 70 > 71 + <PublicationHomeLayout 72 + uri={publication.uri} 73 + showPageBackground={!!showPageBackground} 74 > 75 + <div className="pubHeader flex flex-col pb-8 w-full text-center justify-center "> 76 + {record?.icon && ( 77 + <div 78 + className="shrink-0 w-10 h-10 rounded-full mx-auto" 79 + style={{ 80 + backgroundImage: `url(/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 81 + backgroundRepeat: "no-repeat", 82 + backgroundPosition: "center", 83 + backgroundSize: "cover", 84 + }} 85 + /> 86 + )} 87 + <h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 "> 88 + {publication.name} 89 + </h2> 90 + <p className="sm:text-lg text-secondary"> 91 + {record?.description}{" "} 92 + </p> 93 + {profile && ( 94 + <p className="italic text-tertiary sm:text-base text-sm"> 95 + <strong className="">by {profile.displayName}</strong>{" "} 96 + <a 97 + className="text-tertiary" 98 + href={`https://bsky.app/profile/${profile.handle}`} 99 + > 100 + @{profile.handle} 101 + </a> 102 </p> 103 + )} 104 + <div className="sm:pt-4 pt-4"> 105 + <SubscribeWithBluesky 106 + base_url={getPublicationURL(publication)} 107 + pubName={publication.name} 108 + pub_uri={publication.uri} 109 + subscribers={publication.publication_subscriptions} 110 + /> 111 </div> 112 + </div> 113 + <div className="publicationPostList w-full flex flex-col gap-4"> 114 + {publication.documents_in_publications 115 + .filter((d) => !!d?.documents) 116 + .sort((a, b) => { 117 + let aRecord = a.documents?.data! as PubLeafletDocument.Record; 118 + let bRecord = b.documents?.data! as PubLeafletDocument.Record; 119 + const aDate = aRecord.publishedAt 120 + ? new Date(aRecord.publishedAt) 121 + : new Date(0); 122 + const bDate = bRecord.publishedAt 123 + ? new Date(bRecord.publishedAt) 124 + : new Date(0); 125 + return bDate.getTime() - aDate.getTime(); // Sort by most recent first 126 + }) 127 + .map((doc) => { 128 + if (!doc.documents) return null; 129 + let uri = new AtUri(doc.documents.uri); 130 + let doc_record = doc.documents 131 + .data as PubLeafletDocument.Record; 132 + let quotes = 133 + doc.documents.document_mentions_in_bsky[0].count || 0; 134 + let comments = 135 + record?.preferences?.showComments === false 136 + ? 0 137 + : doc.documents.comments_on_documents[0].count || 0; 138 + let tags = (doc_record?.tags as string[] | undefined) || []; 139 140 + return ( 141 + <React.Fragment key={doc.documents?.uri}> 142 + <div className="flex w-full grow flex-col "> 143 + <SpeedyLink 144 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 145 + className="publishedPost hover:no-underline! flex flex-col" 146 + > 147 + <h3 className="text-primary">{doc_record.title}</h3> 148 + <p className="italic text-secondary"> 149 + {doc_record.description} 150 + </p> 151 + </SpeedyLink> 152 153 + <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2"> 154 + <p className="text-sm text-tertiary "> 155 + {doc_record.publishedAt && ( 156 + <LocalizedDate 157 + dateString={doc_record.publishedAt} 158 + options={{ 159 + year: "numeric", 160 + month: "long", 161 + day: "2-digit", 162 + }} 163 + /> 164 + )}{" "} 165 + </p> 166 + {comments > 0 || quotes > 0 ? "| " : ""} 167 + <InteractionPreview 168 + quotesCount={quotes} 169 + commentsCount={comments} 170 + tags={tags} 171 + postUrl="" 172 + showComments={record?.preferences?.showComments} 173 + /> 174 </div> 175 + </div> 176 + <hr className="last:hidden border-border-light" /> 177 + </React.Fragment> 178 + ); 179 + })} 180 </div> 181 + </PublicationHomeLayout> 182 </PublicationBackgroundProvider> 183 </PublicationThemeProvider> 184 );
+6 -7
app/lish/createPub/CreatePubForm.tsx
··· 127 onChange={(e) => setShowInDiscover(e.target.checked)} 128 > 129 <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 130 - <p className="font-bold italic"> 131 - Show In{" "} 132 <a href="/discover" target="_blank"> 133 Discover 134 - </a> 135 - </p> 136 - <p className="text-sm text-tertiary font-normal"> 137 - You'll be able to change this later! 138 </p> 139 </div> 140 </Checkbox> 141 <hr className="border-border-light" /> 142 143 - <div className="flex w-full justify-center"> 144 <ButtonPrimary 145 type="submit" 146 disabled={
··· 127 onChange={(e) => setShowInDiscover(e.target.checked)} 128 > 129 <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 130 + <p className="font-bold italic">Show In Discover</p> 131 + <p className="text-sm text-tertiary font-normal"> 132 + Your posts will appear on our{" "} 133 <a href="/discover" target="_blank"> 134 Discover 135 + </a>{" "} 136 + page. You can change this at any time! 137 </p> 138 </div> 139 </Checkbox> 140 <hr className="border-border-light" /> 141 142 + <div className="flex w-full justify-end"> 143 <ButtonPrimary 144 type="submit" 145 disabled={
+5 -1
app/lish/createPub/UpdatePubForm.tsx
··· 170 </a> 171 </p> 172 <p className="text-xs text-tertiary font-normal"> 173 - This publication will appear on our public Discover page 174 </p> 175 </div> 176 </Checkbox>
··· 170 </a> 171 </p> 172 <p className="text-xs text-tertiary font-normal"> 173 + Your posts will appear on our{" "} 174 + <a href="/discover" target="_blank"> 175 + Discover 176 + </a>{" "} 177 + page. You can change this at any time! 178 </p> 179 </div> 180 </Checkbox>
+3 -11
app/lish/createPub/getPublicationURL.ts
··· 3 import { isProductionDomain } from "src/utils/isProductionDeployment"; 4 import { Json } from "supabase/database.types"; 5 6 - export function getPublicationURL(pub: { 7 - uri: string; 8 - name: string; 9 - record: Json; 10 - }) { 11 let record = pub.record as PubLeafletPublication.Record; 12 if (isProductionDomain() && record?.base_path) 13 return `https://${record.base_path}`; 14 else return getBasePublicationURL(pub); 15 } 16 17 - export function getBasePublicationURL(pub: { 18 - uri: string; 19 - name: string; 20 - record: Json; 21 - }) { 22 let record = pub.record as PubLeafletPublication.Record; 23 let aturi = new AtUri(pub.uri); 24 - return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name || pub.name)}`; 25 }
··· 3 import { isProductionDomain } from "src/utils/isProductionDeployment"; 4 import { Json } from "supabase/database.types"; 5 6 + export function getPublicationURL(pub: { uri: string; record: Json }) { 7 let record = pub.record as PubLeafletPublication.Record; 8 if (isProductionDomain() && record?.base_path) 9 return `https://${record.base_path}`; 10 else return getBasePublicationURL(pub); 11 } 12 13 + export function getBasePublicationURL(pub: { uri: string; record: Json }) { 14 let record = pub.record as PubLeafletPublication.Record; 15 let aturi = new AtUri(pub.uri); 16 + return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`; 17 }
+1 -1
app/lish/createPub/page.tsx
··· 26 <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto"> 27 <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 28 <h2 className="text-center">Create Your Publication!</h2> 29 - <div className="container w-full p-3"> 30 <CreatePubForm /> 31 </div> 32 </div>
··· 26 <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto"> 27 <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 28 <h2 className="text-center">Create Your Publication!</h2> 29 + <div className="opaque-container w-full sm:py-4 p-3"> 30 <CreatePubForm /> 31 </div> 32 </div>
+91
app/lish/uri/[uri]/route.ts
···
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { AtUri } from "@atproto/api"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + 6 + /** 7 + * Redirect route for AT URIs (publications and documents) 8 + * Redirects to the actual hosted domains from publication records 9 + */ 10 + export async function GET( 11 + request: NextRequest, 12 + { params }: { params: Promise<{ uri: string }> } 13 + ) { 14 + try { 15 + const { uri: uriParam } = await params; 16 + const atUriString = decodeURIComponent(uriParam); 17 + const uri = new AtUri(atUriString); 18 + 19 + if (uri.collection === "pub.leaflet.publication") { 20 + // Get the publication record to retrieve base_path 21 + const { data: publication } = await supabaseServerClient 22 + .from("publications") 23 + .select("record") 24 + .eq("uri", atUriString) 25 + .single(); 26 + 27 + if (!publication?.record) { 28 + return new NextResponse("Publication not found", { status: 404 }); 29 + } 30 + 31 + const record = publication.record as PubLeafletPublication.Record; 32 + const basePath = record.base_path; 33 + 34 + if (!basePath) { 35 + return new NextResponse("Publication has no base_path", { status: 404 }); 36 + } 37 + 38 + // Redirect to the publication's hosted domain (temporary redirect since base_path can change) 39 + return NextResponse.redirect(basePath, 307); 40 + } else if (uri.collection === "pub.leaflet.document") { 41 + // Document link - need to find the publication it belongs to 42 + const { data: docInPub } = await supabaseServerClient 43 + .from("documents_in_publications") 44 + .select("publication, publications!inner(record)") 45 + .eq("document", atUriString) 46 + .single(); 47 + 48 + if (docInPub?.publication && docInPub.publications) { 49 + // Document is in a publication - redirect to domain/rkey 50 + const record = docInPub.publications.record as PubLeafletPublication.Record; 51 + const basePath = record.base_path; 52 + 53 + if (!basePath) { 54 + return new NextResponse("Publication has no base_path", { status: 404 }); 55 + } 56 + 57 + // Ensure basePath ends without trailing slash 58 + const cleanBasePath = basePath.endsWith("/") 59 + ? basePath.slice(0, -1) 60 + : basePath; 61 + 62 + // Redirect to the document on the publication's domain (temporary redirect since base_path can change) 63 + return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307); 64 + } 65 + 66 + // If not in a publication, check if it's a standalone document 67 + const { data: doc } = await supabaseServerClient 68 + .from("documents") 69 + .select("uri") 70 + .eq("uri", atUriString) 71 + .single(); 72 + 73 + if (doc) { 74 + // Standalone document - redirect to /p/did/rkey (temporary redirect) 75 + return NextResponse.redirect( 76 + new URL(`/p/${uri.host}/${uri.rkey}`, request.url), 77 + 307 78 + ); 79 + } 80 + 81 + // Document not found 82 + return new NextResponse("Document not found", { status: 404 }); 83 + } 84 + 85 + // Unsupported collection type 86 + return new NextResponse("Unsupported URI type", { status: 400 }); 87 + } catch (error) { 88 + console.error("Error resolving AT URI:", error); 89 + return new NextResponse("Invalid URI", { status: 400 }); 90 + } 91 + }
+1 -1
app/login/LoginForm.tsx
··· 213 </ButtonPrimary> 214 <button 215 type="button" 216 - className={`${props.compact ? "text-xs" : "text-sm"} text-accent-contrast place-self-center mt-[6px]`} 217 onClick={() => setSigningWithHandle(true)} 218 > 219 use an ATProto handle
··· 213 </ButtonPrimary> 214 <button 215 type="button" 216 + className={`${props.compact ? "text-xs mt-0.5" : "text-sm mt-[6px]"} text-accent-contrast place-self-center`} 217 onClick={() => setSigningWithHandle(true)} 218 > 219 use an ATProto handle
+20
app/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts
···
··· 1 + import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 + import { decodeQuotePosition } from "app/lish/[did]/[publication]/[rkey]/quotePosition"; 3 + 4 + export const runtime = "edge"; 5 + export const revalidate = 60; 6 + 7 + export default async function OpenGraphImage(props: { 8 + params: Promise<{ didOrHandle: string; rkey: string; quote: string }>; 9 + }) { 10 + let params = await props.params; 11 + let quotePosition = decodeQuotePosition(params.quote); 12 + return getMicroLinkOgImage( 13 + `/p/${decodeURIComponent(params.didOrHandle)}/${params.rkey}/l-quote/${params.quote}#${quotePosition?.pageId ? `${quotePosition.pageId}~` : ""}${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`, 14 + { 15 + width: 620, 16 + height: 324, 17 + deviceScaleFactor: 2, 18 + }, 19 + ); 20 + }
+8
app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx
···
··· 1 + import PostPage from "app/p/[didOrHandle]/[rkey]/page"; 2 + 3 + export { generateMetadata } from "app/p/[didOrHandle]/[rkey]/page"; 4 + export default async function Post(props: { 5 + params: Promise<{ didOrHandle: string; rkey: string }>; 6 + }) { 7 + return <PostPage {...props} />; 8 + }
+13
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
···
··· 1 + import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 + 3 + export const runtime = "edge"; 4 + export const revalidate = 60; 5 + 6 + export default async function OpenGraphImage(props: { 7 + params: Promise<{ rkey: string; didOrHandle: string }>; 8 + }) { 9 + let params = await props.params; 10 + return getMicroLinkOgImage( 11 + `/p/${params.didOrHandle}/${params.rkey}/`, 12 + ); 13 + }
+92
app/p/[didOrHandle]/[rkey]/page.tsx
···
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { ids } from "lexicons/api/lexicons"; 4 + import { PubLeafletDocument } from "lexicons/api"; 5 + import { Metadata } from "next"; 6 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 + import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 8 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 9 + 10 + export async function generateMetadata(props: { 11 + params: Promise<{ didOrHandle: string; rkey: string }>; 12 + }): Promise<Metadata> { 13 + let params = await props.params; 14 + let didOrHandle = decodeURIComponent(params.didOrHandle); 15 + 16 + // Resolve handle to DID if necessary 17 + let did = didOrHandle; 18 + if (!didOrHandle.startsWith("did:")) { 19 + try { 20 + let resolved = await idResolver.handle.resolve(didOrHandle); 21 + if (resolved) did = resolved; 22 + } catch (e) { 23 + return { title: "404" }; 24 + } 25 + } 26 + 27 + let { data: document } = await supabaseServerClient 28 + .from("documents") 29 + .select("*, documents_in_publications(publications(*))") 30 + .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) 31 + .single(); 32 + 33 + if (!document) return { title: "404" }; 34 + 35 + let docRecord = document.data as PubLeafletDocument.Record; 36 + 37 + // For documents in publications, include publication name 38 + let publicationName = 39 + document.documents_in_publications[0]?.publications?.name; 40 + 41 + return { 42 + icons: { 43 + other: { 44 + rel: "alternate", 45 + url: document.uri, 46 + }, 47 + }, 48 + title: publicationName 49 + ? `${docRecord.title} - ${publicationName}` 50 + : docRecord.title, 51 + description: docRecord?.description || "", 52 + }; 53 + } 54 + 55 + export default async function StandaloneDocumentPage(props: { 56 + params: Promise<{ didOrHandle: string; rkey: string }>; 57 + }) { 58 + let params = await props.params; 59 + let didOrHandle = decodeURIComponent(params.didOrHandle); 60 + 61 + // Resolve handle to DID if necessary 62 + let did = didOrHandle; 63 + if (!didOrHandle.startsWith("did:")) { 64 + try { 65 + let resolved = await idResolver.handle.resolve(didOrHandle); 66 + if (!resolved) { 67 + return ( 68 + <NotFoundLayout> 69 + <p className="font-bold">Sorry, we can't find this handle!</p> 70 + <p> 71 + This may be a glitch on our end. If the issue persists please{" "} 72 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 73 + </p> 74 + </NotFoundLayout> 75 + ); 76 + } 77 + did = resolved; 78 + } catch (e) { 79 + return ( 80 + <NotFoundLayout> 81 + <p className="font-bold">Sorry, we can't find this leaflet!</p> 82 + <p> 83 + This may be a glitch on our end. If the issue persists please{" "} 84 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 85 + </p> 86 + </NotFoundLayout> 87 + ); 88 + } 89 + } 90 + 91 + return <DocumentPageRenderer did={did} rkey={params.rkey} />; 92 + }
+20 -17
appview/index.ts
··· 104 data: record.value as Json, 105 }); 106 if (docResult.error) console.log(docResult.error); 107 - let publicationURI = new AtUri(record.value.publication); 108 109 - if (publicationURI.host !== evt.uri.host) { 110 - console.log("Unauthorized to create post!"); 111 - return; 112 } 113 - let docInPublicationResult = await supabase 114 - .from("documents_in_publications") 115 - .upsert({ 116 - publication: record.value.publication, 117 - document: evt.uri.toString(), 118 - }); 119 - await supabase 120 - .from("documents_in_publications") 121 - .delete() 122 - .neq("publication", record.value.publication) 123 - .eq("document", evt.uri.toString()); 124 - if (docInPublicationResult.error) 125 - console.log(docInPublicationResult.error); 126 } 127 if (evt.event === "delete") { 128 await supabase.from("documents").delete().eq("uri", evt.uri.toString());
··· 104 data: record.value as Json, 105 }); 106 if (docResult.error) console.log(docResult.error); 107 + if (record.value.publication) { 108 + let publicationURI = new AtUri(record.value.publication); 109 + 110 + if (publicationURI.host !== evt.uri.host) { 111 + console.log("Unauthorized to create post!"); 112 + return; 113 + } 114 + let docInPublicationResult = await supabase 115 + .from("documents_in_publications") 116 + .upsert({ 117 + publication: record.value.publication, 118 + document: evt.uri.toString(), 119 + }); 120 + await supabase 121 + .from("documents_in_publications") 122 + .delete() 123 + .neq("publication", record.value.publication) 124 + .eq("document", evt.uri.toString()); 125 126 + if (docInPublicationResult.error) 127 + console.log(docInPublicationResult.error); 128 } 129 } 130 if (evt.event === "delete") { 131 await supabase.from("documents").delete().eq("uri", evt.uri.toString());
+1 -1
components/ActionBar/ActionButton.tsx
··· 3 import { useContext, useEffect } from "react"; 4 import { SidebarContext } from "./Sidebar"; 5 import React, { forwardRef, type JSX } from "react"; 6 - import { PopoverOpenContext } from "components/Popover"; 7 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9
··· 3 import { useContext, useEffect } from "react"; 4 import { SidebarContext } from "./Sidebar"; 5 import React, { forwardRef, type JSX } from "react"; 6 + import { PopoverOpenContext } from "components/Popover/PopoverContext"; 7 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9
+16 -12
components/ActionBar/Navigation.tsx
··· 18 import { SpeedyLink } from "components/SpeedyLink"; 19 import { Separator } from "components/Layout"; 20 21 - export type navPages = "home" | "reader" | "pub" | "discover" | "notifications"; 22 23 export const DesktopNavigation = (props: { 24 currentPage: navPages; ··· 47 publication?: string; 48 }) => { 49 let { identity } = useIdentityData(); 50 - let thisPublication = identity?.publications?.find( 51 - (pub) => pub.uri === props.publication, 52 - ); 53 return ( 54 <div className="flex gap-1 "> 55 <Popover ··· 100 <DiscoverButton current={props.currentPage === "discover"} /> 101 102 <hr className="border-border-light my-1" /> 103 - <PublicationButtons currentPubUri={thisPublication?.uri} /> 104 </> 105 ); 106 }; ··· 119 }; 120 121 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 122 - let readerUnreads = false; 123 - 124 if (!props.subs) return; 125 return ( 126 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 127 <ActionButton 128 nav 129 - icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 130 label="Reader" 131 - className={` 132 - ${readerUnreads && "text-accent-contrast!"} 133 - ${props.current && "border-accent-contrast!"} 134 - `} 135 /> 136 </SpeedyLink> 137 );
··· 18 import { SpeedyLink } from "components/SpeedyLink"; 19 import { Separator } from "components/Layout"; 20 21 + export type navPages = 22 + | "home" 23 + | "reader" 24 + | "pub" 25 + | "discover" 26 + | "notifications" 27 + | "looseleafs" 28 + | "tag" 29 + | "profile"; 30 31 export const DesktopNavigation = (props: { 32 currentPage: navPages; ··· 55 publication?: string; 56 }) => { 57 let { identity } = useIdentityData(); 58 + 59 return ( 60 <div className="flex gap-1 "> 61 <Popover ··· 106 <DiscoverButton current={props.currentPage === "discover"} /> 107 108 <hr className="border-border-light my-1" /> 109 + <PublicationButtons 110 + currentPage={props.currentPage} 111 + currentPubUri={thisPublication?.uri} 112 + /> 113 </> 114 ); 115 }; ··· 128 }; 129 130 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 131 if (!props.subs) return; 132 return ( 133 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 134 <ActionButton 135 nav 136 + icon={<ReaderUnreadSmall />} 137 label="Reader" 138 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 139 /> 140 </SpeedyLink> 141 );
+49 -25
components/ActionBar/Publications.tsx
··· 12 import { PublishSmall } from "components/Icons/PublishSmall"; 13 import { Popover } from "components/Popover"; 14 import { BlueskyLogin } from "app/login/LoginForm"; 15 - import { ButtonPrimary } from "components/Buttons"; 16 import { useIsMobile } from "src/hooks/isMobile"; 17 import { useState } from "react"; 18 19 export const PublicationButtons = (props: { 20 currentPubUri: string | undefined; 21 }) => { 22 let { identity } = useIdentityData(); 23 24 // don't show pub list button if not logged in or no pub list 25 // we show a "start a pub" banner instead 26 if (!identity || !identity.atp_did || identity.publications.length === 0) 27 return <PubListEmpty />; 28 return ( 29 <div className="pubListWrapper w-full flex flex-col gap-1 sm:bg-transparent sm:border-0"> 30 {identity.publications?.map((d) => { 31 return ( 32 <PublicationOption 33 {...d} 34 key={d.uri} 35 record={d.record} 36 - asActionButton 37 current={d.uri === props.currentPubUri} 38 /> 39 ); ··· 52 uri: string; 53 name: string; 54 record: Json; 55 - asActionButton?: boolean; 56 current?: boolean; 57 }) => { 58 let record = props.record as PubLeafletPublication.Record | null; ··· 63 href={`${getBasePublicationURL(props)}/dashboard`} 64 className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 65 > 66 - {props.asActionButton ? ( 67 - <ActionButton 68 - label={record.name} 69 - icon={<PubIcon record={record} uri={props.uri} />} 70 - nav 71 - className={props.current ? "bg-bg-page! border-border!" : ""} 72 - /> 73 - ) : ( 74 - <> 75 - <PubIcon record={record} uri={props.uri} /> 76 - <div className="truncate">{record.name}</div> 77 - </> 78 - )} 79 </SpeedyLink> 80 ); 81 }; 82 83 const PubListEmpty = () => { 84 - let { identity } = useIdentityData(); 85 let isMobile = useIsMobile(); 86 87 let [state, setState] = useState<"default" | "info">("default"); ··· 98 /> 99 ); 100 101 - if (isMobile && state === "info") return <PublishPopoverContent />; 102 else 103 return ( 104 <Popover ··· 115 /> 116 } 117 > 118 - <PublishPopoverContent /> 119 </Popover> 120 ); 121 }; 122 123 - const PublishPopoverContent = () => { 124 let { identity } = useIdentityData(); 125 126 return ( 127 - <div className="bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm"> 128 <div className="mx-auto pt-2 scale-90"> 129 <PubListEmptyIllo /> 130 </div> ··· 137 on AT Proto 138 </div> 139 <SpeedyLink href={`lish/createPub`} className=" hover:no-underline!"> 140 - <ButtonPrimary className="text-sm mx-auto" compact> 141 Start a Publication! 142 - </ButtonPrimary> 143 </SpeedyLink> 144 </> 145 ) : ( 146 // no ATProto account and no pubs 147 <> 148 <div className="pb-2 text-secondary text-xs"> 149 - Link a Bluesky account to start a new publication on AT Proto 150 </div> 151 152 <BlueskyLogin compact /> ··· 169 170 return props.record.icon ? ( 171 <div 172 - className={`${iconSizeClassName} ${props.className} relative overflow-hidden`} 173 > 174 <img 175 src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`}
··· 12 import { PublishSmall } from "components/Icons/PublishSmall"; 13 import { Popover } from "components/Popover"; 14 import { BlueskyLogin } from "app/login/LoginForm"; 15 + import { ButtonSecondary } from "components/Buttons"; 16 import { useIsMobile } from "src/hooks/isMobile"; 17 import { useState } from "react"; 18 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 19 + import { navPages } from "./Navigation"; 20 21 export const PublicationButtons = (props: { 22 + currentPage: navPages; 23 currentPubUri: string | undefined; 24 }) => { 25 let { identity } = useIdentityData(); 26 + let hasLooseleafs = !!identity?.permission_token_on_homepage.find( 27 + (f) => 28 + f.permission_tokens.leaflets_to_documents && 29 + f.permission_tokens.leaflets_to_documents[0]?.document, 30 + ); 31 32 // don't show pub list button if not logged in or no pub list 33 // we show a "start a pub" banner instead 34 if (!identity || !identity.atp_did || identity.publications.length === 0) 35 return <PubListEmpty />; 36 + 37 return ( 38 <div className="pubListWrapper w-full flex flex-col gap-1 sm:bg-transparent sm:border-0"> 39 + {hasLooseleafs && ( 40 + <> 41 + <SpeedyLink 42 + href={`/looseleafs`} 43 + className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 44 + > 45 + {/*TODO How should i get if this is the current page or not? 46 + theres not "pub" to check the uri for. Do i need to add it as an option to NavPages? thats kinda annoying*/} 47 + <ActionButton 48 + label="Looseleafs" 49 + icon={<LooseLeafSmall />} 50 + nav 51 + className={ 52 + props.currentPage === "looseleafs" 53 + ? "bg-bg-page! border-border!" 54 + : "" 55 + } 56 + /> 57 + </SpeedyLink> 58 + <hr className="border-border-light border-dashed mx-1" /> 59 + </> 60 + )} 61 + 62 {identity.publications?.map((d) => { 63 return ( 64 <PublicationOption 65 {...d} 66 key={d.uri} 67 record={d.record} 68 current={d.uri === props.currentPubUri} 69 /> 70 ); ··· 83 uri: string; 84 name: string; 85 record: Json; 86 current?: boolean; 87 }) => { 88 let record = props.record as PubLeafletPublication.Record | null; ··· 93 href={`${getBasePublicationURL(props)}/dashboard`} 94 className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 95 > 96 + <ActionButton 97 + label={record.name} 98 + icon={<PubIcon record={record} uri={props.uri} />} 99 + nav 100 + className={props.current ? "bg-bg-page! border-border!" : ""} 101 + /> 102 </SpeedyLink> 103 ); 104 }; 105 106 const PubListEmpty = () => { 107 let isMobile = useIsMobile(); 108 109 let [state, setState] = useState<"default" | "info">("default"); ··· 120 /> 121 ); 122 123 + if (isMobile && state === "info") return <PubListEmptyContent />; 124 else 125 return ( 126 <Popover ··· 137 /> 138 } 139 > 140 + <PubListEmptyContent /> 141 </Popover> 142 ); 143 }; 144 145 + export const PubListEmptyContent = (props: { compact?: boolean }) => { 146 let { identity } = useIdentityData(); 147 148 return ( 149 + <div 150 + className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`} 151 + > 152 <div className="mx-auto pt-2 scale-90"> 153 <PubListEmptyIllo /> 154 </div> ··· 161 on AT Proto 162 </div> 163 <SpeedyLink href={`lish/createPub`} className=" hover:no-underline!"> 164 + <ButtonSecondary className="text-sm mx-auto" compact> 165 Start a Publication! 166 + </ButtonSecondary> 167 </SpeedyLink> 168 </> 169 ) : ( 170 // no ATProto account and no pubs 171 <> 172 <div className="pb-2 text-secondary text-xs"> 173 + Link a Bluesky account to start <br /> a new publication on AT Proto 174 </div> 175 176 <BlueskyLogin compact /> ··· 193 194 return props.record.icon ? ( 195 <div 196 + className={`${iconSizeClassName} ${props.className} relative overflow-hidden shrink-0`} 197 > 198 <img 199 src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`}
+46
components/AtMentionLink.tsx
···
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { atUriToUrl } from "src/utils/mentionUtils"; 3 + 4 + /** 5 + * Component for rendering at-uri mentions (publications and documents) as clickable links. 6 + * NOTE: This component's styling and behavior should match the ProseMirror schema rendering 7 + * in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other. 8 + */ 9 + export function AtMentionLink({ 10 + atURI, 11 + children, 12 + className = "", 13 + }: { 14 + atURI: string; 15 + children: React.ReactNode; 16 + className?: string; 17 + }) { 18 + const aturi = new AtUri(atURI); 19 + const isPublication = aturi.collection === "pub.leaflet.publication"; 20 + const isDocument = aturi.collection === "pub.leaflet.document"; 21 + 22 + // Show publication icon if available 23 + const icon = 24 + isPublication || isDocument ? ( 25 + <img 26 + src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`} 27 + className="inline-block w-4 h-4 rounded-full mr-1 mt-[3px] align-text-top" 28 + alt="" 29 + width="20" 30 + height="20" 31 + loading="lazy" 32 + /> 33 + ) : null; 34 + 35 + return ( 36 + <a 37 + href={atUriToUrl(atURI)} 38 + target="_blank" 39 + rel="noopener noreferrer" 40 + className={`mention ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`} 41 + > 42 + {icon} 43 + {children} 44 + </a> 45 + ); 46 + }
+4 -1
components/Avatar.tsx
··· 3 export const Avatar = (props: { 4 src: string | undefined; 5 displayName: string | undefined; 6 tiny?: boolean; 7 }) => { 8 if (props.src) 9 return ( 10 <img 11 - className={`${props.tiny ? "w-4 h-4" : "w-5 h-5"} rounded-full shrink-0 border border-border-light`} 12 src={props.src} 13 alt={ 14 props.displayName
··· 3 export const Avatar = (props: { 4 src: string | undefined; 5 displayName: string | undefined; 6 + className?: string; 7 tiny?: boolean; 8 + large?: boolean; 9 + giant?: boolean; 10 }) => { 11 if (props.src) 12 return ( 13 <img 14 + className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`} 15 src={props.src} 16 alt={ 17 props.displayName
+43 -6
components/Blocks/BaseTextareaBlock.tsx
··· 5 import { BlockProps } from "./Block"; 6 import { getCoordinatesInTextarea } from "src/utils/getCoordinatesInTextarea"; 7 import { focusBlock } from "src/utils/focusBlock"; 8 9 - export function BaseTextareaBlock( 10 - props: AutosizeTextareaProps & { 11 - block: Pick<BlockProps, "previousBlock" | "nextBlock">; 12 - }, 13 - ) { 14 - let { block, ...passDownProps } = props; 15 return ( 16 <AsyncValueAutosizeTextarea 17 {...passDownProps} 18 noWrap 19 onKeyDown={(e) => { 20 if (e.key === "ArrowUp") { 21 let selection = e.currentTarget.selectionStart; 22
··· 5 import { BlockProps } from "./Block"; 6 import { getCoordinatesInTextarea } from "src/utils/getCoordinatesInTextarea"; 7 import { focusBlock } from "src/utils/focusBlock"; 8 + import { generateKeyBetween } from "fractional-indexing"; 9 + import { v7 } from "uuid"; 10 + import { elementId } from "src/utils/elementId"; 11 + import { Replicache } from "replicache"; 12 + import { ReplicacheMutators } from "src/replicache"; 13 14 + type BaseTextareaBlockProps = AutosizeTextareaProps & { 15 + block: Pick< 16 + BlockProps, 17 + "previousBlock" | "nextBlock" | "parent" | "position" | "nextPosition" 18 + >; 19 + rep?: Replicache<ReplicacheMutators> | null; 20 + permissionSet?: string; 21 + }; 22 + 23 + export function BaseTextareaBlock(props: BaseTextareaBlockProps) { 24 + let { block, rep, permissionSet, ...passDownProps } = props; 25 return ( 26 <AsyncValueAutosizeTextarea 27 {...passDownProps} 28 noWrap 29 onKeyDown={(e) => { 30 + // Shift-Enter or Ctrl-Enter: create new text block below and focus it 31 + if ( 32 + (e.shiftKey || e.ctrlKey || e.metaKey) && 33 + e.key === "Enter" && 34 + rep && 35 + permissionSet 36 + ) { 37 + e.preventDefault(); 38 + let newEntityID = v7(); 39 + rep.mutate.addBlock({ 40 + parent: block.parent, 41 + type: "text", 42 + factID: v7(), 43 + permission_set: permissionSet, 44 + position: generateKeyBetween( 45 + block.position, 46 + block.nextPosition || null, 47 + ), 48 + newEntityID, 49 + }); 50 + 51 + setTimeout(() => { 52 + document.getElementById(elementId.block(newEntityID).text)?.focus(); 53 + }, 10); 54 + return true; 55 + } 56 + 57 if (e.key === "ArrowUp") { 58 let selection = e.currentTarget.selectionStart; 59
+4 -2
components/Blocks/BlockCommandBar.tsx
··· 37 const clearCommandSearchText = () => { 38 if (!props.entityID) return; 39 const entityID = props.entityID; 40 - 41 const existingState = useEditorStates.getState().editorStates[entityID]; 42 if (!existingState) return; 43 ··· 69 setHighlighted(commandResults[0].name); 70 } 71 }, [commandResults, setHighlighted, highlighted]); 72 useEffect(() => { 73 let listener = async (e: KeyboardEvent) => { 74 let reverseDir = ref.current?.dataset.side === "top"; ··· 118 return; 119 } 120 }; 121 window.addEventListener("keydown", listener); 122 123 return () => window.removeEventListener("keydown", listener); ··· 200 201 return ( 202 <button 203 - className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`} 204 onMouseOver={() => { 205 props.setHighlighted(props.name); 206 }}
··· 37 const clearCommandSearchText = () => { 38 if (!props.entityID) return; 39 const entityID = props.entityID; 40 + 41 const existingState = useEditorStates.getState().editorStates[entityID]; 42 if (!existingState) return; 43 ··· 69 setHighlighted(commandResults[0].name); 70 } 71 }, [commandResults, setHighlighted, highlighted]); 72 + 73 useEffect(() => { 74 let listener = async (e: KeyboardEvent) => { 75 let reverseDir = ref.current?.dataset.side === "top"; ··· 119 return; 120 } 121 }; 122 + 123 window.addEventListener("keydown", listener); 124 125 return () => window.removeEventListener("keydown", listener); ··· 202 203 return ( 204 <button 205 + className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`} 206 onMouseOver={() => { 207 props.setHighlighted(props.name); 208 }}
+13 -4
components/Blocks/BlockCommands.tsx
··· 2 import { useUIState } from "src/useUIState"; 3 4 import { generateKeyBetween } from "fractional-indexing"; 5 - import { focusPage } from "components/Pages"; 6 import { v7 } from "uuid"; 7 import { Replicache } from "replicache"; 8 import { useEditorStates } from "src/state/useEditorState"; 9 import { elementId } from "src/utils/elementId"; 10 import { UndoManager } from "src/undoManager"; 11 import { focusBlock } from "src/utils/focusBlock"; 12 - import { usePollBlockUIState } from "./PollBlock"; 13 - import { focusElement } from "components/Input"; 14 import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; 15 import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; 16 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; ··· 32 import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 33 import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 34 import { QuoteSmall } from "components/Icons/QuoteSmall"; 35 36 type Props = { 37 parent: string; ··· 310 type: "block", 311 hiddenInPublication: false, 312 onSelect: async (rep, props) => { 313 - createBlockWithType(rep, props, "code"); 314 }, 315 }, 316
··· 2 import { useUIState } from "src/useUIState"; 3 4 import { generateKeyBetween } from "fractional-indexing"; 5 + import { focusPage } from "src/utils/focusPage"; 6 import { v7 } from "uuid"; 7 import { Replicache } from "replicache"; 8 import { useEditorStates } from "src/state/useEditorState"; 9 import { elementId } from "src/utils/elementId"; 10 import { UndoManager } from "src/undoManager"; 11 import { focusBlock } from "src/utils/focusBlock"; 12 + import { usePollBlockUIState } from "./PollBlock/pollBlockState"; 13 + import { focusElement } from "src/utils/focusElement"; 14 import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; 15 import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; 16 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; ··· 32 import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 33 import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 34 import { QuoteSmall } from "components/Icons/QuoteSmall"; 35 + import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 36 37 type Props = { 38 parent: string; ··· 311 type: "block", 312 hiddenInPublication: false, 313 onSelect: async (rep, props) => { 314 + let entity = await createBlockWithType(rep, props, "code"); 315 + let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY); 316 + if (lastLang) { 317 + await rep.mutate.assertFact({ 318 + entity, 319 + attribute: "block/code-language", 320 + data: { type: "string", value: lastLang }, 321 + }); 322 + } 323 }, 324 }, 325
+6 -1
components/Blocks/CodeBlock.tsx
··· 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 import { flushSync } from "react-dom"; 15 import { elementId } from "src/utils/elementId"; 16 17 export function CodeBlock(props: BlockProps) { 18 let { rep, rootEntity } = useReplicache(); ··· 25 let focusedBlock = useUIState( 26 (s) => s.focusedEntity?.entityID === props.entityID, 27 ); 28 - let { permissions } = useEntitySetContext(); 29 const [html, setHTML] = useState<string | null>(null); 30 31 useLayoutEffect(() => { ··· 100 }} 101 value={lang} 102 onChange={async (e) => { 103 await rep?.mutate.assertFact({ 104 attribute: "block/code-language", 105 entity: props.entityID, ··· 123 data-entityid={props.entityID} 124 id={elementId.block(props.entityID).input} 125 block={props} 126 spellCheck={false} 127 autoCapitalize="none" 128 autoCorrect="off"
··· 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 import { flushSync } from "react-dom"; 15 import { elementId } from "src/utils/elementId"; 16 + import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 17 18 export function CodeBlock(props: BlockProps) { 19 let { rep, rootEntity } = useReplicache(); ··· 26 let focusedBlock = useUIState( 27 (s) => s.focusedEntity?.entityID === props.entityID, 28 ); 29 + let entity_set = useEntitySetContext(); 30 + let { permissions } = entity_set; 31 const [html, setHTML] = useState<string | null>(null); 32 33 useLayoutEffect(() => { ··· 102 }} 103 value={lang} 104 onChange={async (e) => { 105 + localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value); 106 await rep?.mutate.assertFact({ 107 attribute: "block/code-language", 108 entity: props.entityID, ··· 126 data-entityid={props.entityID} 127 id={elementId.block(props.entityID).input} 128 block={props} 129 + rep={rep} 130 + permissionSet={entity_set.set} 131 spellCheck={false} 132 autoCapitalize="none" 133 autoCorrect="off"
+2 -120
components/Blocks/DeleteBlock.tsx
··· 1 - import { 2 - Fact, 3 - ReplicacheMutators, 4 - useEntity, 5 - useReplicache, 6 - } from "src/replicache"; 7 - import { Replicache } from "replicache"; 8 - import { useUIState } from "src/useUIState"; 9 - import { scanIndex } from "src/replicache/utils"; 10 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 - import { focusBlock } from "src/utils/focusBlock"; 12 import { ButtonPrimary } from "components/Buttons"; 13 import { CloseTiny } from "components/Icons/CloseTiny"; 14 15 export const AreYouSure = (props: { 16 entityID: string[] | string; ··· 82 ); 83 }; 84 85 - export async function deleteBlock( 86 - entities: string[], 87 - rep: Replicache<ReplicacheMutators>, 88 - ) { 89 - // get what pagess we need to close as a result of deleting this block 90 - let pagesToClose = [] as string[]; 91 - for (let entity of entities) { 92 - let [type] = await rep.query((tx) => 93 - scanIndex(tx).eav(entity, "block/type"), 94 - ); 95 - if (type.data.value === "card") { 96 - let [childPages] = await rep?.query( 97 - (tx) => scanIndex(tx).eav(entity, "block/card") || [], 98 - ); 99 - pagesToClose = [childPages?.data.value]; 100 - } 101 - if (type.data.value === "mailbox") { 102 - let [archive] = await rep?.query( 103 - (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], 104 - ); 105 - let [draft] = await rep?.query( 106 - (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], 107 - ); 108 - pagesToClose = [archive?.data.value, draft?.data.value]; 109 - } 110 - } 111 - 112 - // the next and previous blocks in the block list 113 - // if the focused thing is a page and not a block, return 114 - let focusedBlock = useUIState.getState().focusedEntity; 115 - let parent = 116 - focusedBlock?.entityType === "page" 117 - ? focusedBlock.entityID 118 - : focusedBlock?.parent; 119 - 120 - if (parent) { 121 - let parentType = await rep?.query((tx) => 122 - scanIndex(tx).eav(parent, "page/type"), 123 - ); 124 - if (parentType[0]?.data.value === "canvas") { 125 - useUIState 126 - .getState() 127 - .setFocusedBlock({ entityType: "page", entityID: parent }); 128 - useUIState.getState().setSelectedBlocks([]); 129 - } else { 130 - let siblings = 131 - (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 132 - 133 - let selectedBlocks = useUIState.getState().selectedBlocks; 134 - let firstSelected = selectedBlocks[0]; 135 - let lastSelected = selectedBlocks[entities.length - 1]; 136 - 137 - let prevBlock = 138 - siblings?.[ 139 - siblings.findIndex((s) => s.value === firstSelected?.value) - 1 140 - ]; 141 - let prevBlockType = await rep?.query((tx) => 142 - scanIndex(tx).eav(prevBlock?.value, "block/type"), 143 - ); 144 - 145 - let nextBlock = 146 - siblings?.[ 147 - siblings.findIndex((s) => s.value === lastSelected.value) + 1 148 - ]; 149 - let nextBlockType = await rep?.query((tx) => 150 - scanIndex(tx).eav(nextBlock?.value, "block/type"), 151 - ); 152 - 153 - if (prevBlock) { 154 - useUIState.getState().setSelectedBlock({ 155 - value: prevBlock.value, 156 - parent: prevBlock.parent, 157 - }); 158 - 159 - focusBlock( 160 - { 161 - value: prevBlock.value, 162 - type: prevBlockType?.[0].data.value, 163 - parent: prevBlock.parent, 164 - }, 165 - { type: "end" }, 166 - ); 167 - } else { 168 - useUIState.getState().setSelectedBlock({ 169 - value: nextBlock.value, 170 - parent: nextBlock.parent, 171 - }); 172 - 173 - focusBlock( 174 - { 175 - value: nextBlock.value, 176 - type: nextBlockType?.[0]?.data.value, 177 - parent: nextBlock.parent, 178 - }, 179 - { type: "start" }, 180 - ); 181 - } 182 - } 183 - } 184 - 185 - pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 186 - await Promise.all( 187 - entities.map((entity) => 188 - rep?.mutate.removeBlock({ 189 - blockEntity: entity, 190 - }), 191 - ), 192 - ); 193 - }
··· 1 + import { Fact, useReplicache } from "src/replicache"; 2 import { ButtonPrimary } from "components/Buttons"; 3 import { CloseTiny } from "components/Icons/CloseTiny"; 4 + import { deleteBlock } from "src/utils/deleteBlock"; 5 6 export const AreYouSure = (props: { 7 entityID: string[] | string; ··· 73 ); 74 }; 75
+65 -16
components/Blocks/EmbedBlock.tsx
··· 10 import { Input } from "components/Input"; 11 import { isUrl } from "src/utils/isURL"; 12 import { elementId } from "src/utils/elementId"; 13 - import { deleteBlock } from "./DeleteBlock"; 14 import { focusBlock } from "src/utils/focusBlock"; 15 import { useDrag } from "src/hooks/useDrag"; 16 import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall"; 17 import { CheckTiny } from "components/Icons/CheckTiny"; 18 19 export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => { 20 let { permissions } = useEntitySetContext(); ··· 132 133 let entity_set = useEntitySetContext(); 134 let [linkValue, setLinkValue] = useState(""); 135 let { rep } = useReplicache(); 136 let submit = async () => { 137 let entity = props.entityID; ··· 149 } 150 let link = linkValue; 151 if (!linkValue.startsWith("http")) link = `https://${linkValue}`; 152 - // these mutations = simpler subset of addLinkBlock 153 if (!rep) return; 154 - await rep.mutate.assertFact({ 155 - entity: entity, 156 - attribute: "block/type", 157 - data: { type: "block-type-union", value: "embed" }, 158 - }); 159 - await rep?.mutate.assertFact({ 160 - entity: entity, 161 - attribute: "embed/url", 162 - data: { 163 - type: "string", 164 - value: link, 165 - }, 166 - }); 167 }; 168 let smoker = useSmoker(); 169 ··· 171 <form 172 onSubmit={(e) => { 173 e.preventDefault(); 174 let rect = document 175 .getElementById("embed-block-submit") 176 ?.getBoundingClientRect(); ··· 212 <button 213 type="submit" 214 id="embed-block-submit" 215 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 216 onMouseDown={(e) => { 217 e.preventDefault(); 218 if (!linkValue || linkValue === "") { 219 smoker({ 220 error: true, ··· 234 submit(); 235 }} 236 > 237 - <CheckTiny /> 238 </button> 239 </div> 240 </form>
··· 10 import { Input } from "components/Input"; 11 import { isUrl } from "src/utils/isURL"; 12 import { elementId } from "src/utils/elementId"; 13 import { focusBlock } from "src/utils/focusBlock"; 14 import { useDrag } from "src/hooks/useDrag"; 15 import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall"; 16 import { CheckTiny } from "components/Icons/CheckTiny"; 17 + import { DotLoader } from "components/utils/DotLoader"; 18 + import { 19 + LinkPreviewBody, 20 + LinkPreviewMetadataResult, 21 + } from "app/api/link_previews/route"; 22 23 export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => { 24 let { permissions } = useEntitySetContext(); ··· 136 137 let entity_set = useEntitySetContext(); 138 let [linkValue, setLinkValue] = useState(""); 139 + let [loading, setLoading] = useState(false); 140 let { rep } = useReplicache(); 141 let submit = async () => { 142 let entity = props.entityID; ··· 154 } 155 let link = linkValue; 156 if (!linkValue.startsWith("http")) link = `https://${linkValue}`; 157 if (!rep) return; 158 + 159 + // Try to get embed URL from iframely, fallback to direct URL 160 + setLoading(true); 161 + try { 162 + let res = await fetch("/api/link_previews", { 163 + headers: { "Content-Type": "application/json" }, 164 + method: "POST", 165 + body: JSON.stringify({ url: link, type: "meta" } as LinkPreviewBody), 166 + }); 167 + 168 + let embedUrl = link; 169 + let embedHeight = 360; 170 + 171 + if (res.status === 200) { 172 + let data = await (res.json() as LinkPreviewMetadataResult); 173 + if (data.success && data.data.links?.player?.[0]) { 174 + let embed = data.data.links.player[0]; 175 + embedUrl = embed.href; 176 + embedHeight = embed.media?.height || 300; 177 + } 178 + } 179 + 180 + await rep.mutate.assertFact([ 181 + { 182 + entity: entity, 183 + attribute: "embed/url", 184 + data: { 185 + type: "string", 186 + value: embedUrl, 187 + }, 188 + }, 189 + { 190 + entity: entity, 191 + attribute: "embed/height", 192 + data: { 193 + type: "number", 194 + value: embedHeight, 195 + }, 196 + }, 197 + ]); 198 + } catch { 199 + // On any error, fallback to using the URL directly 200 + await rep.mutate.assertFact([ 201 + { 202 + entity: entity, 203 + attribute: "embed/url", 204 + data: { 205 + type: "string", 206 + value: link, 207 + }, 208 + }, 209 + ]); 210 + } finally { 211 + setLoading(false); 212 + } 213 }; 214 let smoker = useSmoker(); 215 ··· 217 <form 218 onSubmit={(e) => { 219 e.preventDefault(); 220 + if (loading) return; 221 let rect = document 222 .getElementById("embed-block-submit") 223 ?.getBoundingClientRect(); ··· 259 <button 260 type="submit" 261 id="embed-block-submit" 262 + disabled={loading} 263 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 264 onMouseDown={(e) => { 265 e.preventDefault(); 266 + if (loading) return; 267 if (!linkValue || linkValue === "") { 268 smoker({ 269 error: true, ··· 283 submit(); 284 }} 285 > 286 + {loading ? <DotLoader /> : <CheckTiny />} 287 </button> 288 </div> 289 </form>
+2 -1
components/Blocks/ExternalLinkBlock.tsx
··· 8 import { v7 } from "uuid"; 9 import { useSmoker } from "components/Toast"; 10 import { Separator } from "components/Layout"; 11 - import { focusElement, Input } from "components/Input"; 12 import { isUrl } from "src/utils/isURL"; 13 import { elementId } from "src/utils/elementId"; 14 import { focusBlock } from "src/utils/focusBlock";
··· 8 import { v7 } from "uuid"; 9 import { useSmoker } from "components/Toast"; 10 import { Separator } from "components/Layout"; 11 + import { Input } from "components/Input"; 12 + import { focusElement } from "src/utils/focusElement"; 13 import { isUrl } from "src/utils/isURL"; 14 import { elementId } from "src/utils/elementId"; 15 import { focusBlock } from "src/utils/focusBlock";
+1 -1
components/Blocks/MailboxBlock.tsx
··· 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 - import { focusPage } from "components/Pages"; 13 import { v7 } from "uuid"; 14 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 import { getBlocksWithType } from "src/hooks/queries/useBlocks";
··· 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 + import { focusPage } from "src/utils/focusPage"; 13 import { v7 } from "uuid"; 14 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 import { getBlocksWithType } from "src/hooks/queries/useBlocks";
+1 -1
components/Blocks/PageLinkBlock.tsx
··· 2 import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 import { focusBlock } from "src/utils/focusBlock"; 4 5 - import { focusPage } from "components/Pages"; 6 import { useEntity, useReplicache } from "src/replicache"; 7 import { useUIState } from "src/useUIState"; 8 import { RenderedTextBlock } from "components/Blocks/TextBlock";
··· 2 import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 import { focusBlock } from "src/utils/focusBlock"; 4 5 + import { focusPage } from "src/utils/focusPage"; 6 import { useEntity, useReplicache } from "src/replicache"; 7 import { useUIState } from "src/useUIState"; 8 import { RenderedTextBlock } from "components/Blocks/TextBlock";
+501
components/Blocks/PollBlock/index.tsx
···
··· 1 + import { useUIState } from "src/useUIState"; 2 + import { BlockProps } from "../Block"; 3 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 + import { useCallback, useEffect, useState } from "react"; 5 + import { Input } from "components/Input"; 6 + import { focusElement } from "src/utils/focusElement"; 7 + import { Separator } from "components/Layout"; 8 + import { useEntitySetContext } from "components/EntitySetProvider"; 9 + import { theme } from "tailwind.config"; 10 + import { useEntity, useReplicache } from "src/replicache"; 11 + import { v7 } from "uuid"; 12 + import { 13 + useLeafletPublicationData, 14 + usePollData, 15 + } from "components/PageSWRDataProvider"; 16 + import { voteOnPoll } from "actions/pollActions"; 17 + import { elementId } from "src/utils/elementId"; 18 + import { CheckTiny } from "components/Icons/CheckTiny"; 19 + import { CloseTiny } from "components/Icons/CloseTiny"; 20 + import { PublicationPollBlock } from "../PublicationPollBlock"; 21 + import { usePollBlockUIState } from "./pollBlockState"; 22 + 23 + export const PollBlock = (props: BlockProps) => { 24 + let { data: pub } = useLeafletPublicationData(); 25 + if (!pub) return <LeafletPollBlock {...props} />; 26 + return <PublicationPollBlock {...props} />; 27 + }; 28 + 29 + export const LeafletPollBlock = (props: BlockProps) => { 30 + let isSelected = useUIState((s) => 31 + s.selectedBlocks.find((b) => b.value === props.entityID), 32 + ); 33 + let { permissions } = useEntitySetContext(); 34 + 35 + let { data: pollData } = usePollData(); 36 + let hasVoted = 37 + pollData?.voter_token && 38 + pollData.polls.find( 39 + (v) => 40 + v.poll_votes_on_entity.voter_token === pollData.voter_token && 41 + v.poll_votes_on_entity.poll_entity === props.entityID, 42 + ); 43 + 44 + let pollState = usePollBlockUIState((s) => s[props.entityID]?.state); 45 + if (!pollState) { 46 + if (hasVoted) pollState = "results"; 47 + else pollState = "voting"; 48 + } 49 + 50 + const setPollState = useCallback( 51 + (state: "editing" | "voting" | "results") => { 52 + usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } })); 53 + }, 54 + [], 55 + ); 56 + 57 + let votes = 58 + pollData?.polls.filter( 59 + (v) => v.poll_votes_on_entity.poll_entity === props.entityID, 60 + ) || []; 61 + let totalVotes = votes.length; 62 + 63 + return ( 64 + <div 65 + className={`poll flex flex-col gap-2 p-3 w-full 66 + ${isSelected ? "block-border-selected " : "block-border"}`} 67 + style={{ 68 + backgroundColor: 69 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 70 + }} 71 + > 72 + {pollState === "editing" ? ( 73 + <EditPoll 74 + totalVotes={totalVotes} 75 + votes={votes.map((v) => v.poll_votes_on_entity)} 76 + entityID={props.entityID} 77 + close={() => { 78 + if (hasVoted) setPollState("results"); 79 + else setPollState("voting"); 80 + }} 81 + /> 82 + ) : pollState === "results" ? ( 83 + <PollResults 84 + entityID={props.entityID} 85 + pollState={pollState} 86 + setPollState={setPollState} 87 + hasVoted={!!hasVoted} 88 + /> 89 + ) : ( 90 + <PollVote 91 + entityID={props.entityID} 92 + onSubmit={() => setPollState("results")} 93 + pollState={pollState} 94 + setPollState={setPollState} 95 + hasVoted={!!hasVoted} 96 + /> 97 + )} 98 + </div> 99 + ); 100 + }; 101 + 102 + const PollVote = (props: { 103 + entityID: string; 104 + onSubmit: () => void; 105 + pollState: "editing" | "voting" | "results"; 106 + setPollState: (pollState: "editing" | "voting" | "results") => void; 107 + hasVoted: boolean; 108 + }) => { 109 + let { data, mutate } = usePollData(); 110 + let { permissions } = useEntitySetContext(); 111 + 112 + let pollOptions = useEntity(props.entityID, "poll/options"); 113 + let currentVotes = data?.voter_token 114 + ? data.polls 115 + .filter( 116 + (p) => 117 + p.poll_votes_on_entity.poll_entity === props.entityID && 118 + p.poll_votes_on_entity.voter_token === data.voter_token, 119 + ) 120 + .map((v) => v.poll_votes_on_entity.option_entity) 121 + : []; 122 + let [selectedPollOptions, setSelectedPollOptions] = 123 + useState<string[]>(currentVotes); 124 + 125 + return ( 126 + <> 127 + {pollOptions.map((option, index) => ( 128 + <PollVoteButton 129 + key={option.data.value} 130 + selected={selectedPollOptions.includes(option.data.value)} 131 + toggleSelected={() => 132 + setSelectedPollOptions((s) => 133 + s.includes(option.data.value) 134 + ? s.filter((s) => s !== option.data.value) 135 + : [...s, option.data.value], 136 + ) 137 + } 138 + entityID={option.data.value} 139 + /> 140 + ))} 141 + <div className="flex justify-between items-center"> 142 + <div className="flex justify-end gap-2"> 143 + {permissions.write && ( 144 + <button 145 + className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 146 + onClick={() => { 147 + props.setPollState("editing"); 148 + }} 149 + > 150 + Edit Options 151 + </button> 152 + )} 153 + 154 + {permissions.write && <Separator classname="h-6" />} 155 + <PollStateToggle 156 + setPollState={props.setPollState} 157 + pollState={props.pollState} 158 + hasVoted={props.hasVoted} 159 + /> 160 + </div> 161 + <ButtonPrimary 162 + className="place-self-end" 163 + onClick={async () => { 164 + await voteOnPoll(props.entityID, selectedPollOptions); 165 + mutate((oldState) => { 166 + if (!oldState || !oldState.voter_token) return; 167 + return { 168 + ...oldState, 169 + polls: [ 170 + ...oldState.polls.filter( 171 + (p) => 172 + !( 173 + p.poll_votes_on_entity.voter_token === 174 + oldState.voter_token && 175 + p.poll_votes_on_entity.poll_entity == props.entityID 176 + ), 177 + ), 178 + ...selectedPollOptions.map((option_entity) => ({ 179 + poll_votes_on_entity: { 180 + option_entity, 181 + entities: { set: "" }, 182 + poll_entity: props.entityID, 183 + voter_token: oldState.voter_token!, 184 + }, 185 + })), 186 + ], 187 + }; 188 + }); 189 + props.onSubmit(); 190 + }} 191 + disabled={ 192 + selectedPollOptions.length === 0 || 193 + (selectedPollOptions.length === currentVotes.length && 194 + selectedPollOptions.every((s) => currentVotes.includes(s))) 195 + } 196 + > 197 + Vote! 198 + </ButtonPrimary> 199 + </div> 200 + </> 201 + ); 202 + }; 203 + const PollVoteButton = (props: { 204 + entityID: string; 205 + selected: boolean; 206 + toggleSelected: () => void; 207 + }) => { 208 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 209 + if (!optionName) return null; 210 + if (props.selected) 211 + return ( 212 + <div className="flex gap-2 items-center"> 213 + <ButtonPrimary 214 + className={`pollOption grow max-w-full flex`} 215 + onClick={() => { 216 + props.toggleSelected(); 217 + }} 218 + > 219 + {optionName} 220 + </ButtonPrimary> 221 + </div> 222 + ); 223 + return ( 224 + <div className="flex gap-2 items-center"> 225 + <ButtonSecondary 226 + className={`pollOption grow max-w-full flex`} 227 + onClick={() => { 228 + props.toggleSelected(); 229 + }} 230 + > 231 + {optionName} 232 + </ButtonSecondary> 233 + </div> 234 + ); 235 + }; 236 + 237 + const PollResults = (props: { 238 + entityID: string; 239 + pollState: "editing" | "voting" | "results"; 240 + setPollState: (pollState: "editing" | "voting" | "results") => void; 241 + hasVoted: boolean; 242 + }) => { 243 + let { data } = usePollData(); 244 + let { permissions } = useEntitySetContext(); 245 + let pollOptions = useEntity(props.entityID, "poll/options"); 246 + let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID); 247 + let votesByOptions = pollData?.votesByOption || {}; 248 + let highestVotes = Math.max(...Object.values(votesByOptions)); 249 + let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>( 250 + (winningEntities, [entity, votes]) => { 251 + if (votes === highestVotes) winningEntities.push(entity); 252 + return winningEntities; 253 + }, 254 + [], 255 + ); 256 + return ( 257 + <> 258 + {pollOptions.map((p) => ( 259 + <PollResult 260 + key={p.id} 261 + winner={winningOptionEntities.includes(p.data.value)} 262 + entityID={p.data.value} 263 + totalVotes={pollData?.unique_votes || 0} 264 + votes={pollData?.votesByOption[p.data.value] || 0} 265 + /> 266 + ))} 267 + <div className="flex gap-2"> 268 + {permissions.write && ( 269 + <button 270 + className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 271 + onClick={() => { 272 + props.setPollState("editing"); 273 + }} 274 + > 275 + Edit Options 276 + </button> 277 + )} 278 + 279 + {permissions.write && <Separator classname="h-6" />} 280 + <PollStateToggle 281 + setPollState={props.setPollState} 282 + pollState={props.pollState} 283 + hasVoted={props.hasVoted} 284 + /> 285 + </div> 286 + </> 287 + ); 288 + }; 289 + 290 + const PollResult = (props: { 291 + entityID: string; 292 + votes: number; 293 + totalVotes: number; 294 + winner: boolean; 295 + }) => { 296 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 297 + return ( 298 + <div 299 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 300 + > 301 + <div 302 + style={{ 303 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`, 304 + paintOrder: "stroke fill", 305 + }} 306 + className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`} 307 + > 308 + <div className="grow max-w-full truncate">{optionName}</div> 309 + <div>{props.votes}</div> 310 + </div> 311 + <div 312 + className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`} 313 + > 314 + <div 315 + className={`bg-accent-contrast rounded-[2px] m-0.5`} 316 + style={{ 317 + maskImage: "var(--hatchSVG)", 318 + maskRepeat: "repeat repeat", 319 + 320 + ...(props.votes === 0 321 + ? { width: "4px" } 322 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 323 + }} 324 + /> 325 + <div /> 326 + </div> 327 + </div> 328 + ); 329 + }; 330 + 331 + const EditPoll = (props: { 332 + votes: { option_entity: string }[]; 333 + totalVotes: number; 334 + entityID: string; 335 + close: () => void; 336 + }) => { 337 + let pollOptions = useEntity(props.entityID, "poll/options"); 338 + let { rep } = useReplicache(); 339 + let permission_set = useEntitySetContext(); 340 + let [localPollOptionNames, setLocalPollOptionNames] = useState<{ 341 + [k: string]: string; 342 + }>({}); 343 + return ( 344 + <> 345 + {props.totalVotes > 0 && ( 346 + <div className="text-sm italic text-tertiary"> 347 + You can&apos;t edit options people already voted for! 348 + </div> 349 + )} 350 + 351 + {pollOptions.length === 0 && ( 352 + <div className="text-center italic text-tertiary text-sm"> 353 + no options yet... 354 + </div> 355 + )} 356 + {pollOptions.map((p) => ( 357 + <EditPollOption 358 + key={p.id} 359 + entityID={p.data.value} 360 + pollEntity={props.entityID} 361 + disabled={!!props.votes.find((v) => v.option_entity === p.data.value)} 362 + localNameState={localPollOptionNames[p.data.value]} 363 + setLocalNameState={setLocalPollOptionNames} 364 + /> 365 + ))} 366 + 367 + <button 368 + className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 369 + onClick={async () => { 370 + let pollOptionEntity = v7(); 371 + await rep?.mutate.addPollOption({ 372 + pollEntity: props.entityID, 373 + pollOptionEntity, 374 + pollOptionName: "", 375 + permission_set: permission_set.set, 376 + factID: v7(), 377 + }); 378 + 379 + focusElement( 380 + document.getElementById( 381 + elementId.block(props.entityID).pollInput(pollOptionEntity), 382 + ) as HTMLInputElement | null, 383 + ); 384 + }} 385 + > 386 + Add an Option 387 + </button> 388 + 389 + <hr className="border-border" /> 390 + <ButtonPrimary 391 + className="place-self-end" 392 + onClick={async () => { 393 + // remove any poll options that have no name 394 + // look through the localPollOptionNames object and remove any options that have no name 395 + let emptyOptions = Object.entries(localPollOptionNames).filter( 396 + ([optionEntity, optionName]) => optionName === "", 397 + ); 398 + await Promise.all( 399 + emptyOptions.map( 400 + async ([entity]) => 401 + await rep?.mutate.removePollOption({ 402 + optionEntity: entity, 403 + }), 404 + ), 405 + ); 406 + 407 + await rep?.mutate.assertFact( 408 + Object.entries(localPollOptionNames) 409 + .filter(([, name]) => !!name) 410 + .map(([entity, name]) => ({ 411 + entity, 412 + attribute: "poll-option/name", 413 + data: { type: "string", value: name }, 414 + })), 415 + ); 416 + props.close(); 417 + }} 418 + > 419 + Save <CheckTiny /> 420 + </ButtonPrimary> 421 + </> 422 + ); 423 + }; 424 + 425 + const EditPollOption = (props: { 426 + entityID: string; 427 + pollEntity: string; 428 + localNameState: string | undefined; 429 + setLocalNameState: ( 430 + s: (s: { [k: string]: string }) => { [k: string]: string }, 431 + ) => void; 432 + disabled: boolean; 433 + }) => { 434 + let { rep } = useReplicache(); 435 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 436 + useEffect(() => { 437 + props.setLocalNameState((s) => ({ 438 + ...s, 439 + [props.entityID]: optionName || "", 440 + })); 441 + }, [optionName, props.setLocalNameState, props.entityID]); 442 + 443 + return ( 444 + <div className="flex gap-2 items-center"> 445 + <Input 446 + id={elementId.block(props.pollEntity).pollInput(props.entityID)} 447 + type="text" 448 + className="pollOptionInput w-full input-with-border" 449 + placeholder="Option here..." 450 + disabled={props.disabled} 451 + value={ 452 + props.localNameState === undefined ? optionName : props.localNameState 453 + } 454 + onChange={(e) => { 455 + props.setLocalNameState((s) => ({ 456 + ...s, 457 + [props.entityID]: e.target.value, 458 + })); 459 + }} 460 + onKeyDown={(e) => { 461 + if (e.key === "Backspace" && !e.currentTarget.value) { 462 + e.preventDefault(); 463 + rep?.mutate.removePollOption({ optionEntity: props.entityID }); 464 + } 465 + }} 466 + /> 467 + 468 + <button 469 + tabIndex={-1} 470 + disabled={props.disabled} 471 + className="text-accent-contrast disabled:text-border" 472 + onMouseDown={async () => { 473 + await rep?.mutate.removePollOption({ optionEntity: props.entityID }); 474 + }} 475 + > 476 + <CloseTiny /> 477 + </button> 478 + </div> 479 + ); 480 + }; 481 + 482 + const PollStateToggle = (props: { 483 + setPollState: (pollState: "editing" | "voting" | "results") => void; 484 + hasVoted: boolean; 485 + pollState: "editing" | "voting" | "results"; 486 + }) => { 487 + return ( 488 + <button 489 + className="text-sm text-accent-contrast sm:hover:underline" 490 + onClick={() => { 491 + props.setPollState(props.pollState === "voting" ? "results" : "voting"); 492 + }} 493 + > 494 + {props.pollState === "voting" 495 + ? "See Results" 496 + : props.hasVoted 497 + ? "Change Vote" 498 + : "Back to Poll"} 499 + </button> 500 + ); 501 + };
+8
components/Blocks/PollBlock/pollBlockState.ts
···
··· 1 + import { create } from "zustand"; 2 + 3 + export let usePollBlockUIState = create( 4 + () => 5 + ({}) as { 6 + [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 7 + }, 8 + );
-507
components/Blocks/PollBlock.tsx
··· 1 - import { useUIState } from "src/useUIState"; 2 - import { BlockProps } from "./Block"; 3 - import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 - import { useCallback, useEffect, useState } from "react"; 5 - import { focusElement, Input } from "components/Input"; 6 - import { Separator } from "components/Layout"; 7 - import { useEntitySetContext } from "components/EntitySetProvider"; 8 - import { theme } from "tailwind.config"; 9 - import { useEntity, useReplicache } from "src/replicache"; 10 - import { v7 } from "uuid"; 11 - import { 12 - useLeafletPublicationData, 13 - usePollData, 14 - } from "components/PageSWRDataProvider"; 15 - import { voteOnPoll } from "actions/pollActions"; 16 - import { create } from "zustand"; 17 - import { elementId } from "src/utils/elementId"; 18 - import { CheckTiny } from "components/Icons/CheckTiny"; 19 - import { CloseTiny } from "components/Icons/CloseTiny"; 20 - import { PublicationPollBlock } from "./PublicationPollBlock"; 21 - 22 - export let usePollBlockUIState = create( 23 - () => 24 - ({}) as { 25 - [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 26 - }, 27 - ); 28 - 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) => { 36 - let isSelected = useUIState((s) => 37 - s.selectedBlocks.find((b) => b.value === props.entityID), 38 - ); 39 - let { permissions } = useEntitySetContext(); 40 - 41 - let { data: pollData } = usePollData(); 42 - let hasVoted = 43 - pollData?.voter_token && 44 - pollData.polls.find( 45 - (v) => 46 - v.poll_votes_on_entity.voter_token === pollData.voter_token && 47 - v.poll_votes_on_entity.poll_entity === props.entityID, 48 - ); 49 - 50 - let pollState = usePollBlockUIState((s) => s[props.entityID]?.state); 51 - if (!pollState) { 52 - if (hasVoted) pollState = "results"; 53 - else pollState = "voting"; 54 - } 55 - 56 - const setPollState = useCallback( 57 - (state: "editing" | "voting" | "results") => { 58 - usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } })); 59 - }, 60 - [], 61 - ); 62 - 63 - let votes = 64 - pollData?.polls.filter( 65 - (v) => v.poll_votes_on_entity.poll_entity === props.entityID, 66 - ) || []; 67 - let totalVotes = votes.length; 68 - 69 - return ( 70 - <div 71 - className={`poll flex flex-col gap-2 p-3 w-full 72 - ${isSelected ? "block-border-selected " : "block-border"}`} 73 - style={{ 74 - backgroundColor: 75 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 76 - }} 77 - > 78 - {pollState === "editing" ? ( 79 - <EditPoll 80 - totalVotes={totalVotes} 81 - votes={votes.map((v) => v.poll_votes_on_entity)} 82 - entityID={props.entityID} 83 - close={() => { 84 - if (hasVoted) setPollState("results"); 85 - else setPollState("voting"); 86 - }} 87 - /> 88 - ) : pollState === "results" ? ( 89 - <PollResults 90 - entityID={props.entityID} 91 - pollState={pollState} 92 - setPollState={setPollState} 93 - hasVoted={!!hasVoted} 94 - /> 95 - ) : ( 96 - <PollVote 97 - entityID={props.entityID} 98 - onSubmit={() => setPollState("results")} 99 - pollState={pollState} 100 - setPollState={setPollState} 101 - hasVoted={!!hasVoted} 102 - /> 103 - )} 104 - </div> 105 - ); 106 - }; 107 - 108 - const PollVote = (props: { 109 - entityID: string; 110 - onSubmit: () => void; 111 - pollState: "editing" | "voting" | "results"; 112 - setPollState: (pollState: "editing" | "voting" | "results") => void; 113 - hasVoted: boolean; 114 - }) => { 115 - let { data, mutate } = usePollData(); 116 - let { permissions } = useEntitySetContext(); 117 - 118 - let pollOptions = useEntity(props.entityID, "poll/options"); 119 - let currentVotes = data?.voter_token 120 - ? data.polls 121 - .filter( 122 - (p) => 123 - p.poll_votes_on_entity.poll_entity === props.entityID && 124 - p.poll_votes_on_entity.voter_token === data.voter_token, 125 - ) 126 - .map((v) => v.poll_votes_on_entity.option_entity) 127 - : []; 128 - let [selectedPollOptions, setSelectedPollOptions] = 129 - useState<string[]>(currentVotes); 130 - 131 - return ( 132 - <> 133 - {pollOptions.map((option, index) => ( 134 - <PollVoteButton 135 - key={option.data.value} 136 - selected={selectedPollOptions.includes(option.data.value)} 137 - toggleSelected={() => 138 - setSelectedPollOptions((s) => 139 - s.includes(option.data.value) 140 - ? s.filter((s) => s !== option.data.value) 141 - : [...s, option.data.value], 142 - ) 143 - } 144 - entityID={option.data.value} 145 - /> 146 - ))} 147 - <div className="flex justify-between items-center"> 148 - <div className="flex justify-end gap-2"> 149 - {permissions.write && ( 150 - <button 151 - className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 152 - onClick={() => { 153 - props.setPollState("editing"); 154 - }} 155 - > 156 - Edit Options 157 - </button> 158 - )} 159 - 160 - {permissions.write && <Separator classname="h-6" />} 161 - <PollStateToggle 162 - setPollState={props.setPollState} 163 - pollState={props.pollState} 164 - hasVoted={props.hasVoted} 165 - /> 166 - </div> 167 - <ButtonPrimary 168 - className="place-self-end" 169 - onClick={async () => { 170 - await voteOnPoll(props.entityID, selectedPollOptions); 171 - mutate((oldState) => { 172 - if (!oldState || !oldState.voter_token) return; 173 - return { 174 - ...oldState, 175 - polls: [ 176 - ...oldState.polls.filter( 177 - (p) => 178 - !( 179 - p.poll_votes_on_entity.voter_token === 180 - oldState.voter_token && 181 - p.poll_votes_on_entity.poll_entity == props.entityID 182 - ), 183 - ), 184 - ...selectedPollOptions.map((option_entity) => ({ 185 - poll_votes_on_entity: { 186 - option_entity, 187 - entities: { set: "" }, 188 - poll_entity: props.entityID, 189 - voter_token: oldState.voter_token!, 190 - }, 191 - })), 192 - ], 193 - }; 194 - }); 195 - props.onSubmit(); 196 - }} 197 - disabled={ 198 - selectedPollOptions.length === 0 || 199 - (selectedPollOptions.length === currentVotes.length && 200 - selectedPollOptions.every((s) => currentVotes.includes(s))) 201 - } 202 - > 203 - Vote! 204 - </ButtonPrimary> 205 - </div> 206 - </> 207 - ); 208 - }; 209 - const PollVoteButton = (props: { 210 - entityID: string; 211 - selected: boolean; 212 - toggleSelected: () => void; 213 - }) => { 214 - let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 215 - if (!optionName) return null; 216 - if (props.selected) 217 - return ( 218 - <div className="flex gap-2 items-center"> 219 - <ButtonPrimary 220 - className={`pollOption grow max-w-full flex`} 221 - onClick={() => { 222 - props.toggleSelected(); 223 - }} 224 - > 225 - {optionName} 226 - </ButtonPrimary> 227 - </div> 228 - ); 229 - return ( 230 - <div className="flex gap-2 items-center"> 231 - <ButtonSecondary 232 - className={`pollOption grow max-w-full flex`} 233 - onClick={() => { 234 - props.toggleSelected(); 235 - }} 236 - > 237 - {optionName} 238 - </ButtonSecondary> 239 - </div> 240 - ); 241 - }; 242 - 243 - const PollResults = (props: { 244 - entityID: string; 245 - pollState: "editing" | "voting" | "results"; 246 - setPollState: (pollState: "editing" | "voting" | "results") => void; 247 - hasVoted: boolean; 248 - }) => { 249 - let { data } = usePollData(); 250 - let { permissions } = useEntitySetContext(); 251 - let pollOptions = useEntity(props.entityID, "poll/options"); 252 - let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID); 253 - let votesByOptions = pollData?.votesByOption || {}; 254 - let highestVotes = Math.max(...Object.values(votesByOptions)); 255 - let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>( 256 - (winningEntities, [entity, votes]) => { 257 - if (votes === highestVotes) winningEntities.push(entity); 258 - return winningEntities; 259 - }, 260 - [], 261 - ); 262 - return ( 263 - <> 264 - {pollOptions.map((p) => ( 265 - <PollResult 266 - key={p.id} 267 - winner={winningOptionEntities.includes(p.data.value)} 268 - entityID={p.data.value} 269 - totalVotes={pollData?.unique_votes || 0} 270 - votes={pollData?.votesByOption[p.data.value] || 0} 271 - /> 272 - ))} 273 - <div className="flex gap-2"> 274 - {permissions.write && ( 275 - <button 276 - className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 277 - onClick={() => { 278 - props.setPollState("editing"); 279 - }} 280 - > 281 - Edit Options 282 - </button> 283 - )} 284 - 285 - {permissions.write && <Separator classname="h-6" />} 286 - <PollStateToggle 287 - setPollState={props.setPollState} 288 - pollState={props.pollState} 289 - hasVoted={props.hasVoted} 290 - /> 291 - </div> 292 - </> 293 - ); 294 - }; 295 - 296 - const PollResult = (props: { 297 - entityID: string; 298 - votes: number; 299 - totalVotes: number; 300 - winner: boolean; 301 - }) => { 302 - let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 303 - return ( 304 - <div 305 - className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 306 - > 307 - <div 308 - style={{ 309 - WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`, 310 - paintOrder: "stroke fill", 311 - }} 312 - className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`} 313 - > 314 - <div className="grow max-w-full truncate">{optionName}</div> 315 - <div>{props.votes}</div> 316 - </div> 317 - <div 318 - className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`} 319 - > 320 - <div 321 - className={`bg-accent-contrast rounded-[2px] m-0.5`} 322 - style={{ 323 - maskImage: "var(--hatchSVG)", 324 - maskRepeat: "repeat repeat", 325 - 326 - ...(props.votes === 0 327 - ? { width: "4px" } 328 - : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 329 - }} 330 - /> 331 - <div /> 332 - </div> 333 - </div> 334 - ); 335 - }; 336 - 337 - const EditPoll = (props: { 338 - votes: { option_entity: string }[]; 339 - totalVotes: number; 340 - entityID: string; 341 - close: () => void; 342 - }) => { 343 - let pollOptions = useEntity(props.entityID, "poll/options"); 344 - let { rep } = useReplicache(); 345 - let permission_set = useEntitySetContext(); 346 - let [localPollOptionNames, setLocalPollOptionNames] = useState<{ 347 - [k: string]: string; 348 - }>({}); 349 - return ( 350 - <> 351 - {props.totalVotes > 0 && ( 352 - <div className="text-sm italic text-tertiary"> 353 - You can&apos;t edit options people already voted for! 354 - </div> 355 - )} 356 - 357 - {pollOptions.length === 0 && ( 358 - <div className="text-center italic text-tertiary text-sm"> 359 - no options yet... 360 - </div> 361 - )} 362 - {pollOptions.map((p) => ( 363 - <EditPollOption 364 - key={p.id} 365 - entityID={p.data.value} 366 - pollEntity={props.entityID} 367 - disabled={!!props.votes.find((v) => v.option_entity === p.data.value)} 368 - localNameState={localPollOptionNames[p.data.value]} 369 - setLocalNameState={setLocalPollOptionNames} 370 - /> 371 - ))} 372 - 373 - <button 374 - className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 375 - onClick={async () => { 376 - let pollOptionEntity = v7(); 377 - await rep?.mutate.addPollOption({ 378 - pollEntity: props.entityID, 379 - pollOptionEntity, 380 - pollOptionName: "", 381 - permission_set: permission_set.set, 382 - factID: v7(), 383 - }); 384 - 385 - focusElement( 386 - document.getElementById( 387 - elementId.block(props.entityID).pollInput(pollOptionEntity), 388 - ) as HTMLInputElement | null, 389 - ); 390 - }} 391 - > 392 - Add an Option 393 - </button> 394 - 395 - <hr className="border-border" /> 396 - <ButtonPrimary 397 - className="place-self-end" 398 - onClick={async () => { 399 - // remove any poll options that have no name 400 - // look through the localPollOptionNames object and remove any options that have no name 401 - let emptyOptions = Object.entries(localPollOptionNames).filter( 402 - ([optionEntity, optionName]) => optionName === "", 403 - ); 404 - await Promise.all( 405 - emptyOptions.map( 406 - async ([entity]) => 407 - await rep?.mutate.removePollOption({ 408 - optionEntity: entity, 409 - }), 410 - ), 411 - ); 412 - 413 - await rep?.mutate.assertFact( 414 - Object.entries(localPollOptionNames) 415 - .filter(([, name]) => !!name) 416 - .map(([entity, name]) => ({ 417 - entity, 418 - attribute: "poll-option/name", 419 - data: { type: "string", value: name }, 420 - })), 421 - ); 422 - props.close(); 423 - }} 424 - > 425 - Save <CheckTiny /> 426 - </ButtonPrimary> 427 - </> 428 - ); 429 - }; 430 - 431 - const EditPollOption = (props: { 432 - entityID: string; 433 - pollEntity: string; 434 - localNameState: string | undefined; 435 - setLocalNameState: ( 436 - s: (s: { [k: string]: string }) => { [k: string]: string }, 437 - ) => void; 438 - disabled: boolean; 439 - }) => { 440 - let { rep } = useReplicache(); 441 - let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 442 - useEffect(() => { 443 - props.setLocalNameState((s) => ({ 444 - ...s, 445 - [props.entityID]: optionName || "", 446 - })); 447 - }, [optionName, props.setLocalNameState, props.entityID]); 448 - 449 - return ( 450 - <div className="flex gap-2 items-center"> 451 - <Input 452 - id={elementId.block(props.pollEntity).pollInput(props.entityID)} 453 - type="text" 454 - className="pollOptionInput w-full input-with-border" 455 - placeholder="Option here..." 456 - disabled={props.disabled} 457 - value={ 458 - props.localNameState === undefined ? optionName : props.localNameState 459 - } 460 - onChange={(e) => { 461 - props.setLocalNameState((s) => ({ 462 - ...s, 463 - [props.entityID]: e.target.value, 464 - })); 465 - }} 466 - onKeyDown={(e) => { 467 - if (e.key === "Backspace" && !e.currentTarget.value) { 468 - e.preventDefault(); 469 - rep?.mutate.removePollOption({ optionEntity: props.entityID }); 470 - } 471 - }} 472 - /> 473 - 474 - <button 475 - tabIndex={-1} 476 - disabled={props.disabled} 477 - className="text-accent-contrast disabled:text-border" 478 - onMouseDown={async () => { 479 - await rep?.mutate.removePollOption({ optionEntity: props.entityID }); 480 - }} 481 - > 482 - <CloseTiny /> 483 - </button> 484 - </div> 485 - ); 486 - }; 487 - 488 - const PollStateToggle = (props: { 489 - setPollState: (pollState: "editing" | "voting" | "results") => void; 490 - hasVoted: boolean; 491 - pollState: "editing" | "voting" | "results"; 492 - }) => { 493 - return ( 494 - <button 495 - className="text-sm text-accent-contrast sm:hover:underline" 496 - onClick={() => { 497 - props.setPollState(props.pollState === "voting" ? "results" : "voting"); 498 - }} 499 - > 500 - {props.pollState === "voting" 501 - ? "See Results" 502 - : props.hasVoted 503 - ? "Change Vote" 504 - : "Back to Poll"} 505 - </button> 506 - ); 507 - };
···
+2 -1
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";
··· 1 import { useUIState } from "src/useUIState"; 2 import { BlockProps } from "./Block"; 3 import { useMemo } from "react"; 4 + import { AsyncValueInput } from "components/Input"; 5 + import { focusElement } from "src/utils/focusElement"; 6 import { useEntitySetContext } from "components/EntitySetProvider"; 7 import { useEntity, useReplicache } from "src/replicache"; 8 import { v7 } from "uuid";
+2 -2
components/Blocks/RSVPBlock/SendUpdate.tsx
··· 9 import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; 10 import { useReplicache } from "src/replicache"; 11 import { Checkbox } from "components/Checkbox"; 12 - import { usePublishLink } from "components/ShareOptions"; 13 14 export function SendUpdateButton(props: { entityID: string }) { 15 - let publishLink = usePublishLink(); 16 let { permissions } = useEntitySetContext(); 17 let { permission_token } = useReplicache(); 18 let [input, setInput] = useState("");
··· 9 import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; 10 import { useReplicache } from "src/replicache"; 11 import { Checkbox } from "components/Checkbox"; 12 + import { useReadOnlyShareLink } from "app/[leaflet_id]/actions/ShareOptions"; 13 14 export function SendUpdateButton(props: { entityID: string }) { 15 + let publishLink = useReadOnlyShareLink(); 16 let { permissions } = useEntitySetContext(); 17 let { permission_token } = useReplicache(); 18 let [input, setInput] = useState("");
+45 -32
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 3 import { CSSProperties, Fragment } from "react"; 4 import { theme } from "tailwind.config"; 5 import * as base64 from "base64-js"; 6 7 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 8 export function RenderYJSFragment({ ··· 60 ); 61 } 62 63 return null; 64 }) 65 )} ··· 95 case "h3": 96 return <h3 {...props.attrs}>{props.children}</h3>; 97 } 98 - }; 99 - 100 - export type Delta = { 101 - insert: string; 102 - attributes?: { 103 - strong?: {}; 104 - code?: {}; 105 - em?: {}; 106 - underline?: {}; 107 - strikethrough?: {}; 108 - highlight?: { color: string }; 109 - link?: { href: string }; 110 - }; 111 }; 112 113 function attributesToStyle(d: Delta) { ··· 139 140 return props; 141 } 142 - 143 - export function YJSFragmentToString( 144 - node: XmlElement | XmlText | XmlHook, 145 - ): string { 146 - if (node.constructor === XmlElement) { 147 - return node 148 - .toArray() 149 - .map((f) => YJSFragmentToString(f)) 150 - .join(""); 151 - } 152 - if (node.constructor === XmlText) { 153 - return (node.toDelta() as Delta[]) 154 - .map((d) => { 155 - return d.insert; 156 - }) 157 - .join(""); 158 - } 159 - return ""; 160 - }
··· 3 import { CSSProperties, Fragment } from "react"; 4 import { theme } from "tailwind.config"; 5 import * as base64 from "base64-js"; 6 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7 + import { AtMentionLink } from "components/AtMentionLink"; 8 + import { Delta } from "src/utils/yjsFragmentToString"; 9 + import { ProfilePopover } from "components/ProfilePopover"; 10 11 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 12 export function RenderYJSFragment({ ··· 64 ); 65 } 66 67 + if ( 68 + node.constructor === XmlElement && 69 + node.nodeName === "hard_break" 70 + ) { 71 + return <br key={index} />; 72 + } 73 + 74 + // Handle didMention inline nodes 75 + if ( 76 + node.constructor === XmlElement && 77 + node.nodeName === "didMention" 78 + ) { 79 + const did = node.getAttribute("did") || ""; 80 + const text = node.getAttribute("text") || ""; 81 + return ( 82 + <a 83 + href={didToBlueskyUrl(did)} 84 + target="_blank" 85 + rel="noopener noreferrer" 86 + key={index} 87 + className="mention" 88 + > 89 + {text} 90 + </a> 91 + ); 92 + } 93 + 94 + // Handle atMention inline nodes 95 + if ( 96 + node.constructor === XmlElement && 97 + node.nodeName === "atMention" 98 + ) { 99 + const atURI = node.getAttribute("atURI") || ""; 100 + const text = node.getAttribute("text") || ""; 101 + return ( 102 + <AtMentionLink key={index} atURI={atURI}> 103 + {text} 104 + </AtMentionLink> 105 + ); 106 + } 107 + 108 return null; 109 }) 110 )} ··· 140 case "h3": 141 return <h3 {...props.attrs}>{props.children}</h3>; 142 } 143 }; 144 145 function attributesToStyle(d: Delta) { ··· 171 172 return props; 173 }
+109 -14
components/Blocks/TextBlock/index.tsx
··· 1 - import { useRef, useEffect, useState } from "react"; 2 import { elementId } from "src/utils/elementId"; 3 import { useReplicache, useEntity } from "src/replicache"; 4 import { isVisible } from "src/utils/isVisible"; 5 import { EditorState, TextSelection } from "prosemirror-state"; 6 import { RenderYJSFragment } from "./RenderYJSFragment"; 7 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 8 import { BlockProps } from "../Block"; ··· 23 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 24 import { DotLoader } from "components/utils/DotLoader"; 25 import { useMountProsemirror } from "./mountProsemirror"; 26 27 const HeadingStyle = { 28 1: "text-xl font-bold", ··· 183 let editorState = useEditorStates( 184 (s) => s.editorStates[props.entityID], 185 )?.editor; 186 187 let { mountRef, actionTimeout } = useMountProsemirror({ 188 props, 189 }); 190 191 return ( ··· 199 ? "blockquote pt-3" 200 : "blockquote" 201 : "" 202 - } 203 - 204 - `} 205 > 206 <pre 207 data-entityid={props.entityID} ··· 224 } 225 }} 226 onFocus={() => { 227 setTimeout(() => { 228 useUIState.getState().setSelectedBlock(props); 229 useUIState.setState(() => ({ ··· 249 ${props.className}`} 250 ref={mountRef} 251 /> 252 {editorState?.doc.textContent.length === 0 && 253 props.previousBlock === null && 254 props.nextBlock === null ? ( ··· 439 ); 440 }; 441 442 - const useMentionState = () => { 443 - const [editorState, setEditorState] = useState<EditorState | null>(null); 444 - const [mentionState, setMentionState] = useState<{ 445 - active: boolean; 446 - range: { from: number; to: number } | null; 447 - selectedMention: { handle: string; did: string } | null; 448 - }>({ active: false, range: null, selectedMention: null }); 449 - const mentionStateRef = useRef(mentionState); 450 - mentionStateRef.current = mentionState; 451 - return { mentionStateRef }; 452 };
··· 1 + import { useRef, useEffect, useState, useCallback } from "react"; 2 import { elementId } from "src/utils/elementId"; 3 import { useReplicache, useEntity } from "src/replicache"; 4 import { isVisible } from "src/utils/isVisible"; 5 import { EditorState, TextSelection } from "prosemirror-state"; 6 + import { EditorView } from "prosemirror-view"; 7 import { RenderYJSFragment } from "./RenderYJSFragment"; 8 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 9 import { BlockProps } from "../Block"; ··· 24 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 25 import { DotLoader } from "components/utils/DotLoader"; 26 import { useMountProsemirror } from "./mountProsemirror"; 27 + import { schema } from "./schema"; 28 + 29 + import { Mention, MentionAutocomplete } from "components/Mention"; 30 + import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 31 32 const HeadingStyle = { 33 1: "text-xl font-bold", ··· 188 let editorState = useEditorStates( 189 (s) => s.editorStates[props.entityID], 190 )?.editor; 191 + const { 192 + viewRef, 193 + mentionOpen, 194 + mentionCoords, 195 + openMentionAutocomplete, 196 + handleMentionSelect, 197 + handleMentionOpenChange, 198 + } = useMentionState(props.entityID); 199 200 let { mountRef, actionTimeout } = useMountProsemirror({ 201 props, 202 + openMentionAutocomplete, 203 }); 204 205 return ( ··· 213 ? "blockquote pt-3" 214 : "blockquote" 215 : "" 216 + }`} 217 > 218 <pre 219 data-entityid={props.entityID} ··· 236 } 237 }} 238 onFocus={() => { 239 + handleMentionOpenChange(false); 240 setTimeout(() => { 241 useUIState.getState().setSelectedBlock(props); 242 useUIState.setState(() => ({ ··· 262 ${props.className}`} 263 ref={mountRef} 264 /> 265 + {focused && ( 266 + <MentionAutocomplete 267 + open={mentionOpen} 268 + onOpenChange={handleMentionOpenChange} 269 + view={viewRef} 270 + onSelect={handleMentionSelect} 271 + coords={mentionCoords} 272 + /> 273 + )} 274 {editorState?.doc.textContent.length === 0 && 275 props.previousBlock === null && 276 props.nextBlock === null ? ( ··· 461 ); 462 }; 463 464 + const useMentionState = (entityID: string) => { 465 + let view = useEditorStates((s) => s.editorStates[entityID])?.view; 466 + let viewRef = useRef(view || null); 467 + viewRef.current = view || null; 468 + 469 + const [mentionOpen, setMentionOpen] = useState(false); 470 + const [mentionCoords, setMentionCoords] = useState<{ 471 + top: number; 472 + left: number; 473 + } | null>(null); 474 + const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 475 + 476 + // Close autocomplete when this block is no longer focused 477 + const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID); 478 + useEffect(() => { 479 + if (!isFocused) { 480 + setMentionOpen(false); 481 + setMentionCoords(null); 482 + setMentionInsertPos(null); 483 + } 484 + }, [isFocused]); 485 + 486 + const openMentionAutocomplete = useCallback(() => { 487 + const view = useEditorStates.getState().editorStates[entityID]?.view; 488 + if (!view) return; 489 + 490 + // Get the position right after the @ we just inserted 491 + const pos = view.state.selection.from; 492 + setMentionInsertPos(pos); 493 + 494 + // Get coordinates for the popup relative to the positioned parent 495 + const coords = view.coordsAtPos(pos - 1); // Position of the @ 496 + 497 + // Find the relative positioned parent container 498 + const editorEl = view.dom; 499 + const container = editorEl.closest('.relative') as HTMLElement | null; 500 + 501 + if (container) { 502 + const containerRect = container.getBoundingClientRect(); 503 + setMentionCoords({ 504 + top: coords.bottom - containerRect.top, 505 + left: coords.left - containerRect.left, 506 + }); 507 + } else { 508 + setMentionCoords({ 509 + top: coords.bottom, 510 + left: coords.left, 511 + }); 512 + } 513 + setMentionOpen(true); 514 + }, [entityID]); 515 + 516 + const handleMentionSelect = useCallback( 517 + (mention: Mention) => { 518 + const view = useEditorStates.getState().editorStates[entityID]?.view; 519 + if (!view || mentionInsertPos === null) return; 520 + 521 + // The @ is at mentionInsertPos - 1, we need to replace it with the mention 522 + const from = mentionInsertPos - 1; 523 + const to = mentionInsertPos; 524 + 525 + addMentionToEditor(mention, { from, to }, view); 526 + view.focus(); 527 + }, 528 + [entityID, mentionInsertPos], 529 + ); 530 + 531 + const handleMentionOpenChange = useCallback((open: boolean) => { 532 + setMentionOpen(open); 533 + if (!open) { 534 + setMentionCoords(null); 535 + setMentionInsertPos(null); 536 + } 537 + }, []); 538 + 539 + return { 540 + viewRef, 541 + mentionOpen, 542 + mentionCoords, 543 + openMentionAutocomplete, 544 + handleMentionSelect, 545 + handleMentionOpenChange, 546 + }; 547 };
+32 -3
components/Blocks/TextBlock/inputRules.ts
··· 11 import { schema } from "./schema"; 12 import { useUIState } from "src/useUIState"; 13 import { flushSync } from "react-dom"; 14 export const inputrules = ( 15 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 16 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 17 ) => 18 inputRules({ 19 //Strikethrough ··· 108 109 // Code Block 110 new InputRule(/^```\s$/, (state, match) => { 111 - flushSync(() => 112 repRef.current?.mutate.assertFact({ 113 entity: propsRef.current.entityID, 114 attribute: "block/type", 115 data: { type: "block-type-union", value: "code" }, 116 - }), 117 - ); 118 setTimeout(() => { 119 focusBlock({ ...propsRef.current, type: "code" }, { type: "start" }); 120 }, 20); ··· 180 data: { type: "number", value: headingLevel }, 181 }); 182 return tr; 183 }), 184 ], 185 });
··· 11 import { schema } from "./schema"; 12 import { useUIState } from "src/useUIState"; 13 import { flushSync } from "react-dom"; 14 + import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 15 export const inputrules = ( 16 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 17 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 18 + openMentionAutocomplete?: () => void, 19 ) => 20 inputRules({ 21 //Strikethrough ··· 110 111 // Code Block 112 new InputRule(/^```\s$/, (state, match) => { 113 + flushSync(() => { 114 repRef.current?.mutate.assertFact({ 115 entity: propsRef.current.entityID, 116 attribute: "block/type", 117 data: { type: "block-type-union", value: "code" }, 118 + }); 119 + let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY); 120 + if (lastLang) { 121 + repRef.current?.mutate.assertFact({ 122 + entity: propsRef.current.entityID, 123 + attribute: "block/code-language", 124 + data: { type: "string", value: lastLang }, 125 + }); 126 + } 127 + }); 128 setTimeout(() => { 129 focusBlock({ ...propsRef.current, type: "code" }, { type: "start" }); 130 }, 20); ··· 190 data: { type: "number", value: headingLevel }, 191 }); 192 return tr; 193 + }), 194 + 195 + // Mention - @ at start of line, after space, or after hard break 196 + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 197 + if (!openMentionAutocomplete) return null; 198 + // Schedule opening the autocomplete after the transaction is applied 199 + setTimeout(() => openMentionAutocomplete(), 0); 200 + return null; // Let the @ be inserted normally 201 + }), 202 + // Mention - @ immediately after a hard break (hard breaks are nodes, not text) 203 + new InputRule(/@$/, (state, match, start, end) => { 204 + if (!openMentionAutocomplete) return null; 205 + // Check if the character before @ is a hard break node 206 + const $pos = state.doc.resolve(start); 207 + const nodeBefore = $pos.nodeBefore; 208 + if (nodeBefore && nodeBefore.type.name === "hard_break") { 209 + setTimeout(() => openMentionAutocomplete(), 0); 210 + } 211 + return null; // Let the @ be inserted normally 212 }), 213 ], 214 });
+10 -13
components/Blocks/TextBlock/keymap.ts
··· 17 import { schema } from "./schema"; 18 import { useUIState } from "src/useUIState"; 19 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20 - import { focusPage } from "components/Pages"; 21 import { v7 } from "uuid"; 22 import { scanIndex } from "src/replicache/utils"; 23 import { indent, outdent } from "src/utils/list-operations"; 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 import { UndoManager } from "src/undoManager"; 27 - 28 type PropsRef = RefObject< 29 BlockProps & { 30 entity_set: { set: string }; ··· 35 propsRef: PropsRef, 36 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 37 um: UndoManager, 38 - multiLine?: boolean, 39 ) => 40 ({ 41 "Meta-b": toggleMark(schema.marks.strong), ··· 138 ), 139 "Shift-Backspace": backspace(propsRef, repRef), 140 Enter: (state, dispatch, view) => { 141 - if (multiLine && state.doc.content.size - state.selection.anchor > 1) 142 - return false; 143 - return um.withUndoGroup(() => 144 - enter(propsRef, repRef)(state, dispatch, view), 145 - ); 146 }, 147 "Shift-Enter": (state, dispatch, view) => { 148 - if (multiLine) { 149 - return baseKeymap.Enter(state, dispatch, view); 150 } 151 - return um.withUndoGroup(() => 152 - enter(propsRef, repRef)(state, dispatch, view), 153 - ); 154 }, 155 "Ctrl-Enter": CtrlEnter(propsRef, repRef), 156 "Meta-Enter": CtrlEnter(propsRef, repRef),
··· 17 import { schema } from "./schema"; 18 import { useUIState } from "src/useUIState"; 19 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20 + import { focusPage } from "src/utils/focusPage"; 21 import { v7 } from "uuid"; 22 import { scanIndex } from "src/replicache/utils"; 23 import { indent, outdent } from "src/utils/list-operations"; 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 import { UndoManager } from "src/undoManager"; 27 type PropsRef = RefObject< 28 BlockProps & { 29 entity_set: { set: string }; ··· 34 propsRef: PropsRef, 35 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 36 um: UndoManager, 37 + openMentionAutocomplete: () => void, 38 ) => 39 ({ 40 "Meta-b": toggleMark(schema.marks.strong), ··· 137 ), 138 "Shift-Backspace": backspace(propsRef, repRef), 139 Enter: (state, dispatch, view) => { 140 + return um.withUndoGroup(() => { 141 + return enter(propsRef, repRef)(state, dispatch, view); 142 + }); 143 }, 144 "Shift-Enter": (state, dispatch, view) => { 145 + // Insert a hard break 146 + let hardBreak = schema.nodes.hard_break.create(); 147 + if (dispatch) { 148 + dispatch(state.tr.replaceSelectionWith(hardBreak).scrollIntoView()); 149 } 150 + return true; 151 }, 152 "Ctrl-Enter": CtrlEnter(propsRef, repRef), 153 "Meta-Enter": CtrlEnter(propsRef, repRef),
+48 -12
components/Blocks/TextBlock/mountProsemirror.ts
··· 23 import { useHandlePaste } from "./useHandlePaste"; 24 import { BlockProps } from "../Block"; 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 27 - export function useMountProsemirror({ props }: { props: BlockProps }) { 28 let { entityID, parent } = props; 29 let rep = useReplicache(); 30 let mountRef = useRef<HTMLPreElement | null>(null); ··· 44 useLayoutEffect(() => { 45 if (!mountRef.current) return; 46 47 - const km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 48 const editor = EditorState.create({ 49 schema: schema, 50 plugins: [ 51 ySyncPlugin(value), 52 keymap(km), 53 - inputrules(propsRef, repRef), 54 keymap(baseKeymap), 55 highlightSelectionPlugin, 56 autolink({ ··· 69 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 70 if (!direct) return; 71 if (node.nodeSize - 2 <= _pos) return; 72 - let mark = 73 - node 74 - .nodeAt(_pos - 1) 75 - ?.marks.find((f) => f.type === schema.marks.link) || 76 - node 77 - .nodeAt(Math.max(_pos - 2, 0)) 78 - ?.marks.find((f) => f.type === schema.marks.link); 79 - if (mark) { 80 - window.open(mark.attrs.href, "_blank"); 81 } 82 }, 83 dispatchTransaction,
··· 23 import { useHandlePaste } from "./useHandlePaste"; 24 import { BlockProps } from "../Block"; 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 27 28 + export function useMountProsemirror({ 29 + props, 30 + openMentionAutocomplete, 31 + }: { 32 + props: BlockProps; 33 + openMentionAutocomplete: () => void; 34 + }) { 35 let { entityID, parent } = props; 36 let rep = useReplicache(); 37 let mountRef = useRef<HTMLPreElement | null>(null); ··· 51 useLayoutEffect(() => { 52 if (!mountRef.current) return; 53 54 + const km = TextBlockKeymap( 55 + propsRef, 56 + repRef, 57 + rep.undoManager, 58 + openMentionAutocomplete, 59 + ); 60 const editor = EditorState.create({ 61 schema: schema, 62 plugins: [ 63 ySyncPlugin(value), 64 keymap(km), 65 + inputrules(propsRef, repRef, openMentionAutocomplete), 66 keymap(baseKeymap), 67 highlightSelectionPlugin, 68 autolink({ ··· 81 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 82 if (!direct) return; 83 if (node.nodeSize - 2 <= _pos) return; 84 + 85 + // Check for marks at the clicked position 86 + const nodeAt1 = node.nodeAt(_pos - 1); 87 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 88 + 89 + // Check for link marks 90 + let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 91 + nodeAt2?.marks.find((f) => f.type === schema.marks.link); 92 + if (linkMark) { 93 + window.open(linkMark.attrs.href, "_blank"); 94 + return; 95 + } 96 + 97 + // Check for didMention inline nodes 98 + if (nodeAt1?.type === schema.nodes.didMention) { 99 + window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer"); 100 + return; 101 + } 102 + if (nodeAt2?.type === schema.nodes.didMention) { 103 + window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer"); 104 + return; 105 + } 106 + 107 + // Check for atMention inline nodes 108 + if (nodeAt1?.type === schema.nodes.atMention) { 109 + const url = atUriToUrl(nodeAt1.attrs.atURI); 110 + window.open(url, "_blank", "noopener,noreferrer"); 111 + return; 112 + } 113 + if (nodeAt2?.type === schema.nodes.atMention) { 114 + const url = atUriToUrl(nodeAt2.attrs.atURI); 115 + window.open(url, "_blank", "noopener,noreferrer"); 116 + return; 117 } 118 }, 119 dispatchTransaction,
+108 -1
components/Blocks/TextBlock/schema.ts
··· 1 - import { Schema, Node, MarkSpec } from "prosemirror-model"; 2 import { marks } from "prosemirror-schema-basic"; 3 import { theme } from "tailwind.config"; 4 ··· 115 text: { 116 group: "inline", 117 }, 118 }, 119 }; 120 export const schema = new Schema(baseSchema);
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; 3 import { marks } from "prosemirror-schema-basic"; 4 import { theme } from "tailwind.config"; 5 ··· 116 text: { 117 group: "inline", 118 }, 119 + hard_break: { 120 + group: "inline", 121 + inline: true, 122 + selectable: false, 123 + parseDOM: [{ tag: "br" }], 124 + toDOM: () => ["br"] as const, 125 + }, 126 + atMention: { 127 + attrs: { 128 + atURI: {}, 129 + text: { default: "" }, 130 + }, 131 + group: "inline", 132 + inline: true, 133 + atom: true, 134 + selectable: true, 135 + draggable: true, 136 + parseDOM: [ 137 + { 138 + tag: "span.atMention", 139 + getAttrs(dom: HTMLElement) { 140 + return { 141 + atURI: dom.getAttribute("data-at-uri"), 142 + text: dom.textContent || "", 143 + }; 144 + }, 145 + }, 146 + ], 147 + toDOM(node) { 148 + // NOTE: This rendering should match the AtMentionLink component in 149 + // components/AtMentionLink.tsx. If you update one, update the other. 150 + let className = "atMention mention"; 151 + let aturi = new AtUri(node.attrs.atURI); 152 + if (aturi.collection === "pub.leaflet.publication") 153 + className += " font-bold"; 154 + if (aturi.collection === "pub.leaflet.document") className += " italic"; 155 + 156 + // For publications and documents, show icon 157 + if ( 158 + aturi.collection === "pub.leaflet.publication" || 159 + aturi.collection === "pub.leaflet.document" 160 + ) { 161 + return [ 162 + "span", 163 + { 164 + class: className, 165 + "data-at-uri": node.attrs.atURI, 166 + }, 167 + [ 168 + "img", 169 + { 170 + src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, 171 + class: 172 + "inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top", 173 + alt: "", 174 + width: "16", 175 + height: "16", 176 + loading: "lazy", 177 + }, 178 + ], 179 + node.attrs.text, 180 + ]; 181 + } 182 + 183 + return [ 184 + "span", 185 + { 186 + class: className, 187 + "data-at-uri": node.attrs.atURI, 188 + }, 189 + node.attrs.text, 190 + ]; 191 + }, 192 + } as NodeSpec, 193 + didMention: { 194 + attrs: { 195 + did: {}, 196 + text: { default: "" }, 197 + }, 198 + group: "inline", 199 + inline: true, 200 + atom: true, 201 + selectable: true, 202 + draggable: true, 203 + parseDOM: [ 204 + { 205 + tag: "span.didMention", 206 + getAttrs(dom: HTMLElement) { 207 + return { 208 + did: dom.getAttribute("data-did"), 209 + text: dom.textContent || "", 210 + }; 211 + }, 212 + }, 213 + ], 214 + toDOM(node) { 215 + return [ 216 + "span", 217 + { 218 + class: "didMention mention", 219 + "data-did": node.attrs.did, 220 + }, 221 + node.attrs.text, 222 + ]; 223 + }, 224 + } as NodeSpec, 225 }, 226 }; 227 export const schema = new Schema(baseSchema);
+1 -1
components/Blocks/useBlockKeyboardHandlers.ts
··· 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 import { Replicache } from "replicache"; 15 - import { deleteBlock } from "./DeleteBlock"; 16 import { entities } from "drizzle/schema"; 17 import { scanIndex } from "src/replicache/utils"; 18
··· 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 import { Replicache } from "replicache"; 15 + import { deleteBlock } from "src/utils/deleteBlock"; 16 import { entities } from "drizzle/schema"; 17 import { scanIndex } from "src/replicache/utils"; 18
+1 -1
components/Blocks/useBlockMouseHandlers.ts
··· 1 - import { useSelectingMouse } from "components/SelectionManager"; 2 import { MouseEvent, useCallback, useRef } from "react"; 3 import { useUIState } from "src/useUIState"; 4 import { Block } from "./Block";
··· 1 + import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 import { MouseEvent, useCallback, useRef } from "react"; 3 import { useUIState } from "src/useUIState"; 4 import { Block } from "./Block";
+35 -21
components/Buttons.tsx
··· 10 import { PopoverArrow } from "./Icons/PopoverArrow"; 11 12 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 13 export const ButtonPrimary = forwardRef< 14 HTMLButtonElement, 15 ButtonProps & { ··· 35 m-0 h-max 36 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 37 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 38 - bg-accent-1 outline-transparent border border-accent-1 39 - rounded-md text-base font-bold text-accent-2 40 flex gap-2 items-center justify-center shrink-0 41 - transparent-outline focus:outline-accent-1 hover:outline-accent-1 outline-offset-1 42 - disabled:bg-border-light disabled:border-border-light disabled:text-border disabled:hover:text-border 43 ${className} 44 `} 45 > ··· 70 <button 71 {...buttonProps} 72 ref={ref} 73 - className={`m-0 h-max 74 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 75 - ${props.compact ? "py-0 px-1" : "px-2 py-0.5 "} 76 - bg-bg-page outline-transparent 77 - rounded-md text-base font-bold text-accent-contrast 78 - flex gap-2 items-center justify-center shrink-0 79 - transparent-outline focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 80 - border border-accent-contrast 81 - disabled:bg-border-light disabled:text-border disabled:hover:text-border 82 - ${props.className} 83 - `} 84 > 85 {props.children} 86 </button> ··· 92 HTMLButtonElement, 93 { 94 fullWidth?: boolean; 95 children: React.ReactNode; 96 compact?: boolean; 97 } & ButtonProps 98 >((props, ref) => { 99 - let { fullWidth, children, compact, ...buttonProps } = props; 100 return ( 101 <button 102 {...buttonProps} 103 ref={ref} 104 - className={`m-0 h-max ${fullWidth ? "w-full" : "w-max"} ${compact ? "px-0" : "px-1"} 105 - bg-transparent text-base font-bold text-accent-contrast 106 - flex gap-2 items-center justify-center shrink-0 107 - hover:underline disabled:text-border 108 - ${props.className} 109 - `} 110 > 111 {children} 112 </button>
··· 10 import { PopoverArrow } from "./Icons/PopoverArrow"; 11 12 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 13 + 14 export const ButtonPrimary = forwardRef< 15 HTMLButtonElement, 16 ButtonProps & { ··· 36 m-0 h-max 37 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 38 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 39 + bg-accent-1 disabled:bg-border-light 40 + border border-accent-1 rounded-md disabled:border-border-light 41 + outline outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 42 + text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border 43 flex gap-2 items-center justify-center shrink-0 44 ${className} 45 `} 46 > ··· 71 <button 72 {...buttonProps} 73 ref={ref} 74 + className={` 75 + m-0 h-max 76 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 77 + ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 78 + bg-bg-page disabled:bg-border-light 79 + border border-accent-contrast rounded-md 80 + outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 81 + text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border 82 + flex gap-2 items-center justify-center shrink-0 83 + ${props.className} 84 + `} 85 > 86 {props.children} 87 </button> ··· 93 HTMLButtonElement, 94 { 95 fullWidth?: boolean; 96 + fullWidthOnMobile?: boolean; 97 children: React.ReactNode; 98 compact?: boolean; 99 } & ButtonProps 100 >((props, ref) => { 101 + let { 102 + className, 103 + fullWidth, 104 + fullWidthOnMobile, 105 + compact, 106 + children, 107 + ...buttonProps 108 + } = props; 109 return ( 110 <button 111 {...buttonProps} 112 ref={ref} 113 + className={` 114 + m-0 h-max 115 + ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 116 + ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 117 + bg-transparent hover:bg-[var(--accent-light)] 118 + border border-transparent rounded-md hover:border-[var(--accent-light)] 119 + outline outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 120 + text-base font-bold text-accent-contrast disabled:text-border 121 + flex gap-2 items-center justify-center shrink-0 122 + ${props.className} 123 + `} 124 > 125 {children} 126 </button>
-173
components/HelpPopover.tsx
··· 1 - "use client"; 2 - import { ShortcutKey } from "./Layout"; 3 - import { Media } from "./Media"; 4 - import { Popover } from "./Popover"; 5 - import { metaKey } from "src/utils/metaKey"; 6 - import { useEntitySetContext } from "./EntitySetProvider"; 7 - import { useState } from "react"; 8 - import { ActionButton } from "components/ActionBar/ActionButton"; 9 - import { HelpSmall } from "./Icons/HelpSmall"; 10 - import { isMac } from "src/utils/isDevice"; 11 - import { useIsMobile } from "src/hooks/isMobile"; 12 - 13 - export const HelpPopover = (props: { noShortcuts?: boolean }) => { 14 - let entity_set = useEntitySetContext(); 15 - let isMobile = useIsMobile(); 16 - 17 - return entity_set.permissions.write ? ( 18 - <Popover 19 - side={isMobile ? "top" : "right"} 20 - align={isMobile ? "center" : "start"} 21 - asChild 22 - className="max-w-xs w-full" 23 - trigger={<ActionButton icon={<HelpSmall />} label="About" />} 24 - > 25 - <div className="flex flex-col text-sm gap-2 text-secondary"> 26 - {/* about links */} 27 - <HelpLink text="๐Ÿ“– Leaflet Manual" url="https://about.leaflet.pub" /> 28 - <HelpLink text="๐Ÿ’ก Make with Leaflet" url="https://make.leaflet.pub" /> 29 - <HelpLink 30 - text="โœจ Explore Publications" 31 - url="https://leaflet.pub/discover" 32 - /> 33 - <HelpLink text="๐Ÿ“ฃ Newsletter" url="https://buttondown.com/leaflet" /> 34 - {/* contact links */} 35 - <div className="columns-2 gap-2"> 36 - <HelpLink 37 - text="๐Ÿฆ‹ Bluesky" 38 - url="https://bsky.app/profile/leaflet.pub" 39 - /> 40 - <HelpLink text="๐Ÿ’Œ Email" url="mailto:contact@leaflet.pub" /> 41 - </div> 42 - {/* keyboard shortcuts: desktop only */} 43 - <Media mobile={false}> 44 - {!props.noShortcuts && ( 45 - <> 46 - <hr className="text-border my-1" /> 47 - <div className="flex flex-col gap-1"> 48 - <Label>Text Shortcuts</Label> 49 - <KeyboardShortcut name="Bold" keys={[metaKey(), "B"]} /> 50 - <KeyboardShortcut name="Italic" keys={[metaKey(), "I"]} /> 51 - <KeyboardShortcut name="Underline" keys={[metaKey(), "U"]} /> 52 - <KeyboardShortcut 53 - name="Highlight" 54 - keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "H"]} 55 - /> 56 - <KeyboardShortcut 57 - name="Strikethrough" 58 - keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]} 59 - /> 60 - <KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} /> 61 - 62 - <Label>Block Shortcuts</Label> 63 - {/* shift + up/down arrows (or click + drag): select multiple blocks */} 64 - <KeyboardShortcut 65 - name="Move Block Up" 66 - keys={["Shift", metaKey(), "โ†‘"]} 67 - /> 68 - <KeyboardShortcut 69 - name="Move Block Down" 70 - keys={["Shift", metaKey(), "โ†“"]} 71 - /> 72 - {/* cmd/ctrl-a: first selects all text in a block; again selects all blocks on page */} 73 - {/* cmd/ctrl + up/down arrows: go to beginning / end of doc */} 74 - 75 - <Label>Canvas Shortcuts</Label> 76 - <OtherShortcut name="Add Block" description="Double click" /> 77 - <OtherShortcut name="Select Block" description="Long press" /> 78 - 79 - <Label>Outliner Shortcuts</Label> 80 - <KeyboardShortcut 81 - name="Make List" 82 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "L"]} 83 - /> 84 - {/* tab / shift + tab: indent / outdent */} 85 - <KeyboardShortcut 86 - name="Toggle Checkbox" 87 - keys={[metaKey(), "Enter"]} 88 - /> 89 - <KeyboardShortcut 90 - name="Toggle Fold" 91 - keys={[metaKey(), "Shift", "Enter"]} 92 - /> 93 - <KeyboardShortcut 94 - name="Fold All" 95 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "โ†‘"]} 96 - /> 97 - <KeyboardShortcut 98 - name="Unfold All" 99 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "โ†“"]} 100 - /> 101 - </div> 102 - </> 103 - )} 104 - </Media> 105 - {/* links: terms and privacy */} 106 - <hr className="text-border my-1" /> 107 - {/* <HelpLink 108 - text="Terms and Privacy Policy" 109 - url="https://leaflet.pub/legal" 110 - /> */} 111 - <div> 112 - <a href="https://leaflet.pub/legal" target="_blank"> 113 - Terms and Privacy Policy 114 - </a> 115 - </div> 116 - </div> 117 - </Popover> 118 - ) : null; 119 - }; 120 - 121 - const KeyboardShortcut = (props: { name: string; keys: string[] }) => { 122 - return ( 123 - <div className="flex gap-2 justify-between items-center"> 124 - {props.name} 125 - <div className="flex gap-1 items-center font-bold"> 126 - {props.keys.map((key, index) => { 127 - return <ShortcutKey key={index}>{key}</ShortcutKey>; 128 - })} 129 - </div> 130 - </div> 131 - ); 132 - }; 133 - 134 - const OtherShortcut = (props: { name: string; description: string }) => { 135 - return ( 136 - <div className="flex justify-between items-center"> 137 - <span>{props.name}</span> 138 - <span> 139 - <strong>{props.description}</strong> 140 - </span> 141 - </div> 142 - ); 143 - }; 144 - 145 - const Label = (props: { children: React.ReactNode }) => { 146 - return <div className="text-tertiary font-bold pt-2 ">{props.children}</div>; 147 - }; 148 - 149 - const HelpLink = (props: { url: string; text: string }) => { 150 - const [isHovered, setIsHovered] = useState(false); 151 - const handleMouseEnter = () => { 152 - setIsHovered(true); 153 - }; 154 - const handleMouseLeave = () => { 155 - setIsHovered(false); 156 - }; 157 - return ( 158 - <a 159 - href={props.url} 160 - target="_blank" 161 - className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline" 162 - style={{ 163 - backgroundColor: isHovered 164 - ? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)" 165 - : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 166 - }} 167 - onMouseEnter={handleMouseEnter} 168 - onMouseLeave={handleMouseLeave} 169 - > 170 - <strong>{props.text}</strong> 171 - </a> 172 - ); 173 - };
···
-77
components/HomeButton.tsx
··· 1 - "use client"; 2 - import Link from "next/link"; 3 - import { useEntitySetContext } from "./EntitySetProvider"; 4 - import { ActionButton } from "components/ActionBar/ActionButton"; 5 - import { useParams, useSearchParams } from "next/navigation"; 6 - import { useIdentityData } from "./IdentityProvider"; 7 - import { useReplicache } from "src/replicache"; 8 - import { addLeafletToHome } from "actions/addLeafletToHome"; 9 - import { useSmoker } from "./Toast"; 10 - import { AddToHomeSmall } from "./Icons/AddToHomeSmall"; 11 - import { HomeSmall } from "./Icons/HomeSmall"; 12 - import { permission } from "process"; 13 - 14 - export function HomeButton() { 15 - let { permissions } = useEntitySetContext(); 16 - let searchParams = useSearchParams(); 17 - 18 - return ( 19 - <> 20 - <Link 21 - href="/home" 22 - prefetch 23 - className="hover:no-underline" 24 - style={{ textDecorationLine: "none !important" }} 25 - > 26 - <ActionButton icon={<HomeSmall />} label="Go Home" /> 27 - </Link> 28 - {<AddToHomeButton />} 29 - </> 30 - ); 31 - } 32 - 33 - const AddToHomeButton = (props: {}) => { 34 - let { permission_token } = useReplicache(); 35 - let { identity, mutate } = useIdentityData(); 36 - let smoker = useSmoker(); 37 - if ( 38 - identity?.permission_token_on_homepage.find( 39 - (pth) => pth.permission_tokens.id === permission_token.id, 40 - ) || 41 - !identity 42 - ) 43 - return null; 44 - return ( 45 - <ActionButton 46 - onClick={async (e) => { 47 - await addLeafletToHome(permission_token.id); 48 - mutate((identity) => { 49 - if (!identity) return; 50 - return { 51 - ...identity, 52 - permission_token_on_homepage: [ 53 - ...identity.permission_token_on_homepage, 54 - { 55 - archived: null, 56 - created_at: new Date().toISOString(), 57 - permission_tokens: { 58 - ...permission_token, 59 - leaflets_in_publications: [], 60 - }, 61 - }, 62 - ], 63 - }; 64 - }); 65 - smoker({ 66 - position: { 67 - x: e.clientX + 64, 68 - y: e.clientY, 69 - }, 70 - text: "Leaflet added to your home!", 71 - }); 72 - }} 73 - icon={<AddToHomeSmall />} 74 - label="Add to Home" 75 - /> 76 - ); 77 - };
···
+21
components/Icons/GoBackTiny.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const GoBackTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + > 12 + <path 13 + d="M7.40426 3L2.19592 8M2.19592 8L7.40426 13M2.19592 8H13.8041" 14 + stroke="currentColor" 15 + strokeWidth="2" 16 + strokeLinecap="round" 17 + strokeLinejoin="round" 18 + /> 19 + </svg> 20 + ); 21 + };
+19
components/Icons/LooseleafSmall.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const LooseLeafSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M16.5339 4.65788L21.9958 5.24186C22.4035 5.28543 22.7014 5.6481 22.6638 6.05632C22.5159 7.65303 22.3525 9.87767 22.0925 11.9186C21.9621 12.9418 21.805 13.9374 21.6091 14.8034C21.4166 15.6542 21.1733 16.442 20.8454 17.0104C20.1989 18.131 19.0036 18.9569 17.9958 19.4782C17.4793 19.7453 16.9792 19.9495 16.569 20.0827C16.3649 20.1489 16.1724 20.2013 16.0046 20.234C15.8969 20.255 15.7254 20.2816 15.5495 20.2682C15.5466 20.2681 15.5423 20.2684 15.5378 20.2682C15.527 20.2678 15.5112 20.267 15.4919 20.2663C15.4526 20.2647 15.3959 20.2623 15.3239 20.2584C15.1788 20.2506 14.9699 20.2366 14.7116 20.2145C14.1954 20.1703 13.4757 20.0909 12.6598 19.9489C11.0477 19.6681 8.97633 19.1301 7.36198 18.0807C6.70824 17.6557 5.95381 17.064 5.21842 16.4469C5.09798 16.5214 4.97261 16.591 4.81803 16.6706C4.28341 16.9455 3.71779 17.0389 3.17935 16.9137C2.64094 16.7885 2.20091 16.4608 1.89126 16.0231C1.28226 15.1618 1.16463 13.8852 1.5729 12.5514L1.60708 12.4606C1.7005 12.255 1.88295 12.1001 2.10513 12.0436C2.35906 11.9792 2.62917 12.0524 2.81607 12.236L2.82486 12.2448C2.8309 12.2507 2.84033 12.2596 2.8522 12.2712C2.87664 12.295 2.91343 12.3309 2.9606 12.3766C3.05513 12.4682 3.19281 12.6016 3.3649 12.7653C3.70953 13.0931 4.19153 13.5443 4.73795 14.0378C5.84211 15.0349 7.17372 16.1691 8.17937 16.8229C9.53761 17.7059 11.3696 18.2017 12.9177 18.4713C13.6815 18.6043 14.3565 18.679 14.8395 18.7204C15.0804 18.741 15.2731 18.7533 15.404 18.7604C15.4691 18.7639 15.5195 18.7659 15.5524 18.7672C15.5684 18.7679 15.5809 18.7689 15.5886 18.7692H15.5983L15.6374 18.7731C15.6457 18.7724 15.671 18.7704 15.7175 18.7614C15.8087 18.7436 15.9399 18.7095 16.1052 18.6559C16.4345 18.549 16.8594 18.3773 17.3063 18.1461C18.2257 17.6706 19.1147 17.0089 19.5466 16.2604C19.7578 15.8941 19.9618 15.2874 20.1462 14.4723C20.3271 13.6723 20.4767 12.7294 20.6042 11.7292C20.8232 10.0102 20.9711 8.17469 21.1042 6.65397L16.3747 6.14909C15.963 6.10498 15.6648 5.73562 15.7087 5.3239C15.7528 4.91222 16.1222 4.61399 16.5339 4.65788ZM12.0593 13.1315L12.2038 13.1647L12.3776 13.235C12.7592 13.4197 12.9689 13.7541 13.0837 14.0573C13.2089 14.3885 13.2545 14.7654 13.2858 15.0573C13.3144 15.3233 13.3319 15.5214 13.361 15.6774C13.4345 15.6215 13.5233 15.5493 13.6413 15.4479C13.7924 15.318 14.0034 15.1374 14.2429 15.0114C14.4965 14.878 14.8338 14.7772 15.2175 14.8747C15.5354 14.9556 15.7394 15.1539 15.8679 15.3229C15.9757 15.4648 16.0814 15.6631 16.1247 15.736C16.1889 15.8438 16.2218 15.8788 16.239 15.8922C16.2438 15.896 16.2462 15.8979 16.2497 15.8991C16.2541 15.9005 16.2717 15.9049 16.3093 15.9049C16.6541 15.9051 16.934 16.1851 16.9343 16.5299C16.9343 16.875 16.6543 17.1548 16.3093 17.1549C15.9766 17.1549 15.6957 17.0542 15.4694 16.8776C15.2617 16.7153 15.1322 16.5129 15.0505 16.3756C14.9547 16.2147 14.9262 16.1561 14.8815 16.0944C14.8684 16.0989 14.849 16.1051 14.8249 16.1178C14.7289 16.1684 14.6182 16.2555 14.4557 16.3952C14.3175 16.514 14.1171 16.6946 13.9069 16.821C13.6882 16.9524 13.3571 17.0902 12.9684 16.9938C12.4305 16.8602 12.2473 16.3736 12.1764 16.1051C12.1001 15.8159 12.0709 15.4542 12.0427 15.1911C12.0102 14.8884 11.9751 14.662 11.9138 14.4997C11.9011 14.4662 11.8884 14.4403 11.8776 14.4206C11.7899 14.4801 11.6771 14.5721 11.5329 14.7047C11.3855 14.8404 11.181 15.0386 11.0016 15.196C10.8175 15.3575 10.5936 15.5364 10.3512 15.6569C10.19 15.737 9.99118 15.7919 9.77214 15.7594C9.55026 15.7264 9.38367 15.6153 9.27019 15.5045C9.08085 15.3197 8.96362 15.0503 8.91081 14.9391C8.8766 14.8671 8.85074 14.814 8.82585 14.7692C8.541 14.777 8.27798 14.5891 8.20378 14.3014C8.11797 13.9674 8.31907 13.6269 8.653 13.5407L8.79558 13.5124C8.93966 13.4936 9.0875 13.5034 9.23308 13.5485C9.42396 13.6076 9.569 13.7155 9.67449 13.8239C9.85113 14.0055 9.96389 14.244 10.027 14.3776C10.0723 14.3417 10.124 14.3034 10.1774 14.2565C10.3474 14.1073 10.4942 13.9615 10.6862 13.7848C10.8571 13.6276 11.0614 13.4475 11.2731 13.32C11.4428 13.2178 11.7294 13.081 12.0593 13.1315ZM2.84537 14.3366C2.88081 14.6965 2.98677 14.9742 3.11588 15.1569C3.24114 15.334 3.38295 15.4211 3.5192 15.4528C3.63372 15.4794 3.79473 15.4775 4.00553 15.3932C3.9133 15.3109 3.82072 15.2311 3.73209 15.151C3.40947 14.8597 3.10909 14.5828 2.84537 14.3366ZM8.73601 3.86003C9.14672 3.91292 9.43715 4.28918 9.38445 4.69987C9.25964 5.66903 9.14642 7.35598 8.87077 9.02018C8.59001 10.7151 8.11848 12.5766 7.20085 14.1003C6.98712 14.4551 6.52539 14.5698 6.17057 14.3561C5.81623 14.1423 5.70216 13.6814 5.91569 13.3268C6.68703 12.0463 7.121 10.4066 7.39128 8.77506C7.66663 7.11265 7.74965 5.64618 7.89616 4.50847C7.94916 4.09794 8.32546 3.80744 8.73601 3.86003ZM11.7614 8.36784C12.1238 8.21561 12.4973 8.25977 12.8054 8.46452C13.0762 8.64474 13.2601 8.92332 13.3884 9.18912C13.5214 9.46512 13.6241 9.79028 13.7009 10.1354C13.7561 10.3842 13.7827 10.6162 13.8034 10.8044C13.8257 11.0069 13.8398 11.1363 13.864 11.2438C13.8806 11.3174 13.8959 11.3474 13.9011 11.3561C13.9095 11.3609 13.9289 11.3695 13.9655 11.3786C14.0484 11.3991 14.0814 11.3929 14.0895 11.3913C14.1027 11.3885 14.1323 11.3804 14.2028 11.3366C14.3137 11.2677 14.6514 11.0042 15.0563 10.8288L15.1364 10.7985C15.3223 10.7392 15.4987 10.7526 15.6335 10.7838C15.7837 10.8188 15.918 10.883 16.0231 10.9421C16.2276 11.057 16.4458 11.2251 16.613 11.3503C16.8019 11.4917 16.9527 11.5999 17.0827 11.6676C17.1539 11.7047 17.1908 11.7142 17.2009 11.7165L17.2849 11.7047C17.5751 11.6944 17.8425 11.8891 17.9138 12.1823C17.995 12.5174 17.7897 12.8554 17.4548 12.9372C17.0733 13.0299 16.7253 12.8909 16.5046 12.776C16.2705 12.6541 16.042 12.4845 15.864 12.3512C15.6704 12.2064 15.5344 12.1038 15.4216 12.0387C15.2178 12.1436 15.1125 12.2426 14.862 12.3981C14.7283 12.4811 14.5564 12.5716 14.3415 12.6159C14.1216 12.6611 13.8975 12.6501 13.6647 12.5924C13.3819 12.5222 13.1344 12.3858 12.9479 12.1657C12.7701 11.9555 12.689 11.7172 12.6442 11.5182C12.601 11.3259 12.58 11.112 12.5612 10.9411C12.5408 10.7561 12.5194 10.5827 12.4802 10.4059C12.4169 10.1215 12.3411 9.89526 12.2624 9.73209C12.2296 9.66404 12.1981 9.61255 12.1716 9.57487C12.1263 9.61576 12.0615 9.68493 11.9802 9.7985C11.8864 9.92952 11.7821 10.0922 11.6589 10.2838C11.5393 10.4698 11.4043 10.6782 11.2634 10.8786C11.123 11.0782 10.9664 11.2843 10.7975 11.4635C10.633 11.6381 10.4285 11.8185 10.1862 11.9342C9.87476 12.0828 9.50095 11.9507 9.35222 11.6393C9.20377 11.3279 9.33594 10.9551 9.64714 10.8063C9.69148 10.7851 9.77329 10.7282 9.88835 10.6061C9.99931 10.4883 10.1167 10.3365 10.2409 10.1598C10.3647 9.98378 10.4855 9.79617 10.6071 9.60709C10.7249 9.42397 10.8479 9.23258 10.9636 9.07096C11.1814 8.76677 11.4424 8.50191 11.7614 8.36784ZM12.4304 2.81218C13.631 2.81246 14.6042 3.78628 14.6042 4.98698C14.6041 5.39899 14.4869 5.78271 14.2878 6.111L15.0007 6.9069C15.2772 7.21532 15.2515 7.689 14.9431 7.96549C14.6347 8.24164 14.1609 8.21606 13.8845 7.90788L13.1139 7.0485C12.8988 7.11984 12.6695 7.16075 12.4304 7.16081C11.2296 7.16081 10.2558 6.18766 10.2555 4.98698C10.2555 3.7861 11.2295 2.81218 12.4304 2.81218ZM12.4304 4.31218C12.0579 4.31218 11.7555 4.61453 11.7555 4.98698C11.7558 5.35924 12.058 5.66081 12.4304 5.66081C12.8024 5.66053 13.104 5.35907 13.1042 4.98698C13.1042 4.6147 12.8026 4.31246 12.4304 4.31218Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+1
components/Icons/ReplyTiny.tsx
··· 8 viewBox="0 0 16 16" 9 fill="none" 10 xmlns="http://www.w3.org/2000/svg" 11 > 12 <path 13 fillRule="evenodd"
··· 8 viewBox="0 0 16 16" 9 fill="none" 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 > 13 <path 14 fillRule="evenodd"
+19
components/Icons/TagTiny.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const TagTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M3.70775 9.003C3.96622 8.90595 4.25516 9.03656 4.35228 9.29499C4.37448 9.35423 4.38309 9.41497 4.38255 9.47468C4.38208 9.6765 4.25946 9.86621 4.05931 9.94148C3.36545 10.2021 2.74535 10.833 2.42747 11.5479C2.33495 11.7561 2.27242 11.9608 2.239 12.1573C2.15817 12.6374 2.25357 13.069 2.52513 13.3858C2.92043 13.8467 3.51379 14.0403 4.20189 14.0665C4.88917 14.0925 5.59892 13.9482 6.12571 13.8126C7.09158 13.5639 7.81893 13.6157 8.29954 13.9415C8.67856 14.1986 8.83462 14.578 8.8347 14.9298C8.83502 15.0506 8.81652 15.1682 8.78294 15.2764C8.7009 15.5398 8.42049 15.6873 8.15696 15.6055C7.89935 15.5253 7.75386 15.2555 7.82396 14.9971C7.82572 14.9905 7.8258 14.9833 7.82786 14.9766C7.83167 14.9643 7.834 14.9503 7.8347 14.9356C7.83623 14.8847 7.8147 14.823 7.739 14.7716C7.61179 14.6853 7.23586 14.5616 6.37474 14.7833C5.81779 14.9266 4.99695 15.1 4.1638 15.0684C3.33126 15.0368 2.41412 14.7967 1.76536 14.0401C1.30175 13.4992 1.16206 12.8427 1.22728 12.1993C1.23863 12.086 1.25554 11.9732 1.27903 11.8614C1.28235 11.8457 1.28624 11.8302 1.28978 11.8145C1.34221 11.5817 1.41832 11.3539 1.51439 11.1378C1.92539 10.2136 2.72927 9.37064 3.70775 9.003ZM13.8972 7.54695C14.124 7.38948 14.4359 7.44622 14.5935 7.67292C14.7508 7.89954 14.6948 8.21063 14.4685 8.36823L8.65892 12.4044C8.24041 12.695 7.74265 12.8515 7.23314 12.8516H3.9138C3.63794 12.8515 3.41315 12.6274 3.41282 12.3516C3.41282 12.0755 3.63769 11.8517 3.9138 11.8516H7.23216C7.538 11.8516 7.8374 11.7575 8.0886 11.5831L13.8972 7.54695ZM10.1609 0.550851C10.6142 0.235853 11.2372 0.347685 11.5525 0.800851L14.6091 5.19734C14.9239 5.65063 14.8121 6.27369 14.3591 6.58894L7.88841 11.087C7.63297 11.2645 7.32837 11.3586 7.01732 11.3555L4.1804 11.3262C3.76371 11.3218 3.38443 11.1921 3.072 10.9776C3.23822 10.7748 3.43062 10.5959 3.63646 10.4503C3.96958 10.5767 4.35782 10.5421 4.67259 10.3233C5.17899 9.97084 5.30487 9.27438 4.95286 8.76765C4.60048 8.26108 3.90304 8.13639 3.39622 8.48835C3.17656 8.64127 3.02799 8.85895 2.9597 9.09773C2.69658 9.26211 2.45194 9.45783 2.23118 9.67585C2.17892 9.38285 2.19133 9.07163 2.28294 8.76081L3.14818 5.8282C3.24483 5.50092 3.45101 5.21639 3.73118 5.02155L10.1609 0.550851ZM8.76732 3.73835L9.73607 4.91023L8.68626 5.41804L7.79466 6.24323L7.04857 4.91804L6.26634 5.45417L7.22923 6.63386L5.72337 7.40437L6.34739 8.31355L7.60814 7.18464L8.37767 8.53132L9.15989 7.99421L8.17454 6.79792L9.27708 6.25788L10.1179 5.46589L10.8786 6.81452L11.6609 6.27741L10.6745 5.07917L12.1882 4.30476L11.5642 3.39558L10.2976 4.52839L9.54954 3.20124L8.76732 3.73835Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+11 -36
components/Input.tsx
··· 2 import { useEffect, useRef, useState, type JSX } from "react"; 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 import { isIOS } from "src/utils/isDevice"; 5 6 export const Input = ( 7 props: { ··· 58 ); 59 }; 60 61 - export const focusElement = (el?: HTMLInputElement | null) => { 62 - if (!isIOS()) { 63 - el?.focus(); 64 - return; 65 - } 66 - 67 - let fakeInput = document.createElement("input"); 68 - fakeInput.setAttribute("type", "text"); 69 - fakeInput.style.position = "fixed"; 70 - fakeInput.style.height = "0px"; 71 - fakeInput.style.width = "0px"; 72 - fakeInput.style.fontSize = "16px"; // disable auto zoom 73 - document.body.appendChild(fakeInput); 74 - fakeInput.focus(); 75 - setTimeout(() => { 76 - if (!el) return; 77 - el.style.transform = "translateY(-2000px)"; 78 - el?.focus(); 79 - fakeInput.remove(); 80 - el.value = " "; 81 - el.setSelectionRange(1, 1); 82 - requestAnimationFrame(() => { 83 - if (el) { 84 - el.style.transform = ""; 85 - } 86 - }); 87 - setTimeout(() => { 88 - if (!el) return; 89 - el.value = ""; 90 - el.setSelectionRange(0, 0); 91 - }, 50); 92 - }, 20); 93 - }; 94 - 95 export const InputWithLabel = ( 96 props: { 97 label: string; ··· 100 JSX.IntrinsicElements["textarea"], 101 ) => { 102 let { label, textarea, ...inputProps } = props; 103 - let style = `appearance-none w-full font-normal not-italic bg-transparent text-base text-primary focus:outline-0 ${props.className} outline-hidden resize-none`; 104 return ( 105 - <label className=" input-with-border flex flex-col gap-px text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]!"> 106 {props.label} 107 {textarea ? ( 108 <textarea {...inputProps} className={style} />
··· 2 import { useEffect, useRef, useState, type JSX } from "react"; 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 import { isIOS } from "src/utils/isDevice"; 5 + import { focusElement } from "src/utils/focusElement"; 6 7 export const Input = ( 8 props: { ··· 59 ); 60 }; 61 62 export const InputWithLabel = ( 63 props: { 64 label: string; ··· 67 JSX.IntrinsicElements["textarea"], 68 ) => { 69 let { label, textarea, ...inputProps } = props; 70 + let style = ` 71 + appearance-none resize-none w-full 72 + bg-transparent 73 + outline-hidden focus:outline-0 74 + font-normal not-italic text-base text-primary disabled:text-tertiary 75 + disabled:cursor-not-allowed 76 + ${props.className}`; 77 return ( 78 + <label 79 + className={`input-with-border flex flex-col gap-px text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]! ${props.disabled && "bg-border-light! cursor-not-allowed! hover:border-border!"}`} 80 + > 81 {props.label} 82 {textarea ? ( 83 <textarea {...inputProps} className={style} />
+114
components/InteractionsPreview.tsx
···
··· 1 + "use client"; 2 + import { Separator } from "./Layout"; 3 + import { CommentTiny } from "./Icons/CommentTiny"; 4 + import { QuoteTiny } from "./Icons/QuoteTiny"; 5 + import { useSmoker } from "./Toast"; 6 + import { Tag } from "./Tags"; 7 + import { Popover } from "./Popover"; 8 + import { TagTiny } from "./Icons/TagTiny"; 9 + import { SpeedyLink } from "./SpeedyLink"; 10 + 11 + export const InteractionPreview = (props: { 12 + quotesCount: number; 13 + commentsCount: number; 14 + tags?: string[]; 15 + postUrl: string; 16 + showComments: boolean | undefined; 17 + share?: boolean; 18 + }) => { 19 + let smoker = useSmoker(); 20 + let interactionsAvailable = 21 + props.quotesCount > 0 || 22 + (props.showComments !== false && props.commentsCount > 0); 23 + 24 + const tagsCount = props.tags?.length || 0; 25 + 26 + return ( 27 + <div 28 + className={`flex gap-2 text-tertiary text-sm items-center self-start`} 29 + > 30 + {tagsCount === 0 ? null : ( 31 + <> 32 + <TagPopover tags={props.tags!} /> 33 + {interactionsAvailable || props.share ? ( 34 + <Separator classname="h-4!" /> 35 + ) : null} 36 + </> 37 + )} 38 + 39 + {props.quotesCount === 0 ? null : ( 40 + <SpeedyLink 41 + aria-label="Post quotes" 42 + href={`${props.postUrl}?interactionDrawer=quotes`} 43 + className="flex flex-row gap-1 text-sm items-center text-accent-contrast!" 44 + > 45 + <QuoteTiny /> {props.quotesCount} 46 + </SpeedyLink> 47 + )} 48 + {props.showComments === false || props.commentsCount === 0 ? null : ( 49 + <SpeedyLink 50 + aria-label="Post comments" 51 + href={`${props.postUrl}?interactionDrawer=comments`} 52 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 53 + > 54 + <CommentTiny /> {props.commentsCount} 55 + </SpeedyLink> 56 + )} 57 + {interactionsAvailable && props.share ? ( 58 + <Separator classname="h-4! !min-h-0" /> 59 + ) : null} 60 + {props.share && ( 61 + <> 62 + <button 63 + id={`copy-post-link-${props.postUrl}`} 64 + className="flex gap-1 items-center hover:text-accent-contrast relative" 65 + onClick={(e) => { 66 + e.stopPropagation(); 67 + e.preventDefault(); 68 + let mouseX = e.clientX; 69 + let mouseY = e.clientY; 70 + 71 + if (!props.postUrl) return; 72 + navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 73 + 74 + smoker({ 75 + text: <strong>Copied Link!</strong>, 76 + position: { 77 + y: mouseY, 78 + x: mouseX, 79 + }, 80 + }); 81 + }} 82 + > 83 + Share 84 + </button> 85 + </> 86 + )} 87 + </div> 88 + ); 89 + }; 90 + 91 + const TagPopover = (props: { tags: string[] }) => { 92 + return ( 93 + <Popover 94 + className="p-2! max-w-xs" 95 + trigger={ 96 + <div className="relative flex gap-1 items-center hover:text-accent-contrast "> 97 + <TagTiny /> {props.tags.length} 98 + </div> 99 + } 100 + > 101 + <TagList tags={props.tags} className="text-secondary!" /> 102 + </Popover> 103 + ); 104 + }; 105 + 106 + const TagList = (props: { tags: string[]; className?: string }) => { 107 + return ( 108 + <div className="flex gap-1 flex-wrap"> 109 + {props.tags.map((tag, index) => ( 110 + <Tag name={tag} key={index} className={props.className} /> 111 + ))} 112 + </div> 113 + ); 114 + };
+2 -1
components/Layout.tsx
··· 1 import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 2 import { theme } from "tailwind.config"; 3 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 4 import { PopoverArrow } from "./Icons/PopoverArrow"; 5 - import { PopoverOpenContext } from "./Popover"; 6 import { useState } from "react"; 7 8 export const Separator = (props: { classname?: string }) => {
··· 1 + "use client"; 2 import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 3 import { theme } from "tailwind.config"; 4 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 import { PopoverArrow } from "./Icons/PopoverArrow"; 6 + import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 import { useState } from "react"; 8 9 export const Separator = (props: { classname?: string }) => {
+543
components/Mention.tsx
···
··· 1 + "use client"; 2 + import { Agent } from "@atproto/api"; 3 + import { useState, useEffect, Fragment, useRef, useCallback } from "react"; 4 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 5 + import * as Popover from "@radix-ui/react-popover"; 6 + import { EditorView } from "prosemirror-view"; 7 + import { callRPC } from "app/api/rpc/client"; 8 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 9 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 10 + import { SearchTiny } from "components/Icons/SearchTiny"; 11 + import { CloseTiny } from "./Icons/CloseTiny"; 12 + import { GoToArrow } from "./Icons/GoToArrow"; 13 + import { GoBackTiny } from "./Icons/GoBackTiny"; 14 + 15 + export function MentionAutocomplete(props: { 16 + open: boolean; 17 + onOpenChange: (open: boolean) => void; 18 + view: React.RefObject<EditorView | null>; 19 + onSelect: (mention: Mention) => void; 20 + coords: { top: number; left: number } | null; 21 + placeholder?: string; 22 + }) { 23 + const [searchQuery, setSearchQuery] = useState(""); 24 + const [noResults, setNoResults] = useState(false); 25 + const inputRef = useRef<HTMLInputElement>(null); 26 + const contentRef = useRef<HTMLDivElement>(null); 27 + 28 + const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } = 29 + useMentionSuggestions(searchQuery); 30 + 31 + // Clear search when scope changes 32 + const handleScopeChange = useCallback( 33 + (newScope: MentionScope) => { 34 + setSearchQuery(""); 35 + setSuggestionIndex(0); 36 + setScope(newScope); 37 + }, 38 + [setScope, setSuggestionIndex], 39 + ); 40 + 41 + // Focus input when opened 42 + useEffect(() => { 43 + if (props.open && inputRef.current) { 44 + // Small delay to ensure the popover is mounted 45 + setTimeout(() => inputRef.current?.focus(), 0); 46 + } 47 + }, [props.open]); 48 + 49 + // Reset state when closed 50 + useEffect(() => { 51 + if (!props.open) { 52 + setSearchQuery(""); 53 + setScope({ type: "default" }); 54 + setSuggestionIndex(0); 55 + setNoResults(false); 56 + } 57 + }, [props.open, setScope, setSuggestionIndex]); 58 + 59 + // Handle timeout for showing "No results found" 60 + useEffect(() => { 61 + if (searchQuery && suggestions.length === 0) { 62 + setNoResults(false); 63 + const timer = setTimeout(() => { 64 + setNoResults(true); 65 + }, 2000); 66 + return () => clearTimeout(timer); 67 + } else { 68 + setNoResults(false); 69 + } 70 + }, [searchQuery, suggestions.length]); 71 + 72 + // Handle keyboard navigation 73 + const handleKeyDown = (e: React.KeyboardEvent) => { 74 + if (e.key === "Escape") { 75 + e.preventDefault(); 76 + props.onOpenChange(false); 77 + props.view.current?.focus(); 78 + return; 79 + } 80 + 81 + if (e.key === "Backspace" && searchQuery === "") { 82 + // Backspace at the start of input closes autocomplete and refocuses editor 83 + e.preventDefault(); 84 + props.onOpenChange(false); 85 + props.view.current?.focus(); 86 + return; 87 + } 88 + 89 + // Reverse arrow key direction when popover is rendered above 90 + const isReversed = contentRef.current?.dataset.side === "top"; 91 + const upKey = isReversed ? "ArrowDown" : "ArrowUp"; 92 + const downKey = isReversed ? "ArrowUp" : "ArrowDown"; 93 + 94 + if (e.key === upKey) { 95 + e.preventDefault(); 96 + if (suggestionIndex > 0) { 97 + setSuggestionIndex((i) => i - 1); 98 + } 99 + } else if (e.key === downKey) { 100 + e.preventDefault(); 101 + if (suggestionIndex < suggestions.length - 1) { 102 + setSuggestionIndex((i) => i + 1); 103 + } 104 + } else if (e.key === "Tab") { 105 + const selectedSuggestion = suggestions[suggestionIndex]; 106 + if (selectedSuggestion?.type === "publication") { 107 + e.preventDefault(); 108 + handleScopeChange({ 109 + type: "publication", 110 + uri: selectedSuggestion.uri, 111 + name: selectedSuggestion.name, 112 + }); 113 + } 114 + } else if (e.key === "Enter") { 115 + e.preventDefault(); 116 + const selectedSuggestion = suggestions[suggestionIndex]; 117 + if (selectedSuggestion) { 118 + props.onSelect(selectedSuggestion); 119 + props.onOpenChange(false); 120 + } 121 + } else if ( 122 + e.key === " " && 123 + searchQuery === "" && 124 + scope.type === "default" 125 + ) { 126 + // Space immediately after opening closes the autocomplete 127 + e.preventDefault(); 128 + props.onOpenChange(false); 129 + // Insert a space after the @ in the editor 130 + if (props.view.current) { 131 + const view = props.view.current; 132 + const tr = view.state.tr.insertText(" "); 133 + view.dispatch(tr); 134 + view.focus(); 135 + } 136 + } 137 + }; 138 + 139 + if (!props.open || !props.coords) return null; 140 + 141 + const getHeader = (type: Mention["type"], scope?: MentionScope) => { 142 + switch (type) { 143 + case "did": 144 + return "People"; 145 + case "publication": 146 + return "Publications"; 147 + case "post": 148 + if (scope) { 149 + return ( 150 + <ScopeHeader 151 + scope={scope} 152 + handleScopeChange={() => { 153 + handleScopeChange({ type: "default" }); 154 + }} 155 + /> 156 + ); 157 + } else return "Posts"; 158 + } 159 + }; 160 + 161 + const sortedSuggestions = [...suggestions].sort((a, b) => { 162 + const order: Mention["type"][] = ["did", "publication", "post"]; 163 + return order.indexOf(a.type) - order.indexOf(b.type); 164 + }); 165 + 166 + return ( 167 + <Popover.Root open> 168 + <Popover.Anchor 169 + style={{ 170 + top: props.coords.top - 24, 171 + left: props.coords.left, 172 + height: 24, 173 + position: "absolute", 174 + }} 175 + /> 176 + <Popover.Portal> 177 + <Popover.Content 178 + ref={contentRef} 179 + align="start" 180 + sideOffset={4} 181 + collisionPadding={32} 182 + onOpenAutoFocus={(e) => e.preventDefault()} 183 + className={`dropdownMenu group/mention-menu z-20 bg-bg-page 184 + flex data-[side=top]:flex-col-reverse flex-col 185 + p-1 gap-1 text-primary 186 + border border-border rounded-md shadow-md 187 + sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width) 188 + max-h-(--radix-popover-content-available-height) 189 + overflow-hidden`} 190 + > 191 + {/* Dropdown Header - sticky */} 192 + <div className="flex flex-col items-center gap-2 px-2 py-1 border-b group-data-[side=top]/mention-menu:border-b-0 group-data-[side=top]/mention-menu:border-t border-border-light bg-bg-page sticky top-0 group-data-[side=top]/mention-menu:sticky group-data-[side=top]/mention-menu:bottom-0 group-data-[side=top]/mention-menu:top-auto z-10 shrink-0"> 193 + <div className="flex items-center gap-1 flex-1 min-w-0 text-primary"> 194 + <div className="text-tertiary"> 195 + <SearchTiny className="w-4 h-4 shrink-0" /> 196 + </div> 197 + <input 198 + ref={inputRef} 199 + size={100} 200 + type="text" 201 + value={searchQuery} 202 + onChange={(e) => { 203 + setSearchQuery(e.target.value); 204 + setSuggestionIndex(0); 205 + }} 206 + onKeyDown={handleKeyDown} 207 + autoFocus 208 + placeholder={ 209 + scope.type === "publication" 210 + ? "Search posts..." 211 + : props.placeholder ?? "Search people & publications..." 212 + } 213 + className="flex-1 w-full min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary" 214 + /> 215 + </div> 216 + </div> 217 + <div className="overflow-y-auto flex-1 min-h-0"> 218 + {sortedSuggestions.length === 0 && noResults && ( 219 + <div className="text-sm text-tertiary italic px-3 py-1 text-center"> 220 + No results found 221 + </div> 222 + )} 223 + <ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse"> 224 + {sortedSuggestions.map((result, index) => { 225 + const prevResult = sortedSuggestions[index - 1]; 226 + const showHeader = 227 + index === 0 || 228 + (prevResult && prevResult.type !== result.type); 229 + 230 + return ( 231 + <Fragment 232 + key={result.type === "did" ? result.did : result.uri} 233 + > 234 + {showHeader && ( 235 + <> 236 + {index > 0 && ( 237 + <hr className="border-border-light mx-1 my-1" /> 238 + )} 239 + <div className="text-xs text-tertiary font-bold pt-1 px-2"> 240 + {getHeader(result.type, scope)} 241 + </div> 242 + </> 243 + )} 244 + {result.type === "did" ? ( 245 + <DidResult 246 + onClick={() => { 247 + props.onSelect(result); 248 + props.onOpenChange(false); 249 + }} 250 + onMouseDown={(e) => e.preventDefault()} 251 + displayName={result.displayName} 252 + handle={result.handle} 253 + avatar={result.avatar} 254 + selected={index === suggestionIndex} 255 + /> 256 + ) : result.type === "publication" ? ( 257 + <PublicationResult 258 + onClick={() => { 259 + props.onSelect(result); 260 + props.onOpenChange(false); 261 + }} 262 + onMouseDown={(e) => e.preventDefault()} 263 + pubName={result.name} 264 + uri={result.uri} 265 + selected={index === suggestionIndex} 266 + onPostsClick={() => { 267 + handleScopeChange({ 268 + type: "publication", 269 + uri: result.uri, 270 + name: result.name, 271 + }); 272 + }} 273 + /> 274 + ) : ( 275 + <PostResult 276 + onClick={() => { 277 + props.onSelect(result); 278 + props.onOpenChange(false); 279 + }} 280 + onMouseDown={(e) => e.preventDefault()} 281 + title={result.title} 282 + selected={index === suggestionIndex} 283 + /> 284 + )} 285 + </Fragment> 286 + ); 287 + })} 288 + </ul> 289 + </div> 290 + </Popover.Content> 291 + </Popover.Portal> 292 + </Popover.Root> 293 + ); 294 + } 295 + 296 + const Result = (props: { 297 + result: React.ReactNode; 298 + subtext?: React.ReactNode; 299 + icon?: React.ReactNode; 300 + onClick: () => void; 301 + onMouseDown: (e: React.MouseEvent) => void; 302 + selected?: boolean; 303 + }) => { 304 + return ( 305 + <button 306 + className={` 307 + menuItem w-full flex-row! gap-2! 308 + text-secondary leading-snug text-sm 309 + ${props.subtext ? "py-1!" : "py-2!"} 310 + ${props.selected ? "bg-[var(--accent-light)]!" : ""}`} 311 + onClick={() => { 312 + props.onClick(); 313 + }} 314 + onMouseDown={(e) => props.onMouseDown(e)} 315 + > 316 + {props.icon} 317 + <div className="flex flex-col min-w-0 flex-1"> 318 + <div 319 + className={`flex gap-2 items-center w-full truncate justify-between`} 320 + > 321 + {props.result} 322 + </div> 323 + {props.subtext && ( 324 + <div className="text-tertiary italic text-xs font-normal min-w-0 truncate pb-[1px]"> 325 + {props.subtext} 326 + </div> 327 + )} 328 + </div> 329 + </button> 330 + ); 331 + }; 332 + 333 + const ScopeButton = (props: { 334 + onClick: () => void; 335 + children: React.ReactNode; 336 + }) => { 337 + return ( 338 + <span 339 + className="flex flex-row items-center h-full shrink-0 text-xs font-normal text-tertiary hover:text-accent-contrast cursor-pointer" 340 + onClick={(e) => { 341 + e.preventDefault(); 342 + e.stopPropagation(); 343 + props.onClick(); 344 + }} 345 + onMouseDown={(e) => { 346 + e.preventDefault(); 347 + e.stopPropagation(); 348 + }} 349 + > 350 + {props.children} <ArrowRightTiny className="scale-80" /> 351 + </span> 352 + ); 353 + }; 354 + 355 + const DidResult = (props: { 356 + displayName?: string; 357 + handle: string; 358 + avatar?: string; 359 + onClick: () => void; 360 + onMouseDown: (e: React.MouseEvent) => void; 361 + selected?: boolean; 362 + }) => { 363 + return ( 364 + <Result 365 + icon={ 366 + props.avatar ? ( 367 + <img 368 + src={props.avatar} 369 + alt="" 370 + className="w-5 h-5 rounded-full shrink-0" 371 + /> 372 + ) : ( 373 + <div className="w-5 h-5 rounded-full bg-border shrink-0" /> 374 + ) 375 + } 376 + result={props.displayName ? props.displayName : props.handle} 377 + subtext={props.displayName && `@${props.handle}`} 378 + onClick={props.onClick} 379 + onMouseDown={props.onMouseDown} 380 + selected={props.selected} 381 + /> 382 + ); 383 + }; 384 + 385 + const PublicationResult = (props: { 386 + pubName: string; 387 + uri: string; 388 + onClick: () => void; 389 + onMouseDown: (e: React.MouseEvent) => void; 390 + selected?: boolean; 391 + onPostsClick: () => void; 392 + }) => { 393 + return ( 394 + <Result 395 + icon={ 396 + <img 397 + src={`/api/pub_icon?at_uri=${encodeURIComponent(props.uri)}`} 398 + alt="" 399 + className="w-5 h-5 rounded-full shrink-0" 400 + /> 401 + } 402 + result={ 403 + <> 404 + <div className="truncate w-full grow min-w-0">{props.pubName}</div> 405 + <ScopeButton onClick={props.onPostsClick}>Posts</ScopeButton> 406 + </> 407 + } 408 + onClick={props.onClick} 409 + onMouseDown={props.onMouseDown} 410 + selected={props.selected} 411 + /> 412 + ); 413 + }; 414 + 415 + const PostResult = (props: { 416 + title: string; 417 + onClick: () => void; 418 + onMouseDown: (e: React.MouseEvent) => void; 419 + selected?: boolean; 420 + }) => { 421 + return ( 422 + <Result 423 + result={<div className="truncate w-full">{props.title}</div>} 424 + onClick={props.onClick} 425 + onMouseDown={props.onMouseDown} 426 + selected={props.selected} 427 + /> 428 + ); 429 + }; 430 + 431 + const ScopeHeader = (props: { 432 + scope: MentionScope; 433 + handleScopeChange: () => void; 434 + }) => { 435 + if (props.scope.type === "default") return; 436 + if (props.scope.type === "publication") 437 + return ( 438 + <button 439 + className="w-full flex flex-row gap-2 pt-1 rounded text-tertiary hover:text-accent-contrast shrink-0 text-xs" 440 + onClick={() => props.handleScopeChange()} 441 + onMouseDown={(e) => e.preventDefault()} 442 + > 443 + <GoBackTiny className="shrink-0 " /> 444 + 445 + <div className="grow w-full truncate text-left"> 446 + Posts from {props.scope.name} 447 + </div> 448 + </button> 449 + ); 450 + }; 451 + 452 + export type Mention = 453 + | { 454 + type: "did"; 455 + handle: string; 456 + did: string; 457 + displayName?: string; 458 + avatar?: string; 459 + } 460 + | { type: "publication"; uri: string; name: string; url: string } 461 + | { type: "post"; uri: string; title: string; url: string }; 462 + 463 + export type MentionScope = 464 + | { type: "default" } 465 + | { type: "publication"; uri: string; name: string }; 466 + function useMentionSuggestions(query: string | null) { 467 + const [suggestionIndex, setSuggestionIndex] = useState(0); 468 + const [suggestions, setSuggestions] = useState<Array<Mention>>([]); 469 + const [scope, setScope] = useState<MentionScope>({ type: "default" }); 470 + 471 + // Clear suggestions immediately when scope changes 472 + const setScopeAndClear = useCallback((newScope: MentionScope) => { 473 + setSuggestions([]); 474 + setScope(newScope); 475 + }, []); 476 + 477 + useDebouncedEffect( 478 + async () => { 479 + if (!query && scope.type === "default") { 480 + setSuggestions([]); 481 + return; 482 + } 483 + 484 + if (scope.type === "publication") { 485 + // Search within the publication's documents 486 + const documents = await callRPC(`search_publication_documents`, { 487 + publication_uri: scope.uri, 488 + query: query || "", 489 + limit: 10, 490 + }); 491 + setSuggestions( 492 + documents.result.documents.map((d) => ({ 493 + type: "post" as const, 494 + uri: d.uri, 495 + title: d.title, 496 + url: d.url, 497 + })), 498 + ); 499 + } else { 500 + // Default scope: search people and publications 501 + const agent = new Agent("https://public.api.bsky.app"); 502 + const [result, publications] = await Promise.all([ 503 + agent.searchActorsTypeahead({ 504 + q: query || "", 505 + limit: 8, 506 + }), 507 + callRPC(`search_publication_names`, { query: query || "", limit: 8 }), 508 + ]); 509 + setSuggestions([ 510 + ...result.data.actors.map((actor) => ({ 511 + type: "did" as const, 512 + handle: actor.handle, 513 + did: actor.did, 514 + displayName: actor.displayName, 515 + avatar: actor.avatar, 516 + })), 517 + ...publications.result.publications.map((p) => ({ 518 + type: "publication" as const, 519 + uri: p.uri, 520 + name: p.name, 521 + url: p.url, 522 + })), 523 + ]); 524 + } 525 + }, 526 + 300, 527 + [query, scope], 528 + ); 529 + 530 + useEffect(() => { 531 + if (suggestionIndex > suggestions.length - 1) { 532 + setSuggestionIndex(Math.max(0, suggestions.length - 1)); 533 + } 534 + }, [suggestionIndex, suggestions.length]); 535 + 536 + return { 537 + suggestions, 538 + suggestionIndex, 539 + setSuggestionIndex, 540 + scope, 541 + setScope: setScopeAndClear, 542 + }; 543 + }
+7 -8
components/PageHeader.tsx
··· 1 "use client"; 2 import { useState, useEffect } from "react"; 3 4 - export const Header = (props: { 5 - children: React.ReactNode; 6 - cardBorderHidden: boolean; 7 - }) => { 8 let [scrollPos, setScrollPos] = useState(0); 9 10 useEffect(() => { ··· 22 } 23 }, []); 24 25 - let headerBGColor = props.cardBorderHidden 26 ? "var(--bg-leaflet)" 27 : "var(--bg-page)"; 28 ··· 54 style={ 55 scrollPos < 20 56 ? { 57 - backgroundColor: props.cardBorderHidden 58 ? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})` 59 : `rgba(${headerBGColor}, ${scrollPos / 20})`, 60 - paddingLeft: props.cardBorderHidden 61 ? "4px" 62 : `calc(${scrollPos / 20}*4px)`, 63 - paddingRight: props.cardBorderHidden 64 ? "8px" 65 : `calc(${scrollPos / 20}*8px)`, 66 }
··· 1 "use client"; 2 import { useState, useEffect } from "react"; 3 + import { useCardBorderHidden } from "./Pages/useCardBorderHidden"; 4 5 + export const Header = (props: { children: React.ReactNode }) => { 6 + let cardBorderHidden = useCardBorderHidden(); 7 let [scrollPos, setScrollPos] = useState(0); 8 9 useEffect(() => { ··· 21 } 22 }, []); 23 24 + let headerBGColor = !cardBorderHidden 25 ? "var(--bg-leaflet)" 26 : "var(--bg-page)"; 27 ··· 53 style={ 54 scrollPos < 20 55 ? { 56 + backgroundColor: !cardBorderHidden 57 ? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})` 58 : `rgba(${headerBGColor}, ${scrollPos / 20})`, 59 + paddingLeft: !cardBorderHidden 60 ? "4px" 61 : `calc(${scrollPos / 20}*4px)`, 62 + paddingRight: !cardBorderHidden 63 ? "8px" 64 : `calc(${scrollPos / 20}*8px)`, 65 }
+3 -23
components/PageLayouts/DashboardLayout.tsx
··· 25 import Link from "next/link"; 26 import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 27 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 28 29 export type DashboardState = { 30 display?: "grid" | "list"; ··· 133 }, 134 >(props: { 135 id: string; 136 - cardBorderHidden: boolean; 137 tabs: T; 138 defaultTab: keyof T; 139 currentPage: navPages; ··· 186 > 187 {Object.keys(props.tabs).length <= 1 && !controls ? null : ( 188 <> 189 - <Header cardBorderHidden={props.cardBorderHidden}> 190 {headerState === "default" ? ( 191 <> 192 {Object.keys(props.tabs).length > 1 && ( ··· 355 ); 356 }; 357 358 - function Tab(props: { 359 - name: string; 360 - selected: boolean; 361 - onSelect: () => void; 362 - href?: string; 363 - }) { 364 - return ( 365 - <div 366 - className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 367 - onClick={() => props.onSelect()} 368 - > 369 - {props.name} 370 - {props.href && <ExternalLinkTiny />} 371 - </div> 372 - ); 373 - } 374 - 375 - const FilterOptions = (props: { 376 - hasPubs: boolean; 377 - hasArchived: boolean; 378 - }) => { 379 let { filter } = useDashboardState(); 380 let setState = useSetDashboardState(); 381 let filterCount = Object.values(filter).filter(Boolean).length;
··· 25 import Link from "next/link"; 26 import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 27 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 28 + import { Tab } from "components/Tab"; 29 30 export type DashboardState = { 31 display?: "grid" | "list"; ··· 134 }, 135 >(props: { 136 id: string; 137 tabs: T; 138 defaultTab: keyof T; 139 currentPage: navPages; ··· 186 > 187 {Object.keys(props.tabs).length <= 1 && !controls ? null : ( 188 <> 189 + <Header> 190 {headerState === "default" ? ( 191 <> 192 {Object.keys(props.tabs).length > 1 && ( ··· 355 ); 356 }; 357 358 + const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { 359 let { filter } = useDashboardState(); 360 let setState = useSetDashboardState(); 361 let filterCount = Object.values(filter).filter(Boolean).length;
+52 -6
components/PageSWRDataProvider.tsx
··· 7 import { getPollData } from "actions/pollActions"; 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 import { createContext, useContext } from "react"; 10 11 export const StaticLeafletDataContext = createContext< 12 null | GetLeafletDataReturnType["result"]["data"] ··· 66 }; 67 export function useLeafletPublicationData() { 68 let { data, mutate } = useLeafletData(); 69 return { 70 - data: 71 - data?.leaflets_in_publications?.[0] || 72 - data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 73 - (p) => p.leaflets_in_publications.length, 74 - )?.leaflets_in_publications?.[0] || 75 - null, 76 mutate, 77 }; 78 } ··· 80 let { data, mutate } = useLeafletData(); 81 return { data: data?.custom_domain_routes, mutate: mutate }; 82 }
··· 7 import { getPollData } from "actions/pollActions"; 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 import { createContext, useContext } from "react"; 10 + import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 11 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 12 + import { AtUri } from "@atproto/syntax"; 13 14 export const StaticLeafletDataContext = createContext< 15 null | GetLeafletDataReturnType["result"]["data"] ··· 69 }; 70 export function useLeafletPublicationData() { 71 let { data, mutate } = useLeafletData(); 72 + 73 + // First check for leaflets in publications 74 + let pubData = getPublicationMetadataFromLeafletData(data); 75 + 76 return { 77 + data: pubData || null, 78 mutate, 79 }; 80 } ··· 82 let { data, mutate } = useLeafletData(); 83 return { data: data?.custom_domain_routes, mutate: mutate }; 84 } 85 + 86 + export function useLeafletPublicationStatus() { 87 + const data = useContext(StaticLeafletDataContext); 88 + if (!data) return null; 89 + 90 + const publishedInPublication = data.leaflets_in_publications?.find( 91 + (l) => l.doc, 92 + ); 93 + const publishedStandalone = data.leaflets_to_documents?.find( 94 + (l) => !!l.documents, 95 + ); 96 + 97 + const documentUri = 98 + publishedInPublication?.documents?.uri ?? publishedStandalone?.document; 99 + 100 + // Compute the full post URL for sharing 101 + let postShareLink: string | undefined; 102 + if (publishedInPublication?.publications && publishedInPublication.documents) { 103 + // Published in a publication - use publication URL + document rkey 104 + const docUri = new AtUri(publishedInPublication.documents.uri); 105 + postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`; 106 + } else if (publishedStandalone?.document) { 107 + // Standalone published post - use /p/{did}/{rkey} format 108 + const docUri = new AtUri(publishedStandalone.document); 109 + postShareLink = `/p/${docUri.host}/${docUri.rkey}`; 110 + } 111 + 112 + return { 113 + token: data, 114 + leafletId: data.root_entity, 115 + shareLink: data.id, 116 + // Draft state - in a publication but not yet published 117 + draftInPublication: 118 + data.leaflets_in_publications?.[0]?.publication ?? undefined, 119 + // Published state 120 + isPublished: !!(publishedInPublication || publishedStandalone), 121 + publishedAt: 122 + publishedInPublication?.documents?.indexed_at ?? 123 + publishedStandalone?.documents?.indexed_at, 124 + documentUri, 125 + // Full URL for sharing published posts 126 + postShareLink, 127 + }; 128 + }
+8 -7
components/Pages/Page.tsx
··· 12 import { Blocks } from "components/Blocks"; 13 import { PublicationMetadata } from "./PublicationMetadata"; 14 import { useCardBorderHidden } from "./useCardBorderHidden"; 15 - import { focusPage } from "."; 16 import { PageOptions } from "./PageOptions"; 17 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 18 import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; 19 20 export function Page(props: { 21 entityID: string; ··· 33 return focusedPageID === props.entityID; 34 }); 35 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 36 - let cardBorderHidden = useCardBorderHidden(props.entityID); 37 38 let drawerOpen = useDrawerOpen(props.entityID); 39 return ( ··· 48 }} 49 id={elementId.page(props.entityID).container} 50 drawerOpen={!!drawerOpen} 51 - cardBorderHidden={!!cardBorderHidden} 52 isFocused={isFocused} 53 fullPageScroll={props.fullPageScroll} 54 pageType={pageType} ··· 60 /> 61 } 62 > 63 - {props.first && ( 64 <> 65 <PublicationMetadata /> 66 </> ··· 76 id: string; 77 children: React.ReactNode; 78 pageOptions?: React.ReactNode; 79 - cardBorderHidden: boolean; 80 fullPageScroll: boolean; 81 isFocused?: boolean; 82 onClickAction?: (e: React.MouseEvent) => void; 83 pageType: "canvas" | "doc"; 84 drawerOpen: boolean | undefined; 85 }) => { 86 return ( 87 // this div wraps the contents AND the page options. 88 // it needs to be its own div because this container does NOT scroll, and therefore doesn't clip the absolutely positioned pageOptions ··· 95 it needs to be a separate div so that the user can scroll from anywhere on the page if there isn't a card border 96 */} 97 <div 98 onClick={props.onClickAction} 99 id={props.id} 100 className={` ··· 103 shrink-0 snap-center 104 overflow-y-scroll 105 ${ 106 - !props.cardBorderHidden && 107 `h-full border 108 bg-[rgba(var(--bg-page),var(--bg-page-alpha))] 109 ${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"} 110 ${props.isFocused ? "shadow-md border-border" : "border-border-light"}` 111 } 112 - ${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 113 ${props.fullPageScroll && "max-w-full "} 114 ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 115 ${
··· 12 import { Blocks } from "components/Blocks"; 13 import { PublicationMetadata } from "./PublicationMetadata"; 14 import { useCardBorderHidden } from "./useCardBorderHidden"; 15 + import { focusPage } from "src/utils/focusPage"; 16 import { PageOptions } from "./PageOptions"; 17 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 18 import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; 19 + import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 20 21 export function Page(props: { 22 entityID: string; ··· 34 return focusedPageID === props.entityID; 35 }); 36 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 37 38 let drawerOpen = useDrawerOpen(props.entityID); 39 return ( ··· 48 }} 49 id={elementId.page(props.entityID).container} 50 drawerOpen={!!drawerOpen} 51 isFocused={isFocused} 52 fullPageScroll={props.fullPageScroll} 53 pageType={pageType} ··· 59 /> 60 } 61 > 62 + {props.first && pageType === "doc" && ( 63 <> 64 <PublicationMetadata /> 65 </> ··· 75 id: string; 76 children: React.ReactNode; 77 pageOptions?: React.ReactNode; 78 fullPageScroll: boolean; 79 isFocused?: boolean; 80 onClickAction?: (e: React.MouseEvent) => void; 81 pageType: "canvas" | "doc"; 82 drawerOpen: boolean | undefined; 83 }) => { 84 + const cardBorderHidden = useCardBorderHidden(); 85 + let { ref } = usePreserveScroll<HTMLDivElement>(props.id); 86 return ( 87 // this div wraps the contents AND the page options. 88 // it needs to be its own div because this container does NOT scroll, and therefore doesn't clip the absolutely positioned pageOptions ··· 95 it needs to be a separate div so that the user can scroll from anywhere on the page if there isn't a card border 96 */} 97 <div 98 + ref={ref} 99 onClick={props.onClickAction} 100 id={props.id} 101 className={` ··· 104 shrink-0 snap-center 105 overflow-y-scroll 106 ${ 107 + !cardBorderHidden && 108 `h-full border 109 bg-[rgba(var(--bg-page),var(--bg-page-alpha))] 110 ${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"} 111 ${props.isFocused ? "shadow-md border-border" : "border-border-light"}` 112 } 113 + ${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 114 ${props.fullPageScroll && "max-w-full "} 115 ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 116 ${
+7 -29
components/Pages/PageOptions.tsx
··· 21 export const PageOptionButton = ({ 22 children, 23 secondary, 24 - cardBorderHidden, 25 className, 26 disabled, 27 ...props 28 }: { 29 children: React.ReactNode; 30 secondary?: boolean; 31 - cardBorderHidden: boolean | undefined; 32 className?: string; 33 disabled?: boolean; 34 } & Omit<JSX.IntrinsicElements["button"], "content">) => { 35 return ( 36 <button 37 className={` ··· 58 first: boolean | undefined; 59 isFocused: boolean; 60 }) => { 61 - let cardBorderHidden = useCardBorderHidden(props.entityID); 62 - 63 return ( 64 <div 65 className={`pageOptions w-fit z-10 ··· 69 > 70 {!props.first && ( 71 <PageOptionButton 72 - cardBorderHidden={cardBorderHidden} 73 secondary 74 onClick={() => { 75 useUIState.getState().closePage(props.entityID); ··· 78 <CloseTiny /> 79 </PageOptionButton> 80 )} 81 - <OptionsMenu 82 - entityID={props.entityID} 83 - first={!!props.first} 84 - cardBorderHidden={cardBorderHidden} 85 - /> 86 - <UndoButtons cardBorderHidden={cardBorderHidden} /> 87 </div> 88 ); 89 }; 90 91 - export const UndoButtons = (props: { 92 - cardBorderHidden: boolean | undefined; 93 - }) => { 94 let undoState = useUndoState(); 95 let { undoManager } = useReplicache(); 96 return ( 97 <Media mobile> 98 {undoState.canUndo && ( 99 <div className="gap-1 flex sm:flex-col"> 100 - <PageOptionButton 101 - secondary 102 - cardBorderHidden={props.cardBorderHidden} 103 - onClick={() => undoManager.undo()} 104 - > 105 <UndoTiny /> 106 </PageOptionButton> 107 108 <PageOptionButton 109 secondary 110 - cardBorderHidden={props.cardBorderHidden} 111 onClick={() => undoManager.undo()} 112 disabled={!undoState.canRedo} 113 > ··· 119 ); 120 }; 121 122 - export const OptionsMenu = (props: { 123 - entityID: string; 124 - first: boolean; 125 - cardBorderHidden: boolean | undefined; 126 - }) => { 127 let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 128 let { permissions } = useEntitySetContext(); 129 if (!permissions.write) return null; ··· 138 if (!open) setState("normal"); 139 }} 140 trigger={ 141 - <PageOptionButton 142 - cardBorderHidden={props.cardBorderHidden} 143 - className="!w-8 !h-5 sm:!w-5 sm:!h-8" 144 - > 145 <MoreOptionsTiny className="sm:rotate-90" /> 146 </PageOptionButton> 147 }
··· 21 export const PageOptionButton = ({ 22 children, 23 secondary, 24 className, 25 disabled, 26 ...props 27 }: { 28 children: React.ReactNode; 29 secondary?: boolean; 30 className?: string; 31 disabled?: boolean; 32 } & Omit<JSX.IntrinsicElements["button"], "content">) => { 33 + const cardBorderHidden = useCardBorderHidden(); 34 return ( 35 <button 36 className={` ··· 57 first: boolean | undefined; 58 isFocused: boolean; 59 }) => { 60 return ( 61 <div 62 className={`pageOptions w-fit z-10 ··· 66 > 67 {!props.first && ( 68 <PageOptionButton 69 secondary 70 onClick={() => { 71 useUIState.getState().closePage(props.entityID); ··· 74 <CloseTiny /> 75 </PageOptionButton> 76 )} 77 + <OptionsMenu entityID={props.entityID} first={!!props.first} /> 78 + <UndoButtons /> 79 </div> 80 ); 81 }; 82 83 + export const UndoButtons = () => { 84 let undoState = useUndoState(); 85 let { undoManager } = useReplicache(); 86 return ( 87 <Media mobile> 88 {undoState.canUndo && ( 89 <div className="gap-1 flex sm:flex-col"> 90 + <PageOptionButton secondary onClick={() => undoManager.undo()}> 91 <UndoTiny /> 92 </PageOptionButton> 93 94 <PageOptionButton 95 secondary 96 onClick={() => undoManager.undo()} 97 disabled={!undoState.canRedo} 98 > ··· 104 ); 105 }; 106 107 + export const OptionsMenu = (props: { entityID: string; first: boolean }) => { 108 let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 109 let { permissions } = useEntitySetContext(); 110 if (!permissions.write) return null; ··· 119 if (!open) setState("normal"); 120 }} 121 trigger={ 122 + <PageOptionButton className="!w-8 !h-5 sm:!w-5 sm:!h-8"> 123 <MoreOptionsTiny className="sm:rotate-90" /> 124 </PageOptionButton> 125 }
+5 -2
components/Pages/PageShareMenu.tsx
··· 1 import { useLeafletDomains } from "components/PageSWRDataProvider"; 2 - import { ShareButton, usePublishLink } from "components/ShareOptions"; 3 import { useEffect, useState } from "react"; 4 5 export const PageShareMenu = (props: { entityID: string }) => { 6 - let publishLink = usePublishLink(); 7 let { data: domains } = useLeafletDomains(); 8 let [collabLink, setCollabLink] = useState<null | string>(null); 9 useEffect(() => {
··· 1 import { useLeafletDomains } from "components/PageSWRDataProvider"; 2 + import { 3 + ShareButton, 4 + useReadOnlyShareLink, 5 + } from "app/[leaflet_id]/actions/ShareOptions"; 6 import { useEffect, useState } from "react"; 7 8 export const PageShareMenu = (props: { entityID: string }) => { 9 + let publishLink = useReadOnlyShareLink(); 10 let { data: domains } = useLeafletDomains(); 11 let [collabLink, setCollabLink] = useState<null | string>(null); 12 useEffect(() => {
+158 -80
components/Pages/PublicationMetadata.tsx
··· 1 import Link from "next/link"; 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 - import { useRef } from "react"; 4 import { useReplicache } from "src/replicache"; 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 import { Separator } from "components/Layout"; 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument } from "lexicons/api"; 9 import { 10 getBasePublicationURL, 11 getPublicationURL, ··· 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 import { timeAgo } from "src/utils/timeAgo"; 16 import { useIdentityData } from "components/IdentityProvider"; 17 export const PublicationMetadata = () => { 18 let { rep } = useReplicache(); 19 let { data: pub } = useLeafletPublicationData(); ··· 23 tx.get<string>("publication_description"), 24 ); 25 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 26 let publishedAt = record?.publishedAt; 27 28 - if (!pub || !pub.publications) return null; 29 30 if (typeof title !== "string") { 31 title = pub?.title || ""; ··· 33 if (typeof description !== "string") { 34 description = pub?.description || ""; 35 } 36 return ( 37 - <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 38 - <div className="flex gap-2"> 39 - <Link 40 - href={ 41 - identity?.atp_did === pub.publications?.identity_did 42 - ? `${getBasePublicationURL(pub.publications)}/dashboard` 43 - : getPublicationURL(pub.publications) 44 - } 45 - className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 46 - > 47 - {pub.publications?.name} 48 - </Link> 49 - <div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md "> 50 - Editor 51 </div> 52 - </div> 53 - <TextField 54 - className="text-xl font-bold outline-hidden bg-transparent" 55 - value={title} 56 - onChange={async (newTitle) => { 57 - await rep?.mutate.updatePublicationDraft({ 58 - title: newTitle, 59 - description, 60 - }); 61 - }} 62 - placeholder="Untitled" 63 - /> 64 - <TextField 65 - placeholder="add an optional description..." 66 - className="italic text-secondary outline-hidden bg-transparent" 67 - value={description} 68 - onChange={async (newDescription) => { 69 - await rep?.mutate.updatePublicationDraft({ 70 - title, 71 - description: newDescription, 72 - }); 73 - }} 74 - /> 75 - {pub.doc ? ( 76 - <div className="flex flex-row items-center gap-2 pt-3"> 77 - <p className="text-sm text-tertiary"> 78 - Published {publishedAt && timeAgo(publishedAt)} 79 - </p> 80 - <Separator classname="h-4" /> 81 - <Link 82 - target="_blank" 83 - className="text-sm" 84 - href={`${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`} 85 - > 86 - View Post 87 - </Link> 88 - </div> 89 - ) : ( 90 - <p className="text-sm text-tertiary pt-2">Draft</p> 91 - )} 92 - </div> 93 ); 94 }; 95 ··· 169 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 170 let publishedAt = record?.publishedAt; 171 172 - if (!pub || !pub.publications) return null; 173 174 return ( 175 - <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 176 - <div className="text-accent-contrast font-bold hover:no-underline"> 177 - {pub.publications?.name} 178 - </div> 179 180 - <div 181 - className={`text-xl font-bold outline-hidden bg-transparent ${!pub.title && "text-tertiary italic"}`} 182 - > 183 - {pub.title ? pub.title : "Untitled"} 184 - </div> 185 - <div className="italic text-secondary outline-hidden bg-transparent"> 186 - {pub.description} 187 - </div> 188 189 - {pub.doc ? ( 190 - <div className="flex flex-row items-center gap-2 pt-3"> 191 - <p className="text-sm text-tertiary"> 192 - Published {publishedAt && timeAgo(publishedAt)} 193 - </p> 194 </div> 195 - ) : ( 196 - <p className="text-sm text-tertiary pt-2">Draft</p> 197 - )} 198 - </div> 199 ); 200 };
··· 1 import Link from "next/link"; 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 + import { useRef, useState } from "react"; 4 import { useReplicache } from "src/replicache"; 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 import { Separator } from "components/Layout"; 7 import { AtUri } from "@atproto/syntax"; 8 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 import { 10 getBasePublicationURL, 11 getPublicationURL, ··· 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 import { timeAgo } from "src/utils/timeAgo"; 16 + import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 18 + import { TagTiny } from "components/Icons/TagTiny"; 19 + import { Popover } from "components/Popover"; 20 + import { TagSelector } from "components/Tags"; 21 import { useIdentityData } from "components/IdentityProvider"; 22 + import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 23 export const PublicationMetadata = () => { 24 let { rep } = useReplicache(); 25 let { data: pub } = useLeafletPublicationData(); ··· 29 tx.get<string>("publication_description"), 30 ); 31 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 32 + let pubRecord = pub?.publications?.record as 33 + | PubLeafletPublication.Record 34 + | undefined; 35 let publishedAt = record?.publishedAt; 36 37 + if (!pub) return null; 38 39 if (typeof title !== "string") { 40 title = pub?.title || ""; ··· 42 if (typeof description !== "string") { 43 description = pub?.description || ""; 44 } 45 + let tags = true; 46 + 47 return ( 48 + <PostHeaderLayout 49 + pubLink={ 50 + <div className="flex gap-2 items-center"> 51 + {pub.publications && ( 52 + <Link 53 + href={ 54 + identity?.atp_did === pub.publications?.identity_did 55 + ? `${getBasePublicationURL(pub.publications)}/dashboard` 56 + : getPublicationURL(pub.publications) 57 + } 58 + className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 59 + > 60 + {pub.publications?.name} 61 + </Link> 62 + )} 63 + <div className="font-bold text-tertiary px-1 h-[20px] text-sm flex place-items-center bg-border-light rounded-md "> 64 + DRAFT 65 + </div> 66 </div> 67 + } 68 + postTitle={ 69 + <TextField 70 + className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent" 71 + value={title} 72 + onChange={async (newTitle) => { 73 + await rep?.mutate.updatePublicationDraft({ 74 + title: newTitle, 75 + description, 76 + }); 77 + }} 78 + placeholder="Untitled" 79 + /> 80 + } 81 + postDescription={ 82 + <TextField 83 + placeholder="add an optional description..." 84 + className="pt-1 italic text-secondary outline-hidden bg-transparent" 85 + value={description} 86 + onChange={async (newDescription) => { 87 + await rep?.mutate.updatePublicationDraft({ 88 + title, 89 + description: newDescription, 90 + }); 91 + }} 92 + /> 93 + } 94 + postInfo={ 95 + <> 96 + {pub.doc ? ( 97 + <div className="flex gap-2 items-center"> 98 + <p className="text-sm text-tertiary"> 99 + Published {publishedAt && timeAgo(publishedAt)} 100 + </p> 101 + 102 + <Link 103 + target="_blank" 104 + className="text-sm" 105 + href={ 106 + pub.publications 107 + ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 108 + : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 109 + } 110 + > 111 + View 112 + </Link> 113 + </div> 114 + ) : ( 115 + <p>Draft</p> 116 + )} 117 + <div className="flex gap-2 text-border items-center"> 118 + {tags && ( 119 + <> 120 + <AddTags /> 121 + <Separator classname="h-4!" /> 122 + </> 123 + )} 124 + <div className="flex gap-1 items-center"> 125 + <QuoteTiny />โ€” 126 + </div> 127 + {pubRecord?.preferences?.showComments && ( 128 + <div className="flex gap-1 items-center"> 129 + <CommentTiny />โ€” 130 + </div> 131 + )} 132 + </div> 133 + </> 134 + } 135 + /> 136 ); 137 }; 138 ··· 212 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 213 let publishedAt = record?.publishedAt; 214 215 + if (!pub) return null; 216 217 return ( 218 + <PostHeaderLayout 219 + pubLink={ 220 + <div className="text-accent-contrast font-bold hover:no-underline"> 221 + {pub.publications?.name} 222 + </div> 223 + } 224 + postTitle={pub.title} 225 + postDescription={pub.description} 226 + postInfo={ 227 + pub.doc ? ( 228 + <p>Published {publishedAt && timeAgo(publishedAt)}</p> 229 + ) : ( 230 + <p>Draft</p> 231 + ) 232 + } 233 + /> 234 + ); 235 + }; 236 + 237 + const AddTags = () => { 238 + let { data: pub } = useLeafletPublicationData(); 239 + let { rep } = useReplicache(); 240 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 241 + 242 + // Get tags from Replicache local state or published document 243 + let replicacheTags = useSubscribe(rep, (tx) => 244 + tx.get<string[]>("publication_tags"), 245 + ); 246 247 + // Determine which tags to use - prioritize Replicache state 248 + let tags: string[] = []; 249 + if (Array.isArray(replicacheTags)) { 250 + tags = replicacheTags; 251 + } else if (record?.tags && Array.isArray(record.tags)) { 252 + tags = record.tags as string[]; 253 + } 254 + 255 + // Update tags in replicache local state 256 + const handleTagsChange = async (newTags: string[]) => { 257 + // Store tags in replicache for next publish/update 258 + await rep?.mutate.updatePublicationDraft({ 259 + tags: newTags, 260 + }); 261 + }; 262 263 + return ( 264 + <Popover 265 + className="p-2! w-full min-w-xs" 266 + trigger={ 267 + <div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary"> 268 + <TagTiny />{" "} 269 + {tags.length > 0 270 + ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` 271 + : "Add Tags"} 272 </div> 273 + } 274 + > 275 + <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 276 + </Popover> 277 ); 278 };
+2 -75
components/Pages/index.tsx
··· 4 import { useUIState } from "src/useUIState"; 5 import { useSearchParams } from "next/navigation"; 6 7 - import { focusBlock } from "src/utils/focusBlock"; 8 - import { elementId } from "src/utils/elementId"; 9 10 - import { Replicache } from "replicache"; 11 - import { Fact, ReplicacheMutators, useEntity } from "src/replicache"; 12 - 13 - import { scanIndex } from "src/replicache/utils"; 14 - import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; 15 - import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 16 import { useCardBorderHidden } from "./useCardBorderHidden"; 17 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 18 import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; ··· 62 ); 63 } 64 65 - export async function focusPage( 66 - pageID: string, 67 - rep: Replicache<ReplicacheMutators>, 68 - focusFirstBlock?: "focusFirstBlock", 69 - ) { 70 - // if this page is already focused, 71 - let focusedBlock = useUIState.getState().focusedEntity; 72 - // else set this page as focused 73 - useUIState.setState(() => ({ 74 - focusedEntity: { 75 - entityType: "page", 76 - entityID: pageID, 77 - }, 78 - })); 79 - 80 - setTimeout(async () => { 81 - //scroll to page 82 - 83 - scrollIntoViewIfNeeded( 84 - document.getElementById(elementId.page(pageID).container), 85 - false, 86 - "smooth", 87 - ); 88 - 89 - // if we asked that the function focus the first block, focus the first block 90 - if (focusFirstBlock === "focusFirstBlock") { 91 - let firstBlock = await rep.query(async (tx) => { 92 - let type = await scanIndex(tx).eav(pageID, "page/type"); 93 - let blocks = await scanIndex(tx).eav( 94 - pageID, 95 - type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 96 - ); 97 - 98 - let firstBlock = blocks[0]; 99 - 100 - if (!firstBlock) { 101 - return null; 102 - } 103 - 104 - let blockType = ( 105 - await tx 106 - .scan< 107 - Fact<"block/type"> 108 - >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 109 - .toArray() 110 - )[0]; 111 - 112 - if (!blockType) return null; 113 - 114 - return { 115 - value: firstBlock.data.value, 116 - type: blockType.data.value, 117 - parent: firstBlock.entity, 118 - position: firstBlock.data.position, 119 - }; 120 - }); 121 - 122 - if (firstBlock) { 123 - setTimeout(() => { 124 - focusBlock(firstBlock, { type: "start" }); 125 - }, 500); 126 - } 127 - } 128 - }, 50); 129 - } 130 - 131 - export const blurPage = () => { 132 useUIState.setState(() => ({ 133 focusedEntity: null, 134 selectedBlocks: [],
··· 4 import { useUIState } from "src/useUIState"; 5 import { useSearchParams } from "next/navigation"; 6 7 + import { useEntity } from "src/replicache"; 8 9 import { useCardBorderHidden } from "./useCardBorderHidden"; 10 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 11 import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; ··· 55 ); 56 } 57 58 + const blurPage = () => { 59 useUIState.setState(() => ({ 60 focusedEntity: null, 61 selectedBlocks: [],
+3 -18
components/Pages/useCardBorderHidden.ts
··· 1 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 2 - import { PubLeafletPublication } from "lexicons/api"; 3 - import { useEntity, useReplicache } from "src/replicache"; 4 5 - export function useCardBorderHidden(entityID: string | null) { 6 - let { rootEntity } = useReplicache(); 7 - let { data: pub } = useLeafletPublicationData(); 8 - let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 9 - 10 - let cardBorderHidden = 11 - useEntity(entityID, "theme/card-border-hidden") || rootCardBorderHidden; 12 - if (!cardBorderHidden && !rootCardBorderHidden) { 13 - if (pub?.publications?.record) { 14 - let record = pub.publications.record as PubLeafletPublication.Record; 15 - return !record.theme?.showPageBackground; 16 - } 17 - return false; 18 - } 19 - return (cardBorderHidden || rootCardBorderHidden)?.data.value; 20 }
··· 1 + import { useCardBorderHiddenContext } from "components/ThemeManager/ThemeProvider"; 2 3 + export function useCardBorderHidden(entityID?: string | null) { 4 + return useCardBorderHiddenContext(); 5 }
+3
components/Popover/PopoverContext.ts
···
··· 1 + import { createContext } from "react"; 2 + 3 + export const PopoverOpenContext = createContext(false);
+87
components/Popover/index.tsx
···
··· 1 + "use client"; 2 + import * as RadixPopover from "@radix-ui/react-popover"; 3 + import { theme } from "tailwind.config"; 4 + import { NestedCardThemeProvider } from "../ThemeManager/ThemeProvider"; 5 + import { useEffect, useState } from "react"; 6 + import { PopoverArrow } from "../Icons/PopoverArrow"; 7 + import { PopoverOpenContext } from "./PopoverContext"; 8 + export const Popover = (props: { 9 + trigger: React.ReactNode; 10 + disabled?: boolean; 11 + children: React.ReactNode; 12 + align?: "start" | "end" | "center"; 13 + side?: "top" | "bottom" | "left" | "right"; 14 + sideOffset?: number; 15 + background?: string; 16 + border?: string; 17 + className?: string; 18 + open?: boolean; 19 + onOpenChange?: (open: boolean) => void; 20 + onOpenAutoFocus?: (e: Event) => void; 21 + asChild?: boolean; 22 + arrowFill?: string; 23 + noArrow?: boolean; 24 + }) => { 25 + let [open, setOpen] = useState(props.open || false); 26 + useEffect(() => { 27 + if (props.open !== undefined) setOpen(props.open); 28 + }, [props.open]); 29 + return ( 30 + <RadixPopover.Root 31 + open={props.open} 32 + onOpenChange={(o) => { 33 + setOpen(o); 34 + props.onOpenChange?.(o); 35 + }} 36 + > 37 + <PopoverOpenContext value={open}> 38 + <RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}> 39 + {props.trigger} 40 + </RadixPopover.Trigger> 41 + <RadixPopover.Portal> 42 + <NestedCardThemeProvider> 43 + <RadixPopover.Content 44 + className={` 45 + z-20 bg-bg-page 46 + px-3 py-2 47 + max-w-(--radix-popover-content-available-width) 48 + max-h-(--radix-popover-content-available-height) 49 + border border-border rounded-md shadow-md 50 + overflow-y-scroll 51 + ${props.className} 52 + `} 53 + side={props.side} 54 + align={props.align ? props.align : "center"} 55 + sideOffset={props.sideOffset ? props.sideOffset : 4} 56 + collisionPadding={16} 57 + onOpenAutoFocus={props.onOpenAutoFocus} 58 + > 59 + {props.children} 60 + {!props.noArrow && ( 61 + <RadixPopover.Arrow 62 + asChild 63 + width={16} 64 + height={8} 65 + viewBox="0 0 16 8" 66 + > 67 + <PopoverArrow 68 + arrowFill={ 69 + props.arrowFill 70 + ? props.arrowFill 71 + : props.background 72 + ? props.background 73 + : theme.colors["bg-page"] 74 + } 75 + arrowStroke={ 76 + props.border ? props.border : theme.colors["border"] 77 + } 78 + /> 79 + </RadixPopover.Arrow> 80 + )} 81 + </RadixPopover.Content> 82 + </NestedCardThemeProvider> 83 + </RadixPopover.Portal> 84 + </PopoverOpenContext> 85 + </RadixPopover.Root> 86 + ); 87 + };
-84
components/Popover.tsx
··· 1 - "use client"; 2 - import * as RadixPopover from "@radix-ui/react-popover"; 3 - import { theme } from "tailwind.config"; 4 - import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 - import { createContext, useEffect, useState } from "react"; 6 - import { PopoverArrow } from "./Icons/PopoverArrow"; 7 - 8 - export const PopoverOpenContext = createContext(false); 9 - export const Popover = (props: { 10 - trigger: React.ReactNode; 11 - disabled?: boolean; 12 - children: React.ReactNode; 13 - align?: "start" | "end" | "center"; 14 - side?: "top" | "bottom" | "left" | "right"; 15 - background?: string; 16 - border?: string; 17 - className?: string; 18 - open?: boolean; 19 - onOpenChange?: (open: boolean) => void; 20 - onOpenAutoFocus?: (e: Event) => void; 21 - asChild?: boolean; 22 - arrowFill?: string; 23 - }) => { 24 - let [open, setOpen] = useState(props.open || false); 25 - useEffect(() => { 26 - if (props.open !== undefined) setOpen(props.open); 27 - }, [props.open]); 28 - return ( 29 - <RadixPopover.Root 30 - open={props.open} 31 - onOpenChange={(o) => { 32 - setOpen(o); 33 - props.onOpenChange?.(o); 34 - }} 35 - > 36 - <PopoverOpenContext value={open}> 37 - <RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}> 38 - {props.trigger} 39 - </RadixPopover.Trigger> 40 - <RadixPopover.Portal> 41 - <NestedCardThemeProvider> 42 - <RadixPopover.Content 43 - className={` 44 - z-20 bg-bg-page 45 - px-3 py-2 46 - max-w-(--radix-popover-content-available-width) 47 - max-h-(--radix-popover-content-available-height) 48 - border border-border rounded-md shadow-md 49 - overflow-y-scroll 50 - ${props.className} 51 - `} 52 - side={props.side} 53 - align={props.align ? props.align : "center"} 54 - sideOffset={4} 55 - collisionPadding={16} 56 - onOpenAutoFocus={props.onOpenAutoFocus} 57 - > 58 - {props.children} 59 - <RadixPopover.Arrow 60 - asChild 61 - width={16} 62 - height={8} 63 - viewBox="0 0 16 8" 64 - > 65 - <PopoverArrow 66 - arrowFill={ 67 - props.arrowFill 68 - ? props.arrowFill 69 - : props.background 70 - ? props.background 71 - : theme.colors["bg-page"] 72 - } 73 - arrowStroke={ 74 - props.border ? props.border : theme.colors["border"] 75 - } 76 - /> 77 - </RadixPopover.Arrow> 78 - </RadixPopover.Content> 79 - </NestedCardThemeProvider> 80 - </RadixPopover.Portal> 81 - </PopoverOpenContext> 82 - </RadixPopover.Root> 83 - ); 84 - };
···
+141
components/PostListing.tsx
···
··· 1 + "use client"; 2 + import { AtUri } from "@atproto/api"; 3 + import { PubIcon } from "components/ActionBar/Publications"; 4 + import { CommentTiny } from "components/Icons/CommentTiny"; 5 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 6 + import { Separator } from "components/Layout"; 7 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 + import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 + import { useSmoker } from "components/Toast"; 10 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 11 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 12 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 13 + 14 + import Link from "next/link"; 15 + import { InteractionPreview } from "./InteractionsPreview"; 16 + 17 + export const PostListing = (props: Post) => { 18 + let pubRecord = props.publication?.pubRecord as 19 + | PubLeafletPublication.Record 20 + | undefined; 21 + 22 + let postRecord = props.documents.data as PubLeafletDocument.Record; 23 + let postUri = new AtUri(props.documents.uri); 24 + 25 + let theme = usePubTheme(pubRecord?.theme); 26 + let backgroundImage = 27 + pubRecord?.theme?.backgroundImage?.image?.ref && props.publication 28 + ? blobRefToSrc( 29 + pubRecord.theme.backgroundImage.image.ref, 30 + new AtUri(props.publication.uri).host, 31 + ) 32 + : null; 33 + 34 + let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 35 + let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 36 + 37 + let showPageBackground = pubRecord?.theme?.showPageBackground; 38 + 39 + let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 40 + let comments = 41 + pubRecord?.preferences?.showComments === false 42 + ? 0 43 + : props.documents.comments_on_documents?.[0]?.count || 0; 44 + let tags = (postRecord?.tags as string[] | undefined) || []; 45 + 46 + // For standalone posts, link directly to the document 47 + let postHref = props.publication 48 + ? `${props.publication.href}/${postUri.rkey}` 49 + : `/doc/${postUri.host}/${postUri.rkey}`; 50 + 51 + return ( 52 + <BaseThemeProvider {...theme} local> 53 + <div 54 + style={{ 55 + backgroundImage: backgroundImage 56 + ? `url(${backgroundImage})` 57 + : undefined, 58 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 59 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 60 + }} 61 + className={`no-underline! flex flex-row gap-2 w-full relative 62 + bg-bg-leaflet 63 + border border-border-light rounded-lg 64 + sm:p-2 p-2 selected-outline 65 + hover:outline-accent-contrast hover:border-accent-contrast 66 + `} 67 + > 68 + <Link className="h-full w-full absolute top-0 left-0" href={postHref} /> 69 + <div 70 + className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 71 + style={{ 72 + backgroundColor: showPageBackground 73 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 74 + : "transparent", 75 + }} 76 + > 77 + <h3 className="text-primary truncate">{postRecord.title}</h3> 78 + 79 + <p className="text-secondary italic">{postRecord.description}</p> 80 + <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full"> 81 + {props.publication && pubRecord && ( 82 + <PubInfo 83 + href={props.publication.href} 84 + pubRecord={pubRecord} 85 + uri={props.publication.uri} 86 + /> 87 + )} 88 + <div className="flex flex-row justify-between gap-2 items-center w-full"> 89 + <PostInfo publishedAt={postRecord.publishedAt} /> 90 + <InteractionPreview 91 + postUrl={postHref} 92 + quotesCount={quotes} 93 + commentsCount={comments} 94 + tags={tags} 95 + showComments={pubRecord?.preferences?.showComments} 96 + share 97 + /> 98 + </div> 99 + </div> 100 + </div> 101 + </div> 102 + </BaseThemeProvider> 103 + ); 104 + }; 105 + 106 + const PubInfo = (props: { 107 + href: string; 108 + pubRecord: PubLeafletPublication.Record; 109 + uri: string; 110 + }) => { 111 + return ( 112 + <div className="flex flex-col md:w-auto shrink-0 w-full"> 113 + <hr className="md:hidden block border-border-light mb-2" /> 114 + <Link 115 + href={props.href} 116 + className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0" 117 + > 118 + <PubIcon small record={props.pubRecord} uri={props.uri} /> 119 + {props.pubRecord.name} 120 + </Link> 121 + </div> 122 + ); 123 + }; 124 + 125 + const PostInfo = (props: { publishedAt: string | undefined }) => { 126 + return ( 127 + <div className="flex gap-2 items-center shrink-0 self-start"> 128 + {props.publishedAt && ( 129 + <> 130 + <div className="shrink-0"> 131 + {new Date(props.publishedAt).toLocaleDateString("en-US", { 132 + year: "numeric", 133 + month: "short", 134 + day: "numeric", 135 + })} 136 + </div> 137 + </> 138 + )} 139 + </div> 140 + ); 141 + };
+98
components/ProfilePopover.tsx
···
··· 1 + "use client"; 2 + import { Popover } from "./Popover"; 3 + import useSWR from "swr"; 4 + import { callRPC } from "app/api/rpc/client"; 5 + import { useRef, useState } from "react"; 6 + import { ProfileHeader } from "app/(home-pages)/p/[didOrHandle]/ProfileHeader"; 7 + import { SpeedyLink } from "./SpeedyLink"; 8 + import { Tooltip } from "./Tooltip"; 9 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 + 11 + export const ProfilePopover = (props: { 12 + trigger: React.ReactNode; 13 + didOrHandle: string; 14 + }) => { 15 + const [isOpen, setIsOpen] = useState(false); 16 + let [isHovered, setIsHovered] = useState(false); 17 + const hoverTimeout = useRef<null | number>(null); 18 + 19 + const { data, isLoading } = useSWR( 20 + isHovered ? ["profile-data", props.didOrHandle] : null, 21 + async () => { 22 + const response = await callRPC("get_profile_data", { 23 + didOrHandle: props.didOrHandle, 24 + }); 25 + return response.result; 26 + }, 27 + ); 28 + 29 + return ( 30 + <Tooltip 31 + className="max-w-sm p-0! text-center" 32 + asChild 33 + trigger={ 34 + <a 35 + className="no-underline" 36 + href={`https://leaflet.pub/p/${props.didOrHandle}`} 37 + target="_blank" 38 + onPointerEnter={(e) => { 39 + if (hoverTimeout.current) { 40 + window.clearTimeout(hoverTimeout.current); 41 + } 42 + hoverTimeout.current = window.setTimeout(async () => { 43 + setIsHovered(true); 44 + }, 150); 45 + }} 46 + onPointerLeave={() => { 47 + if (isHovered) return; 48 + if (hoverTimeout.current) { 49 + window.clearTimeout(hoverTimeout.current); 50 + hoverTimeout.current = null; 51 + } 52 + setIsHovered(false); 53 + }} 54 + > 55 + {props.trigger} 56 + </a> 57 + } 58 + onOpenChange={setIsOpen} 59 + > 60 + {isLoading ? ( 61 + <div className="text-secondary p-4">Loading...</div> 62 + ) : data ? ( 63 + <div> 64 + <ProfileHeader 65 + profile={data.profile} 66 + publications={data.publications} 67 + popover 68 + /> 69 + <KnownFollowers viewer={data.profile.viewer} did={data.profile.did} /> 70 + </div> 71 + ) : ( 72 + <div className="text-secondary py-2 px-4">Profile not found</div> 73 + )} 74 + </Tooltip> 75 + ); 76 + }; 77 + 78 + let KnownFollowers = (props: { 79 + viewer: ProfileViewDetailed["viewer"]; 80 + did: string; 81 + }) => { 82 + if (!props.viewer?.knownFollowers) return null; 83 + let count = props.viewer.knownFollowers.count; 84 + return ( 85 + <> 86 + <hr className="border-border" /> 87 + Followed by{" "} 88 + <a 89 + className="hover:underline" 90 + href={`https://bsky.social/profile/${props.did}/known-followers`} 91 + target="_blank" 92 + > 93 + {props.viewer?.knownFollowers?.followers[0]?.displayName}{" "} 94 + {count > 1 ? `and ${count - 1} other${count > 2 ? "s" : ""}` : ""} 95 + </a> 96 + </> 97 + ); 98 + };
+717
components/SelectionManager/index.tsx
···
··· 1 + "use client"; 2 + import { useEffect, useRef, useState } from "react"; 3 + import { useReplicache } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { scanIndex } from "src/replicache/utils"; 6 + import { focusBlock } from "src/utils/focusBlock"; 7 + import { useEditorStates } from "src/state/useEditorState"; 8 + import { useEntitySetContext } from "../EntitySetProvider"; 9 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 10 + import { indent, outdent, outdentFull } from "src/utils/list-operations"; 11 + import { addShortcut, Shortcut } from "src/shortcuts"; 12 + import { elementId } from "src/utils/elementId"; 13 + import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 14 + import { copySelection } from "src/utils/copySelection"; 15 + import { useIsMobile } from "src/hooks/isMobile"; 16 + import { deleteBlock } from "src/utils/deleteBlock"; 17 + import { schema } from "../Blocks/TextBlock/schema"; 18 + import { MarkType } from "prosemirror-model"; 19 + import { useSelectingMouse, getSortedSelection } from "./selectionState"; 20 + 21 + //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 22 + // How does this relate to *when dragging* ? 23 + 24 + export function SelectionManager() { 25 + let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1); 26 + let entity_set = useEntitySetContext(); 27 + let { rep, undoManager } = useReplicache(); 28 + let isMobile = useIsMobile(); 29 + useEffect(() => { 30 + if (!entity_set.permissions.write || !rep) return; 31 + const getSortedSelectionBound = getSortedSelection.bind(null, rep); 32 + let shortcuts: Shortcut[] = [ 33 + { 34 + metaKey: true, 35 + key: "ArrowUp", 36 + handler: async () => { 37 + let [firstBlock] = 38 + (await rep?.query((tx) => 39 + getBlocksWithType( 40 + tx, 41 + useUIState.getState().selectedBlocks[0].parent, 42 + ), 43 + )) || []; 44 + if (firstBlock) focusBlock(firstBlock, { type: "start" }); 45 + }, 46 + }, 47 + { 48 + metaKey: true, 49 + key: "ArrowDown", 50 + handler: async () => { 51 + let blocks = 52 + (await rep?.query((tx) => 53 + getBlocksWithType( 54 + tx, 55 + useUIState.getState().selectedBlocks[0].parent, 56 + ), 57 + )) || []; 58 + let folded = useUIState.getState().foldedBlocks; 59 + blocks = blocks.filter( 60 + (f) => 61 + !f.listData || 62 + !f.listData.path.find( 63 + (path) => 64 + folded.includes(path.entity) && f.value !== path.entity, 65 + ), 66 + ); 67 + let lastBlock = blocks[blocks.length - 1]; 68 + if (lastBlock) focusBlock(lastBlock, { type: "end" }); 69 + }, 70 + }, 71 + { 72 + metaKey: true, 73 + altKey: true, 74 + key: ["l", "ยฌ"], 75 + handler: async () => { 76 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 77 + for (let block of sortedBlocks) { 78 + if (!block.listData) { 79 + await rep?.mutate.assertFact({ 80 + entity: block.value, 81 + attribute: "block/is-list", 82 + data: { type: "boolean", value: true }, 83 + }); 84 + } else { 85 + outdentFull(block, rep); 86 + } 87 + } 88 + }, 89 + }, 90 + { 91 + metaKey: true, 92 + shift: true, 93 + key: ["ArrowDown", "J"], 94 + handler: async () => { 95 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 96 + let block = sortedBlocks[0]; 97 + let nextBlock = siblings 98 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 99 + .find( 100 + (f) => 101 + f.listData && 102 + block.listData && 103 + !f.listData.path.find((f) => f.entity === block.value), 104 + ); 105 + if ( 106 + nextBlock?.listData && 107 + block.listData && 108 + nextBlock.listData.depth === block.listData.depth - 1 109 + ) { 110 + if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 111 + useUIState.getState().toggleFold(nextBlock.value); 112 + await rep?.mutate.moveBlock({ 113 + block: block.value, 114 + oldParent: block.listData?.parent, 115 + newParent: nextBlock.value, 116 + position: { type: "first" }, 117 + }); 118 + } else { 119 + await rep?.mutate.moveBlockDown({ 120 + entityID: block.value, 121 + parent: block.listData?.parent || block.parent, 122 + }); 123 + } 124 + }, 125 + }, 126 + { 127 + metaKey: true, 128 + shift: true, 129 + key: ["ArrowUp", "K"], 130 + handler: async () => { 131 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 132 + let block = sortedBlocks[0]; 133 + let previousBlock = 134 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 135 + if (previousBlock.value === block.listData?.parent) { 136 + previousBlock = 137 + siblings?.[ 138 + siblings.findIndex((s) => s.value === block.value) - 2 139 + ]; 140 + } 141 + 142 + if ( 143 + previousBlock?.listData && 144 + block.listData && 145 + block.listData.depth > 1 && 146 + !previousBlock.listData.path.find( 147 + (f) => f.entity === block.listData?.parent, 148 + ) 149 + ) { 150 + let depth = block.listData.depth; 151 + let newParent = previousBlock.listData.path.find( 152 + (f) => f.depth === depth - 1, 153 + ); 154 + if (!newParent) return; 155 + if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 156 + useUIState.getState().toggleFold(newParent.entity); 157 + rep?.mutate.moveBlock({ 158 + block: block.value, 159 + oldParent: block.listData?.parent, 160 + newParent: newParent.entity, 161 + position: { type: "end" }, 162 + }); 163 + } else { 164 + rep?.mutate.moveBlockUp({ 165 + entityID: block.value, 166 + parent: block.listData?.parent || block.parent, 167 + }); 168 + } 169 + }, 170 + }, 171 + 172 + { 173 + metaKey: true, 174 + shift: true, 175 + key: "Enter", 176 + handler: async () => { 177 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 178 + if (!sortedBlocks[0].listData) return; 179 + useUIState.getState().toggleFold(sortedBlocks[0].value); 180 + }, 181 + }, 182 + ]; 183 + if (moreThanOneSelected) 184 + shortcuts = shortcuts.concat([ 185 + { 186 + metaKey: true, 187 + key: "u", 188 + handler: async () => { 189 + let [sortedBlocks] = await getSortedSelectionBound(); 190 + toggleMarkInBlocks( 191 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 192 + schema.marks.underline, 193 + ); 194 + }, 195 + }, 196 + { 197 + metaKey: true, 198 + key: "i", 199 + handler: async () => { 200 + let [sortedBlocks] = await getSortedSelectionBound(); 201 + toggleMarkInBlocks( 202 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 203 + schema.marks.em, 204 + ); 205 + }, 206 + }, 207 + { 208 + metaKey: true, 209 + key: "b", 210 + handler: async () => { 211 + let [sortedBlocks] = await getSortedSelectionBound(); 212 + toggleMarkInBlocks( 213 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 214 + schema.marks.strong, 215 + ); 216 + }, 217 + }, 218 + { 219 + metaAndCtrl: true, 220 + key: "h", 221 + handler: async () => { 222 + let [sortedBlocks] = await getSortedSelectionBound(); 223 + toggleMarkInBlocks( 224 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 225 + schema.marks.highlight, 226 + { 227 + color: useUIState.getState().lastUsedHighlight, 228 + }, 229 + ); 230 + }, 231 + }, 232 + { 233 + metaAndCtrl: true, 234 + key: "x", 235 + handler: async () => { 236 + let [sortedBlocks] = await getSortedSelectionBound(); 237 + toggleMarkInBlocks( 238 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 239 + schema.marks.strikethrough, 240 + ); 241 + }, 242 + }, 243 + ]); 244 + let removeListener = addShortcut( 245 + shortcuts.map((shortcut) => ({ 246 + ...shortcut, 247 + handler: () => undoManager.withUndoGroup(() => shortcut.handler()), 248 + })), 249 + ); 250 + let listener = async (e: KeyboardEvent) => 251 + undoManager.withUndoGroup(async () => { 252 + //used here and in cut 253 + const deleteBlocks = async () => { 254 + if (!entity_set.permissions.write) return; 255 + if (moreThanOneSelected) { 256 + e.preventDefault(); 257 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 258 + let selectedBlocks = useUIState.getState().selectedBlocks; 259 + let firstBlock = sortedBlocks[0]; 260 + 261 + await rep?.mutate.removeBlock( 262 + selectedBlocks.map((block) => ({ blockEntity: block.value })), 263 + ); 264 + useUIState.getState().closePage(selectedBlocks.map((b) => b.value)); 265 + 266 + let nextBlock = 267 + siblings?.[ 268 + siblings.findIndex((s) => s.value === firstBlock.value) - 1 269 + ]; 270 + if (nextBlock) { 271 + useUIState.getState().setSelectedBlock({ 272 + value: nextBlock.value, 273 + parent: nextBlock.parent, 274 + }); 275 + let type = await rep?.query((tx) => 276 + scanIndex(tx).eav(nextBlock.value, "block/type"), 277 + ); 278 + if (!type?.[0]) return; 279 + if ( 280 + type[0]?.data.value === "text" || 281 + type[0]?.data.value === "heading" 282 + ) 283 + focusBlock( 284 + { 285 + value: nextBlock.value, 286 + type: "text", 287 + parent: nextBlock.parent, 288 + }, 289 + { type: "end" }, 290 + ); 291 + } 292 + } 293 + }; 294 + if (e.key === "Backspace" || e.key === "Delete") { 295 + deleteBlocks(); 296 + } 297 + if (e.key === "ArrowUp") { 298 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 299 + let focusedBlock = useUIState.getState().focusedEntity; 300 + if (!e.shiftKey && !e.ctrlKey) { 301 + if (e.defaultPrevented) return; 302 + if (sortedBlocks.length === 1) return; 303 + let firstBlock = sortedBlocks[0]; 304 + if (!firstBlock) return; 305 + let type = await rep?.query((tx) => 306 + scanIndex(tx).eav(firstBlock.value, "block/type"), 307 + ); 308 + if (!type?.[0]) return; 309 + useUIState.getState().setSelectedBlock(firstBlock); 310 + focusBlock( 311 + { ...firstBlock, type: type[0].data.value }, 312 + { type: "start" }, 313 + ); 314 + } else { 315 + if (e.defaultPrevented) return; 316 + if ( 317 + sortedBlocks.length <= 1 || 318 + !focusedBlock || 319 + focusedBlock.entityType === "page" 320 + ) 321 + return; 322 + let b = focusedBlock; 323 + let focusedBlockIndex = sortedBlocks.findIndex( 324 + (s) => s.value == b.entityID, 325 + ); 326 + if (focusedBlockIndex === 0) { 327 + let index = siblings.findIndex((s) => s.value === b.entityID); 328 + let nextSelectedBlock = siblings[index - 1]; 329 + if (!nextSelectedBlock) return; 330 + 331 + scrollIntoViewIfNeeded( 332 + document.getElementById( 333 + elementId.block(nextSelectedBlock.value).container, 334 + ), 335 + false, 336 + ); 337 + useUIState.getState().addBlockToSelection({ 338 + ...nextSelectedBlock, 339 + }); 340 + useUIState.getState().setFocusedBlock({ 341 + entityType: "block", 342 + parent: nextSelectedBlock.parent, 343 + entityID: nextSelectedBlock.value, 344 + }); 345 + } else { 346 + let nextBlock = sortedBlocks[sortedBlocks.length - 2]; 347 + useUIState.getState().setFocusedBlock({ 348 + entityType: "block", 349 + parent: b.parent, 350 + entityID: nextBlock.value, 351 + }); 352 + scrollIntoViewIfNeeded( 353 + document.getElementById( 354 + elementId.block(nextBlock.value).container, 355 + ), 356 + false, 357 + ); 358 + if (sortedBlocks.length === 2) { 359 + useEditorStates 360 + .getState() 361 + .editorStates[nextBlock.value]?.view?.focus(); 362 + } 363 + useUIState 364 + .getState() 365 + .removeBlockFromSelection(sortedBlocks[focusedBlockIndex]); 366 + } 367 + } 368 + } 369 + if (e.key === "ArrowLeft") { 370 + let [sortedSelection, siblings] = await getSortedSelectionBound(); 371 + if (sortedSelection.length === 1) return; 372 + let firstBlock = sortedSelection[0]; 373 + if (!firstBlock) return; 374 + let type = await rep?.query((tx) => 375 + scanIndex(tx).eav(firstBlock.value, "block/type"), 376 + ); 377 + if (!type?.[0]) return; 378 + useUIState.getState().setSelectedBlock(firstBlock); 379 + focusBlock( 380 + { ...firstBlock, type: type[0].data.value }, 381 + { type: "start" }, 382 + ); 383 + } 384 + if (e.key === "ArrowRight") { 385 + let [sortedSelection, siblings] = await getSortedSelectionBound(); 386 + if (sortedSelection.length === 1) return; 387 + let lastBlock = sortedSelection[sortedSelection.length - 1]; 388 + if (!lastBlock) return; 389 + let type = await rep?.query((tx) => 390 + scanIndex(tx).eav(lastBlock.value, "block/type"), 391 + ); 392 + if (!type?.[0]) return; 393 + useUIState.getState().setSelectedBlock(lastBlock); 394 + focusBlock( 395 + { ...lastBlock, type: type[0].data.value }, 396 + { type: "end" }, 397 + ); 398 + } 399 + if (e.key === "Tab") { 400 + let [sortedSelection, siblings] = await getSortedSelectionBound(); 401 + if (sortedSelection.length <= 1) return; 402 + e.preventDefault(); 403 + if (e.shiftKey) { 404 + for (let i = siblings.length - 1; i >= 0; i--) { 405 + let block = siblings[i]; 406 + if (!sortedSelection.find((s) => s.value === block.value)) 407 + continue; 408 + if ( 409 + sortedSelection.find((s) => s.value === block.listData?.parent) 410 + ) 411 + continue; 412 + let parentoffset = 1; 413 + let previousBlock = siblings[i - parentoffset]; 414 + while ( 415 + previousBlock && 416 + sortedSelection.find((s) => previousBlock.value === s.value) 417 + ) { 418 + parentoffset += 1; 419 + previousBlock = siblings[i - parentoffset]; 420 + } 421 + if (!block.listData || !previousBlock.listData) continue; 422 + outdent(block, previousBlock, rep); 423 + } 424 + } else { 425 + for (let i = 0; i < siblings.length; i++) { 426 + let block = siblings[i]; 427 + if (!sortedSelection.find((s) => s.value === block.value)) 428 + continue; 429 + if ( 430 + sortedSelection.find((s) => s.value === block.listData?.parent) 431 + ) 432 + continue; 433 + let parentoffset = 1; 434 + let previousBlock = siblings[i - parentoffset]; 435 + while ( 436 + previousBlock && 437 + sortedSelection.find((s) => previousBlock.value === s.value) 438 + ) { 439 + parentoffset += 1; 440 + previousBlock = siblings[i - parentoffset]; 441 + } 442 + if (!block.listData || !previousBlock.listData) continue; 443 + indent(block, previousBlock, rep); 444 + } 445 + } 446 + } 447 + if (e.key === "ArrowDown") { 448 + let [sortedSelection, siblings] = await getSortedSelectionBound(); 449 + let focusedBlock = useUIState.getState().focusedEntity; 450 + if (!e.shiftKey) { 451 + if (sortedSelection.length === 1) return; 452 + let lastBlock = sortedSelection[sortedSelection.length - 1]; 453 + if (!lastBlock) return; 454 + let type = await rep?.query((tx) => 455 + scanIndex(tx).eav(lastBlock.value, "block/type"), 456 + ); 457 + if (!type?.[0]) return; 458 + useUIState.getState().setSelectedBlock(lastBlock); 459 + focusBlock( 460 + { ...lastBlock, type: type[0].data.value }, 461 + { type: "end" }, 462 + ); 463 + } 464 + if (e.shiftKey) { 465 + if (e.defaultPrevented) return; 466 + if ( 467 + sortedSelection.length <= 1 || 468 + !focusedBlock || 469 + focusedBlock.entityType === "page" 470 + ) 471 + return; 472 + let b = focusedBlock; 473 + let focusedBlockIndex = sortedSelection.findIndex( 474 + (s) => s.value == b.entityID, 475 + ); 476 + if (focusedBlockIndex === sortedSelection.length - 1) { 477 + let index = siblings.findIndex((s) => s.value === b.entityID); 478 + let nextSelectedBlock = siblings[index + 1]; 479 + if (!nextSelectedBlock) return; 480 + useUIState.getState().addBlockToSelection({ 481 + ...nextSelectedBlock, 482 + }); 483 + 484 + scrollIntoViewIfNeeded( 485 + document.getElementById( 486 + elementId.block(nextSelectedBlock.value).container, 487 + ), 488 + false, 489 + ); 490 + useUIState.getState().setFocusedBlock({ 491 + entityType: "block", 492 + parent: nextSelectedBlock.parent, 493 + entityID: nextSelectedBlock.value, 494 + }); 495 + } else { 496 + let nextBlock = sortedSelection[1]; 497 + useUIState 498 + .getState() 499 + .removeBlockFromSelection({ value: b.entityID }); 500 + scrollIntoViewIfNeeded( 501 + document.getElementById( 502 + elementId.block(nextBlock.value).container, 503 + ), 504 + false, 505 + ); 506 + useUIState.getState().setFocusedBlock({ 507 + entityType: "block", 508 + parent: b.parent, 509 + entityID: nextBlock.value, 510 + }); 511 + if (sortedSelection.length === 2) { 512 + useEditorStates 513 + .getState() 514 + .editorStates[nextBlock.value]?.view?.focus(); 515 + } 516 + } 517 + } 518 + } 519 + if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) { 520 + if (!rep) return; 521 + if (e.shiftKey || (e.metaKey && e.ctrlKey)) return; 522 + let [, , selectionWithFoldedChildren] = 523 + await getSortedSelectionBound(); 524 + if (!selectionWithFoldedChildren) return; 525 + let el = document.activeElement as HTMLElement; 526 + if ( 527 + el?.tagName === "LABEL" || 528 + el?.tagName === "INPUT" || 529 + el?.tagName === "TEXTAREA" 530 + ) { 531 + return; 532 + } 533 + 534 + if ( 535 + el.contentEditable === "true" && 536 + selectionWithFoldedChildren.length <= 1 537 + ) 538 + return; 539 + e.preventDefault(); 540 + await copySelection(rep, selectionWithFoldedChildren); 541 + if (e.key === "x") deleteBlocks(); 542 + } 543 + }); 544 + window.addEventListener("keydown", listener); 545 + return () => { 546 + removeListener(); 547 + window.removeEventListener("keydown", listener); 548 + }; 549 + }, [moreThanOneSelected, rep, entity_set.permissions.write]); 550 + 551 + let [mouseDown, setMouseDown] = useState(false); 552 + let initialContentEditableParent = useRef<null | Node>(null); 553 + let savedSelection = useRef<SavedRange[] | null>(undefined); 554 + useEffect(() => { 555 + if (isMobile) return; 556 + if (!entity_set.permissions.write) return; 557 + let mouseDownListener = (e: MouseEvent) => { 558 + if ((e.target as Element).getAttribute("data-draggable")) return; 559 + let contentEditableParent = getContentEditableParent(e.target as Node); 560 + if (contentEditableParent) { 561 + setMouseDown(true); 562 + let entityID = (contentEditableParent as Element).getAttribute( 563 + "data-entityid", 564 + ); 565 + useSelectingMouse.setState({ start: entityID }); 566 + } 567 + initialContentEditableParent.current = contentEditableParent; 568 + }; 569 + let mouseUpListener = (e: MouseEvent) => { 570 + savedSelection.current = null; 571 + if ( 572 + initialContentEditableParent.current && 573 + !(e.target as Element).getAttribute("data-draggable") && 574 + getContentEditableParent(e.target as Node) !== 575 + initialContentEditableParent.current 576 + ) { 577 + setTimeout(() => { 578 + window.getSelection()?.removeAllRanges(); 579 + }, 5); 580 + } 581 + initialContentEditableParent.current = null; 582 + useSelectingMouse.setState({ start: null }); 583 + setMouseDown(false); 584 + }; 585 + window.addEventListener("mousedown", mouseDownListener); 586 + window.addEventListener("mouseup", mouseUpListener); 587 + return () => { 588 + window.removeEventListener("mousedown", mouseDownListener); 589 + window.removeEventListener("mouseup", mouseUpListener); 590 + }; 591 + }, [entity_set.permissions.write, isMobile]); 592 + useEffect(() => { 593 + if (!mouseDown) return; 594 + if (isMobile) return; 595 + let mouseMoveListener = (e: MouseEvent) => { 596 + if (e.buttons !== 1) return; 597 + if (initialContentEditableParent.current) { 598 + if ( 599 + initialContentEditableParent.current === 600 + getContentEditableParent(e.target as Node) 601 + ) { 602 + if (savedSelection.current) { 603 + restoreSelection(savedSelection.current); 604 + } 605 + savedSelection.current = null; 606 + return; 607 + } 608 + if (!savedSelection.current) savedSelection.current = saveSelection(); 609 + window.getSelection()?.removeAllRanges(); 610 + } 611 + }; 612 + window.addEventListener("mousemove", mouseMoveListener); 613 + return () => { 614 + window.removeEventListener("mousemove", mouseMoveListener); 615 + }; 616 + }, [mouseDown, isMobile]); 617 + return null; 618 + } 619 + 620 + type SavedRange = { 621 + startContainer: Node; 622 + startOffset: number; 623 + endContainer: Node; 624 + endOffset: number; 625 + direction: "forward" | "backward"; 626 + }; 627 + function saveSelection() { 628 + let selection = window.getSelection(); 629 + if (selection && selection.rangeCount > 0) { 630 + let ranges: SavedRange[] = []; 631 + for (let i = 0; i < selection.rangeCount; i++) { 632 + let range = selection.getRangeAt(i); 633 + ranges.push({ 634 + startContainer: range.startContainer, 635 + startOffset: range.startOffset, 636 + endContainer: range.endContainer, 637 + endOffset: range.endOffset, 638 + direction: 639 + selection.anchorNode === range.startContainer && 640 + selection.anchorOffset === range.startOffset 641 + ? "forward" 642 + : "backward", 643 + }); 644 + } 645 + return ranges; 646 + } 647 + return []; 648 + } 649 + 650 + function restoreSelection(savedRanges: SavedRange[]) { 651 + if (savedRanges && savedRanges.length > 0) { 652 + let selection = window.getSelection(); 653 + if (!selection) return; 654 + selection.removeAllRanges(); 655 + for (let i = 0; i < savedRanges.length; i++) { 656 + let range = document.createRange(); 657 + range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset); 658 + range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset); 659 + 660 + selection.addRange(range); 661 + 662 + // If the direction is backward, collapse the selection to the end and then extend it backward 663 + if (savedRanges[i].direction === "backward") { 664 + selection.collapseToEnd(); 665 + selection.extend( 666 + savedRanges[i].startContainer, 667 + savedRanges[i].startOffset, 668 + ); 669 + } 670 + } 671 + } 672 + } 673 + 674 + function getContentEditableParent(e: Node | null): Node | null { 675 + let element: Node | null = e; 676 + while (element && element !== document) { 677 + if ( 678 + (element as HTMLElement).contentEditable === "true" || 679 + (element as HTMLElement).getAttribute("data-editable-block") 680 + ) { 681 + return element; 682 + } 683 + element = element.parentNode; 684 + } 685 + return null; 686 + } 687 + 688 + 689 + function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 690 + let everyBlockHasMark = blocks.reduce((acc, block) => { 691 + let editor = useEditorStates.getState().editorStates[block]; 692 + if (!editor) return acc; 693 + let { view } = editor; 694 + let from = 0; 695 + let to = view.state.doc.content.size; 696 + let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark); 697 + return acc && hasMarkInRange; 698 + }, true); 699 + for (let block of blocks) { 700 + let editor = useEditorStates.getState().editorStates[block]; 701 + if (!editor) return; 702 + let { view } = editor; 703 + let tr = view.state.tr; 704 + 705 + let from = 0; 706 + let to = view.state.doc.content.size; 707 + 708 + tr.setMeta("bulkOp", true); 709 + if (everyBlockHasMark) { 710 + tr.removeMark(from, to, mark); 711 + } else { 712 + tr.addMark(from, to, mark.create(attrs)); 713 + } 714 + 715 + view.dispatch(tr); 716 + } 717 + }
+48
components/SelectionManager/selectionState.ts
···
··· 1 + import { create } from "zustand"; 2 + import { Replicache } from "replicache"; 3 + import { ReplicacheMutators } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 + 7 + export const useSelectingMouse = create(() => ({ 8 + start: null as null | string, 9 + })); 10 + 11 + export const getSortedSelection = async ( 12 + rep: Replicache<ReplicacheMutators>, 13 + ) => { 14 + let selectedBlocks = useUIState.getState().selectedBlocks; 15 + let foldedBlocks = useUIState.getState().foldedBlocks; 16 + if (!selectedBlocks[0]) return [[], []]; 17 + let siblings = 18 + (await rep?.query((tx) => 19 + getBlocksWithType(tx, selectedBlocks[0].parent), 20 + )) || []; 21 + let sortedBlocks = siblings.filter((s) => { 22 + let selected = selectedBlocks.find((sb) => sb.value === s.value); 23 + return selected; 24 + }); 25 + let sortedBlocksWithChildren = siblings.filter((s) => { 26 + let selected = selectedBlocks.find((sb) => sb.value === s.value); 27 + if (s.listData && !selected) { 28 + //Select the children of folded list blocks (in order to copy them) 29 + return s.listData.path.find( 30 + (p) => 31 + selectedBlocks.find((sb) => sb.value === p.entity) && 32 + foldedBlocks.includes(p.entity), 33 + ); 34 + } 35 + return selected; 36 + }); 37 + return [ 38 + sortedBlocks, 39 + siblings.filter( 40 + (f) => 41 + !f.listData || 42 + !f.listData.path.find( 43 + (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, 44 + ), 45 + ), 46 + sortedBlocksWithChildren, 47 + ]; 48 + };
-763
components/SelectionManager.tsx
··· 1 - "use client"; 2 - import { useEffect, useRef, useState } from "react"; 3 - import { create } from "zustand"; 4 - import { ReplicacheMutators, useReplicache } from "src/replicache"; 5 - import { useUIState } from "src/useUIState"; 6 - import { scanIndex } from "src/replicache/utils"; 7 - import { focusBlock } from "src/utils/focusBlock"; 8 - import { useEditorStates } from "src/state/useEditorState"; 9 - import { useEntitySetContext } from "./EntitySetProvider"; 10 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 - import { v7 } from "uuid"; 12 - import { indent, outdent, outdentFull } from "src/utils/list-operations"; 13 - import { addShortcut, Shortcut } from "src/shortcuts"; 14 - import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 15 - import { elementId } from "src/utils/elementId"; 16 - import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 17 - import { copySelection } from "src/utils/copySelection"; 18 - import { isTextBlock } from "src/utils/isTextBlock"; 19 - import { useIsMobile } from "src/hooks/isMobile"; 20 - import { deleteBlock } from "./Blocks/DeleteBlock"; 21 - import { Replicache } from "replicache"; 22 - import { schema } from "./Blocks/TextBlock/schema"; 23 - import { TextSelection } from "prosemirror-state"; 24 - import { MarkType } from "prosemirror-model"; 25 - export const useSelectingMouse = create(() => ({ 26 - start: null as null | string, 27 - })); 28 - 29 - //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 30 - // How does this relate to *when dragging* ? 31 - 32 - export function SelectionManager() { 33 - let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1); 34 - let entity_set = useEntitySetContext(); 35 - let { rep, undoManager } = useReplicache(); 36 - let isMobile = useIsMobile(); 37 - useEffect(() => { 38 - if (!entity_set.permissions.write || !rep) return; 39 - const getSortedSelectionBound = getSortedSelection.bind(null, rep); 40 - let shortcuts: Shortcut[] = [ 41 - { 42 - metaKey: true, 43 - key: "ArrowUp", 44 - handler: async () => { 45 - let [firstBlock] = 46 - (await rep?.query((tx) => 47 - getBlocksWithType( 48 - tx, 49 - useUIState.getState().selectedBlocks[0].parent, 50 - ), 51 - )) || []; 52 - if (firstBlock) focusBlock(firstBlock, { type: "start" }); 53 - }, 54 - }, 55 - { 56 - metaKey: true, 57 - key: "ArrowDown", 58 - handler: async () => { 59 - let blocks = 60 - (await rep?.query((tx) => 61 - getBlocksWithType( 62 - tx, 63 - useUIState.getState().selectedBlocks[0].parent, 64 - ), 65 - )) || []; 66 - let folded = useUIState.getState().foldedBlocks; 67 - blocks = blocks.filter( 68 - (f) => 69 - !f.listData || 70 - !f.listData.path.find( 71 - (path) => 72 - folded.includes(path.entity) && f.value !== path.entity, 73 - ), 74 - ); 75 - let lastBlock = blocks[blocks.length - 1]; 76 - if (lastBlock) focusBlock(lastBlock, { type: "end" }); 77 - }, 78 - }, 79 - { 80 - metaKey: true, 81 - altKey: true, 82 - key: ["l", "ยฌ"], 83 - handler: async () => { 84 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 85 - for (let block of sortedBlocks) { 86 - if (!block.listData) { 87 - await rep?.mutate.assertFact({ 88 - entity: block.value, 89 - attribute: "block/is-list", 90 - data: { type: "boolean", value: true }, 91 - }); 92 - } else { 93 - outdentFull(block, rep); 94 - } 95 - } 96 - }, 97 - }, 98 - { 99 - metaKey: true, 100 - shift: true, 101 - key: ["ArrowDown", "J"], 102 - handler: async () => { 103 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 104 - let block = sortedBlocks[0]; 105 - let nextBlock = siblings 106 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 107 - .find( 108 - (f) => 109 - f.listData && 110 - block.listData && 111 - !f.listData.path.find((f) => f.entity === block.value), 112 - ); 113 - if ( 114 - nextBlock?.listData && 115 - block.listData && 116 - nextBlock.listData.depth === block.listData.depth - 1 117 - ) { 118 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 119 - useUIState.getState().toggleFold(nextBlock.value); 120 - await rep?.mutate.moveBlock({ 121 - block: block.value, 122 - oldParent: block.listData?.parent, 123 - newParent: nextBlock.value, 124 - position: { type: "first" }, 125 - }); 126 - } else { 127 - await rep?.mutate.moveBlockDown({ 128 - entityID: block.value, 129 - parent: block.listData?.parent || block.parent, 130 - }); 131 - } 132 - }, 133 - }, 134 - { 135 - metaKey: true, 136 - shift: true, 137 - key: ["ArrowUp", "K"], 138 - handler: async () => { 139 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 140 - let block = sortedBlocks[0]; 141 - let previousBlock = 142 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 143 - if (previousBlock.value === block.listData?.parent) { 144 - previousBlock = 145 - siblings?.[ 146 - siblings.findIndex((s) => s.value === block.value) - 2 147 - ]; 148 - } 149 - 150 - if ( 151 - previousBlock?.listData && 152 - block.listData && 153 - block.listData.depth > 1 && 154 - !previousBlock.listData.path.find( 155 - (f) => f.entity === block.listData?.parent, 156 - ) 157 - ) { 158 - let depth = block.listData.depth; 159 - let newParent = previousBlock.listData.path.find( 160 - (f) => f.depth === depth - 1, 161 - ); 162 - if (!newParent) return; 163 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 164 - useUIState.getState().toggleFold(newParent.entity); 165 - rep?.mutate.moveBlock({ 166 - block: block.value, 167 - oldParent: block.listData?.parent, 168 - newParent: newParent.entity, 169 - position: { type: "end" }, 170 - }); 171 - } else { 172 - rep?.mutate.moveBlockUp({ 173 - entityID: block.value, 174 - parent: block.listData?.parent || block.parent, 175 - }); 176 - } 177 - }, 178 - }, 179 - 180 - { 181 - metaKey: true, 182 - shift: true, 183 - key: "Enter", 184 - handler: async () => { 185 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 186 - if (!sortedBlocks[0].listData) return; 187 - useUIState.getState().toggleFold(sortedBlocks[0].value); 188 - }, 189 - }, 190 - ]; 191 - if (moreThanOneSelected) 192 - shortcuts = shortcuts.concat([ 193 - { 194 - metaKey: true, 195 - key: "u", 196 - handler: async () => { 197 - let [sortedBlocks] = await getSortedSelectionBound(); 198 - toggleMarkInBlocks( 199 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 200 - schema.marks.underline, 201 - ); 202 - }, 203 - }, 204 - { 205 - metaKey: true, 206 - key: "i", 207 - handler: async () => { 208 - let [sortedBlocks] = await getSortedSelectionBound(); 209 - toggleMarkInBlocks( 210 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 211 - schema.marks.em, 212 - ); 213 - }, 214 - }, 215 - { 216 - metaKey: true, 217 - key: "b", 218 - handler: async () => { 219 - let [sortedBlocks] = await getSortedSelectionBound(); 220 - toggleMarkInBlocks( 221 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 222 - schema.marks.strong, 223 - ); 224 - }, 225 - }, 226 - { 227 - metaAndCtrl: true, 228 - key: "h", 229 - handler: async () => { 230 - let [sortedBlocks] = await getSortedSelectionBound(); 231 - toggleMarkInBlocks( 232 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 233 - schema.marks.highlight, 234 - { 235 - color: useUIState.getState().lastUsedHighlight, 236 - }, 237 - ); 238 - }, 239 - }, 240 - { 241 - metaAndCtrl: true, 242 - key: "x", 243 - handler: async () => { 244 - let [sortedBlocks] = await getSortedSelectionBound(); 245 - toggleMarkInBlocks( 246 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 247 - schema.marks.strikethrough, 248 - ); 249 - }, 250 - }, 251 - ]); 252 - let removeListener = addShortcut( 253 - shortcuts.map((shortcut) => ({ 254 - ...shortcut, 255 - handler: () => undoManager.withUndoGroup(() => shortcut.handler()), 256 - })), 257 - ); 258 - let listener = async (e: KeyboardEvent) => 259 - undoManager.withUndoGroup(async () => { 260 - //used here and in cut 261 - const deleteBlocks = async () => { 262 - if (!entity_set.permissions.write) return; 263 - if (moreThanOneSelected) { 264 - e.preventDefault(); 265 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 266 - let selectedBlocks = useUIState.getState().selectedBlocks; 267 - let firstBlock = sortedBlocks[0]; 268 - 269 - await rep?.mutate.removeBlock( 270 - selectedBlocks.map((block) => ({ blockEntity: block.value })), 271 - ); 272 - useUIState.getState().closePage(selectedBlocks.map((b) => b.value)); 273 - 274 - let nextBlock = 275 - siblings?.[ 276 - siblings.findIndex((s) => s.value === firstBlock.value) - 1 277 - ]; 278 - if (nextBlock) { 279 - useUIState.getState().setSelectedBlock({ 280 - value: nextBlock.value, 281 - parent: nextBlock.parent, 282 - }); 283 - let type = await rep?.query((tx) => 284 - scanIndex(tx).eav(nextBlock.value, "block/type"), 285 - ); 286 - if (!type?.[0]) return; 287 - if ( 288 - type[0]?.data.value === "text" || 289 - type[0]?.data.value === "heading" 290 - ) 291 - focusBlock( 292 - { 293 - value: nextBlock.value, 294 - type: "text", 295 - parent: nextBlock.parent, 296 - }, 297 - { type: "end" }, 298 - ); 299 - } 300 - } 301 - }; 302 - if (e.key === "Backspace" || e.key === "Delete") { 303 - deleteBlocks(); 304 - } 305 - if (e.key === "ArrowUp") { 306 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 307 - let focusedBlock = useUIState.getState().focusedEntity; 308 - if (!e.shiftKey && !e.ctrlKey) { 309 - if (e.defaultPrevented) return; 310 - if (sortedBlocks.length === 1) return; 311 - let firstBlock = sortedBlocks[0]; 312 - if (!firstBlock) return; 313 - let type = await rep?.query((tx) => 314 - scanIndex(tx).eav(firstBlock.value, "block/type"), 315 - ); 316 - if (!type?.[0]) return; 317 - useUIState.getState().setSelectedBlock(firstBlock); 318 - focusBlock( 319 - { ...firstBlock, type: type[0].data.value }, 320 - { type: "start" }, 321 - ); 322 - } else { 323 - if (e.defaultPrevented) return; 324 - if ( 325 - sortedBlocks.length <= 1 || 326 - !focusedBlock || 327 - focusedBlock.entityType === "page" 328 - ) 329 - return; 330 - let b = focusedBlock; 331 - let focusedBlockIndex = sortedBlocks.findIndex( 332 - (s) => s.value == b.entityID, 333 - ); 334 - if (focusedBlockIndex === 0) { 335 - let index = siblings.findIndex((s) => s.value === b.entityID); 336 - let nextSelectedBlock = siblings[index - 1]; 337 - if (!nextSelectedBlock) return; 338 - 339 - scrollIntoViewIfNeeded( 340 - document.getElementById( 341 - elementId.block(nextSelectedBlock.value).container, 342 - ), 343 - false, 344 - ); 345 - useUIState.getState().addBlockToSelection({ 346 - ...nextSelectedBlock, 347 - }); 348 - useUIState.getState().setFocusedBlock({ 349 - entityType: "block", 350 - parent: nextSelectedBlock.parent, 351 - entityID: nextSelectedBlock.value, 352 - }); 353 - } else { 354 - let nextBlock = sortedBlocks[sortedBlocks.length - 2]; 355 - useUIState.getState().setFocusedBlock({ 356 - entityType: "block", 357 - parent: b.parent, 358 - entityID: nextBlock.value, 359 - }); 360 - scrollIntoViewIfNeeded( 361 - document.getElementById( 362 - elementId.block(nextBlock.value).container, 363 - ), 364 - false, 365 - ); 366 - if (sortedBlocks.length === 2) { 367 - useEditorStates 368 - .getState() 369 - .editorStates[nextBlock.value]?.view?.focus(); 370 - } 371 - useUIState 372 - .getState() 373 - .removeBlockFromSelection(sortedBlocks[focusedBlockIndex]); 374 - } 375 - } 376 - } 377 - if (e.key === "ArrowLeft") { 378 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 379 - if (sortedSelection.length === 1) return; 380 - let firstBlock = sortedSelection[0]; 381 - if (!firstBlock) return; 382 - let type = await rep?.query((tx) => 383 - scanIndex(tx).eav(firstBlock.value, "block/type"), 384 - ); 385 - if (!type?.[0]) return; 386 - useUIState.getState().setSelectedBlock(firstBlock); 387 - focusBlock( 388 - { ...firstBlock, type: type[0].data.value }, 389 - { type: "start" }, 390 - ); 391 - } 392 - if (e.key === "ArrowRight") { 393 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 394 - if (sortedSelection.length === 1) return; 395 - let lastBlock = sortedSelection[sortedSelection.length - 1]; 396 - if (!lastBlock) return; 397 - let type = await rep?.query((tx) => 398 - scanIndex(tx).eav(lastBlock.value, "block/type"), 399 - ); 400 - if (!type?.[0]) return; 401 - useUIState.getState().setSelectedBlock(lastBlock); 402 - focusBlock( 403 - { ...lastBlock, type: type[0].data.value }, 404 - { type: "end" }, 405 - ); 406 - } 407 - if (e.key === "Tab") { 408 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 409 - if (sortedSelection.length <= 1) return; 410 - e.preventDefault(); 411 - if (e.shiftKey) { 412 - for (let i = siblings.length - 1; i >= 0; i--) { 413 - let block = siblings[i]; 414 - if (!sortedSelection.find((s) => s.value === block.value)) 415 - continue; 416 - if ( 417 - sortedSelection.find((s) => s.value === block.listData?.parent) 418 - ) 419 - continue; 420 - let parentoffset = 1; 421 - let previousBlock = siblings[i - parentoffset]; 422 - while ( 423 - previousBlock && 424 - sortedSelection.find((s) => previousBlock.value === s.value) 425 - ) { 426 - parentoffset += 1; 427 - previousBlock = siblings[i - parentoffset]; 428 - } 429 - if (!block.listData || !previousBlock.listData) continue; 430 - outdent(block, previousBlock, rep); 431 - } 432 - } else { 433 - for (let i = 0; i < siblings.length; i++) { 434 - let block = siblings[i]; 435 - if (!sortedSelection.find((s) => s.value === block.value)) 436 - continue; 437 - if ( 438 - sortedSelection.find((s) => s.value === block.listData?.parent) 439 - ) 440 - continue; 441 - let parentoffset = 1; 442 - let previousBlock = siblings[i - parentoffset]; 443 - while ( 444 - previousBlock && 445 - sortedSelection.find((s) => previousBlock.value === s.value) 446 - ) { 447 - parentoffset += 1; 448 - previousBlock = siblings[i - parentoffset]; 449 - } 450 - if (!block.listData || !previousBlock.listData) continue; 451 - indent(block, previousBlock, rep); 452 - } 453 - } 454 - } 455 - if (e.key === "ArrowDown") { 456 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 457 - let focusedBlock = useUIState.getState().focusedEntity; 458 - if (!e.shiftKey) { 459 - if (sortedSelection.length === 1) return; 460 - let lastBlock = sortedSelection[sortedSelection.length - 1]; 461 - if (!lastBlock) return; 462 - let type = await rep?.query((tx) => 463 - scanIndex(tx).eav(lastBlock.value, "block/type"), 464 - ); 465 - if (!type?.[0]) return; 466 - useUIState.getState().setSelectedBlock(lastBlock); 467 - focusBlock( 468 - { ...lastBlock, type: type[0].data.value }, 469 - { type: "end" }, 470 - ); 471 - } 472 - if (e.shiftKey) { 473 - if (e.defaultPrevented) return; 474 - if ( 475 - sortedSelection.length <= 1 || 476 - !focusedBlock || 477 - focusedBlock.entityType === "page" 478 - ) 479 - return; 480 - let b = focusedBlock; 481 - let focusedBlockIndex = sortedSelection.findIndex( 482 - (s) => s.value == b.entityID, 483 - ); 484 - if (focusedBlockIndex === sortedSelection.length - 1) { 485 - let index = siblings.findIndex((s) => s.value === b.entityID); 486 - let nextSelectedBlock = siblings[index + 1]; 487 - if (!nextSelectedBlock) return; 488 - useUIState.getState().addBlockToSelection({ 489 - ...nextSelectedBlock, 490 - }); 491 - 492 - scrollIntoViewIfNeeded( 493 - document.getElementById( 494 - elementId.block(nextSelectedBlock.value).container, 495 - ), 496 - false, 497 - ); 498 - useUIState.getState().setFocusedBlock({ 499 - entityType: "block", 500 - parent: nextSelectedBlock.parent, 501 - entityID: nextSelectedBlock.value, 502 - }); 503 - } else { 504 - let nextBlock = sortedSelection[1]; 505 - useUIState 506 - .getState() 507 - .removeBlockFromSelection({ value: b.entityID }); 508 - scrollIntoViewIfNeeded( 509 - document.getElementById( 510 - elementId.block(nextBlock.value).container, 511 - ), 512 - false, 513 - ); 514 - useUIState.getState().setFocusedBlock({ 515 - entityType: "block", 516 - parent: b.parent, 517 - entityID: nextBlock.value, 518 - }); 519 - if (sortedSelection.length === 2) { 520 - useEditorStates 521 - .getState() 522 - .editorStates[nextBlock.value]?.view?.focus(); 523 - } 524 - } 525 - } 526 - } 527 - if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) { 528 - if (!rep) return; 529 - if (e.shiftKey || (e.metaKey && e.ctrlKey)) return; 530 - let [, , selectionWithFoldedChildren] = 531 - await getSortedSelectionBound(); 532 - if (!selectionWithFoldedChildren) return; 533 - let el = document.activeElement as HTMLElement; 534 - if ( 535 - el?.tagName === "LABEL" || 536 - el?.tagName === "INPUT" || 537 - el?.tagName === "TEXTAREA" 538 - ) { 539 - return; 540 - } 541 - 542 - if ( 543 - el.contentEditable === "true" && 544 - selectionWithFoldedChildren.length <= 1 545 - ) 546 - return; 547 - e.preventDefault(); 548 - await copySelection(rep, selectionWithFoldedChildren); 549 - if (e.key === "x") deleteBlocks(); 550 - } 551 - }); 552 - window.addEventListener("keydown", listener); 553 - return () => { 554 - removeListener(); 555 - window.removeEventListener("keydown", listener); 556 - }; 557 - }, [moreThanOneSelected, rep, entity_set.permissions.write]); 558 - 559 - let [mouseDown, setMouseDown] = useState(false); 560 - let initialContentEditableParent = useRef<null | Node>(null); 561 - let savedSelection = useRef<SavedRange[] | null>(undefined); 562 - useEffect(() => { 563 - if (isMobile) return; 564 - if (!entity_set.permissions.write) return; 565 - let mouseDownListener = (e: MouseEvent) => { 566 - if ((e.target as Element).getAttribute("data-draggable")) return; 567 - let contentEditableParent = getContentEditableParent(e.target as Node); 568 - if (contentEditableParent) { 569 - setMouseDown(true); 570 - let entityID = (contentEditableParent as Element).getAttribute( 571 - "data-entityid", 572 - ); 573 - useSelectingMouse.setState({ start: entityID }); 574 - } 575 - initialContentEditableParent.current = contentEditableParent; 576 - }; 577 - let mouseUpListener = (e: MouseEvent) => { 578 - savedSelection.current = null; 579 - if ( 580 - initialContentEditableParent.current && 581 - !(e.target as Element).getAttribute("data-draggable") && 582 - getContentEditableParent(e.target as Node) !== 583 - initialContentEditableParent.current 584 - ) { 585 - setTimeout(() => { 586 - window.getSelection()?.removeAllRanges(); 587 - }, 5); 588 - } 589 - initialContentEditableParent.current = null; 590 - useSelectingMouse.setState({ start: null }); 591 - setMouseDown(false); 592 - }; 593 - window.addEventListener("mousedown", mouseDownListener); 594 - window.addEventListener("mouseup", mouseUpListener); 595 - return () => { 596 - window.removeEventListener("mousedown", mouseDownListener); 597 - window.removeEventListener("mouseup", mouseUpListener); 598 - }; 599 - }, [entity_set.permissions.write, isMobile]); 600 - useEffect(() => { 601 - if (!mouseDown) return; 602 - if (isMobile) return; 603 - let mouseMoveListener = (e: MouseEvent) => { 604 - if (e.buttons !== 1) return; 605 - if (initialContentEditableParent.current) { 606 - if ( 607 - initialContentEditableParent.current === 608 - getContentEditableParent(e.target as Node) 609 - ) { 610 - if (savedSelection.current) { 611 - restoreSelection(savedSelection.current); 612 - } 613 - savedSelection.current = null; 614 - return; 615 - } 616 - if (!savedSelection.current) savedSelection.current = saveSelection(); 617 - window.getSelection()?.removeAllRanges(); 618 - } 619 - }; 620 - window.addEventListener("mousemove", mouseMoveListener); 621 - return () => { 622 - window.removeEventListener("mousemove", mouseMoveListener); 623 - }; 624 - }, [mouseDown, isMobile]); 625 - return null; 626 - } 627 - 628 - type SavedRange = { 629 - startContainer: Node; 630 - startOffset: number; 631 - endContainer: Node; 632 - endOffset: number; 633 - direction: "forward" | "backward"; 634 - }; 635 - export function saveSelection() { 636 - let selection = window.getSelection(); 637 - if (selection && selection.rangeCount > 0) { 638 - let ranges: SavedRange[] = []; 639 - for (let i = 0; i < selection.rangeCount; i++) { 640 - let range = selection.getRangeAt(i); 641 - ranges.push({ 642 - startContainer: range.startContainer, 643 - startOffset: range.startOffset, 644 - endContainer: range.endContainer, 645 - endOffset: range.endOffset, 646 - direction: 647 - selection.anchorNode === range.startContainer && 648 - selection.anchorOffset === range.startOffset 649 - ? "forward" 650 - : "backward", 651 - }); 652 - } 653 - return ranges; 654 - } 655 - return []; 656 - } 657 - 658 - export function restoreSelection(savedRanges: SavedRange[]) { 659 - if (savedRanges && savedRanges.length > 0) { 660 - let selection = window.getSelection(); 661 - if (!selection) return; 662 - selection.removeAllRanges(); 663 - for (let i = 0; i < savedRanges.length; i++) { 664 - let range = document.createRange(); 665 - range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset); 666 - range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset); 667 - 668 - selection.addRange(range); 669 - 670 - // If the direction is backward, collapse the selection to the end and then extend it backward 671 - if (savedRanges[i].direction === "backward") { 672 - selection.collapseToEnd(); 673 - selection.extend( 674 - savedRanges[i].startContainer, 675 - savedRanges[i].startOffset, 676 - ); 677 - } 678 - } 679 - } 680 - } 681 - 682 - function getContentEditableParent(e: Node | null): Node | null { 683 - let element: Node | null = e; 684 - while (element && element !== document) { 685 - if ( 686 - (element as HTMLElement).contentEditable === "true" || 687 - (element as HTMLElement).getAttribute("data-editable-block") 688 - ) { 689 - return element; 690 - } 691 - element = element.parentNode; 692 - } 693 - return null; 694 - } 695 - 696 - export const getSortedSelection = async ( 697 - rep: Replicache<ReplicacheMutators>, 698 - ) => { 699 - let selectedBlocks = useUIState.getState().selectedBlocks; 700 - let foldedBlocks = useUIState.getState().foldedBlocks; 701 - if (!selectedBlocks[0]) return [[], []]; 702 - let siblings = 703 - (await rep?.query((tx) => 704 - getBlocksWithType(tx, selectedBlocks[0].parent), 705 - )) || []; 706 - let sortedBlocks = siblings.filter((s) => { 707 - let selected = selectedBlocks.find((sb) => sb.value === s.value); 708 - return selected; 709 - }); 710 - let sortedBlocksWithChildren = siblings.filter((s) => { 711 - let selected = selectedBlocks.find((sb) => sb.value === s.value); 712 - if (s.listData && !selected) { 713 - //Select the children of folded list blocks (in order to copy them) 714 - return s.listData.path.find( 715 - (p) => 716 - selectedBlocks.find((sb) => sb.value === p.entity) && 717 - foldedBlocks.includes(p.entity), 718 - ); 719 - } 720 - return selected; 721 - }); 722 - return [ 723 - sortedBlocks, 724 - siblings.filter( 725 - (f) => 726 - !f.listData || 727 - !f.listData.path.find( 728 - (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, 729 - ), 730 - ), 731 - sortedBlocksWithChildren, 732 - ]; 733 - }; 734 - 735 - function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 736 - let everyBlockHasMark = blocks.reduce((acc, block) => { 737 - let editor = useEditorStates.getState().editorStates[block]; 738 - if (!editor) return acc; 739 - let { view } = editor; 740 - let from = 0; 741 - let to = view.state.doc.content.size; 742 - let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark); 743 - return acc && hasMarkInRange; 744 - }, true); 745 - for (let block of blocks) { 746 - let editor = useEditorStates.getState().editorStates[block]; 747 - if (!editor) return; 748 - let { view } = editor; 749 - let tr = view.state.tr; 750 - 751 - let from = 0; 752 - let to = view.state.doc.content.size; 753 - 754 - tr.setMeta("bulkOp", true); 755 - if (everyBlockHasMark) { 756 - tr.removeMark(from, to, mark); 757 - } else { 758 - tr.addMark(from, to, mark.create(attrs)); 759 - } 760 - 761 - view.dispatch(tr); 762 - } 763 - }
···
-394
components/ShareOptions/DomainOptions.tsx
··· 1 - import { useState } from "react"; 2 - import { ButtonPrimary } from "components/Buttons"; 3 - 4 - import { useSmoker, useToaster } from "components/Toast"; 5 - import { Input, InputWithLabel } from "components/Input"; 6 - import useSWR from "swr"; 7 - import { useIdentityData } from "components/IdentityProvider"; 8 - import { addDomain } from "actions/domains/addDomain"; 9 - import { callRPC } from "app/api/rpc/client"; 10 - import { useLeafletDomains } from "components/PageSWRDataProvider"; 11 - import { usePublishLink } from "."; 12 - import { addDomainPath } from "actions/domains/addDomainPath"; 13 - import { useReplicache } from "src/replicache"; 14 - import { deleteDomain } from "actions/domains/deleteDomain"; 15 - import { AddTiny } from "components/Icons/AddTiny"; 16 - 17 - type DomainMenuState = 18 - | { 19 - state: "default"; 20 - } 21 - | { 22 - state: "domain-settings"; 23 - domain: string; 24 - } 25 - | { 26 - state: "add-domain"; 27 - } 28 - | { 29 - state: "has-domain"; 30 - domain: string; 31 - }; 32 - export function CustomDomainMenu(props: { 33 - setShareMenuState: (s: "default") => void; 34 - }) { 35 - let { data: domains } = useLeafletDomains(); 36 - let [state, setState] = useState<DomainMenuState>( 37 - domains?.[0] 38 - ? { state: "has-domain", domain: domains[0].domain } 39 - : { state: "default" }, 40 - ); 41 - switch (state.state) { 42 - case "has-domain": 43 - case "default": 44 - return ( 45 - <DomainOptions 46 - setDomainMenuState={setState} 47 - domainConnected={false} 48 - setShareMenuState={props.setShareMenuState} 49 - /> 50 - ); 51 - case "domain-settings": 52 - return ( 53 - <DomainSettings domain={state.domain} setDomainMenuState={setState} /> 54 - ); 55 - case "add-domain": 56 - return <AddDomain setDomainMenuState={setState} />; 57 - } 58 - } 59 - 60 - export const DomainOptions = (props: { 61 - setShareMenuState: (s: "default") => void; 62 - setDomainMenuState: (state: DomainMenuState) => void; 63 - domainConnected: boolean; 64 - }) => { 65 - let { data: domains, mutate: mutateDomains } = useLeafletDomains(); 66 - let [selectedDomain, setSelectedDomain] = useState<string | undefined>( 67 - domains?.[0]?.domain, 68 - ); 69 - let [selectedRoute, setSelectedRoute] = useState( 70 - domains?.[0]?.route.slice(1) || "", 71 - ); 72 - let { identity } = useIdentityData(); 73 - let { permission_token } = useReplicache(); 74 - 75 - let toaster = useToaster(); 76 - let smoker = useSmoker(); 77 - let publishLink = usePublishLink(); 78 - 79 - return ( 80 - <div className="px-3 py-1 flex flex-col gap-3 max-w-full w-[600px]"> 81 - <h3 className="text-secondary">Choose a Domain</h3> 82 - <div className="flex flex-col gap-1 text-secondary"> 83 - {identity?.custom_domains 84 - .filter((d) => !d.publication_domains.length) 85 - .map((domain) => { 86 - return ( 87 - <DomainOption 88 - selectedRoute={selectedRoute} 89 - setSelectedRoute={setSelectedRoute} 90 - key={domain.domain} 91 - domain={domain.domain} 92 - checked={selectedDomain === domain.domain} 93 - setChecked={setSelectedDomain} 94 - setDomainMenuState={props.setDomainMenuState} 95 - /> 96 - ); 97 - })} 98 - <button 99 - onMouseDown={() => { 100 - props.setDomainMenuState({ state: "add-domain" }); 101 - }} 102 - className="text-accent-contrast flex gap-2 items-center px-1 py-0.5" 103 - > 104 - <AddTiny /> Add a New Domain 105 - </button> 106 - </div> 107 - 108 - {/* ONLY SHOW IF A DOMAIN IS CURRENTLY CONNECTED */} 109 - <div className="flex gap-3 items-center justify-end"> 110 - {props.domainConnected && ( 111 - <button 112 - onMouseDown={() => { 113 - props.setShareMenuState("default"); 114 - toaster({ 115 - content: ( 116 - <div className="font-bold"> 117 - Unpublished from custom domain! 118 - </div> 119 - ), 120 - type: "error", 121 - }); 122 - }} 123 - > 124 - Unpublish 125 - </button> 126 - )} 127 - 128 - <ButtonPrimary 129 - id="publish-to-domain" 130 - disabled={ 131 - domains?.[0] 132 - ? domains[0].domain === selectedDomain && 133 - domains[0].route.slice(1) === selectedRoute 134 - : !selectedDomain 135 - } 136 - onClick={async () => { 137 - // let rect = document 138 - // .getElementById("publish-to-domain") 139 - // ?.getBoundingClientRect(); 140 - // smoker({ 141 - // error: true, 142 - // text: "url already in use!", 143 - // position: { 144 - // x: rect ? rect.left : 0, 145 - // y: rect ? rect.top + 26 : 0, 146 - // }, 147 - // }); 148 - if (!selectedDomain || !publishLink) return; 149 - await addDomainPath({ 150 - domain: selectedDomain, 151 - route: "/" + selectedRoute, 152 - view_permission_token: publishLink, 153 - edit_permission_token: permission_token.id, 154 - }); 155 - 156 - toaster({ 157 - content: ( 158 - <div className="font-bold"> 159 - Published to custom domain!{" "} 160 - <a 161 - className="underline text-accent-2" 162 - href={`https://${selectedDomain}/${selectedRoute}`} 163 - target="_blank" 164 - > 165 - View 166 - </a> 167 - </div> 168 - ), 169 - type: "success", 170 - }); 171 - mutateDomains(); 172 - props.setShareMenuState("default"); 173 - }} 174 - > 175 - Publish! 176 - </ButtonPrimary> 177 - </div> 178 - </div> 179 - ); 180 - }; 181 - 182 - const DomainOption = (props: { 183 - selectedRoute: string; 184 - setSelectedRoute: (s: string) => void; 185 - checked: boolean; 186 - setChecked: (checked: string) => void; 187 - domain: string; 188 - setDomainMenuState: (state: DomainMenuState) => void; 189 - }) => { 190 - let [value, setValue] = useState(""); 191 - let { data } = useSWR(props.domain, async (domain) => { 192 - return await callRPC("get_domain_status", { domain }); 193 - }); 194 - let pending = data?.config?.misconfigured || data?.error; 195 - return ( 196 - <label htmlFor={props.domain}> 197 - <input 198 - type="radio" 199 - name={props.domain} 200 - id={props.domain} 201 - value={props.domain} 202 - checked={props.checked} 203 - className="hidden appearance-none" 204 - onChange={() => { 205 - if (pending) return; 206 - props.setChecked(props.domain); 207 - }} 208 - /> 209 - <div 210 - className={` 211 - px-[6px] py-1 212 - flex 213 - border rounded-md 214 - ${ 215 - pending 216 - ? "border-border-light text-secondary justify-between gap-2 items-center " 217 - : !props.checked 218 - ? "flex-wrap border-border-light" 219 - : "flex-wrap border-accent-1 bg-accent-1 text-accent-2 font-bold" 220 - } `} 221 - > 222 - <div className={`w-max truncate ${pending && "animate-pulse"}`}> 223 - {props.domain} 224 - </div> 225 - {props.checked && ( 226 - <div className="flex gap-0 w-full"> 227 - <span 228 - className="font-normal" 229 - style={value === "" ? { opacity: "0.5" } : {}} 230 - > 231 - / 232 - </span> 233 - 234 - <Input 235 - type="text" 236 - autoFocus 237 - className="appearance-none focus:outline-hidden font-normal text-accent-2 w-full bg-transparent placeholder:text-accent-2 placeholder:opacity-50" 238 - placeholder="add-optional-path" 239 - onChange={(e) => props.setSelectedRoute(e.target.value)} 240 - value={props.selectedRoute} 241 - /> 242 - </div> 243 - )} 244 - {pending && ( 245 - <button 246 - className="text-accent-contrast text-sm" 247 - onMouseDown={() => { 248 - props.setDomainMenuState({ 249 - state: "domain-settings", 250 - domain: props.domain, 251 - }); 252 - }} 253 - > 254 - pending 255 - </button> 256 - )} 257 - </div> 258 - </label> 259 - ); 260 - }; 261 - 262 - export const AddDomain = (props: { 263 - setDomainMenuState: (state: DomainMenuState) => void; 264 - }) => { 265 - let [value, setValue] = useState(""); 266 - let { mutate } = useIdentityData(); 267 - let smoker = useSmoker(); 268 - return ( 269 - <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]"> 270 - <div> 271 - <h3 className="text-secondary">Add a New Domain</h3> 272 - <div className="text-xs italic text-secondary"> 273 - Don't include the protocol or path, just the base domain name for now 274 - </div> 275 - </div> 276 - 277 - <Input 278 - className="input-with-border text-primary" 279 - placeholder="www.example.com" 280 - value={value} 281 - onChange={(e) => setValue(e.target.value)} 282 - /> 283 - 284 - <ButtonPrimary 285 - disabled={!value} 286 - className="place-self-end mt-2" 287 - onMouseDown={async (e) => { 288 - // call the vercel api, set the thing... 289 - let { error } = await addDomain(value); 290 - if (error) { 291 - smoker({ 292 - error: true, 293 - text: 294 - error === "invalid_domain" 295 - ? "Invalid domain! Use just the base domain" 296 - : error === "domain_already_in_use" 297 - ? "That domain is already in use!" 298 - : "An unknown error occured", 299 - position: { 300 - y: e.clientY, 301 - x: e.clientX - 5, 302 - }, 303 - }); 304 - return; 305 - } 306 - mutate(); 307 - props.setDomainMenuState({ state: "domain-settings", domain: value }); 308 - }} 309 - > 310 - Verify Domain 311 - </ButtonPrimary> 312 - </div> 313 - ); 314 - }; 315 - 316 - const DomainSettings = (props: { 317 - domain: string; 318 - setDomainMenuState: (s: DomainMenuState) => void; 319 - }) => { 320 - let isSubdomain = props.domain.split(".").length > 2; 321 - return ( 322 - <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]"> 323 - <h3 className="text-secondary">Verify Domain</h3> 324 - 325 - <div className="text-secondary text-sm flex flex-col gap-3"> 326 - <div className="flex flex-col gap-[6px]"> 327 - <div> 328 - To verify this domain, add the following record to your DNS provider 329 - for <strong>{props.domain}</strong>. 330 - </div> 331 - 332 - {isSubdomain ? ( 333 - <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 334 - <div className="flex flex-col "> 335 - <div className="text-tertiary">Type</div> 336 - <div>CNAME</div> 337 - </div> 338 - <div className="flex flex-col"> 339 - <div className="text-tertiary">Name</div> 340 - <div style={{ wordBreak: "break-word" }}> 341 - {props.domain.split(".").slice(0, -2).join(".")} 342 - </div> 343 - </div> 344 - <div className="flex flex-col"> 345 - <div className="text-tertiary">Value</div> 346 - <div style={{ wordBreak: "break-word" }}> 347 - cname.vercel-dns.com 348 - </div> 349 - </div> 350 - </div> 351 - ) : ( 352 - <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 353 - <div className="flex flex-col "> 354 - <div className="text-tertiary">Type</div> 355 - <div>A</div> 356 - </div> 357 - <div className="flex flex-col"> 358 - <div className="text-tertiary">Name</div> 359 - <div>@</div> 360 - </div> 361 - <div className="flex flex-col"> 362 - <div className="text-tertiary">Value</div> 363 - <div>76.76.21.21</div> 364 - </div> 365 - </div> 366 - )} 367 - </div> 368 - <div> 369 - Once you do this, the status may be pending for up to a few hours. 370 - </div> 371 - <div>Check back later to see if verification was successful.</div> 372 - </div> 373 - 374 - <div className="flex gap-3 justify-between items-center mt-2"> 375 - <button 376 - className="text-accent-contrast font-bold " 377 - onMouseDown={async () => { 378 - await deleteDomain({ domain: props.domain }); 379 - props.setDomainMenuState({ state: "default" }); 380 - }} 381 - > 382 - Delete Domain 383 - </button> 384 - <ButtonPrimary 385 - onMouseDown={() => { 386 - props.setDomainMenuState({ state: "default" }); 387 - }} 388 - > 389 - Back to Domains 390 - </ButtonPrimary> 391 - </div> 392 - </div> 393 - ); 394 - };
···
-70
components/ShareOptions/getShareLink.ts
··· 1 - "use server"; 2 - 3 - import { eq, and } from "drizzle-orm"; 4 - import { drizzle } from "drizzle-orm/node-postgres"; 5 - import { permission_token_rights, permission_tokens } from "drizzle/schema"; 6 - import { pool } from "supabase/pool"; 7 - export async function getShareLink( 8 - token: { id: string; entity_set: string }, 9 - rootEntity: string, 10 - ) { 11 - const client = await pool.connect(); 12 - const db = drizzle(client); 13 - let link = await db.transaction(async (tx) => { 14 - // This will likely error out when if we have multiple permission 15 - // token rights associated with a single token 16 - let [tokenW] = await tx 17 - .select() 18 - .from(permission_tokens) 19 - .leftJoin( 20 - permission_token_rights, 21 - eq(permission_token_rights.token, permission_tokens.id), 22 - ) 23 - .where(eq(permission_tokens.id, token.id)); 24 - if ( 25 - !tokenW.permission_token_rights || 26 - tokenW.permission_token_rights.create_token !== true || 27 - tokenW.permission_tokens.root_entity !== rootEntity || 28 - tokenW.permission_token_rights.entity_set !== token.entity_set 29 - ) { 30 - return null; 31 - } 32 - 33 - let [existingToken] = await tx 34 - .select() 35 - .from(permission_tokens) 36 - .rightJoin( 37 - permission_token_rights, 38 - eq(permission_token_rights.token, permission_tokens.id), 39 - ) 40 - .where( 41 - and( 42 - eq(permission_token_rights.read, true), 43 - eq(permission_token_rights.write, false), 44 - eq(permission_token_rights.create_token, false), 45 - eq(permission_token_rights.change_entity_set, false), 46 - eq(permission_token_rights.entity_set, token.entity_set), 47 - eq(permission_tokens.root_entity, rootEntity), 48 - ), 49 - ); 50 - if (existingToken) { 51 - return existingToken.permission_tokens; 52 - } 53 - let [newToken] = await tx 54 - .insert(permission_tokens) 55 - .values({ root_entity: rootEntity }) 56 - .returning(); 57 - await tx.insert(permission_token_rights).values({ 58 - entity_set: token.entity_set, 59 - token: newToken.id, 60 - read: true, 61 - write: false, 62 - create_token: false, 63 - change_entity_set: false, 64 - }); 65 - return newToken; 66 - }); 67 - 68 - client.release(); 69 - return link; 70 - }
···
-256
components/ShareOptions/index.tsx
··· 1 - import { useReplicache } from "src/replicache"; 2 - import React, { useEffect, useState } from "react"; 3 - import { getShareLink } from "./getShareLink"; 4 - import { useEntitySetContext } from "components/EntitySetProvider"; 5 - import { useSmoker } from "components/Toast"; 6 - import { Menu, MenuItem } from "components/Layout"; 7 - import { ActionButton } from "components/ActionBar/ActionButton"; 8 - import useSWR from "swr"; 9 - import LoginForm from "app/login/LoginForm"; 10 - import { CustomDomainMenu } from "./DomainOptions"; 11 - import { useIdentityData } from "components/IdentityProvider"; 12 - import { 13 - useLeafletDomains, 14 - useLeafletPublicationData, 15 - } from "components/PageSWRDataProvider"; 16 - import { ShareSmall } from "components/Icons/ShareSmall"; 17 - import { PubLeafletDocument } from "lexicons/api"; 18 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 19 - import { AtUri } from "@atproto/syntax"; 20 - import { useIsMobile } from "src/hooks/isMobile"; 21 - 22 - export type ShareMenuStates = "default" | "login" | "domain"; 23 - 24 - export let usePublishLink = () => { 25 - let { permission_token, rootEntity } = useReplicache(); 26 - let entity_set = useEntitySetContext(); 27 - let { data: publishLink } = useSWR( 28 - "publishLink-" + permission_token.id, 29 - async () => { 30 - if ( 31 - !permission_token.permission_token_rights.find( 32 - (s) => s.entity_set === entity_set.set && s.create_token, 33 - ) 34 - ) 35 - return; 36 - let shareLink = await getShareLink( 37 - { id: permission_token.id, entity_set: entity_set.set }, 38 - rootEntity, 39 - ); 40 - return shareLink?.id; 41 - }, 42 - ); 43 - return publishLink; 44 - }; 45 - 46 - export function ShareOptions() { 47 - let [menuState, setMenuState] = useState<ShareMenuStates>("default"); 48 - let { data: pub } = useLeafletPublicationData(); 49 - let isMobile = useIsMobile(); 50 - 51 - return ( 52 - <Menu 53 - asChild 54 - side={isMobile ? "top" : "right"} 55 - align={isMobile ? "center" : "start"} 56 - className="max-w-xs" 57 - onOpenChange={() => { 58 - setMenuState("default"); 59 - }} 60 - trigger={ 61 - <ActionButton 62 - icon=<ShareSmall /> 63 - primary={!!!pub} 64 - secondary={!!pub} 65 - label={`Share ${pub ? "Draft" : ""}`} 66 - /> 67 - } 68 - > 69 - {menuState === "login" ? ( 70 - <div className="px-3 py-1"> 71 - <LoginForm text="Save your Leaflets and access them on multiple devices!" /> 72 - </div> 73 - ) : menuState === "domain" ? ( 74 - <CustomDomainMenu setShareMenuState={setMenuState} /> 75 - ) : ( 76 - <ShareMenu 77 - setMenuState={setMenuState} 78 - domainConnected={false} 79 - isPub={!!pub} 80 - /> 81 - )} 82 - </Menu> 83 - ); 84 - } 85 - 86 - const ShareMenu = (props: { 87 - setMenuState: (state: ShareMenuStates) => void; 88 - domainConnected: boolean; 89 - isPub?: boolean; 90 - }) => { 91 - let { permission_token } = useReplicache(); 92 - let { data: pub } = useLeafletPublicationData(); 93 - 94 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 95 - 96 - let postLink = 97 - pub?.publications && pub.documents 98 - ? `${getPublicationURL(pub.publications)}/${new AtUri(pub?.documents.uri).rkey}` 99 - : null; 100 - let publishLink = usePublishLink(); 101 - let [collabLink, setCollabLink] = useState<null | string>(null); 102 - useEffect(() => { 103 - // strip leading '/' character from pathname 104 - setCollabLink(window.location.pathname.slice(1)); 105 - }, []); 106 - let { data: domains } = useLeafletDomains(); 107 - 108 - return ( 109 - <> 110 - <ShareButton 111 - text={`Share ${postLink ? "Draft" : ""} Edit Link`} 112 - subtext="" 113 - smokerText="Edit link copied!" 114 - id="get-edit-link" 115 - link={collabLink} 116 - /> 117 - <ShareButton 118 - text={`Share ${postLink ? "Draft" : ""} View Link`} 119 - subtext=<> 120 - {domains?.[0] ? ( 121 - <> 122 - This Leaflet is published on{" "} 123 - <span className="italic underline"> 124 - {domains[0].domain} 125 - {domains[0].route} 126 - </span> 127 - </> 128 - ) : ( 129 - "" 130 - )} 131 - </> 132 - smokerText="View link copied!" 133 - id="get-view-link" 134 - fullLink={ 135 - domains?.[0] 136 - ? `https://${domains[0].domain}${domains[0].route}` 137 - : undefined 138 - } 139 - link={publishLink || ""} 140 - /> 141 - {postLink && ( 142 - <> 143 - <hr className="border-border-light" /> 144 - 145 - <ShareButton 146 - text="Share Published Link" 147 - subtext="" 148 - smokerText="Post link copied!" 149 - id="get-post-link" 150 - fullLink={postLink.includes("http") ? postLink : undefined} 151 - link={postLink} 152 - /> 153 - </> 154 - )} 155 - {!props.isPub && ( 156 - <> 157 - <hr className="border-border mt-1" /> 158 - <DomainMenuItem setMenuState={props.setMenuState} /> 159 - </> 160 - )} 161 - </> 162 - ); 163 - }; 164 - 165 - export const ShareButton = (props: { 166 - text: React.ReactNode; 167 - subtext?: React.ReactNode; 168 - smokerText: string; 169 - id: string; 170 - link: null | string; 171 - fullLink?: string; 172 - className?: string; 173 - }) => { 174 - let smoker = useSmoker(); 175 - 176 - return ( 177 - <MenuItem 178 - id={props.id} 179 - onSelect={(e) => { 180 - e.preventDefault(); 181 - let rect = document.getElementById(props.id)?.getBoundingClientRect(); 182 - if (props.link || props.fullLink) { 183 - navigator.clipboard.writeText( 184 - props.fullLink 185 - ? props.fullLink 186 - : `${location.protocol}//${location.host}/${props.link}`, 187 - ); 188 - smoker({ 189 - position: { 190 - x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 191 - y: rect ? rect.top + 26 : 0, 192 - }, 193 - text: props.smokerText, 194 - }); 195 - } 196 - }} 197 - > 198 - <div className={`group/${props.id} ${props.className} leading-snug`}> 199 - {props.text} 200 - 201 - {props.subtext && ( 202 - <div className={`text-sm font-normal text-tertiary`}> 203 - {props.subtext} 204 - </div> 205 - )} 206 - </div> 207 - </MenuItem> 208 - ); 209 - }; 210 - 211 - const DomainMenuItem = (props: { 212 - setMenuState: (state: ShareMenuStates) => void; 213 - }) => { 214 - let { identity } = useIdentityData(); 215 - let { data: domains } = useLeafletDomains(); 216 - 217 - if (identity === null) 218 - return ( 219 - <div className="text-tertiary font-normal text-sm px-3 py-1"> 220 - <button 221 - className="text-accent-contrast hover:font-bold" 222 - onClick={() => { 223 - props.setMenuState("login"); 224 - }} 225 - > 226 - Log In 227 - </button>{" "} 228 - to publish on a custom domain! 229 - </div> 230 - ); 231 - else 232 - return ( 233 - <> 234 - {domains?.[0] ? ( 235 - <button 236 - className="px-3 py-1 text-accent-contrast text-sm hover:font-bold w-fit text-left" 237 - onMouseDown={() => { 238 - props.setMenuState("domain"); 239 - }} 240 - > 241 - Edit custom domain 242 - </button> 243 - ) : ( 244 - <MenuItem 245 - className="font-normal text-tertiary text-sm" 246 - onSelect={(e) => { 247 - e.preventDefault(); 248 - props.setMenuState("domain"); 249 - }} 250 - > 251 - Publish on a custom domain 252 - </MenuItem> 253 - )} 254 - </> 255 - ); 256 - };
···
+18
components/Tab.tsx
···
··· 1 + import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; 2 + 3 + export const Tab = (props: { 4 + name: string; 5 + selected: boolean; 6 + onSelect: () => void; 7 + href?: string; 8 + }) => { 9 + return ( 10 + <div 11 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 12 + onClick={() => props.onSelect()} 13 + > 14 + {props.name} 15 + {props.href && <ExternalLinkTiny />} 16 + </div> 17 + ); 18 + };
+296
components/Tags.tsx
···
··· 1 + "use client"; 2 + import { CloseTiny } from "components/Icons/CloseTiny"; 3 + import { Input } from "components/Input"; 4 + import { useState, useRef } from "react"; 5 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 + import { Popover } from "components/Popover"; 7 + import Link from "next/link"; 8 + import { searchTags, type TagSearchResult } from "actions/searchTags"; 9 + 10 + export const Tag = (props: { 11 + name: string; 12 + selected?: boolean; 13 + onDelete?: (tag: string) => void; 14 + className?: string; 15 + }) => { 16 + return ( 17 + <div 18 + className={`tag flex items-center text-xs rounded-md border ${props.selected ? "bg-accent-1 border-accent-1 font-bold" : "bg-bg-page border-border"} ${props.className}`} 19 + > 20 + <Link 21 + href={`https://leaflet.pub/tag/${encodeURIComponent(props.name)}`} 22 + className={`px-1 py-0.5 hover:no-underline! ${props.selected ? "text-accent-2" : "text-tertiary"}`} 23 + > 24 + {props.name}{" "} 25 + </Link> 26 + {props.selected ? ( 27 + <button 28 + type="button" 29 + onClick={() => (props.onDelete ? props.onDelete(props.name) : null)} 30 + > 31 + <CloseTiny className="scale-75 pr-1 text-accent-2" /> 32 + </button> 33 + ) : null} 34 + </div> 35 + ); 36 + }; 37 + 38 + export const TagSelector = (props: { 39 + selectedTags: string[]; 40 + setSelectedTags: (tags: string[]) => void; 41 + }) => { 42 + return ( 43 + <div className="flex flex-col gap-2 text-primary"> 44 + <TagSearchInput 45 + selectedTags={props.selectedTags} 46 + setSelectedTags={props.setSelectedTags} 47 + /> 48 + {props.selectedTags.length > 0 ? ( 49 + <div className="flex flex-wrap gap-2 "> 50 + {props.selectedTags.map((tag) => ( 51 + <Tag 52 + key={tag} 53 + name={tag} 54 + selected 55 + onDelete={() => { 56 + props.setSelectedTags( 57 + props.selectedTags.filter((t) => t !== tag), 58 + ); 59 + }} 60 + /> 61 + ))} 62 + </div> 63 + ) : ( 64 + <div className="text-tertiary italic text-sm h-6">no tags selected</div> 65 + )} 66 + </div> 67 + ); 68 + }; 69 + 70 + export const TagSearchInput = (props: { 71 + selectedTags: string[]; 72 + setSelectedTags: (tags: string[]) => void; 73 + }) => { 74 + let [tagInputValue, setTagInputValue] = useState(""); 75 + let [isOpen, setIsOpen] = useState(false); 76 + let [highlightedIndex, setHighlightedIndex] = useState(0); 77 + let [searchResults, setSearchResults] = useState<TagSearchResult[]>([]); 78 + let [isSearching, setIsSearching] = useState(false); 79 + 80 + const placeholderInputRef = useRef<HTMLButtonElement | null>(null); 81 + 82 + let inputWidth = placeholderInputRef.current?.clientWidth; 83 + 84 + // Fetch tags whenever the input value changes 85 + useDebouncedEffect( 86 + async () => { 87 + setIsSearching(true); 88 + const results = await searchTags(tagInputValue); 89 + if (results) { 90 + setSearchResults(results); 91 + } 92 + setIsSearching(false); 93 + }, 94 + 300, 95 + [tagInputValue], 96 + ); 97 + 98 + const filteredTags = searchResults 99 + .filter((tag) => !props.selectedTags.includes(tag.name)) 100 + .filter((tag) => 101 + tag.name.toLowerCase().includes(tagInputValue.toLowerCase()), 102 + ); 103 + 104 + const showResults = tagInputValue.length >= 3; 105 + 106 + function clearTagInput() { 107 + setHighlightedIndex(0); 108 + setTagInputValue(""); 109 + } 110 + 111 + function selectTag(tag: string) { 112 + console.log("selected " + tag); 113 + props.setSelectedTags([...props.selectedTags, tag]); 114 + clearTagInput(); 115 + } 116 + 117 + const handleKeyDown = ( 118 + e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>, 119 + ) => { 120 + if (!isOpen) return; 121 + 122 + if (e.key === "ArrowDown") { 123 + e.preventDefault(); 124 + setHighlightedIndex((prev) => 125 + prev < filteredTags.length ? prev + 1 : prev, 126 + ); 127 + } else if (e.key === "ArrowUp") { 128 + e.preventDefault(); 129 + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0)); 130 + } else if (e.key === "Enter") { 131 + e.preventDefault(); 132 + selectTag( 133 + userInputResult 134 + ? highlightedIndex === 0 135 + ? tagInputValue 136 + : filteredTags[highlightedIndex - 1].name 137 + : filteredTags[highlightedIndex].name, 138 + ); 139 + clearTagInput(); 140 + } else if (e.key === "Escape") { 141 + setIsOpen(false); 142 + } 143 + }; 144 + 145 + const userInputResult = 146 + showResults && 147 + tagInputValue !== "" && 148 + !filteredTags.some((tag) => tag.name === tagInputValue); 149 + 150 + return ( 151 + <div className="relative"> 152 + <Input 153 + className="input-with-border grow w-full outline-none!" 154 + id="placeholder-tag-search-input" 155 + value={tagInputValue} 156 + placeholder="search tagsโ€ฆ" 157 + onChange={(e) => { 158 + setTagInputValue(e.target.value); 159 + setIsOpen(true); 160 + setHighlightedIndex(0); 161 + }} 162 + onKeyDown={handleKeyDown} 163 + onFocus={() => { 164 + setIsOpen(true); 165 + document.getElementById("tag-search-input")?.focus(); 166 + }} 167 + /> 168 + <Popover 169 + open={isOpen} 170 + onOpenChange={() => { 171 + setIsOpen(!isOpen); 172 + if (!isOpen) 173 + setTimeout(() => { 174 + document.getElementById("tag-search-input")?.focus(); 175 + }, 100); 176 + }} 177 + className="w-full p-2! min-w-xs text-primary" 178 + sideOffset={-39} 179 + onOpenAutoFocus={(e) => e.preventDefault()} 180 + asChild 181 + trigger={ 182 + <button 183 + ref={placeholderInputRef} 184 + className="absolute left-0 top-0 right-0 h-[30px]" 185 + ></button> 186 + } 187 + noArrow 188 + > 189 + <div className="" style={{ width: `${inputWidth}px` }}> 190 + <Input 191 + className="input-with-border grow w-full mb-2" 192 + id="tag-search-input" 193 + placeholder="search tagsโ€ฆ" 194 + value={tagInputValue} 195 + onChange={(e) => { 196 + setTagInputValue(e.target.value); 197 + setIsOpen(true); 198 + setHighlightedIndex(0); 199 + }} 200 + onKeyDown={handleKeyDown} 201 + onFocus={() => { 202 + setIsOpen(true); 203 + }} 204 + /> 205 + {props.selectedTags.length > 0 ? ( 206 + <div className="flex flex-wrap gap-2 pb-[6px]"> 207 + {props.selectedTags.map((tag) => ( 208 + <Tag 209 + key={tag} 210 + name={tag} 211 + selected 212 + onDelete={() => { 213 + props.setSelectedTags( 214 + props.selectedTags.filter((t) => t !== tag), 215 + ); 216 + }} 217 + /> 218 + ))} 219 + </div> 220 + ) : ( 221 + <div className="text-tertiary italic text-sm h-6"> 222 + no tags selected 223 + </div> 224 + )} 225 + <hr className=" mb-[2px] border-border-light" /> 226 + 227 + {showResults ? ( 228 + <> 229 + {userInputResult && ( 230 + <TagResult 231 + key={"userInput"} 232 + index={0} 233 + name={tagInputValue} 234 + tagged={0} 235 + highlighted={0 === highlightedIndex} 236 + setHighlightedIndex={setHighlightedIndex} 237 + onSelect={() => { 238 + selectTag(tagInputValue); 239 + }} 240 + /> 241 + )} 242 + {filteredTags.map((tag, i) => ( 243 + <TagResult 244 + key={tag.name} 245 + index={userInputResult ? i + 1 : i} 246 + name={tag.name} 247 + tagged={tag.document_count} 248 + highlighted={ 249 + (userInputResult ? i + 1 : i) === highlightedIndex 250 + } 251 + setHighlightedIndex={setHighlightedIndex} 252 + onSelect={() => { 253 + selectTag(tag.name); 254 + }} 255 + /> 256 + ))} 257 + </> 258 + ) : ( 259 + <div className="text-tertiary italic text-sm py-1"> 260 + type at least 3 characters to search 261 + </div> 262 + )} 263 + </div> 264 + </Popover> 265 + </div> 266 + ); 267 + }; 268 + 269 + const TagResult = (props: { 270 + name: string; 271 + tagged: number; 272 + onSelect: () => void; 273 + index: number; 274 + highlighted: boolean; 275 + setHighlightedIndex: (i: number) => void; 276 + }) => { 277 + return ( 278 + <div className="-mx-1"> 279 + <button 280 + className={`w-full flex justify-between items-center text-left pr-1 pl-[6px] py-0.5 rounded-md ${props.highlighted ? "bg-border-light" : ""}`} 281 + onSelect={(e) => { 282 + e.preventDefault(); 283 + props.onSelect(); 284 + }} 285 + onClick={(e) => { 286 + e.preventDefault(); 287 + props.onSelect(); 288 + }} 289 + onMouseEnter={(e) => props.setHighlightedIndex(props.index)} 290 + > 291 + {props.name} 292 + <div className="text-tertiary text-sm"> {props.tagged}</div> 293 + </button> 294 + </div> 295 + ); 296 + };
+2 -43
components/ThemeManager/PubThemeSetter.tsx
··· 16 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 import { Separator } from "components/Layout"; 18 import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; 19 20 export type ImageState = { 21 src: string; ··· 39 theme: localPubTheme, 40 setTheme, 41 changes, 42 - } = useLocalPubTheme(record, showPageBackground); 43 let [image, setImage] = useState<ImageState | null>( 44 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) 45 ? { ··· 343 </div> 344 ); 345 }; 346 - 347 - export function ColorToRGBA(color: Color) { 348 - if (!color) 349 - return { 350 - $type: "pub.leaflet.theme.color#rgba" as const, 351 - r: 0, 352 - g: 0, 353 - b: 0, 354 - a: 1, 355 - }; 356 - let c = color.toFormat("rgba"); 357 - const r = c.getChannelValue("red"); 358 - const g = c.getChannelValue("green"); 359 - const b = c.getChannelValue("blue"); 360 - const a = c.getChannelValue("alpha"); 361 - return { 362 - $type: "pub.leaflet.theme.color#rgba" as const, 363 - r: Math.round(r), 364 - g: Math.round(g), 365 - b: Math.round(b), 366 - a: Math.round(a * 100), 367 - }; 368 - } 369 - function ColorToRGB(color: Color) { 370 - if (!color) 371 - return { 372 - $type: "pub.leaflet.theme.color#rgb" as const, 373 - r: 0, 374 - g: 0, 375 - b: 0, 376 - }; 377 - let c = color.toFormat("rgb"); 378 - const r = c.getChannelValue("red"); 379 - const g = c.getChannelValue("green"); 380 - const b = c.getChannelValue("blue"); 381 - return { 382 - $type: "pub.leaflet.theme.color#rgb" as const, 383 - r: Math.round(r), 384 - g: Math.round(g), 385 - b: Math.round(b), 386 - }; 387 - }
··· 16 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 import { Separator } from "components/Layout"; 18 import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; 19 + import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; 20 21 export type ImageState = { 22 src: string; ··· 40 theme: localPubTheme, 41 setTheme, 42 changes, 43 + } = useLocalPubTheme(record?.theme, showPageBackground); 44 let [image, setImage] = useState<ImageState | null>( 45 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) 46 ? { ··· 344 </div> 345 ); 346 };
+46 -31
components/ThemeManager/PublicationThemeProvider.tsx
··· 2 import { useMemo, useState } from "react"; 3 import { parseColor } from "react-aria-components"; 4 import { useEntity } from "src/replicache"; 5 - import { getColorContrast } from "./ThemeProvider"; 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 - import { BaseThemeProvider } from "./ThemeProvider"; 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; ··· 16 accentText: "#FFFFFF", 17 accentBackground: "#0000FF", 18 }; 19 function parseThemeColor( 20 c: PubLeafletThemeColor.Rgb | PubLeafletThemeColor.Rgba, 21 ) { ··· 26 } 27 28 let useColor = ( 29 - record: PubLeafletPublication.Record | null | undefined, 30 c: keyof typeof PubThemeDefaults, 31 ) => { 32 return useMemo(() => { 33 - let v = record?.theme?.[c]; 34 if (isColor(v)) { 35 return parseThemeColor(v); 36 } else return parseColor(PubThemeDefaults[c]); 37 - }, [record?.theme?.[c]]); 38 }; 39 let isColor = ( 40 c: any, ··· 53 return ( 54 <PublicationThemeProvider 55 pub_creator={pub?.identity_did || ""} 56 - record={pub?.record as PubLeafletPublication.Record} 57 > 58 <PublicationBackgroundProvider 59 - record={pub?.record as PubLeafletPublication.Record} 60 pub_creator={pub?.identity_did || ""} 61 > 62 {props.children} ··· 66 } 67 68 export function PublicationBackgroundProvider(props: { 69 - record?: PubLeafletPublication.Record | null; 70 pub_creator: string; 71 className?: string; 72 children: React.ReactNode; 73 }) { 74 - let backgroundImage = props.record?.theme?.backgroundImage?.image?.ref 75 - ? blobRefToSrc( 76 - props.record?.theme?.backgroundImage?.image?.ref, 77 - props.pub_creator, 78 - ) 79 : null; 80 81 - let backgroundImageRepeat = props.record?.theme?.backgroundImage?.repeat; 82 - let backgroundImageSize = props.record?.theme?.backgroundImage?.width || 500; 83 return ( 84 <div 85 className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 86 style={{ 87 - backgroundImage: `url(${backgroundImage})`, 88 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 89 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 90 }} ··· 96 export function PublicationThemeProvider(props: { 97 local?: boolean; 98 children: React.ReactNode; 99 - record?: PubLeafletPublication.Record | null; 100 pub_creator: string; 101 }) { 102 - let colors = usePubTheme(props.record); 103 return ( 104 - <BaseThemeProvider local={props.local} {...colors}> 105 - {props.children} 106 - </BaseThemeProvider> 107 ); 108 } 109 110 - export const usePubTheme = (record?: PubLeafletPublication.Record | null) => { 111 - let bgLeaflet = useColor(record, "backgroundColor"); 112 - let bgPage = useColor(record, "pageBackground"); 113 - bgPage = record?.theme?.pageBackground ? bgPage : bgLeaflet; 114 - let showPageBackground = record?.theme?.showPageBackground; 115 116 - let primary = useColor(record, "primary"); 117 118 - let accent1 = useColor(record, "accentBackground"); 119 - let accent2 = useColor(record, "accentText"); 120 121 let highlight1 = useEntity(null, "theme/highlight-1")?.data.value; 122 let highlight2 = useColorAttribute(null, "theme/highlight-2"); ··· 136 }; 137 138 export const useLocalPubTheme = ( 139 - record: PubLeafletPublication.Record | undefined, 140 showPageBackground?: boolean, 141 ) => { 142 - const pubTheme = usePubTheme(record); 143 const [localOverrides, setTheme] = useState<Partial<typeof pubTheme>>({}); 144 145 const mergedTheme = useMemo(() => {
··· 2 import { useMemo, useState } from "react"; 3 import { parseColor } from "react-aria-components"; 4 import { useEntity } from "src/replicache"; 5 + import { getColorContrast } from "./themeUtils"; 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 + import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; ··· 16 accentText: "#FFFFFF", 17 accentBackground: "#0000FF", 18 }; 19 + 20 + // Default page background for standalone leaflets (matches editor default) 21 + const StandalonePageBackground = "#FFFFFF"; 22 function parseThemeColor( 23 c: PubLeafletThemeColor.Rgb | PubLeafletThemeColor.Rgba, 24 ) { ··· 29 } 30 31 let useColor = ( 32 + theme: PubLeafletPublication.Record["theme"] | null | undefined, 33 c: keyof typeof PubThemeDefaults, 34 ) => { 35 return useMemo(() => { 36 + let v = theme?.[c]; 37 if (isColor(v)) { 38 return parseThemeColor(v); 39 } else return parseColor(PubThemeDefaults[c]); 40 + }, [theme?.[c]]); 41 }; 42 let isColor = ( 43 c: any, ··· 56 return ( 57 <PublicationThemeProvider 58 pub_creator={pub?.identity_did || ""} 59 + theme={(pub?.record as PubLeafletPublication.Record)?.theme} 60 > 61 <PublicationBackgroundProvider 62 + theme={(pub?.record as PubLeafletPublication.Record)?.theme} 63 pub_creator={pub?.identity_did || ""} 64 > 65 {props.children} ··· 69 } 70 71 export function PublicationBackgroundProvider(props: { 72 + theme?: PubLeafletPublication.Record["theme"] | null; 73 pub_creator: string; 74 className?: string; 75 children: React.ReactNode; 76 }) { 77 + let backgroundImage = props.theme?.backgroundImage?.image?.ref 78 + ? blobRefToSrc(props.theme?.backgroundImage?.image?.ref, props.pub_creator) 79 : null; 80 81 + let backgroundImageRepeat = props.theme?.backgroundImage?.repeat; 82 + let backgroundImageSize = props.theme?.backgroundImage?.width || 500; 83 return ( 84 <div 85 className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 86 style={{ 87 + backgroundImage: backgroundImage 88 + ? `url(${backgroundImage})` 89 + : undefined, 90 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 91 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 92 }} ··· 98 export function PublicationThemeProvider(props: { 99 local?: boolean; 100 children: React.ReactNode; 101 + theme?: PubLeafletPublication.Record["theme"] | null; 102 pub_creator: string; 103 + isStandalone?: boolean; 104 }) { 105 + let colors = usePubTheme(props.theme, props.isStandalone); 106 + let cardBorderHidden = !colors.showPageBackground; 107 return ( 108 + <CardBorderHiddenContext.Provider value={cardBorderHidden}> 109 + <BaseThemeProvider local={props.local} {...colors}> 110 + {props.children} 111 + </BaseThemeProvider> 112 + </CardBorderHiddenContext.Provider> 113 ); 114 } 115 116 + export const usePubTheme = ( 117 + theme?: PubLeafletPublication.Record["theme"] | null, 118 + isStandalone?: boolean, 119 + ) => { 120 + let bgLeaflet = useColor(theme, "backgroundColor"); 121 + let bgPage = useColor(theme, "pageBackground"); 122 + // For standalone documents, use the editor default page background (#FFFFFF) 123 + // For publications without explicit pageBackground, use bgLeaflet 124 + if (isStandalone && !theme?.pageBackground) { 125 + bgPage = parseColor(StandalonePageBackground); 126 + } else if (theme && !theme.pageBackground) { 127 + bgPage = bgLeaflet; 128 + } 129 + let showPageBackground = theme?.showPageBackground; 130 131 + let primary = useColor(theme, "primary"); 132 133 + let accent1 = useColor(theme, "accentBackground"); 134 + let accent2 = useColor(theme, "accentText"); 135 136 let highlight1 = useEntity(null, "theme/highlight-1")?.data.value; 137 let highlight2 = useColorAttribute(null, "theme/highlight-2"); ··· 151 }; 152 153 export const useLocalPubTheme = ( 154 + theme: PubLeafletPublication.Record["theme"] | undefined, 155 showPageBackground?: boolean, 156 ) => { 157 + const pubTheme = usePubTheme(theme); 158 const [localOverrides, setTheme] = useState<Partial<typeof pubTheme>>({}); 159 160 const mergedTheme = useMemo(() => {
+31 -64
components/ThemeManager/ThemeProvider.tsx
··· 1 "use client"; 2 3 - import { 4 - createContext, 5 - CSSProperties, 6 - useContext, 7 - useEffect, 8 - useMemo, 9 - useState, 10 - } from "react"; 11 import { 12 colorToString, 13 useColorAttribute, 14 useColorAttributeNullable, 15 } from "./useColorAttribute"; 16 import { Color as AriaColor, parseColor } from "react-aria-components"; 17 - import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 18 19 import { useEntity } from "src/replicache"; 20 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 23 PublicationThemeProvider, 24 } from "./PublicationThemeProvider"; 25 import { PubLeafletPublication } from "lexicons/api"; 26 - 27 - type CSSVariables = { 28 - "--bg-leaflet": string; 29 - "--bg-page": string; 30 - "--primary": string; 31 - "--accent-1": string; 32 - "--accent-2": string; 33 - "--accent-contrast": string; 34 - "--highlight-1": string; 35 - "--highlight-2": string; 36 - "--highlight-3": string; 37 - }; 38 - 39 - // define the color defaults for everything 40 - export const ThemeDefaults = { 41 - "theme/page-background": "#FDFCFA", 42 - "theme/card-background": "#FFFFFF", 43 - "theme/primary": "#272727", 44 - "theme/highlight-1": "#FFFFFF", 45 - "theme/highlight-2": "#EDD280", 46 - "theme/highlight-3": "#FFCDC3", 47 - 48 - //everywhere else, accent-background = accent-1 and accent-text = accent-2. 49 - // we just need to create a migration pipeline before we can change this 50 - "theme/accent-text": "#FFFFFF", 51 - "theme/accent-background": "#0000FF", 52 - "theme/accent-contrast": "#0000FF", 53 - }; 54 55 // define a function to set an Aria Color to a CSS Variable in RGB 56 function setCSSVariableToColor( ··· 73 return ( 74 <PublicationThemeProvider 75 {...props} 76 - record={pub.publications?.record as PubLeafletPublication.Record} 77 pub_creator={pub.publications?.identity_did} 78 /> 79 ); ··· 88 }) { 89 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 90 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 91 - let showPageBackground = !useEntity( 92 props.entityID, 93 "theme/card-border-hidden", 94 )?.data.value; 95 let primary = useColorAttribute(props.entityID, "theme/primary"); 96 97 let highlight1 = useEntity(props.entityID, "theme/highlight-1"); ··· 102 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 103 104 return ( 105 - <BaseThemeProvider 106 - local={props.local} 107 - bgLeaflet={bgLeaflet} 108 - bgPage={bgPage} 109 - primary={primary} 110 - highlight2={highlight2} 111 - highlight3={highlight3} 112 - highlight1={highlight1?.data.value} 113 - accent1={accent1} 114 - accent2={accent2} 115 - showPageBackground={showPageBackground} 116 - > 117 - {props.children} 118 - </BaseThemeProvider> 119 ); 120 } 121 ··· 339 return ( 340 <PublicationBackgroundProvider 341 pub_creator={pub?.publications.identity_did || ""} 342 - record={pub?.publications.record as PubLeafletPublication.Record} 343 > 344 {props.children} 345 </PublicationBackgroundProvider> ··· 365 </div> 366 ); 367 }; 368 - 369 - // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 370 - export function getColorContrast(color1: string, color2: string) { 371 - ColorSpace.register(sRGB); 372 - 373 - let parsedColor1 = parse(`rgb(${color1})`); 374 - let parsedColor2 = parse(`rgb(${color2})`); 375 - 376 - return contrastLstar(parsedColor1, parsedColor2); 377 - }
··· 1 "use client"; 2 3 + import { createContext, CSSProperties, useContext, useEffect } from "react"; 4 + 5 + // Context for cardBorderHidden 6 + export const CardBorderHiddenContext = createContext<boolean>(false); 7 + 8 + export function useCardBorderHiddenContext() { 9 + return useContext(CardBorderHiddenContext); 10 + } 11 import { 12 colorToString, 13 useColorAttribute, 14 useColorAttributeNullable, 15 } from "./useColorAttribute"; 16 import { Color as AriaColor, parseColor } from "react-aria-components"; 17 18 import { useEntity } from "src/replicache"; 19 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 22 PublicationThemeProvider, 23 } from "./PublicationThemeProvider"; 24 import { PubLeafletPublication } from "lexicons/api"; 25 + import { getColorContrast } from "./themeUtils"; 26 27 // define a function to set an Aria Color to a CSS Variable in RGB 28 function setCSSVariableToColor( ··· 45 return ( 46 <PublicationThemeProvider 47 {...props} 48 + theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme} 49 pub_creator={pub.publications?.identity_did} 50 /> 51 ); ··· 60 }) { 61 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 62 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 63 + let cardBorderHiddenValue = useEntity( 64 props.entityID, 65 "theme/card-border-hidden", 66 )?.data.value; 67 + let showPageBackground = !cardBorderHiddenValue; 68 let primary = useColorAttribute(props.entityID, "theme/primary"); 69 70 let highlight1 = useEntity(props.entityID, "theme/highlight-1"); ··· 75 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 76 77 return ( 78 + <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> 79 + <BaseThemeProvider 80 + local={props.local} 81 + bgLeaflet={bgLeaflet} 82 + bgPage={bgPage} 83 + primary={primary} 84 + highlight2={highlight2} 85 + highlight3={highlight3} 86 + highlight1={highlight1?.data.value} 87 + accent1={accent1} 88 + accent2={accent2} 89 + showPageBackground={showPageBackground} 90 + > 91 + {props.children} 92 + </BaseThemeProvider> 93 + </CardBorderHiddenContext.Provider> 94 ); 95 } 96 ··· 314 return ( 315 <PublicationBackgroundProvider 316 pub_creator={pub?.publications.identity_did || ""} 317 + theme={ 318 + (pub.publications?.record as PubLeafletPublication.Record)?.theme 319 + } 320 > 321 {props.children} 322 </PublicationBackgroundProvider> ··· 342 </div> 343 ); 344 };
+2 -2
components/ThemeManager/ThemeSetter.tsx
··· 70 }, [rep, props.entityID]); 71 72 if (!permission) return null; 73 - if (pub) return null; 74 75 return ( 76 <> ··· 111 }, [rep, props.entityID]); 112 113 if (!permission) return null; 114 - if (pub) return null; 115 return ( 116 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 117 <div className="themeBGLeaflet flex">
··· 70 }, [rep, props.entityID]); 71 72 if (!permission) return null; 73 + if (pub?.publications) return null; 74 75 return ( 76 <> ··· 111 }, [rep, props.entityID]); 112 113 if (!permission) return null; 114 + if (pub?.publications) return null; 115 return ( 116 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 117 <div className="themeBGLeaflet flex">
+44
components/ThemeManager/colorToLexicons.ts
···
··· 1 + import { Color } from "react-aria-components"; 2 + 3 + export function ColorToRGBA(color: Color) { 4 + if (!color) 5 + return { 6 + $type: "pub.leaflet.theme.color#rgba" as const, 7 + r: 0, 8 + g: 0, 9 + b: 0, 10 + a: 1, 11 + }; 12 + let c = color.toFormat("rgba"); 13 + const r = c.getChannelValue("red"); 14 + const g = c.getChannelValue("green"); 15 + const b = c.getChannelValue("blue"); 16 + const a = c.getChannelValue("alpha"); 17 + return { 18 + $type: "pub.leaflet.theme.color#rgba" as const, 19 + r: Math.round(r), 20 + g: Math.round(g), 21 + b: Math.round(b), 22 + a: Math.round(a * 100), 23 + }; 24 + } 25 + 26 + export function ColorToRGB(color: Color) { 27 + if (!color) 28 + return { 29 + $type: "pub.leaflet.theme.color#rgb" as const, 30 + r: 0, 31 + g: 0, 32 + b: 0, 33 + }; 34 + let c = color.toFormat("rgb"); 35 + const r = c.getChannelValue("red"); 36 + const g = c.getChannelValue("green"); 37 + const b = c.getChannelValue("blue"); 38 + return { 39 + $type: "pub.leaflet.theme.color#rgb" as const, 40 + r: Math.round(r), 41 + g: Math.round(g), 42 + b: Math.round(b), 43 + }; 44 + }
+27
components/ThemeManager/themeUtils.ts
···
··· 1 + import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 2 + 3 + // define the color defaults for everything 4 + export const ThemeDefaults = { 5 + "theme/page-background": "#FDFCFA", 6 + "theme/card-background": "#FFFFFF", 7 + "theme/primary": "#272727", 8 + "theme/highlight-1": "#FFFFFF", 9 + "theme/highlight-2": "#EDD280", 10 + "theme/highlight-3": "#FFCDC3", 11 + 12 + //everywhere else, accent-background = accent-1 and accent-text = accent-2. 13 + // we just need to create a migration pipeline before we can change this 14 + "theme/accent-text": "#FFFFFF", 15 + "theme/accent-background": "#0000FF", 16 + "theme/accent-contrast": "#0000FF", 17 + }; 18 + 19 + // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 20 + export function getColorContrast(color1: string, color2: string) { 21 + ColorSpace.register(sRGB); 22 + 23 + let parsedColor1 = parse(`rgb(${color1})`); 24 + let parsedColor2 = parse(`rgb(${color2})`); 25 + 26 + return contrastLstar(parsedColor1, parsedColor2); 27 + }
+1 -1
components/ThemeManager/useColorAttribute.ts
··· 2 import { Color, parseColor } from "react-aria-components"; 3 import { useEntity, useReplicache } from "src/replicache"; 4 import { FilterAttributes } from "src/replicache/attributes"; 5 - import { ThemeDefaults } from "./ThemeProvider"; 6 7 export function useColorAttribute( 8 entity: string | null,
··· 2 import { Color, parseColor } from "react-aria-components"; 3 import { useEntity, useReplicache } from "src/replicache"; 4 import { FilterAttributes } from "src/replicache/attributes"; 5 + import { ThemeDefaults } from "./themeUtils"; 6 7 export function useColorAttribute( 8 entity: string | null,
+5 -14
components/Toolbar/BlockToolbar.tsx
··· 2 import { ToolbarButton } from "."; 3 import { Separator, ShortcutKey } from "components/Layout"; 4 import { metaKey } from "src/utils/metaKey"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 import { useUIState } from "src/useUIState"; 7 import { LockBlockButton } from "./LockBlockButton"; 8 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 9 import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 10 import { DeleteSmall } from "components/Icons/DeleteSmall"; 11 12 export const BlockToolbar = (props: { 13 setToolbarState: ( ··· 66 67 const MoveBlockButtons = () => { 68 let { rep } = useReplicache(); 69 - const getSortedSelection = async () => { 70 - let selectedBlocks = useUIState.getState().selectedBlocks; 71 - let siblings = 72 - (await rep?.query((tx) => 73 - getBlocksWithType(tx, selectedBlocks[0].parent), 74 - )) || []; 75 - let sortedBlocks = siblings.filter((s) => 76 - selectedBlocks.find((sb) => sb.value === s.value), 77 - ); 78 - return [sortedBlocks, siblings]; 79 - }; 80 return ( 81 <> 82 <ToolbarButton 83 hiddenOnCanvas 84 onClick={async () => { 85 - let [sortedBlocks, siblings] = await getSortedSelection(); 86 if (sortedBlocks.length > 1) return; 87 let block = sortedBlocks[0]; 88 let previousBlock = ··· 139 <ToolbarButton 140 hiddenOnCanvas 141 onClick={async () => { 142 - let [sortedBlocks, siblings] = await getSortedSelection(); 143 if (sortedBlocks.length > 1) return; 144 let block = sortedBlocks[0]; 145 let nextBlock = siblings
··· 2 import { ToolbarButton } from "."; 3 import { Separator, ShortcutKey } from "components/Layout"; 4 import { metaKey } from "src/utils/metaKey"; 5 import { useUIState } from "src/useUIState"; 6 import { LockBlockButton } from "./LockBlockButton"; 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 12 export const BlockToolbar = (props: { 13 setToolbarState: ( ··· 66 67 const MoveBlockButtons = () => { 68 let { rep } = useReplicache(); 69 return ( 70 <> 71 <ToolbarButton 72 hiddenOnCanvas 73 onClick={async () => { 74 + if (!rep) return; 75 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 76 if (sortedBlocks.length > 1) return; 77 let block = sortedBlocks[0]; 78 let previousBlock = ··· 129 <ToolbarButton 130 hiddenOnCanvas 131 onClick={async () => { 132 + if (!rep) return; 133 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 134 if (sortedBlocks.length > 1) return; 135 let block = sortedBlocks[0]; 136 let nextBlock = siblings
+1 -1
components/Toolbar/MultiSelectToolbar.tsx
··· 8 import { LockBlockButton } from "./LockBlockButton"; 9 import { Props } from "components/Icons/Props"; 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { getSortedSelection } from "components/SelectionManager"; 12 13 export const MultiselectToolbar = (props: { 14 setToolbarState: (
··· 8 import { LockBlockButton } from "./LockBlockButton"; 9 import { Props } from "components/Icons/Props"; 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 12 13 export const MultiselectToolbar = (props: { 14 setToolbarState: (
+2 -1
components/Toolbar/index.tsx
··· 13 import { TextToolbar } from "./TextToolbar"; 14 import { BlockToolbar } from "./BlockToolbar"; 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 - import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock"; 17 import { TooltipButton } from "components/Buttons"; 18 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 19 import { useIsMobile } from "src/hooks/isMobile";
··· 13 import { TextToolbar } from "./TextToolbar"; 14 import { BlockToolbar } from "./BlockToolbar"; 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 + import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 + import { deleteBlock } from "src/utils/deleteBlock"; 18 import { TooltipButton } from "components/Buttons"; 19 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 20 import { useIsMobile } from "src/hooks/isMobile";
+1 -1
components/Tooltip.tsx
··· 26 props.skipDelayDuration ? props.skipDelayDuration : 300 27 } 28 > 29 - <RadixTooltip.Root> 30 <RadixTooltip.Trigger disabled={props.disabled} asChild={props.asChild}> 31 {props.trigger} 32 </RadixTooltip.Trigger>
··· 26 props.skipDelayDuration ? props.skipDelayDuration : 300 27 } 28 > 29 + <RadixTooltip.Root onOpenChange={props.onOpenChange} open={props.open}> 30 <RadixTooltip.Trigger disabled={props.disabled} asChild={props.asChild}> 31 {props.trigger} 32 </RadixTooltip.Trigger>
+1 -1
components/utils/UpdateLeafletTitle.tsx
··· 8 import { useEntity, useReplicache } from "src/replicache"; 9 import * as Y from "yjs"; 10 import * as base64 from "base64-js"; 11 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 12 import { useParams, useRouter, useSearchParams } from "next/navigation"; 13 import { focusBlock } from "src/utils/focusBlock"; 14 import { useIsMobile } from "src/hooks/isMobile";
··· 8 import { useEntity, useReplicache } from "src/replicache"; 9 import * as Y from "yjs"; 10 import * as base64 from "base64-js"; 11 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 12 import { useParams, useRouter, useSearchParams } from "next/navigation"; 13 import { focusBlock } from "src/utils/focusBlock"; 14 import { useIsMobile } from "src/hooks/isMobile";
+1 -1
feeds/index.ts
··· 92 .from("documents") 93 .select( 94 `*, 95 - documents_in_publications!inner(publications!inner(*))`, 96 ) 97 .or( 98 "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true",
··· 92 .from("documents") 93 .select( 94 `*, 95 + documents_in_publications(publications(*))`, 96 ) 97 .or( 98 "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true",
+36 -1
lexicons/api/lexicons.ts
··· 1408 description: 'Record containing a document', 1409 record: { 1410 type: 'object', 1411 - required: ['pages', 'author', 'title', 'publication'], 1412 properties: { 1413 title: { 1414 type: 'string', ··· 1435 author: { 1436 type: 'string', 1437 format: 'at-identifier', 1438 }, 1439 pages: { 1440 type: 'array', ··· 1861 type: 'union', 1862 refs: [ 1863 'lex:pub.leaflet.richtext.facet#link', 1864 'lex:pub.leaflet.richtext.facet#code', 1865 'lex:pub.leaflet.richtext.facet#highlight', 1866 'lex:pub.leaflet.richtext.facet#underline', ··· 1897 properties: { 1898 uri: { 1899 type: 'string', 1900 }, 1901 }, 1902 },
··· 1408 description: 'Record containing a document', 1409 record: { 1410 type: 'object', 1411 + required: ['pages', 'author', 'title'], 1412 properties: { 1413 title: { 1414 type: 'string', ··· 1435 author: { 1436 type: 'string', 1437 format: 'at-identifier', 1438 + }, 1439 + theme: { 1440 + type: 'ref', 1441 + ref: 'lex:pub.leaflet.publication#theme', 1442 + }, 1443 + tags: { 1444 + type: 'array', 1445 + items: { 1446 + type: 'string', 1447 + maxLength: 50, 1448 + }, 1449 }, 1450 pages: { 1451 type: 'array', ··· 1872 type: 'union', 1873 refs: [ 1874 'lex:pub.leaflet.richtext.facet#link', 1875 + 'lex:pub.leaflet.richtext.facet#didMention', 1876 + 'lex:pub.leaflet.richtext.facet#atMention', 1877 'lex:pub.leaflet.richtext.facet#code', 1878 'lex:pub.leaflet.richtext.facet#highlight', 1879 'lex:pub.leaflet.richtext.facet#underline', ··· 1910 properties: { 1911 uri: { 1912 type: 'string', 1913 + }, 1914 + }, 1915 + }, 1916 + didMention: { 1917 + type: 'object', 1918 + description: 'Facet feature for mentioning a did.', 1919 + required: ['did'], 1920 + properties: { 1921 + did: { 1922 + type: 'string', 1923 + format: 'did', 1924 + }, 1925 + }, 1926 + }, 1927 + atMention: { 1928 + type: 'object', 1929 + description: 'Facet feature for mentioning an AT URI.', 1930 + required: ['atURI'], 1931 + properties: { 1932 + atURI: { 1933 + type: 'string', 1934 + format: 'uri', 1935 }, 1936 }, 1937 },
+4 -1
lexicons/api/types/pub/leaflet/document.ts
··· 6 import { validate as _validate } from '../../../lexicons' 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 10 import type * as PubLeafletPagesCanvas from './pages/canvas' 11 ··· 19 postRef?: ComAtprotoRepoStrongRef.Main 20 description?: string 21 publishedAt?: string 22 - publication: string 23 author: string 24 pages: ( 25 | $Typed<PubLeafletPagesLinearDocument.Main> 26 | $Typed<PubLeafletPagesCanvas.Main>
··· 6 import { validate as _validate } from '../../../lexicons' 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 + import type * as PubLeafletPublication from './publication' 10 import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 11 import type * as PubLeafletPagesCanvas from './pages/canvas' 12 ··· 20 postRef?: ComAtprotoRepoStrongRef.Main 21 description?: string 22 publishedAt?: string 23 + publication?: string 24 author: string 25 + theme?: PubLeafletPublication.Theme 26 + tags?: string[] 27 pages: ( 28 | $Typed<PubLeafletPagesLinearDocument.Main> 29 | $Typed<PubLeafletPagesCanvas.Main>
+34
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 20 index: ByteSlice 21 features: ( 22 | $Typed<Link> 23 | $Typed<Code> 24 | $Typed<Highlight> 25 | $Typed<Underline> ··· 72 73 export function validateLink<V>(v: V) { 74 return validate<Link & V>(v, id, hashLink) 75 } 76 77 /** Facet feature for inline code. */
··· 20 index: ByteSlice 21 features: ( 22 | $Typed<Link> 23 + | $Typed<DidMention> 24 + | $Typed<AtMention> 25 | $Typed<Code> 26 | $Typed<Highlight> 27 | $Typed<Underline> ··· 74 75 export function validateLink<V>(v: V) { 76 return validate<Link & V>(v, id, hashLink) 77 + } 78 + 79 + /** Facet feature for mentioning a did. */ 80 + export interface DidMention { 81 + $type?: 'pub.leaflet.richtext.facet#didMention' 82 + did: string 83 + } 84 + 85 + const hashDidMention = 'didMention' 86 + 87 + export function isDidMention<V>(v: V) { 88 + return is$typed(v, id, hashDidMention) 89 + } 90 + 91 + export function validateDidMention<V>(v: V) { 92 + return validate<DidMention & V>(v, id, hashDidMention) 93 + } 94 + 95 + /** Facet feature for mentioning an AT URI. */ 96 + export interface AtMention { 97 + $type?: 'pub.leaflet.richtext.facet#atMention' 98 + atURI: string 99 + } 100 + 101 + const hashAtMention = 'atMention' 102 + 103 + export function isAtMention<V>(v: V) { 104 + return is$typed(v, id, hashAtMention) 105 + } 106 + 107 + export function validateAtMention<V>(v: V) { 108 + return validate<AtMention & V>(v, id, hashAtMention) 109 } 110 111 /** Facet feature for inline code. */
+12 -2
lexicons/pub/leaflet/document.json
··· 13 "required": [ 14 "pages", 15 "author", 16 - "title", 17 - "publication" 18 ], 19 "properties": { 20 "title": { ··· 42 "author": { 43 "type": "string", 44 "format": "at-identifier" 45 }, 46 "pages": { 47 "type": "array",
··· 13 "required": [ 14 "pages", 15 "author", 16 + "title" 17 ], 18 "properties": { 19 "title": { ··· 41 "author": { 42 "type": "string", 43 "format": "at-identifier" 44 + }, 45 + "theme": { 46 + "type": "ref", 47 + "ref": "pub.leaflet.publication#theme" 48 + }, 49 + "tags": { 50 + "type": "array", 51 + "items": { 52 + "type": "string", 53 + "maxLength": 50 54 + } 55 }, 56 "pages": { 57 "type": "array",
+28
lexicons/pub/leaflet/richtext/facet.json
··· 20 "type": "union", 21 "refs": [ 22 "#link", 23 "#code", 24 "#highlight", 25 "#underline", ··· 59 "properties": { 60 "uri": { 61 "type": "string" 62 } 63 } 64 },
··· 20 "type": "union", 21 "refs": [ 22 "#link", 23 + "#didMention", 24 + "#atMention", 25 "#code", 26 "#highlight", 27 "#underline", ··· 61 "properties": { 62 "uri": { 63 "type": "string" 64 + } 65 + } 66 + }, 67 + "didMention": { 68 + "type": "object", 69 + "description": "Facet feature for mentioning a did.", 70 + "required": [ 71 + "did" 72 + ], 73 + "properties": { 74 + "did": { 75 + "type": "string", 76 + "format": "did" 77 + } 78 + } 79 + }, 80 + "atMention": { 81 + "type": "object", 82 + "description": "Facet feature for mentioning an AT URI.", 83 + "required": [ 84 + "atURI" 85 + ], 86 + "properties": { 87 + "atURI": { 88 + "type": "string", 89 + "format": "uri" 90 } 91 } 92 },
+3 -1
lexicons/src/document.ts
··· 14 description: "Record containing a document", 15 record: { 16 type: "object", 17 - required: ["pages", "author", "title", "publication"], 18 properties: { 19 title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, 20 postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, ··· 22 publishedAt: { type: "string", format: "datetime" }, 23 publication: { type: "string", format: "at-uri" }, 24 author: { type: "string", format: "at-identifier" }, 25 pages: { 26 type: "array", 27 items: {
··· 14 description: "Record containing a document", 15 record: { 16 type: "object", 17 + required: ["pages", "author", "title"], 18 properties: { 19 title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, 20 postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, ··· 22 publishedAt: { type: "string", format: "datetime" }, 23 publication: { type: "string", format: "at-uri" }, 24 author: { type: "string", format: "at-identifier" }, 25 + theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 + tags: { type: "array", items: { type: "string", maxLength: 50 } }, 27 pages: { 28 type: "array", 29 items: {
+12
lexicons/src/facet.ts
··· 9 uri: { type: "string" }, 10 }, 11 }, 12 code: { 13 type: "object", 14 description: "Facet feature for inline code.",
··· 9 uri: { type: "string" }, 10 }, 11 }, 12 + didMention: { 13 + type: "object", 14 + description: "Facet feature for mentioning a did.", 15 + required: ["did"], 16 + properties: { did: { type: "string", format: "did" } }, 17 + }, 18 + atMention: { 19 + type: "object", 20 + description: "Facet feature for mentioning an AT URI.", 21 + required: ["atURI"], 22 + properties: { atURI: { type: "string", format: "uri" } }, 23 + }, 24 code: { 25 type: "object", 26 description: "Facet feature for inline code.",
+1 -1
next-env.d.ts
··· 1 /// <reference types="next" /> 2 /// <reference types="next/image-types/global" /> 3 - /// <reference path="./.next/types/routes.d.ts" /> 4 5 // NOTE: This file should not be edited 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
··· 1 /// <reference types="next" /> 2 /// <reference types="next/image-types/global" /> 3 + import "./.next/dev/types/routes.d.ts"; 4 5 // NOTE: This file should not be edited 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+68 -49
package-lock.json
··· 48 "inngest": "^3.40.1", 49 "ioredis": "^5.6.1", 50 "katex": "^0.16.22", 51 "linkifyjs": "^4.2.0", 52 "luxon": "^3.7.2", 53 "multiformats": "^13.3.2", 54 - "next": "16.0.3", 55 "pg": "^8.16.3", 56 "prosemirror-commands": "^1.5.2", 57 "prosemirror-inputrules": "^1.4.0", ··· 59 "prosemirror-model": "^1.21.0", 60 "prosemirror-schema-basic": "^1.2.2", 61 "prosemirror-state": "^1.4.3", 62 - "react": "19.2.0", 63 "react-aria-components": "^1.8.0", 64 "react-day-picker": "^9.3.0", 65 - "react-dom": "19.2.0", 66 "react-use-measure": "^2.1.1", 67 "redlock": "^5.0.0-beta.2", 68 "rehype-parse": "^9.0.0", ··· 2734 } 2735 }, 2736 "node_modules/@next/env": { 2737 - "version": "16.0.3", 2738 - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz", 2739 - "integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==" 2740 }, 2741 "node_modules/@next/eslint-plugin-next": { 2742 "version": "16.0.3", ··· 2804 } 2805 }, 2806 "node_modules/@next/swc-darwin-arm64": { 2807 - "version": "16.0.3", 2808 - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz", 2809 - "integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==", 2810 "cpu": [ 2811 "arm64" 2812 ], 2813 "optional": true, 2814 "os": [ 2815 "darwin" ··· 2819 } 2820 }, 2821 "node_modules/@next/swc-darwin-x64": { 2822 - "version": "16.0.3", 2823 - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz", 2824 - "integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==", 2825 "cpu": [ 2826 "x64" 2827 ], 2828 "optional": true, 2829 "os": [ 2830 "darwin" ··· 2834 } 2835 }, 2836 "node_modules/@next/swc-linux-arm64-gnu": { 2837 - "version": "16.0.3", 2838 - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz", 2839 - "integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==", 2840 "cpu": [ 2841 "arm64" 2842 ], 2843 "optional": true, 2844 "os": [ 2845 "linux" ··· 2849 } 2850 }, 2851 "node_modules/@next/swc-linux-arm64-musl": { 2852 - "version": "16.0.3", 2853 - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz", 2854 - "integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==", 2855 "cpu": [ 2856 "arm64" 2857 ], 2858 "optional": true, 2859 "os": [ 2860 "linux" ··· 2864 } 2865 }, 2866 "node_modules/@next/swc-linux-x64-gnu": { 2867 - "version": "16.0.3", 2868 - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz", 2869 - "integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==", 2870 "cpu": [ 2871 "x64" 2872 ], 2873 "optional": true, 2874 "os": [ 2875 "linux" ··· 2879 } 2880 }, 2881 "node_modules/@next/swc-linux-x64-musl": { 2882 - "version": "16.0.3", 2883 - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz", 2884 - "integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==", 2885 "cpu": [ 2886 "x64" 2887 ], 2888 "optional": true, 2889 "os": [ 2890 "linux" ··· 2894 } 2895 }, 2896 "node_modules/@next/swc-win32-arm64-msvc": { 2897 - "version": "16.0.3", 2898 - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz", 2899 - "integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==", 2900 "cpu": [ 2901 "arm64" 2902 ], 2903 "optional": true, 2904 "os": [ 2905 "win32" ··· 2909 } 2910 }, 2911 "node_modules/@next/swc-win32-x64-msvc": { 2912 - "version": "16.0.3", 2913 - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz", 2914 - "integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==", 2915 "cpu": [ 2916 "x64" 2917 ], 2918 "optional": true, 2919 "os": [ 2920 "win32" ··· 13360 "json-buffer": "3.0.1" 13361 } 13362 }, 13363 "node_modules/language-subtag-registry": { 13364 "version": "0.3.23", 13365 "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", ··· 15108 } 15109 }, 15110 "node_modules/next": { 15111 - "version": "16.0.3", 15112 - "resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz", 15113 - "integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==", 15114 "dependencies": { 15115 - "@next/env": "16.0.3", 15116 "@swc/helpers": "0.5.15", 15117 "caniuse-lite": "^1.0.30001579", 15118 "postcss": "8.4.31", ··· 15125 "node": ">=20.9.0" 15126 }, 15127 "optionalDependencies": { 15128 - "@next/swc-darwin-arm64": "16.0.3", 15129 - "@next/swc-darwin-x64": "16.0.3", 15130 - "@next/swc-linux-arm64-gnu": "16.0.3", 15131 - "@next/swc-linux-arm64-musl": "16.0.3", 15132 - "@next/swc-linux-x64-gnu": "16.0.3", 15133 - "@next/swc-linux-x64-musl": "16.0.3", 15134 - "@next/swc-win32-arm64-msvc": "16.0.3", 15135 - "@next/swc-win32-x64-msvc": "16.0.3", 15136 "sharp": "^0.34.4" 15137 }, 15138 "peerDependencies": { ··· 16321 } 16322 }, 16323 "node_modules/react": { 16324 - "version": "19.2.0", 16325 - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", 16326 - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", 16327 "engines": { 16328 "node": ">=0.10.0" 16329 } ··· 16442 } 16443 }, 16444 "node_modules/react-dom": { 16445 - "version": "19.2.0", 16446 - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", 16447 - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", 16448 "dependencies": { 16449 "scheduler": "^0.27.0" 16450 }, 16451 "peerDependencies": { 16452 - "react": "^19.2.0" 16453 } 16454 }, 16455 "node_modules/react-is": {
··· 48 "inngest": "^3.40.1", 49 "ioredis": "^5.6.1", 50 "katex": "^0.16.22", 51 + "l": "^0.6.0", 52 "linkifyjs": "^4.2.0", 53 "luxon": "^3.7.2", 54 "multiformats": "^13.3.2", 55 + "next": "^16.0.7", 56 "pg": "^8.16.3", 57 "prosemirror-commands": "^1.5.2", 58 "prosemirror-inputrules": "^1.4.0", ··· 60 "prosemirror-model": "^1.21.0", 61 "prosemirror-schema-basic": "^1.2.2", 62 "prosemirror-state": "^1.4.3", 63 + "react": "19.2.1", 64 "react-aria-components": "^1.8.0", 65 "react-day-picker": "^9.3.0", 66 + "react-dom": "19.2.1", 67 "react-use-measure": "^2.1.1", 68 "redlock": "^5.0.0-beta.2", 69 "rehype-parse": "^9.0.0", ··· 2735 } 2736 }, 2737 "node_modules/@next/env": { 2738 + "version": "16.0.7", 2739 + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz", 2740 + "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==", 2741 + "license": "MIT" 2742 }, 2743 "node_modules/@next/eslint-plugin-next": { 2744 "version": "16.0.3", ··· 2806 } 2807 }, 2808 "node_modules/@next/swc-darwin-arm64": { 2809 + "version": "16.0.7", 2810 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz", 2811 + "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==", 2812 "cpu": [ 2813 "arm64" 2814 ], 2815 + "license": "MIT", 2816 "optional": true, 2817 "os": [ 2818 "darwin" ··· 2822 } 2823 }, 2824 "node_modules/@next/swc-darwin-x64": { 2825 + "version": "16.0.7", 2826 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz", 2827 + "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==", 2828 "cpu": [ 2829 "x64" 2830 ], 2831 + "license": "MIT", 2832 "optional": true, 2833 "os": [ 2834 "darwin" ··· 2838 } 2839 }, 2840 "node_modules/@next/swc-linux-arm64-gnu": { 2841 + "version": "16.0.7", 2842 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz", 2843 + "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==", 2844 "cpu": [ 2845 "arm64" 2846 ], 2847 + "license": "MIT", 2848 "optional": true, 2849 "os": [ 2850 "linux" ··· 2854 } 2855 }, 2856 "node_modules/@next/swc-linux-arm64-musl": { 2857 + "version": "16.0.7", 2858 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz", 2859 + "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==", 2860 "cpu": [ 2861 "arm64" 2862 ], 2863 + "license": "MIT", 2864 "optional": true, 2865 "os": [ 2866 "linux" ··· 2870 } 2871 }, 2872 "node_modules/@next/swc-linux-x64-gnu": { 2873 + "version": "16.0.7", 2874 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz", 2875 + "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==", 2876 "cpu": [ 2877 "x64" 2878 ], 2879 + "license": "MIT", 2880 "optional": true, 2881 "os": [ 2882 "linux" ··· 2886 } 2887 }, 2888 "node_modules/@next/swc-linux-x64-musl": { 2889 + "version": "16.0.7", 2890 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz", 2891 + "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==", 2892 "cpu": [ 2893 "x64" 2894 ], 2895 + "license": "MIT", 2896 "optional": true, 2897 "os": [ 2898 "linux" ··· 2902 } 2903 }, 2904 "node_modules/@next/swc-win32-arm64-msvc": { 2905 + "version": "16.0.7", 2906 + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz", 2907 + "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==", 2908 "cpu": [ 2909 "arm64" 2910 ], 2911 + "license": "MIT", 2912 "optional": true, 2913 "os": [ 2914 "win32" ··· 2918 } 2919 }, 2920 "node_modules/@next/swc-win32-x64-msvc": { 2921 + "version": "16.0.7", 2922 + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz", 2923 + "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==", 2924 "cpu": [ 2925 "x64" 2926 ], 2927 + "license": "MIT", 2928 "optional": true, 2929 "os": [ 2930 "win32" ··· 13370 "json-buffer": "3.0.1" 13371 } 13372 }, 13373 + "node_modules/l": { 13374 + "version": "0.6.0", 13375 + "resolved": "https://registry.npmjs.org/l/-/l-0.6.0.tgz", 13376 + "integrity": "sha512-rB5disIyfKRBQ1xcedByHCcAmPWy2NPnjWo5u4mVVIPtathROHyfHjkloqSBT49mLnSRnupkpoIUOFCL7irCVQ==", 13377 + "license": "MIT" 13378 + }, 13379 "node_modules/language-subtag-registry": { 13380 "version": "0.3.23", 13381 "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", ··· 15124 } 15125 }, 15126 "node_modules/next": { 15127 + "version": "16.0.7", 15128 + "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", 15129 + "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", 15130 + "license": "MIT", 15131 "dependencies": { 15132 + "@next/env": "16.0.7", 15133 "@swc/helpers": "0.5.15", 15134 "caniuse-lite": "^1.0.30001579", 15135 "postcss": "8.4.31", ··· 15142 "node": ">=20.9.0" 15143 }, 15144 "optionalDependencies": { 15145 + "@next/swc-darwin-arm64": "16.0.7", 15146 + "@next/swc-darwin-x64": "16.0.7", 15147 + "@next/swc-linux-arm64-gnu": "16.0.7", 15148 + "@next/swc-linux-arm64-musl": "16.0.7", 15149 + "@next/swc-linux-x64-gnu": "16.0.7", 15150 + "@next/swc-linux-x64-musl": "16.0.7", 15151 + "@next/swc-win32-arm64-msvc": "16.0.7", 15152 + "@next/swc-win32-x64-msvc": "16.0.7", 15153 "sharp": "^0.34.4" 15154 }, 15155 "peerDependencies": { ··· 16338 } 16339 }, 16340 "node_modules/react": { 16341 + "version": "19.2.1", 16342 + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", 16343 + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", 16344 + "license": "MIT", 16345 "engines": { 16346 "node": ">=0.10.0" 16347 } ··· 16460 } 16461 }, 16462 "node_modules/react-dom": { 16463 + "version": "19.2.1", 16464 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", 16465 + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", 16466 + "license": "MIT", 16467 "dependencies": { 16468 "scheduler": "^0.27.0" 16469 }, 16470 "peerDependencies": { 16471 + "react": "^19.2.1" 16472 } 16473 }, 16474 "node_modules/react-is": {
+4 -3
package.json
··· 58 "inngest": "^3.40.1", 59 "ioredis": "^5.6.1", 60 "katex": "^0.16.22", 61 "linkifyjs": "^4.2.0", 62 "luxon": "^3.7.2", 63 "multiformats": "^13.3.2", 64 - "next": "16.0.3", 65 "pg": "^8.16.3", 66 "prosemirror-commands": "^1.5.2", 67 "prosemirror-inputrules": "^1.4.0", ··· 69 "prosemirror-model": "^1.21.0", 70 "prosemirror-schema-basic": "^1.2.2", 71 "prosemirror-state": "^1.4.3", 72 - "react": "19.2.0", 73 "react-aria-components": "^1.8.0", 74 "react-day-picker": "^9.3.0", 75 - "react-dom": "19.2.0", 76 "react-use-measure": "^2.1.1", 77 "redlock": "^5.0.0-beta.2", 78 "rehype-parse": "^9.0.0",
··· 58 "inngest": "^3.40.1", 59 "ioredis": "^5.6.1", 60 "katex": "^0.16.22", 61 + "l": "^0.6.0", 62 "linkifyjs": "^4.2.0", 63 "luxon": "^3.7.2", 64 "multiformats": "^13.3.2", 65 + "next": "^16.0.7", 66 "pg": "^8.16.3", 67 "prosemirror-commands": "^1.5.2", 68 "prosemirror-inputrules": "^1.4.0", ··· 70 "prosemirror-model": "^1.21.0", 71 "prosemirror-schema-basic": "^1.2.2", 72 "prosemirror-state": "^1.4.3", 73 + "react": "19.2.1", 74 "react-aria-components": "^1.8.0", 75 "react-day-picker": "^9.3.0", 76 + "react-dom": "19.2.1", 77 "react-use-measure": "^2.1.1", 78 "redlock": "^5.0.0-beta.2", 79 "rehype-parse": "^9.0.0",
+1 -1
src/hooks/useLocalizedDate.ts
··· 28 29 // On initial page load, use header timezone. After hydration, use system timezone 30 const effectiveTimezone = !hasPageLoaded 31 - ? timezone 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 34 // Apply timezone if available
··· 28 29 // On initial page load, use header timezone. After hydration, use system timezone 30 const effectiveTimezone = !hasPageLoaded 31 + ? timezone || "UTC" 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 34 // Apply timezone if available
+4 -3
src/hooks/usePreserveScroll.ts
··· 6 useEffect(() => { 7 if (!ref.current || !key) return; 8 9 - window.requestAnimationFrame(() => { 10 - ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 11 - }); 12 13 const listener = () => { 14 if (!ref.current?.scrollTop) return;
··· 6 useEffect(() => { 7 if (!ref.current || !key) return; 8 9 + if (scrollPositions[key] !== undefined) 10 + window.requestAnimationFrame(() => { 11 + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 12 + }); 13 14 const listener = () => { 15 if (!ref.current?.scrollTop) return;
+287 -27
src/notifications.ts
··· 2 3 import { supabaseServerClient } from "supabase/serverClient"; 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 6 type NotificationRow = Tables<"notifications">; 7 ··· 11 12 export type NotificationData = 13 | { type: "comment"; comment_uri: string; parent_uri?: string } 14 - | { type: "subscribe"; subscription_uri: string }; 15 16 export type HydratedNotification = 17 | HydratedCommentNotification 18 - | HydratedSubscribeNotification; 19 export async function hydrateNotifications( 20 notifications: NotificationRow[], 21 ): Promise<Array<HydratedNotification>> { 22 // Call all hydrators in parallel 23 - const [commentNotifications, subscribeNotifications] = await Promise.all([ 24 hydrateCommentNotifications(notifications), 25 hydrateSubscribeNotifications(notifications), 26 ]); 27 28 // Combine all hydrated notifications 29 - const allHydrated = [...commentNotifications, ...subscribeNotifications]; 30 31 // Sort by created_at to maintain order 32 allHydrated.sort( ··· 70 ) 71 .in("uri", commentUris); 72 73 - return commentNotifications.map((notification) => ({ 74 - id: notification.id, 75 - recipient: notification.recipient, 76 - created_at: notification.created_at, 77 - type: "comment" as const, 78 - comment_uri: notification.data.comment_uri, 79 - parentData: notification.data.parent_uri 80 - ? comments?.find((c) => c.uri === notification.data.parent_uri)! 81 - : undefined, 82 - commentData: comments?.find( 83 - (c) => c.uri === notification.data.comment_uri, 84 - )!, 85 - })); 86 } 87 88 export type HydratedSubscribeNotification = Awaited< ··· 110 .select("*, identities(bsky_profiles(*)), publications(*)") 111 .in("uri", subscriptionUris); 112 113 - return subscribeNotifications.map((notification) => ({ 114 - id: notification.id, 115 - recipient: notification.recipient, 116 - created_at: notification.created_at, 117 - type: "subscribe" as const, 118 - subscription_uri: notification.data.subscription_uri, 119 - subscriptionData: subscriptions?.find( 120 - (s) => s.uri === notification.data.subscription_uri, 121 - )!, 122 - })); 123 } 124 125 export async function pingIdentityToUpdateNotification(did: string) {
··· 2 3 import { supabaseServerClient } from "supabase/serverClient"; 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 8 type NotificationRow = Tables<"notifications">; 9 ··· 13 14 export type NotificationData = 15 | { type: "comment"; comment_uri: string; parent_uri?: string } 16 + | { type: "subscribe"; subscription_uri: string } 17 + | { type: "quote"; bsky_post_uri: string; document_uri: string } 18 + | { type: "mention"; document_uri: string; mention_type: "did" } 19 + | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 20 + | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } 21 + | { type: "comment_mention"; comment_uri: string; mention_type: "did" } 22 + | { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string } 23 + | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string }; 24 25 export type HydratedNotification = 26 | HydratedCommentNotification 27 + | HydratedSubscribeNotification 28 + | HydratedQuoteNotification 29 + | HydratedMentionNotification 30 + | HydratedCommentMentionNotification; 31 export async function hydrateNotifications( 32 notifications: NotificationRow[], 33 ): Promise<Array<HydratedNotification>> { 34 // Call all hydrators in parallel 35 + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 36 hydrateCommentNotifications(notifications), 37 hydrateSubscribeNotifications(notifications), 38 + hydrateQuoteNotifications(notifications), 39 + hydrateMentionNotifications(notifications), 40 + hydrateCommentMentionNotifications(notifications), 41 ]); 42 43 // Combine all hydrated notifications 44 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 45 46 // Sort by created_at to maintain order 47 allHydrated.sort( ··· 85 ) 86 .in("uri", commentUris); 87 88 + return commentNotifications 89 + .map((notification) => { 90 + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 91 + if (!commentData) return null; 92 + return { 93 + id: notification.id, 94 + recipient: notification.recipient, 95 + created_at: notification.created_at, 96 + type: "comment" as const, 97 + comment_uri: notification.data.comment_uri, 98 + parentData: notification.data.parent_uri 99 + ? comments?.find((c) => c.uri === notification.data.parent_uri) 100 + : undefined, 101 + commentData, 102 + }; 103 + }) 104 + .filter((n) => n !== null); 105 } 106 107 export type HydratedSubscribeNotification = Awaited< ··· 129 .select("*, identities(bsky_profiles(*)), publications(*)") 130 .in("uri", subscriptionUris); 131 132 + return subscribeNotifications 133 + .map((notification) => { 134 + const subscriptionData = subscriptions?.find((s) => s.uri === notification.data.subscription_uri); 135 + if (!subscriptionData) return null; 136 + return { 137 + id: notification.id, 138 + recipient: notification.recipient, 139 + created_at: notification.created_at, 140 + type: "subscribe" as const, 141 + subscription_uri: notification.data.subscription_uri, 142 + subscriptionData, 143 + }; 144 + }) 145 + .filter((n) => n !== null); 146 + } 147 + 148 + export type HydratedQuoteNotification = Awaited< 149 + ReturnType<typeof hydrateQuoteNotifications> 150 + >[0]; 151 + 152 + async function hydrateQuoteNotifications(notifications: NotificationRow[]) { 153 + const quoteNotifications = notifications.filter( 154 + (n): n is NotificationRow & { data: ExtractNotificationType<"quote"> } => 155 + (n.data as NotificationData)?.type === "quote", 156 + ); 157 + 158 + if (quoteNotifications.length === 0) { 159 + return []; 160 + } 161 + 162 + // Fetch bsky post data and document data 163 + const bskyPostUris = quoteNotifications.map((n) => n.data.bsky_post_uri); 164 + const documentUris = quoteNotifications.map((n) => n.data.document_uri); 165 + 166 + const { data: bskyPosts } = await supabaseServerClient 167 + .from("bsky_posts") 168 + .select("*") 169 + .in("uri", bskyPostUris); 170 + 171 + const { data: documents } = await supabaseServerClient 172 + .from("documents") 173 + .select("*, documents_in_publications(publications(*))") 174 + .in("uri", documentUris); 175 + 176 + return quoteNotifications 177 + .map((notification) => { 178 + const bskyPost = bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri); 179 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 180 + if (!bskyPost || !document) return null; 181 + return { 182 + id: notification.id, 183 + recipient: notification.recipient, 184 + created_at: notification.created_at, 185 + type: "quote" as const, 186 + bsky_post_uri: notification.data.bsky_post_uri, 187 + document_uri: notification.data.document_uri, 188 + bskyPost, 189 + document, 190 + }; 191 + }) 192 + .filter((n) => n !== null); 193 + } 194 + 195 + export type HydratedMentionNotification = Awaited< 196 + ReturnType<typeof hydrateMentionNotifications> 197 + >[0]; 198 + 199 + async function hydrateMentionNotifications(notifications: NotificationRow[]) { 200 + const mentionNotifications = notifications.filter( 201 + (n): n is NotificationRow & { data: ExtractNotificationType<"mention"> } => 202 + (n.data as NotificationData)?.type === "mention", 203 + ); 204 + 205 + if (mentionNotifications.length === 0) { 206 + return []; 207 + } 208 + 209 + // Fetch document data from the database 210 + const documentUris = mentionNotifications.map((n) => n.data.document_uri); 211 + const { data: documents } = await supabaseServerClient 212 + .from("documents") 213 + .select("*, documents_in_publications(publications(*))") 214 + .in("uri", documentUris); 215 + 216 + // Extract unique DIDs from document URIs to resolve handles 217 + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; 218 + 219 + // Resolve DIDs to handles in parallel 220 + const didToHandleMap = new Map<string, string | null>(); 221 + await Promise.all( 222 + documentCreatorDids.map(async (did) => { 223 + try { 224 + const resolved = await idResolver.did.resolve(did); 225 + const handle = resolved?.alsoKnownAs?.[0] 226 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 227 + : null; 228 + didToHandleMap.set(did, handle); 229 + } catch (error) { 230 + console.error(`Failed to resolve DID ${did}:`, error); 231 + didToHandleMap.set(did, null); 232 + } 233 + }), 234 + ); 235 + 236 + // Fetch mentioned publications and documents 237 + const mentionedPublicationUris = mentionNotifications 238 + .filter((n) => n.data.mention_type === "publication") 239 + .map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "publication" }>).mentioned_uri); 240 + 241 + const mentionedDocumentUris = mentionNotifications 242 + .filter((n) => n.data.mention_type === "document") 243 + .map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "document" }>).mentioned_uri); 244 + 245 + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ 246 + mentionedPublicationUris.length > 0 247 + ? supabaseServerClient 248 + .from("publications") 249 + .select("*") 250 + .in("uri", mentionedPublicationUris) 251 + : Promise.resolve({ data: [] }), 252 + mentionedDocumentUris.length > 0 253 + ? supabaseServerClient 254 + .from("documents") 255 + .select("*, documents_in_publications(publications(*))") 256 + .in("uri", mentionedDocumentUris) 257 + : Promise.resolve({ data: [] }), 258 + ]); 259 + 260 + return mentionNotifications 261 + .map((notification) => { 262 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 263 + if (!document) return null; 264 + 265 + const mentionedUri = notification.data.mention_type !== "did" 266 + ? (notification.data as Extract<ExtractNotificationType<"mention">, { mentioned_uri: string }>).mentioned_uri 267 + : undefined; 268 + 269 + const documentCreatorDid = new AtUri(notification.data.document_uri).host; 270 + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 271 + 272 + return { 273 + id: notification.id, 274 + recipient: notification.recipient, 275 + created_at: notification.created_at, 276 + type: "mention" as const, 277 + document_uri: notification.data.document_uri, 278 + mention_type: notification.data.mention_type, 279 + mentioned_uri: mentionedUri, 280 + document, 281 + documentCreatorHandle, 282 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 283 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 284 + }; 285 + }) 286 + .filter((n) => n !== null); 287 + } 288 + 289 + export type HydratedCommentMentionNotification = Awaited< 290 + ReturnType<typeof hydrateCommentMentionNotifications> 291 + >[0]; 292 + 293 + async function hydrateCommentMentionNotifications(notifications: NotificationRow[]) { 294 + const commentMentionNotifications = notifications.filter( 295 + (n): n is NotificationRow & { data: ExtractNotificationType<"comment_mention"> } => 296 + (n.data as NotificationData)?.type === "comment_mention", 297 + ); 298 + 299 + if (commentMentionNotifications.length === 0) { 300 + return []; 301 + } 302 + 303 + // Fetch comment data from the database 304 + const commentUris = commentMentionNotifications.map((n) => n.data.comment_uri); 305 + const { data: comments } = await supabaseServerClient 306 + .from("comments_on_documents") 307 + .select( 308 + "*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 309 + ) 310 + .in("uri", commentUris); 311 + 312 + // Extract unique DIDs from comment URIs to resolve handles 313 + const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))]; 314 + 315 + // Resolve DIDs to handles in parallel 316 + const didToHandleMap = new Map<string, string | null>(); 317 + await Promise.all( 318 + commenterDids.map(async (did) => { 319 + try { 320 + const resolved = await idResolver.did.resolve(did); 321 + const handle = resolved?.alsoKnownAs?.[0] 322 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 323 + : null; 324 + didToHandleMap.set(did, handle); 325 + } catch (error) { 326 + console.error(`Failed to resolve DID ${did}:`, error); 327 + didToHandleMap.set(did, null); 328 + } 329 + }), 330 + ); 331 + 332 + // Fetch mentioned publications and documents 333 + const mentionedPublicationUris = commentMentionNotifications 334 + .filter((n) => n.data.mention_type === "publication") 335 + .map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "publication" }>).mentioned_uri); 336 + 337 + const mentionedDocumentUris = commentMentionNotifications 338 + .filter((n) => n.data.mention_type === "document") 339 + .map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "document" }>).mentioned_uri); 340 + 341 + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ 342 + mentionedPublicationUris.length > 0 343 + ? supabaseServerClient 344 + .from("publications") 345 + .select("*") 346 + .in("uri", mentionedPublicationUris) 347 + : Promise.resolve({ data: [] }), 348 + mentionedDocumentUris.length > 0 349 + ? supabaseServerClient 350 + .from("documents") 351 + .select("*, documents_in_publications(publications(*))") 352 + .in("uri", mentionedDocumentUris) 353 + : Promise.resolve({ data: [] }), 354 + ]); 355 + 356 + return commentMentionNotifications 357 + .map((notification) => { 358 + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 359 + if (!commentData) return null; 360 + 361 + const mentionedUri = notification.data.mention_type !== "did" 362 + ? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri 363 + : undefined; 364 + 365 + const commenterDid = new AtUri(notification.data.comment_uri).host; 366 + const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 367 + 368 + return { 369 + id: notification.id, 370 + recipient: notification.recipient, 371 + created_at: notification.created_at, 372 + type: "comment_mention" as const, 373 + comment_uri: notification.data.comment_uri, 374 + mention_type: notification.data.mention_type, 375 + mentioned_uri: mentionedUri, 376 + commentData, 377 + commenterHandle, 378 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 379 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 380 + }; 381 + }) 382 + .filter((n) => n !== null); 383 } 384 385 export async function pingIdentityToUpdateNotification(did: string) {
+34 -8
src/replicache/mutations.ts
··· 609 }; 610 611 const updatePublicationDraft: Mutation<{ 612 - title: string; 613 - description: string; 614 }> = async (args, ctx) => { 615 await ctx.runOnServer(async (serverCtx) => { 616 console.log("updating"); 617 - await serverCtx.supabase 618 - .from("leaflets_in_publications") 619 - .update({ description: args.description, title: args.title }) 620 - .eq("leaflet", ctx.permission_token_id); 621 }); 622 await ctx.runOnClient(async ({ tx }) => { 623 - await tx.set("publication_title", args.title); 624 - await tx.set("publication_description", args.description); 625 }); 626 }; 627
··· 609 }; 610 611 const updatePublicationDraft: Mutation<{ 612 + title?: string; 613 + description?: string; 614 + tags?: string[]; 615 }> = async (args, ctx) => { 616 await ctx.runOnServer(async (serverCtx) => { 617 console.log("updating"); 618 + const updates: { 619 + description?: string; 620 + title?: string; 621 + tags?: string[]; 622 + } = {}; 623 + if (args.description !== undefined) updates.description = args.description; 624 + if (args.title !== undefined) updates.title = args.title; 625 + if (args.tags !== undefined) updates.tags = args.tags; 626 + 627 + if (Object.keys(updates).length > 0) { 628 + // First try to update leaflets_in_publications (for publications) 629 + const { data: pubResult } = await serverCtx.supabase 630 + .from("leaflets_in_publications") 631 + .update(updates) 632 + .eq("leaflet", ctx.permission_token_id) 633 + .select("leaflet"); 634 + 635 + // If no rows were updated in leaflets_in_publications, 636 + // try leaflets_to_documents (for standalone documents) 637 + if (!pubResult || pubResult.length === 0) { 638 + await serverCtx.supabase 639 + .from("leaflets_to_documents") 640 + .update(updates) 641 + .eq("leaflet", ctx.permission_token_id); 642 + } 643 + } 644 }); 645 await ctx.runOnClient(async ({ tx }) => { 646 + if (args.title !== undefined) 647 + await tx.set("publication_title", args.title); 648 + if (args.description !== undefined) 649 + await tx.set("publication_description", args.description); 650 + if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 651 }); 652 }; 653
+1
src/utils/codeLanguageStorage.ts
···
··· 1 + export const LAST_USED_CODE_LANGUAGE_KEY = "lastUsedCodeLanguage";
+116
src/utils/deleteBlock.ts
···
··· 1 + import { Replicache } from "replicache"; 2 + import { ReplicacheMutators } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { scanIndex } from "src/replicache/utils"; 5 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 + import { focusBlock } from "src/utils/focusBlock"; 7 + 8 + export async function deleteBlock( 9 + entities: string[], 10 + rep: Replicache<ReplicacheMutators>, 11 + ) { 12 + // get what pagess we need to close as a result of deleting this block 13 + let pagesToClose = [] as string[]; 14 + for (let entity of entities) { 15 + let [type] = await rep.query((tx) => 16 + scanIndex(tx).eav(entity, "block/type"), 17 + ); 18 + if (type.data.value === "card") { 19 + let [childPages] = await rep?.query( 20 + (tx) => scanIndex(tx).eav(entity, "block/card") || [], 21 + ); 22 + pagesToClose = [childPages?.data.value]; 23 + } 24 + if (type.data.value === "mailbox") { 25 + let [archive] = await rep?.query( 26 + (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], 27 + ); 28 + let [draft] = await rep?.query( 29 + (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], 30 + ); 31 + pagesToClose = [archive?.data.value, draft?.data.value]; 32 + } 33 + } 34 + 35 + // the next and previous blocks in the block list 36 + // if the focused thing is a page and not a block, return 37 + let focusedBlock = useUIState.getState().focusedEntity; 38 + let parent = 39 + focusedBlock?.entityType === "page" 40 + ? focusedBlock.entityID 41 + : focusedBlock?.parent; 42 + 43 + if (parent) { 44 + let parentType = await rep?.query((tx) => 45 + scanIndex(tx).eav(parent, "page/type"), 46 + ); 47 + if (parentType[0]?.data.value === "canvas") { 48 + useUIState 49 + .getState() 50 + .setFocusedBlock({ entityType: "page", entityID: parent }); 51 + useUIState.getState().setSelectedBlocks([]); 52 + } else { 53 + let siblings = 54 + (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 55 + 56 + let selectedBlocks = useUIState.getState().selectedBlocks; 57 + let firstSelected = selectedBlocks[0]; 58 + let lastSelected = selectedBlocks[entities.length - 1]; 59 + 60 + let prevBlock = 61 + siblings?.[ 62 + siblings.findIndex((s) => s.value === firstSelected?.value) - 1 63 + ]; 64 + let prevBlockType = await rep?.query((tx) => 65 + scanIndex(tx).eav(prevBlock?.value, "block/type"), 66 + ); 67 + 68 + let nextBlock = 69 + siblings?.[ 70 + siblings.findIndex((s) => s.value === lastSelected.value) + 1 71 + ]; 72 + let nextBlockType = await rep?.query((tx) => 73 + scanIndex(tx).eav(nextBlock?.value, "block/type"), 74 + ); 75 + 76 + if (prevBlock) { 77 + useUIState.getState().setSelectedBlock({ 78 + value: prevBlock.value, 79 + parent: prevBlock.parent, 80 + }); 81 + 82 + focusBlock( 83 + { 84 + value: prevBlock.value, 85 + type: prevBlockType?.[0].data.value, 86 + parent: prevBlock.parent, 87 + }, 88 + { type: "end" }, 89 + ); 90 + } else { 91 + useUIState.getState().setSelectedBlock({ 92 + value: nextBlock.value, 93 + parent: nextBlock.parent, 94 + }); 95 + 96 + focusBlock( 97 + { 98 + value: nextBlock.value, 99 + type: nextBlockType?.[0]?.data.value, 100 + parent: nextBlock.parent, 101 + }, 102 + { type: "start" }, 103 + ); 104 + } 105 + } 106 + } 107 + 108 + pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 109 + await Promise.all( 110 + entities.map((entity) => 111 + rep?.mutate.removeBlock({ 112 + blockEntity: entity, 113 + }), 114 + ), 115 + ); 116 + }
+37
src/utils/focusElement.ts
···
··· 1 + import { isIOS } from "src/utils/isDevice"; 2 + 3 + export const focusElement = ( 4 + el?: HTMLInputElement | HTMLTextAreaElement | null, 5 + ) => { 6 + if (!isIOS()) { 7 + el?.focus(); 8 + return; 9 + } 10 + 11 + let fakeInput = document.createElement("input"); 12 + fakeInput.setAttribute("type", "text"); 13 + fakeInput.style.position = "fixed"; 14 + fakeInput.style.height = "0px"; 15 + fakeInput.style.width = "0px"; 16 + fakeInput.style.fontSize = "16px"; // disable auto zoom 17 + document.body.appendChild(fakeInput); 18 + fakeInput.focus(); 19 + setTimeout(() => { 20 + if (!el) return; 21 + el.style.transform = "translateY(-2000px)"; 22 + el?.focus(); 23 + fakeInput.remove(); 24 + el.value = " "; 25 + el.setSelectionRange(1, 1); 26 + requestAnimationFrame(() => { 27 + if (el) { 28 + el.style.transform = ""; 29 + } 30 + }); 31 + setTimeout(() => { 32 + if (!el) return; 33 + el.value = ""; 34 + el.setSelectionRange(0, 0); 35 + }, 50); 36 + }, 20); 37 + };
+73
src/utils/focusPage.ts
···
··· 1 + import { Replicache } from "replicache"; 2 + import { Fact, ReplicacheMutators } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { scanIndex } from "src/replicache/utils"; 5 + import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 6 + import { elementId } from "src/utils/elementId"; 7 + import { focusBlock } from "src/utils/focusBlock"; 8 + 9 + export async function focusPage( 10 + pageID: string, 11 + rep: Replicache<ReplicacheMutators>, 12 + focusFirstBlock?: "focusFirstBlock", 13 + ) { 14 + // if this page is already focused, 15 + let focusedBlock = useUIState.getState().focusedEntity; 16 + // else set this page as focused 17 + useUIState.setState(() => ({ 18 + focusedEntity: { 19 + entityType: "page", 20 + entityID: pageID, 21 + }, 22 + })); 23 + 24 + setTimeout(async () => { 25 + //scroll to page 26 + 27 + scrollIntoViewIfNeeded( 28 + document.getElementById(elementId.page(pageID).container), 29 + false, 30 + "smooth", 31 + ); 32 + 33 + // if we asked that the function focus the first block, focus the first block 34 + if (focusFirstBlock === "focusFirstBlock") { 35 + let firstBlock = await rep.query(async (tx) => { 36 + let type = await scanIndex(tx).eav(pageID, "page/type"); 37 + let blocks = await scanIndex(tx).eav( 38 + pageID, 39 + type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 40 + ); 41 + 42 + let firstBlock = blocks[0]; 43 + 44 + if (!firstBlock) { 45 + return null; 46 + } 47 + 48 + let blockType = ( 49 + await tx 50 + .scan< 51 + Fact<"block/type"> 52 + >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 53 + .toArray() 54 + )[0]; 55 + 56 + if (!blockType) return null; 57 + 58 + return { 59 + value: firstBlock.data.value, 60 + type: blockType.data.value, 61 + parent: firstBlock.entity, 62 + position: firstBlock.data.position, 63 + }; 64 + }); 65 + 66 + if (firstBlock) { 67 + setTimeout(() => { 68 + focusBlock(firstBlock, { type: "start" }); 69 + }, 500); 70 + } 71 + } 72 + }, 50); 73 + }
+6 -1
src/utils/getMicroLinkOgImage.ts
··· 17 hostname = "leaflet.pub"; 18 } 19 let full_path = `${protocol}://${hostname}${path}`; 20 - return getWebpageImage(full_path, options); 21 } 22 23 export async function getWebpageImage( 24 url: string, 25 options?: { 26 width?: number; 27 height?: number; 28 deviceScaleFactor?: number; ··· 39 }, 40 body: JSON.stringify({ 41 url, 42 scrollPage: true, 43 addStyleTag: [ 44 {
··· 17 hostname = "leaflet.pub"; 18 } 19 let full_path = `${protocol}://${hostname}${path}`; 20 + return getWebpageImage(full_path, { 21 + ...options, 22 + setJavaScriptEnabled: false, 23 + }); 24 } 25 26 export async function getWebpageImage( 27 url: string, 28 options?: { 29 + setJavaScriptEnabled?: boolean; 30 width?: number; 31 height?: number; 32 deviceScaleFactor?: number; ··· 43 }, 44 body: JSON.stringify({ 45 url, 46 + setJavaScriptEnabled: options?.setJavaScriptEnabled, 47 scrollPage: true, 48 addStyleTag: [ 49 {
+50
src/utils/getPublicationMetadataFromLeafletData.ts
···
··· 1 + import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 2 + import { Json } from "supabase/database.types"; 3 + 4 + export function getPublicationMetadataFromLeafletData( 5 + data?: GetLeafletDataReturnType["result"]["data"], 6 + ) { 7 + if (!data) return null; 8 + 9 + let pubData: 10 + | { 11 + description: string; 12 + title: string; 13 + leaflet: string; 14 + doc: string | null; 15 + publications: { 16 + identity_did: string; 17 + name: string; 18 + indexed_at: string; 19 + record: Json | null; 20 + uri: string; 21 + } | null; 22 + documents: { 23 + data: Json; 24 + indexed_at: string; 25 + uri: string; 26 + } | null; 27 + } 28 + | undefined 29 + | null = 30 + data?.leaflets_in_publications?.[0] || 31 + data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 32 + (p) => p.leaflets_in_publications?.length, 33 + )?.leaflets_in_publications?.[0]; 34 + 35 + // If not found, check for standalone documents 36 + let standaloneDoc = 37 + data?.leaflets_to_documents?.[0] || 38 + data?.permission_token_rights[0].entity_sets?.permission_tokens.find( 39 + (p) => p.leaflets_to_documents?.length, 40 + )?.leaflets_to_documents?.[0]; 41 + if (!pubData && standaloneDoc) { 42 + // Transform standalone document data to match the expected format 43 + pubData = { 44 + ...standaloneDoc, 45 + publications: null, // No publication for standalone docs 46 + doc: standaloneDoc.document, 47 + }; 48 + } 49 + return pubData; 50 + }
+59
src/utils/mentionUtils.ts
···
··· 1 + import { AtUri } from "@atproto/api"; 2 + 3 + /** 4 + * Converts a DID to a Bluesky profile URL 5 + */ 6 + export function didToBlueskyUrl(did: string): string { 7 + return `https://bsky.app/profile/${did}`; 8 + } 9 + 10 + /** 11 + * Converts an AT URI (publication or document) to the appropriate URL 12 + */ 13 + export function atUriToUrl(atUri: string): string { 14 + try { 15 + const uri = new AtUri(atUri); 16 + 17 + if (uri.collection === "pub.leaflet.publication") { 18 + // Publication URL: /lish/{did}/{rkey} 19 + return `/lish/${uri.host}/${uri.rkey}`; 20 + } else if (uri.collection === "pub.leaflet.document") { 21 + // Document URL - we need to resolve this via the API 22 + // For now, create a redirect route that will handle it 23 + return `/lish/uri/${encodeURIComponent(atUri)}`; 24 + } 25 + 26 + return "#"; 27 + } catch (e) { 28 + console.error("Failed to parse AT URI:", atUri, e); 29 + return "#"; 30 + } 31 + } 32 + 33 + /** 34 + * Opens a mention link in the appropriate way 35 + * - DID mentions open in a new tab (external Bluesky) 36 + * - Publication/document mentions navigate in the same tab 37 + */ 38 + export function handleMentionClick( 39 + e: MouseEvent | React.MouseEvent, 40 + type: "did" | "at-uri", 41 + value: string 42 + ) { 43 + e.preventDefault(); 44 + e.stopPropagation(); 45 + 46 + if (type === "did") { 47 + // Open Bluesky profile in new tab 48 + window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer"); 49 + } else { 50 + // Navigate to publication/document in same tab 51 + const url = atUriToUrl(value); 52 + if (url.startsWith("/lish/uri/")) { 53 + // Redirect route - navigate to it 54 + window.location.href = url; 55 + } else { 56 + window.location.href = url; 57 + } 58 + } 59 + }
+41
src/utils/yjsFragmentToString.ts
···
··· 1 + import { XmlElement, XmlText, XmlHook } from "yjs"; 2 + 3 + export type Delta = { 4 + insert: string; 5 + attributes?: { 6 + strong?: {}; 7 + code?: {}; 8 + em?: {}; 9 + underline?: {}; 10 + strikethrough?: {}; 11 + highlight?: { color: string }; 12 + link?: { href: string }; 13 + }; 14 + }; 15 + 16 + export function YJSFragmentToString( 17 + node: XmlElement | XmlText | XmlHook, 18 + ): string { 19 + if (node.constructor === XmlElement) { 20 + // Handle hard_break nodes specially 21 + if (node.nodeName === "hard_break") { 22 + return "\n"; 23 + } 24 + // Handle inline mention nodes 25 + if (node.nodeName === "didMention" || node.nodeName === "atMention") { 26 + return node.getAttribute("text") || ""; 27 + } 28 + return node 29 + .toArray() 30 + .map((f) => YJSFragmentToString(f)) 31 + .join(""); 32 + } 33 + if (node.constructor === XmlText) { 34 + return (node.toDelta() as Delta[]) 35 + .map((d) => { 36 + return d.insert; 37 + }) 38 + .join(""); 39 + } 40 + return ""; 41 + }
+9
supabase/database.types.ts
··· 1158 } 1159 Returns: Database["public"]["CompositeTypes"]["pull_result"] 1160 } 1161 } 1162 Enums: { 1163 rsvp_status: "GOING" | "NOT_GOING" | "MAYBE"
··· 1158 } 1159 Returns: Database["public"]["CompositeTypes"]["pull_result"] 1160 } 1161 + search_tags: { 1162 + Args: { 1163 + search_query: string 1164 + } 1165 + Returns: { 1166 + name: string 1167 + document_count: number 1168 + }[] 1169 + } 1170 } 1171 Enums: { 1172 rsvp_status: "GOING" | "NOT_GOING" | "MAYBE"
+15
supabase/migrations/20251122220118_add_cascade_on_update_to_pt_relations.sql
···
··· 1 + alter table "public"."permission_token_on_homepage" drop constraint "permission_token_creator_token_fkey"; 2 + 3 + alter table "public"."leaflets_in_publications" drop constraint "leaflets_in_publications_leaflet_fkey"; 4 + 5 + alter table "public"."leaflets_in_publications" drop column "archived"; 6 + 7 + alter table "public"."permission_token_on_homepage" drop column "archived"; 8 + 9 + alter table "public"."permission_token_on_homepage" add constraint "permission_token_on_homepage_token_fkey" FOREIGN KEY (token) REFERENCES permission_tokens(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; 10 + 11 + alter table "public"."permission_token_on_homepage" validate constraint "permission_token_on_homepage_token_fkey"; 12 + 13 + alter table "public"."leaflets_in_publications" add constraint "leaflets_in_publications_leaflet_fkey" FOREIGN KEY (leaflet) REFERENCES permission_tokens(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; 14 + 15 + alter table "public"."leaflets_in_publications" validate constraint "leaflets_in_publications_leaflet_fkey";
+2
supabase/migrations/20251124214105_add_back_archived_cols.sql
···
··· 1 + alter table "public"."permission_token_on_homepage" add column "archived" boolean; 2 + alter table "public"."leaflets_in_publications" add column "archived" boolean;
+30
supabase/migrations/20251204120000_add_tags_support.sql
···
··· 1 + -- Create GIN index on the tags array in the JSONB data field 2 + -- This allows efficient querying of documents by tag 3 + CREATE INDEX IF NOT EXISTS idx_documents_tags 4 + ON "public"."documents" USING gin ((data->'tags')); 5 + 6 + -- Function to search and aggregate tags from documents 7 + -- This does the aggregation in the database rather than fetching all documents 8 + CREATE OR REPLACE FUNCTION search_tags(search_query text) 9 + RETURNS TABLE (name text, document_count bigint) AS $$ 10 + BEGIN 11 + RETURN QUERY 12 + SELECT 13 + LOWER(tag::text) as name, 14 + COUNT(DISTINCT d.uri) as document_count 15 + FROM 16 + "public"."documents" d, 17 + jsonb_array_elements_text(d.data->'tags') as tag 18 + WHERE 19 + CASE 20 + WHEN search_query = '' THEN true 21 + ELSE LOWER(tag::text) LIKE '%' || search_query || '%' 22 + END 23 + GROUP BY 24 + LOWER(tag::text) 25 + ORDER BY 26 + COUNT(DISTINCT d.uri) DESC, 27 + LOWER(tag::text) ASC 28 + LIMIT 20; 29 + END; 30 + $$ LANGUAGE plpgsql STABLE;
+7
supabase/migrations/20251204130000_add_tags_to_drafts.sql
···
··· 1 + -- Add tags column to leaflets_in_publications for publication drafts 2 + ALTER TABLE "public"."leaflets_in_publications" 3 + ADD COLUMN "tags" text[] DEFAULT ARRAY[]::text[]; 4 + 5 + -- Add tags column to leaflets_to_documents for standalone document drafts 6 + ALTER TABLE "public"."leaflets_to_documents" 7 + ADD COLUMN "tags" text[] DEFAULT ARRAY[]::text[];
+38
supabase/migrations/20251204140000_update_pull_data_with_tags.sql
···
··· 1 + set check_function_bodies = off; 2 + 3 + CREATE OR REPLACE FUNCTION public.pull_data(token_id uuid, client_group_id text) 4 + RETURNS pull_result 5 + LANGUAGE plpgsql 6 + AS $function$DECLARE 7 + result pull_result; 8 + BEGIN 9 + -- Get client group data as JSON array 10 + SELECT json_agg(row_to_json(rc)) 11 + FROM replicache_clients rc 12 + WHERE rc.client_group = client_group_id 13 + INTO result.client_groups; 14 + 15 + -- Get facts as JSON array 16 + SELECT json_agg(row_to_json(f)) 17 + FROM permission_tokens pt, 18 + get_facts(pt.root_entity) f 19 + WHERE pt.id = token_id 20 + INTO result.facts; 21 + 22 + -- Get publication data - try leaflets_in_publications first, then leaflets_to_documents 23 + SELECT json_agg(row_to_json(lip)) 24 + FROM leaflets_in_publications lip 25 + WHERE lip.leaflet = token_id 26 + INTO result.publications; 27 + 28 + -- If no publication data found, try leaflets_to_documents (for standalone documents) 29 + IF result.publications IS NULL THEN 30 + SELECT json_agg(row_to_json(ltd)) 31 + FROM leaflets_to_documents ltd 32 + WHERE ltd.leaflet = token_id 33 + INTO result.publications; 34 + END IF; 35 + 36 + RETURN result; 37 + END;$function$ 38 + ;
+1 -1
tsconfig.json
··· 21 "moduleResolution": "node", 22 "resolveJsonModule": true, 23 "isolatedModules": true, 24 - "jsx": "preserve", 25 "plugins": [ 26 { 27 "name": "next"
··· 21 "moduleResolution": "node", 22 "resolveJsonModule": true, 23 "isolatedModules": true, 24 + "jsx": "react-jsx", 25 "plugins": [ 26 { 27 "name": "next"