a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

Changed files
+12629 -6360
actions
app
(home-pages)
[leaflet_id]
api
lish
login
p
[didOrHandle]
templates
appview
components
drizzle
feeds
lexicons
api
types
pub
leaflet
pub
leaflet
src
src
supabase
+6
actions/createPublicationDraft.ts
··· 11 11 redirectUser: false, 12 12 firstBlockType: "text", 13 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; 14 20 15 21 await supabaseServerClient 16 22 .from("leaflets_in_publications")
+127
actions/deleteLeaflet.ts
··· 1 1 "use server"; 2 + import { refresh } from "next/cache"; 2 3 3 4 import { drizzle } from "drizzle-orm/node-postgres"; 4 5 import { ··· 9 10 import { eq } from "drizzle-orm"; 10 11 import { PermissionToken } from "src/replicache"; 11 12 import { pool } from "supabase/pool"; 13 + import { getIdentityData } from "./getIdentityData"; 14 + import { supabaseServerClient } from "supabase/serverClient"; 12 15 13 16 export async function deleteLeaflet(permission_token: PermissionToken) { 14 17 const client = await pool.connect(); 15 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 + 16 75 await db.transaction(async (tx) => { 17 76 let [token] = await tx 18 77 .select() ··· 32 91 .where(eq(permission_tokens.id, permission_token.id)); 33 92 }); 34 93 client.release(); 94 + 95 + refresh(); 96 + return; 97 + } 98 + 99 + export async function archivePost(token: string) { 100 + let identity = await getIdentityData(); 101 + if (!identity) throw new Error("No Identity"); 102 + 103 + // Archive on homepage 104 + await supabaseServerClient 105 + .from("permission_token_on_homepage") 106 + .update({ archived: true }) 107 + .eq("token", token) 108 + .eq("identity", identity.id); 109 + 110 + // Check if leaflet is in any publications where user is the creator 111 + let { data: leafletInPubs } = await supabaseServerClient 112 + .from("leaflets_in_publications") 113 + .select("publication, publications!inner(identity_did)") 114 + .eq("leaflet", token); 115 + 116 + if (leafletInPubs) { 117 + for (let pub of leafletInPubs) { 118 + if (pub.publications.identity_did === identity.atp_did) { 119 + await supabaseServerClient 120 + .from("leaflets_in_publications") 121 + .update({ archived: true }) 122 + .eq("leaflet", token) 123 + .eq("publication", pub.publication); 124 + } 125 + } 126 + } 127 + 128 + refresh(); 129 + return; 130 + } 131 + 132 + export async function unarchivePost(token: string) { 133 + let identity = await getIdentityData(); 134 + if (!identity) throw new Error("No Identity"); 135 + 136 + // Unarchive on homepage 137 + await supabaseServerClient 138 + .from("permission_token_on_homepage") 139 + .update({ archived: false }) 140 + .eq("token", token) 141 + .eq("identity", identity.id); 142 + 143 + // Check if leaflet is in any publications where user is the creator 144 + let { data: leafletInPubs } = await supabaseServerClient 145 + .from("leaflets_in_publications") 146 + .select("publication, publications!inner(identity_did)") 147 + .eq("leaflet", token); 148 + 149 + if (leafletInPubs) { 150 + for (let pub of leafletInPubs) { 151 + if (pub.publications.identity_did === identity.atp_did) { 152 + await supabaseServerClient 153 + .from("leaflets_in_publications") 154 + .update({ archived: false }) 155 + .eq("leaflet", token) 156 + .eq("publication", pub.publication); 157 + } 158 + } 159 + } 160 + 161 + refresh(); 35 162 return; 36 163 }
+2
actions/getIdentityData.ts
··· 24 24 entity_sets(entities(facts(*))) 25 25 )), 26 26 permission_token_on_homepage( 27 + archived, 27 28 created_at, 28 29 permission_tokens!inner( 29 30 id, 30 31 root_entity, 31 32 permission_token_rights(*), 33 + leaflets_to_documents(*, documents(*)), 32 34 leaflets_in_publications(*, publications(*), documents(*)) 33 35 ) 34 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 - }
+396 -69
actions/publishToPublication.ts
··· 32 32 import { scanIndexLocal } from "src/replicache/utils"; 33 33 import type { Fact } from "src/replicache"; 34 34 import type { Attribute } from "src/replicache/attributes"; 35 - import { 36 - Delta, 37 - YJSFragmentToString, 38 - } from "components/Blocks/TextBlock/RenderYJSFragment"; 35 + import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39 36 import { ids } from "lexicons/api/lexicons"; 40 37 import { BlobRef } from "@atproto/lexicon"; 41 38 import { AtUri } from "@atproto/syntax"; ··· 44 41 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 45 42 import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 46 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"; 47 52 48 53 export async function publishToPublication({ 49 54 root_entity, ··· 51 56 leaflet_id, 52 57 title, 53 58 description, 59 + tags, 60 + entitiesToDelete, 54 61 }: { 55 62 root_entity: string; 56 - publication_uri: string; 63 + publication_uri?: string; 57 64 leaflet_id: string; 58 65 title?: string; 59 66 description?: string; 67 + tags?: string[]; 68 + entitiesToDelete?: string[]; 60 69 }) { 61 70 const oauthClient = await createOauthClient(); 62 71 let identity = await getIdentityData(); ··· 66 75 let agent = new AtpBaseClient( 67 76 credentialSession.fetchHandler.bind(credentialSession), 68 77 ); 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"); 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 + 77 117 let { data } = await supabaseServerClient.rpc("get_facts", { 78 118 root: root_entity, 79 119 }); 80 120 let facts = (data as unknown as Fact<Attribute>[]) || []; 81 121 82 - let { firstPageBlocks, pages } = await processBlocksToPages( 122 + let { pages } = await processBlocksToPages( 83 123 facts, 84 124 agent, 85 125 root_entity, ··· 88 128 89 129 let existingRecord = 90 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 + 91 138 let record: PubLeafletDocument.Record = { 139 + publishedAt: new Date().toISOString(), 140 + ...existingRecord, 92 141 $type: "pub.leaflet.document", 93 142 author: credentialSession.did!, 94 - publication: publication_uri, 95 - publishedAt: new Date().toISOString(), 96 - ...existingRecord, 143 + ...(publication_uri && { publication: publication_uri }), 144 + ...(theme && { theme }), 97 145 title: title || "Untitled", 98 146 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 - ], 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 + }), 120 163 }; 121 - let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); 164 + 165 + // Keep the same rkey if updating an existing document 166 + let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 122 167 let { data: result } = await agent.com.atproto.repo.putRecord({ 123 168 rkey, 124 169 repo: credentialSession.did!, ··· 127 172 validate: false, //TODO publish the lexicon so we can validate! 128 173 }); 129 174 175 + // Optimistically create database entries 130 176 await supabaseServerClient.from("documents").upsert({ 131 177 uri: result.uri, 132 178 data: record as Json, 133 179 }); 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({ 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({ 143 189 doc: result.uri, 144 - }) 145 - .eq("leaflet", leaflet_id) 146 - .eq("publication", publication_uri), 147 - ]); 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 + } 148 219 149 220 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 150 221 } ··· 169 240 170 241 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 171 242 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 }; 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 }; 175 267 176 268 async function uploadImage(src: string) { 177 269 let data = await fetch(src); ··· 213 305 if (!b) return []; 214 306 let block: PubLeafletPagesLinearDocument.Block = { 215 307 $type: "pub.leaflet.pages.linearDocument#block", 216 - alignment, 217 308 block: b, 218 309 }; 310 + if (alignment) block.alignment = alignment; 219 311 return [block]; 220 312 } else { 221 313 let block: PubLeafletPagesLinearDocument.Block = { ··· 257 349 Y.applyUpdate(doc, update); 258 350 let nodes = doc.getXmlElement("prosemirror").toArray(); 259 351 let stringValue = YJSFragmentToString(nodes[0]); 260 - let facets = YJSFragmentToFacets(nodes[0]); 352 + let { facets } = YJSFragmentToFacets(nodes[0]); 261 353 return [stringValue, facets] as const; 262 354 }; 263 355 if (b.type === "card") { ··· 313 405 let [stringValue, facets] = getBlockContent(b.value); 314 406 let block: $Typed<PubLeafletBlocksHeader.Main> = { 315 407 $type: "pub.leaflet.blocks.header", 316 - level: headingLevel?.data.value || 1, 408 + level: Math.floor(headingLevel?.data.value || 1), 317 409 plaintext: stringValue, 318 410 facets, 319 411 }; ··· 346 438 let block: $Typed<PubLeafletBlocksIframe.Main> = { 347 439 $type: "pub.leaflet.blocks.iframe", 348 440 url: url.data.value, 349 - height: height?.data.value || 600, 441 + height: Math.floor(height?.data.value || 600), 350 442 }; 351 443 return block; 352 444 } ··· 360 452 $type: "pub.leaflet.blocks.image", 361 453 image: blobref, 362 454 aspectRatio: { 363 - height: image.data.height, 364 - width: image.data.width, 455 + height: Math.floor(image.data.height), 456 + width: Math.floor(image.data.width), 365 457 }, 366 458 alt: altText ? altText.data.value : undefined, 367 459 }; ··· 518 610 519 611 function YJSFragmentToFacets( 520 612 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 521 - ): PubLeafletRichtextFacet.Main[] { 613 + byteOffset: number = 0, 614 + ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 522 615 if (node.constructor === Y.XmlElement) { 523 - return node 524 - .toArray() 525 - .map((f) => YJSFragmentToFacets(f)) 526 - .flat(); 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 }; 527 667 } 668 + 528 669 if (node.constructor === Y.XmlText) { 529 670 let facets: PubLeafletRichtextFacet.Main[] = []; 530 671 let delta = node.toDelta() as Delta[]; 531 - let byteStart = 0; 672 + let byteStart = byteOffset; 673 + let totalLength = 0; 532 674 for (let d of delta) { 533 675 let unicodestring = new UnicodeString(d.insert); 534 676 let facet: PubLeafletRichtextFacet.Main = { ··· 561 703 }); 562 704 if (facet.features.length > 0) facets.push(facet); 563 705 byteStart += unicodestring.length; 706 + totalLength += unicodestring.length; 564 707 } 565 - return facets; 708 + return { facets, byteLength: totalLength }; 566 709 } 567 - return []; 710 + return { facets: [], byteLength: 0 }; 568 711 } 569 712 570 713 type ExcludeString<T> = T extends string ··· 572 715 ? never 573 716 : T /* maybe literal, not the whole `string` */ 574 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: Math.floor(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/sendPostToSubscribers.ts
··· 57 57 ) { 58 58 return; 59 59 } 60 - let domain = getCurrentDeploymentDomain(); 60 + let domain = await getCurrentDeploymentDomain(); 61 61 let res = await fetch("https://api.postmarkapp.com/email/batch", { 62 62 method: "POST", 63 63 headers: {
+1 -1
actions/subscriptions/subscribeToMailboxWithEmail.ts
··· 11 11 import type { Attribute } from "src/replicache/attributes"; 12 12 import { Database } from "supabase/database.types"; 13 13 import * as Y from "yjs"; 14 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 14 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 15 15 import { pool } from "supabase/pool"; 16 16 17 17 let supabase = createServerClient<Database>(
+2 -1
app/(home-pages)/discover/PubListing.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 4 5 import { PubIcon } from "components/ActionBar/Publications"; 5 6 import { Separator } from "components/Layout"; 6 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; ··· 16 17 }, 17 18 ) => { 18 19 let record = props.record as PubLeafletPublication.Record; 19 - let theme = usePubTheme(record); 20 + let theme = usePubTheme(record.theme); 20 21 let backgroundImage = record?.theme?.backgroundImage?.image?.ref 21 22 ? blobRefToSrc( 22 23 record?.theme?.backgroundImage?.image?.ref,
+1 -2
app/(home-pages)/home/Actions/Actions.tsx
··· 1 1 "use client"; 2 2 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 3 3 import { CreateNewLeafletButton } from "./CreateNewButton"; 4 - import { HelpPopover } from "components/HelpPopover"; 4 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 5 5 import { AccountSettings } from "./AccountSettings"; 6 6 import { useIdentityData } from "components/IdentityProvider"; 7 7 import { useReplicache } from "src/replicache"; ··· 18 18 ) : ( 19 19 <LoginActionButton /> 20 20 )} 21 - <HelpPopover /> 22 21 </> 23 22 ); 24 23 };
-48
app/(home-pages)/home/Actions/CreateNewButton.tsx
··· 1 1 "use client"; 2 2 3 3 import { createNewLeaflet } from "actions/createNewLeaflet"; 4 - import { createNewLeafletFromTemplate } from "actions/createNewLeafletFromTemplate"; 5 4 import { ActionButton } from "components/ActionBar/ActionButton"; 6 5 import { AddTiny } from "components/Icons/AddTiny"; 7 6 import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 8 7 import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 9 - import { TemplateSmall } from "components/Icons/TemplateSmall"; 10 8 import { Menu, MenuItem } from "components/Layout"; 11 9 import { useIsMobile } from "src/hooks/isMobile"; 12 - import { create } from "zustand"; 13 - import { combine, createJSONStorage, persist } from "zustand/middleware"; 14 10 15 - export const useTemplateState = create( 16 - persist( 17 - combine( 18 - { 19 - templates: [] as { id: string; name: string }[], 20 - }, 21 - (set) => ({ 22 - removeTemplate: (template: { id: string }) => 23 - set((state) => { 24 - return { 25 - templates: state.templates.filter((t) => t.id !== template.id), 26 - }; 27 - }), 28 - addTemplate: (template: { id: string; name: string }) => 29 - set((state) => { 30 - if (state.templates.find((t) => t.id === template.id)) return state; 31 - return { templates: [...state.templates, template] }; 32 - }), 33 - }), 34 - ), 35 - { 36 - name: "home-templates", 37 - storage: createJSONStorage(() => localStorage), 38 - }, 39 - ), 40 - ); 41 11 export const CreateNewLeafletButton = (props: {}) => { 42 12 let isMobile = useIsMobile(); 43 - let templates = useTemplateState((s) => s.templates); 44 13 let openNewLeaflet = (id: string) => { 45 14 if (isMobile) { 46 15 window.location.href = `/${id}?focusFirstBlock`; ··· 96 65 </div> 97 66 </div> 98 67 </MenuItem> 99 - {templates.length > 0 && ( 100 - <hr className="border-border-light mx-2 mb-0.5" /> 101 - )} 102 - {templates.map((t) => { 103 - return ( 104 - <MenuItem 105 - key={t.id} 106 - onSelect={async () => { 107 - let id = await createNewLeafletFromTemplate(t.id, false); 108 - if (!id.error) openNewLeaflet(id.id); 109 - }} 110 - > 111 - <TemplateSmall /> 112 - New {t.name} 113 - </MenuItem> 114 - ); 115 - })} 116 68 </Menu> 117 69 ); 118 70 };
+49 -42
app/(home-pages)/home/HomeLayout.tsx
··· 21 21 } from "components/PageLayouts/DashboardLayout"; 22 22 import { Actions } from "./Actions/Actions"; 23 23 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 24 - import { useTemplateState } from "./Actions/CreateNewButton"; 25 24 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 26 25 import { useState } from "react"; 27 26 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; ··· 31 30 PublicationBanner, 32 31 } from "./HomeEmpty/HomeEmpty"; 33 32 34 - type Leaflet = { 33 + export type Leaflet = { 35 34 added_at: string; 35 + archived?: boolean | null; 36 36 token: PermissionToken & { 37 37 leaflets_in_publications?: Exclude< 38 38 GetLeafletDataReturnType["result"]["data"], 39 39 null 40 40 >["leaflets_in_publications"]; 41 + leaflets_to_documents?: Exclude< 42 + GetLeafletDataReturnType["result"]["data"], 43 + null 44 + >["leaflets_to_documents"]; 41 45 }; 42 46 }; 43 47 ··· 68 72 let { identity } = useIdentityData(); 69 73 70 74 let hasPubs = !identity || identity.publications.length === 0 ? false : true; 71 - let hasTemplates = 72 - useTemplateState((s) => s.templates).length === 0 ? false : true; 75 + let hasArchived = 76 + identity && 77 + identity.permission_token_on_homepage.filter( 78 + (leaflet) => leaflet.archived === true, 79 + ).length > 0; 73 80 74 81 return ( 75 82 <DashboardLayout ··· 87 94 setSearchValueAction={setSearchValue} 88 95 hasBackgroundImage={hasBackgroundImage} 89 96 hasPubs={hasPubs} 90 - hasTemplates={hasTemplates} 97 + hasArchived={!!hasArchived} 91 98 /> 92 99 ), 93 100 content: ( ··· 127 134 ...identity.permission_token_on_homepage.reduce( 128 135 (acc, tok) => { 129 136 let title = 130 - tok.permission_tokens.leaflets_in_publications[0]?.title; 137 + tok.permission_tokens.leaflets_in_publications[0]?.title || 138 + tok.permission_tokens.leaflets_to_documents[0]?.title; 131 139 if (title) acc[tok.permission_tokens.root_entity] = title; 132 140 return acc; 133 141 }, ··· 147 155 ? identity.permission_token_on_homepage.map((ptoh) => ({ 148 156 added_at: ptoh.created_at, 149 157 token: ptoh.permission_tokens as PermissionToken, 158 + archived: ptoh.archived, 150 159 })) 151 160 : localLeaflets 152 161 .sort((a, b) => (a.added_at > b.added_at ? -1 : 1)) ··· 204 213 w-full 205 214 ${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2 pt-2"} `} 206 215 > 207 - {props.leaflets.map(({ token: leaflet, added_at }, index) => ( 216 + {props.leaflets.map(({ token: leaflet, added_at, archived }, index) => ( 208 217 <ReplicacheProvider 209 218 disablePull 210 219 initialFactsOnly={!!identity} ··· 218 227 value={{ 219 228 ...leaflet, 220 229 leaflets_in_publications: leaflet.leaflets_in_publications || [], 230 + leaflets_to_documents: leaflet.leaflets_to_documents || [], 221 231 blocked_by_admin: null, 222 232 custom_domain_routes: [], 223 233 }} 224 234 > 225 235 <LeafletListItem 226 - title={props?.titles?.[leaflet.root_entity] || "Untitled"} 227 - token={leaflet} 228 - draft={!!leaflet.leaflets_in_publications?.length} 229 - published={!!leaflet.leaflets_in_publications?.find((l) => l.doc)} 230 - publishedAt={ 231 - leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents 232 - ?.indexed_at 233 - } 234 - leaflet_id={leaflet.root_entity} 236 + title={props?.titles?.[leaflet.root_entity]} 237 + archived={archived} 235 238 loggedIn={!!identity} 236 239 display={display} 237 240 added_at={added_at} ··· 260 263 261 264 let sortedLeaflets = leaflets.sort((a, b) => { 262 265 if (sort === "alphabetical") { 263 - if (titles[a.token.root_entity] === titles[b.token.root_entity]) { 266 + let titleA = titles[a.token.root_entity] ?? "Untitled"; 267 + let titleB = titles[b.token.root_entity] ?? "Untitled"; 268 + 269 + if (titleA === titleB) { 264 270 return a.added_at > b.added_at ? -1 : 1; 265 271 } else { 266 - return titles[a.token.root_entity].toLocaleLowerCase() > 267 - titles[b.token.root_entity].toLocaleLowerCase() 268 - ? 1 269 - : -1; 272 + return titleA.toLocaleLowerCase() > titleB.toLocaleLowerCase() ? 1 : -1; 270 273 } 271 274 } else { 272 275 return a.added_at === b.added_at ··· 279 282 } 280 283 }); 281 284 282 - let allTemplates = useTemplateState((s) => s.templates); 283 - let filteredLeaflets = sortedLeaflets.filter(({ token: leaflet }) => { 284 - let published = !!leaflet.leaflets_in_publications?.find((l) => l.doc); 285 - let drafts = !!leaflet.leaflets_in_publications?.length && !published; 286 - let docs = !leaflet.leaflets_in_publications?.length; 287 - let templates = !!allTemplates.find((t) => t.id === leaflet.id); 288 - // If no filters are active, show all 289 - if ( 290 - !filter.drafts && 291 - !filter.published && 292 - !filter.docs && 293 - !filter.templates 294 - ) 295 - return true; 285 + let filteredLeaflets = sortedLeaflets.filter( 286 + ({ token: leaflet, archived: archived }) => { 287 + let published = 288 + !!leaflet.leaflets_in_publications?.find((l) => l.doc) || 289 + !!leaflet.leaflets_to_documents?.find((l) => l.document); 290 + let drafts = !!leaflet.leaflets_in_publications?.length && !published; 291 + let docs = !leaflet.leaflets_in_publications?.length && !archived; 292 + 293 + // If no filters are active, show everything that is not archived 294 + if ( 295 + !filter.drafts && 296 + !filter.published && 297 + !filter.docs && 298 + !filter.archived 299 + ) 300 + return archived === false || archived === null || archived == undefined; 296 301 297 - return ( 298 - (filter.drafts && drafts) || 299 - (filter.published && published) || 300 - (filter.docs && docs) || 301 - (filter.templates && templates) 302 - ); 303 - }); 302 + //if a filter is on, return itemsd of that filter that are also NOT archived 303 + return ( 304 + (filter.drafts && drafts && !archived) || 305 + (filter.published && published && !archived) || 306 + (filter.docs && docs && !archived) || 307 + (filter.archived && archived) 308 + ); 309 + }, 310 + ); 304 311 if (searchValue === "") return filteredLeaflets; 305 312 let searchedLeaflets = filteredLeaflets.filter(({ token: leaflet }) => { 306 313 return titles[leaflet.root_entity]
+29 -57
app/(home-pages)/home/LeafletList/LeafletInfo.tsx
··· 1 1 "use client"; 2 - import { PermissionToken } from "src/replicache"; 2 + import { useEntity } from "src/replicache"; 3 3 import { LeafletOptions } from "./LeafletOptions"; 4 - import Link from "next/link"; 5 - import { useState } from "react"; 6 - import { theme } from "tailwind.config"; 7 - import { TemplateSmall } from "components/Icons/TemplateSmall"; 8 4 import { timeAgo } from "src/utils/timeAgo"; 5 + import { usePageTitle } from "components/utils/UpdateLeafletTitle"; 6 + import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 9 7 10 8 export const LeafletInfo = (props: { 11 9 title?: string; 12 - draft?: boolean; 13 - published?: boolean; 14 - token: PermissionToken; 15 - leaflet_id: string; 16 - loggedIn: boolean; 17 - isTemplate: boolean; 18 10 className?: string; 19 11 display: "grid" | "list"; 20 12 added_at: string; 21 - publishedAt?: string; 13 + archived?: boolean | null; 14 + loggedIn: boolean; 22 15 }) => { 23 - let [prefetch, setPrefetch] = useState(false); 16 + const pubStatus = useLeafletPublicationStatus(); 24 17 let prettyCreatedAt = props.added_at ? timeAgo(props.added_at) : ""; 18 + let prettyPublishedAt = pubStatus?.publishedAt 19 + ? timeAgo(pubStatus.publishedAt) 20 + : ""; 25 21 26 - let prettyPublishedAt = props.publishedAt ? timeAgo(props.publishedAt) : ""; 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"; 27 28 28 29 return ( 29 30 <div 30 31 className={`leafletInfo w-full min-w-0 flex flex-col ${props.className}`} 31 32 > 32 33 <div className="flex justify-between items-center shrink-0 max-w-full gap-2 leading-tight overflow-hidden"> 33 - <Link 34 - onMouseEnter={() => setPrefetch(true)} 35 - onPointerDown={() => setPrefetch(true)} 36 - prefetch={prefetch} 37 - href={`/${props.token.id}`} 38 - className="no-underline sm:hover:no-underline text-primary grow min-w-0" 39 - > 40 - <h3 className="sm:text-lg text-base truncate w-full min-w-0"> 41 - {props.title} 42 - </h3> 43 - </Link> 34 + <h3 className="sm:text-lg text-base truncate w-full min-w-0"> 35 + {title} 36 + </h3> 44 37 <div className="flex gap-1 shrink-0"> 45 - {props.isTemplate && props.display === "list" ? ( 46 - <TemplateSmall 47 - fill={theme.colors["bg-page"]} 48 - className="text-tertiary" 49 - /> 50 - ) : null} 51 - <LeafletOptions 52 - leaflet={props.token} 53 - isTemplate={props.isTemplate} 54 - loggedIn={props.loggedIn} 55 - added_at={props.added_at} 56 - /> 38 + <LeafletOptions archived={props.archived} loggedIn={props.loggedIn} /> 57 39 </div> 58 40 </div> 59 - <Link 60 - onMouseEnter={() => setPrefetch(true)} 61 - onPointerDown={() => setPrefetch(true)} 62 - prefetch={prefetch} 63 - href={`/${props.token.id}`} 64 - className="no-underline sm:hover:no-underline text-primary w-full" 65 - > 66 - {props.draft || props.published ? ( 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 ? ( 67 45 <div 68 - className={`text-xs ${props.published ? "font-bold text-tertiary" : "text-tertiary"}`} 46 + className={`text-xs w-max grow truncate ${pubStatus?.isPublished ? "font-bold text-tertiary" : "text-tertiary"}`} 69 47 > 70 - {props.published 48 + {pubStatus?.isPublished 71 49 ? `Published ${prettyPublishedAt}` 72 50 : `Draft ${prettyCreatedAt}`} 73 51 </div> 74 52 ) : ( 75 - <div className="text-xs text-tertiary">{prettyCreatedAt}</div> 53 + <div className="text-xs text-tertiary grow w-max truncate"> 54 + {prettyCreatedAt} 55 + </div> 76 56 )} 77 - </Link> 78 - {props.isTemplate && props.display === "grid" ? ( 79 - <div className="absolute -top-2 right-1"> 80 - <TemplateSmall 81 - className="text-tertiary" 82 - fill={theme.colors["bg-page"]} 83 - /> 84 - </div> 85 - ) : null} 57 + </div> 86 58 </div> 87 59 ); 88 60 };
+45 -26
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
··· 1 1 "use client"; 2 - import { PermissionToken } from "src/replicache"; 3 - import { useTemplateState } from "../Actions/CreateNewButton"; 4 2 import { LeafletListPreview, LeafletGridPreview } from "./LeafletPreview"; 5 3 import { LeafletInfo } from "./LeafletInfo"; 6 4 import { useState, useRef, useEffect } from "react"; 5 + import { SpeedyLink } from "components/SpeedyLink"; 6 + import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 7 8 8 export const LeafletListItem = (props: { 9 - token: PermissionToken; 10 - leaflet_id: string; 9 + archived?: boolean | null; 11 10 loggedIn: boolean; 12 11 display: "list" | "grid"; 13 12 cardBorderHidden: boolean; 14 13 added_at: string; 15 - title: string; 16 - draft?: boolean; 17 - published?: boolean; 18 - publishedAt?: string; 14 + title?: string; 19 15 index: number; 20 16 isHidden: boolean; 21 17 showPreview?: boolean; 22 18 }) => { 23 - let isTemplate = useTemplateState( 24 - (s) => !!s.templates.find((t) => t.id === props.token.id), 25 - ); 26 - 19 + const pubStatus = useLeafletPublicationStatus(); 27 20 let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); 28 21 let previewRef = useRef<HTMLDivElement | null>(null); 29 22 ··· 45 38 return () => observer.disconnect(); 46 39 }, [previewRef]); 47 40 41 + const tokenId = pubStatus?.shareLink ?? ""; 42 + 48 43 if (props.display === "list") 49 44 return ( 50 45 <> 51 46 <div 52 47 ref={previewRef} 53 - className={`gap-3 w-full ${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border"}`} 48 + className={`relative flex gap-3 w-full 49 + ${props.isHidden ? "hidden" : "flex"} 50 + ${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 54 51 style={{ 55 52 backgroundColor: props.cardBorderHidden 56 53 ? "transparent" 57 54 : "rgba(var(--bg-page), var(--bg-page-alpha))", 58 - 59 - display: props.isHidden ? "none" : "flex", 60 55 }} 61 56 > 62 - {props.showPreview && ( 63 - <LeafletListPreview isVisible={isOnScreen} {...props} /> 64 - )} 65 - <LeafletInfo isTemplate={isTemplate} {...props} /> 57 + <SpeedyLink 58 + href={`/${tokenId}`} 59 + className={`absolute w-full h-full top-0 left-0 no-underline hover:no-underline! text-primary`} 60 + /> 61 + {props.showPreview && <LeafletListPreview isVisible={isOnScreen} />} 62 + <LeafletInfo 63 + title={props.title} 64 + display={props.display} 65 + added_at={props.added_at} 66 + archived={props.archived} 67 + loggedIn={props.loggedIn} 68 + /> 66 69 </div> 67 70 {props.cardBorderHidden && ( 68 71 <hr ··· 77 80 return ( 78 81 <div 79 82 ref={previewRef} 80 - className={`leafletGridListItem relative 81 - flex flex-col gap-1 p-1 h-52 83 + className={` 84 + relative 85 + flex flex-col gap-1 p-1 h-52 w-full 82 86 block-border border-border! hover:outline-border 87 + ${props.isHidden ? "hidden" : "flex"} 83 88 `} 84 89 style={{ 85 90 backgroundColor: props.cardBorderHidden 86 91 ? "transparent" 87 92 : "rgba(var(--bg-page), var(--bg-page-alpha))", 88 - 89 - display: props.isHidden ? "none" : "flex", 90 93 }} 91 94 > 95 + <SpeedyLink 96 + href={`/${tokenId}`} 97 + className={`absolute w-full h-full top-0 left-0 no-underline hover:no-underline! text-primary`} 98 + /> 92 99 <div className="grow"> 93 - <LeafletGridPreview {...props} isVisible={isOnScreen} /> 100 + <LeafletGridPreview isVisible={isOnScreen} /> 94 101 </div> 95 102 <LeafletInfo 96 - isTemplate={isTemplate} 97 103 className="px-1 pb-0.5 shrink-0" 98 - {...props} 104 + title={props.title} 105 + display={props.display} 106 + added_at={props.added_at} 107 + archived={props.archived} 108 + loggedIn={props.loggedIn} 99 109 /> 100 110 </div> 101 111 ); 102 112 }; 113 + 114 + const LeafletLink = (props: { id: string; className: string }) => { 115 + return ( 116 + <SpeedyLink 117 + href={`/${props.id}`} 118 + className={`no-underline hover:no-underline! text-primary ${props.className}`} 119 + /> 120 + ); 121 + };
+301 -170
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
··· 1 1 "use client"; 2 2 3 3 import { Menu, MenuItem } from "components/Layout"; 4 - import { useReplicache, type PermissionToken } from "src/replicache"; 5 - import { hideDoc } from "../storage"; 6 4 import { useState } from "react"; 7 - import { ButtonPrimary } from "components/Buttons"; 8 - import { useTemplateState } from "../Actions/CreateNewButton"; 9 - import { useSmoker, useToaster } from "components/Toast"; 10 - import { removeLeafletFromHome } from "actions/removeLeafletFromHome"; 11 - import { useIdentityData } from "components/IdentityProvider"; 5 + import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 6 + import { useToaster } from "components/Toast"; 7 + import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 8 + import { DeleteSmall } from "components/Icons/DeleteSmall"; 9 + import { 10 + archivePost, 11 + deleteLeaflet, 12 + unarchivePost, 13 + } from "actions/deleteLeaflet"; 14 + import { ArchiveSmall } from "components/Icons/ArchiveSmall"; 15 + import { UnpublishSmall } from "components/Icons/UnpublishSmall"; 16 + import { 17 + deletePost, 18 + unpublishPost, 19 + } from "app/lish/[did]/[publication]/dashboard/deletePost"; 20 + import { ShareSmall } from "components/Icons/ShareSmall"; 12 21 import { HideSmall } from "components/Icons/HideSmall"; 13 - import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 14 - import { TemplateRemoveSmall } from "components/Icons/TemplateRemoveSmall"; 15 - import { TemplateSmall } from "components/Icons/TemplateSmall"; 16 - import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 17 - import { addLeafletToHome } from "actions/addLeafletToHome"; 22 + import { hideDoc } from "../storage"; 23 + 24 + import { 25 + useIdentityData, 26 + mutateIdentityData, 27 + } from "components/IdentityProvider"; 28 + import { 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"; 18 34 19 35 export const LeafletOptions = (props: { 20 - leaflet: PermissionToken; 21 - isTemplate: boolean; 22 - loggedIn: boolean; 23 - added_at: string; 36 + archived?: boolean | null; 37 + loggedIn?: boolean; 24 38 }) => { 25 - let { mutate: mutateIdentity } = useIdentityData(); 26 - let [state, setState] = useState<"normal" | "template">("normal"); 39 + const pubStatus = useLeafletPublicationStatus(); 40 + let [state, setState] = useState<"normal" | "areYouSure">("normal"); 27 41 let [open, setOpen] = useState(false); 28 - let smoker = useSmoker(); 29 - let toaster = useToaster(); 42 + let { identity } = useIdentityData(); 43 + let isPublicationOwner = 44 + !!identity?.atp_did && !!pubStatus?.documentUri?.includes(identity.atp_did); 30 45 return ( 31 46 <> 32 47 <Menu ··· 38 53 }} 39 54 trigger={ 40 55 <div 41 - className="text-secondary shrink-0" 56 + className="text-secondary shrink-0 relative" 42 57 onClick={(e) => { 43 58 e.preventDefault; 44 59 e.stopPropagation; ··· 49 64 } 50 65 > 51 66 {state === "normal" ? ( 52 - <> 53 - {!props.isTemplate ? ( 54 - <MenuItem 55 - onSelect={(e) => { 56 - e.preventDefault(); 57 - setState("template"); 58 - }} 59 - > 60 - <TemplateSmall /> Add as Template 61 - </MenuItem> 62 - ) : ( 63 - <MenuItem 64 - onSelect={(e) => { 65 - useTemplateState.getState().removeTemplate(props.leaflet); 66 - let newLeafletButton = 67 - document.getElementById("new-leaflet-button"); 68 - if (!newLeafletButton) return; 69 - let rect = newLeafletButton.getBoundingClientRect(); 70 - smoker({ 71 - static: true, 72 - text: <strong>Removed template!</strong>, 73 - position: { 74 - y: rect.top, 75 - x: rect.right + 5, 76 - }, 77 - }); 78 - }} 79 - > 80 - <TemplateRemoveSmall /> Remove from Templates 81 - </MenuItem> 82 - )} 83 - <MenuItem 84 - onSelect={async () => { 85 - if (props.loggedIn) { 86 - mutateIdentity( 87 - (s) => { 88 - if (!s) return s; 89 - return { 90 - ...s, 91 - permission_token_on_homepage: 92 - s.permission_token_on_homepage.filter( 93 - (ptrh) => 94 - ptrh.permission_tokens.id !== props.leaflet.id, 95 - ), 96 - }; 97 - }, 98 - { revalidate: false }, 99 - ); 100 - await removeLeafletFromHome([props.leaflet.id]); 101 - mutateIdentity(); 102 - } else { 103 - hideDoc(props.leaflet); 104 - } 105 - toaster({ 106 - content: ( 107 - <div className="font-bold"> 108 - Doc removed!{" "} 109 - <UndoRemoveFromHomeButton 110 - leaflet={props.leaflet} 111 - added_at={props.added_at} 112 - /> 113 - </div> 114 - ), 115 - type: "success", 116 - }); 117 - }} 118 - > 119 - <HideSmall /> 120 - Remove from Home 121 - </MenuItem> 122 - </> 123 - ) : state === "template" ? ( 124 - <AddTemplateForm 125 - leaflet={props.leaflet} 126 - close={() => setOpen(false)} 127 - /> 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")} /> 128 76 ) : null} 129 77 </Menu> 130 78 </> 131 79 ); 132 80 }; 133 81 134 - const UndoRemoveFromHomeButton = (props: { 135 - leaflet: PermissionToken; 136 - added_at: string | undefined; 82 + const DefaultOptions = (props: { 83 + setState: (s: "areYouSure") => void; 84 + archived?: boolean | null; 137 85 }) => { 138 - let toaster = useToaster(); 139 - let { mutate } = useIdentityData(); 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 + 140 98 return ( 141 - <button 142 - onClick={async (e) => { 143 - await mutate( 144 - (identity) => { 145 - if (!identity) return; 146 - return { 147 - ...identity, 148 - permission_token_on_homepage: [ 149 - ...identity.permission_token_on_homepage, 150 - { 151 - created_at: props.added_at || new Date().toISOString(), 152 - permission_tokens: { 153 - ...props.leaflet, 154 - leaflets_in_publications: [], 155 - }, 156 - }, 157 - ], 158 - }; 159 - }, 160 - { revalidate: false }, 161 - ); 162 - await addLeafletToHome(props.leaflet.id); 163 - await mutate(); 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 + }} 123 + > 124 + Undo? 125 + </ButtonTertiary> 126 + </div> 127 + ), 128 + type: "success", 129 + }); 130 + } else { 131 + await unarchivePost(tokenId); 132 + toaster({ 133 + content: <div className="font-bold">Unarchived!</div>, 134 + type: "success", 135 + }); 136 + } 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(); 164 157 165 - toaster({ 166 - content: <div className="font-bold">Recovered Doc!</div>, 167 - type: "success", 168 - }); 169 - }} 170 - className="underline" 171 - > 172 - Undo? 173 - </button> 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", 168 + }); 169 + }} 170 + > 171 + <HideSmall /> 172 + Remove from Home 173 + </MenuItem> 174 + <DeleteForeverMenuItem 175 + onSelect={(e) => { 176 + e.preventDefault(); 177 + props.setState("areYouSure"); 178 + }} 179 + /> 180 + </> 174 181 ); 175 182 }; 176 183 177 - const AddTemplateForm = (props: { 178 - leaflet: PermissionToken; 179 - close: () => void; 184 + const PublishedPostOptions = (props: { 185 + setState: (s: "areYouSure") => void; 180 186 }) => { 181 - let [name, setName] = useState(""); 182 - let smoker = useSmoker(); 183 - return ( 184 - <div className="flex flex-col gap-2 px-3 py-1"> 185 - <label className="font-bold flex flex-col gap-1 text-secondary"> 186 - Template Name 187 - <input 188 - value={name} 189 - onChange={(e) => setName(e.target.value)} 190 - type="text" 191 - className=" text-primary font-normal border border-border rounded-md outline-hidden px-2 py-1 w-64" 192 - /> 193 - </label> 187 + const pubStatus = useLeafletPublicationStatus(); 188 + const toaster = useToaster(); 189 + const postLink = pubStatus?.postShareLink ?? ""; 190 + const isFullUrl = postLink.includes("http"); 194 191 195 - <ButtonPrimary 196 - onClick={() => { 197 - useTemplateState.getState().addTemplate({ 198 - name, 199 - id: props.leaflet.id, 192 + return ( 193 + <> 194 + <ShareButton 195 + text={ 196 + <div className="flex gap-2"> 197 + <ShareSmall /> 198 + Copy Post Link 199 + </div> 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>, 214 + type: "success", 200 215 }); 201 - let newLeafletButton = document.getElementById("new-leaflet-button"); 202 - if (!newLeafletButton) return; 203 - let rect = newLeafletButton.getBoundingClientRect(); 204 - smoker({ 205 - static: true, 206 - text: <strong>Added {name}!</strong>, 207 - position: { 208 - y: rect.top, 209 - x: rect.right + 5, 210 - }, 211 - }); 212 - props.close(); 213 216 }} 214 - className="place-self-end" 215 217 > 216 - Add Template 217 - </ButtonPrimary> 218 + <UnpublishSmall /> 219 + <div className="flex flex-col"> 220 + Unpublish Post 221 + <div className="text-tertiary text-sm font-normal!"> 222 + Move this post back into drafts 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"> 251 + <div className="text-primary font-bold"> Are you sure?</div> 252 + <div className="text-sm text-secondary"> 253 + This will delete it forever for everyone! 254 + </div> 255 + <div className="flex gap-2 mx-auto items-center mt-2"> 256 + <ButtonTertiary onClick={() => props.backToMenu()}> 257 + Nevermind 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 + }} 272 + > 273 + Delete it! 274 + </ButtonPrimary> 275 + </div> 218 276 </div> 219 277 ); 220 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 + }
+49 -112
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
··· 3 3 ThemeBackgroundProvider, 4 4 ThemeProvider, 5 5 } from "components/ThemeManager/ThemeProvider"; 6 - import { 7 - PermissionToken, 8 - useEntity, 9 - useReferenceToEntity, 10 - } from "src/replicache"; 11 - import { useTemplateState } from "../Actions/CreateNewButton"; 6 + import { useEntity, useReferenceToEntity } from "src/replicache"; 12 7 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 13 8 import { LeafletContent } from "./LeafletContent"; 14 9 import { Tooltip } from "components/Tooltip"; 15 - import { useState } from "react"; 16 - import Link from "next/link"; 17 - import { SpeedyLink } from "components/SpeedyLink"; 10 + import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 11 + import { CSSProperties } from "react"; 18 12 19 - export const LeafletListPreview = (props: { 20 - draft?: boolean; 21 - published?: boolean; 22 - isVisible: boolean; 23 - token: PermissionToken; 24 - leaflet_id: string; 25 - loggedIn: boolean; 26 - }) => { 27 - let root = 28 - useReferenceToEntity("root/page", props.leaflet_id)[0]?.entity || 29 - props.leaflet_id; 30 - let firstPage = useEntity(root, "root/page")[0]; 31 - let page = firstPage?.data.value || root; 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; 32 20 33 - let cardBorderHidden = useCardBorderHidden(root); 34 - let rootBackgroundImage = useEntity(root, "theme/card-background-image"); 35 - let rootBackgroundRepeat = useEntity( 21 + const cardBorderHidden = useCardBorderHidden(root); 22 + const rootBackgroundImage = useEntity(root, "theme/card-background-image"); 23 + const rootBackgroundRepeat = useEntity( 36 24 root, 37 25 "theme/card-background-image-repeat", 38 26 ); 39 - let rootBackgroundOpacity = useEntity( 27 + const rootBackgroundOpacity = useEntity( 40 28 root, 41 29 "theme/card-background-image-opacity", 42 30 ); 43 31 32 + const contentWrapperStyle: CSSProperties = cardBorderHidden 33 + ? {} 34 + : { 35 + backgroundImage: rootBackgroundImage 36 + ? `url(${rootBackgroundImage.data.src}), url(${rootBackgroundImage.data.fallback})` 37 + : undefined, 38 + backgroundRepeat: rootBackgroundRepeat ? "repeat" : "no-repeat", 39 + backgroundPosition: "center", 40 + backgroundSize: !rootBackgroundRepeat 41 + ? "cover" 42 + : rootBackgroundRepeat?.data.value / 3, 43 + opacity: 44 + rootBackgroundImage?.data.src && rootBackgroundOpacity 45 + ? rootBackgroundOpacity.data.value 46 + : 1, 47 + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 48 + }; 49 + 50 + 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"}`; 51 + 52 + return { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass }; 53 + } 54 + 55 + export const LeafletListPreview = (props: { isVisible: boolean }) => { 56 + const { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass } = 57 + useLeafletPreviewData(); 58 + 44 59 return ( 45 60 <Tooltip 46 61 open={true} ··· 77 92 <ThemeProvider local entityID={root} className="rounded-sm"> 78 93 <ThemeBackgroundProvider entityID={root}> 79 94 <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] "> 80 - <div 81 - className={`leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`} 82 - style={ 83 - cardBorderHidden 84 - ? {} 85 - : { 86 - backgroundImage: rootBackgroundImage 87 - ? `url(${rootBackgroundImage.data.src}), url(${rootBackgroundImage.data.fallback})` 88 - : undefined, 89 - backgroundRepeat: rootBackgroundRepeat 90 - ? "repeat" 91 - : "no-repeat", 92 - backgroundPosition: "center", 93 - backgroundSize: !rootBackgroundRepeat 94 - ? "cover" 95 - : rootBackgroundRepeat?.data.value / 3, 96 - opacity: 97 - rootBackgroundImage?.data.src && rootBackgroundOpacity 98 - ? rootBackgroundOpacity.data.value 99 - : 1, 100 - backgroundColor: 101 - "rgba(var(--bg-page), var(--bg-page-alpha))", 102 - } 103 - } 104 - > 95 + <div className={contentWrapperClass} style={contentWrapperStyle}> 105 96 <LeafletContent entityID={page} isOnScreen={props.isVisible} /> 106 97 </div> 107 98 </div> ··· 111 102 ); 112 103 }; 113 104 114 - export const LeafletGridPreview = (props: { 115 - draft?: boolean; 116 - published?: boolean; 117 - token: PermissionToken; 118 - leaflet_id: string; 119 - loggedIn: boolean; 120 - isVisible: boolean; 121 - }) => { 122 - let root = 123 - useReferenceToEntity("root/page", props.leaflet_id)[0]?.entity || 124 - props.leaflet_id; 125 - let firstPage = useEntity(root, "root/page")[0]; 126 - let page = firstPage?.data.value || root; 105 + export const LeafletGridPreview = (props: { isVisible: boolean }) => { 106 + const { root, page, contentWrapperStyle, contentWrapperClass } = 107 + useLeafletPreviewData(); 127 108 128 - let cardBorderHidden = useCardBorderHidden(root); 129 - let rootBackgroundImage = useEntity(root, "theme/card-background-image"); 130 - let rootBackgroundRepeat = useEntity( 131 - root, 132 - "theme/card-background-image-repeat", 133 - ); 134 - let rootBackgroundOpacity = useEntity( 135 - root, 136 - "theme/card-background-image-opacity", 137 - ); 138 109 return ( 139 110 <ThemeProvider local entityID={root} className="w-full!"> 140 - <div className="border border-border-light rounded-md w-full h-full overflow-hidden relative"> 141 - <div className="relative w-full h-full"> 111 + <div className="border border-border-light rounded-md w-full h-full overflow-hidden "> 112 + <div className="w-full h-full"> 142 113 <ThemeBackgroundProvider entityID={root}> 143 114 <div 144 115 inert 145 - className="leafletPreview relative grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none" 116 + 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" 146 117 > 147 - <div 148 - className={`leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`} 149 - style={ 150 - cardBorderHidden 151 - ? {} 152 - : { 153 - backgroundImage: rootBackgroundImage 154 - ? `url(${rootBackgroundImage.data.src}), url(${rootBackgroundImage.data.fallback})` 155 - : undefined, 156 - backgroundRepeat: rootBackgroundRepeat 157 - ? "repeat" 158 - : "no-repeat", 159 - backgroundPosition: "center", 160 - backgroundSize: !rootBackgroundRepeat 161 - ? "cover" 162 - : rootBackgroundRepeat?.data.value / 3, 163 - opacity: 164 - rootBackgroundImage?.data.src && rootBackgroundOpacity 165 - ? rootBackgroundOpacity.data.value 166 - : 1, 167 - backgroundColor: 168 - "rgba(var(--bg-page), var(--bg-page-alpha))", 169 - } 170 - } 171 - > 118 + <div className={contentWrapperClass} style={contentWrapperStyle}> 172 119 <LeafletContent entityID={page} isOnScreen={props.isVisible} /> 173 120 </div> 174 121 </div> 175 122 </ThemeBackgroundProvider> 176 123 </div> 177 - <LeafletPreviewLink id={props.token.id} /> 178 124 </div> 179 125 </ThemeProvider> 180 126 ); 181 127 }; 182 - 183 - const LeafletPreviewLink = (props: { id: string }) => { 184 - return ( 185 - <SpeedyLink 186 - href={`/${props.id}`} 187 - className={`hello no-underline sm:hover:no-underline text-primary absolute inset-0 w-full h-full bg-bg-test`} 188 - /> 189 - ); 190 - };
+2 -1
app/(home-pages)/home/page.tsx
··· 29 29 ...auth_res?.permission_token_on_homepage.reduce( 30 30 (acc, tok) => { 31 31 let title = 32 - tok.permission_tokens.leaflets_in_publications[0]?.title; 32 + tok.permission_tokens.leaflets_in_publications[0]?.title || 33 + tok.permission_tokens.leaflets_to_documents[0]?.title; 33 34 if (title) acc[tok.permission_tokens.root_entity] = title; 34 35 return acc; 35 36 },
+116
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
··· 1 + "use client"; 2 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 4 + import { useState } from "react"; 5 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 + import { Fact, PermissionToken } from "src/replicache"; 7 + import { Attribute } from "src/replicache/attributes"; 8 + import { Actions } from "../home/Actions/Actions"; 9 + import { callRPC } from "app/api/rpc/client"; 10 + import { useIdentityData } from "components/IdentityProvider"; 11 + import useSWR from "swr"; 12 + import { getHomeDocs } from "../home/storage"; 13 + import { Leaflet, LeafletList } from "../home/HomeLayout"; 14 + 15 + export const LooseleafsLayout = (props: { 16 + entityID: string | null; 17 + titles: { [root_entity: string]: string }; 18 + initialFacts: { 19 + [root_entity: string]: Fact<Attribute>[]; 20 + }; 21 + }) => { 22 + let [searchValue, setSearchValue] = useState(""); 23 + let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); 24 + 25 + useDebouncedEffect( 26 + () => { 27 + setDebouncedSearchValue(searchValue); 28 + }, 29 + 200, 30 + [searchValue], 31 + ); 32 + 33 + let cardBorderHidden = !!useCardBorderHidden(props.entityID); 34 + return ( 35 + <DashboardLayout 36 + id="looseleafs" 37 + cardBorderHidden={cardBorderHidden} 38 + currentPage="looseleafs" 39 + defaultTab="home" 40 + actions={<Actions />} 41 + tabs={{ 42 + home: { 43 + controls: null, 44 + content: ( 45 + <LooseleafList 46 + titles={props.titles} 47 + initialFacts={props.initialFacts} 48 + cardBorderHidden={cardBorderHidden} 49 + searchValue={debouncedSearchValue} 50 + /> 51 + ), 52 + }, 53 + }} 54 + /> 55 + ); 56 + }; 57 + 58 + export const LooseleafList = (props: { 59 + titles: { [root_entity: string]: string }; 60 + initialFacts: { 61 + [root_entity: string]: Fact<Attribute>[]; 62 + }; 63 + searchValue: string; 64 + cardBorderHidden: boolean; 65 + }) => { 66 + let { identity } = useIdentityData(); 67 + let { data: initialFacts } = useSWR( 68 + "home-leaflet-data", 69 + async () => { 70 + if (identity) { 71 + let { result } = await callRPC("getFactsFromHomeLeaflets", { 72 + tokens: identity.permission_token_on_homepage.map( 73 + (ptrh) => ptrh.permission_tokens.root_entity, 74 + ), 75 + }); 76 + let titles = { 77 + ...result.titles, 78 + ...identity.permission_token_on_homepage.reduce( 79 + (acc, tok) => { 80 + let title = 81 + tok.permission_tokens.leaflets_in_publications[0]?.title || 82 + tok.permission_tokens.leaflets_to_documents[0]?.title; 83 + if (title) acc[tok.permission_tokens.root_entity] = title; 84 + return acc; 85 + }, 86 + {} as { [k: string]: string }, 87 + ), 88 + }; 89 + return { ...result, titles }; 90 + } 91 + }, 92 + { fallbackData: { facts: props.initialFacts, titles: props.titles } }, 93 + ); 94 + 95 + let leaflets: Leaflet[] = identity 96 + ? identity.permission_token_on_homepage 97 + .filter( 98 + (ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0, 99 + ) 100 + .map((ptoh) => ({ 101 + added_at: ptoh.created_at, 102 + token: ptoh.permission_tokens as PermissionToken, 103 + })) 104 + : []; 105 + return ( 106 + <LeafletList 107 + defaultDisplay="list" 108 + searchValue={props.searchValue} 109 + leaflets={leaflets} 110 + titles={initialFacts?.titles || {}} 111 + cardBorderHidden={props.cardBorderHidden} 112 + initialFacts={initialFacts?.facts || {}} 113 + showPreview 114 + /> 115 + ); 116 + };
+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 27 props.commentData.bsky_profiles?.handle || 28 28 "Someone"; 29 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; 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`; 32 38 33 39 return ( 34 40 <Notification 35 41 timestamp={props.commentData.indexed_at} 36 - href={`https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`} 42 + href={href} 37 43 icon={<CommentTiny />} 38 44 actionText={<>{displayName} commented on your post</>} 39 45 content={
+55 -40
app/(home-pages)/notifications/MentionNotification.tsx
··· 1 1 import { MentionTiny } from "components/Icons/MentionTiny"; 2 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"; 3 6 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 - }; 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 + } 29 54 30 - export const DummyUserMentionNotification = (props: { 31 - cardBorderHidden: boolean; 32 - }) => { 33 55 return ( 34 56 <Notification 35 - timestamp={""} 36 - href="/" 57 + timestamp={props.created_at} 58 + href={href} 37 59 icon={<MentionTiny />} 38 - actionText={<>celine mentioned you</>} 60 + actionText={actionText} 39 61 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> 62 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 63 + {docRecord.description && docRecord.description} 49 64 </ContentLayout> 50 65 } 51 66 />
+3 -3
app/(home-pages)/notifications/Notification.tsx
··· 69 69 <div 70 70 className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`} 71 71 > 72 - <div className="text-tertiary text-sm italic font-bold pb-1"> 72 + <div className="text-tertiary text-sm italic font-bold "> 73 73 {props.postTitle} 74 74 </div> 75 - {props.children} 75 + {props.children && <div className="mb-2 text-sm">{props.children}</div>} 76 76 {props.pubRecord && ( 77 77 <> 78 - <hr className="mt-3 mb-1 border-border-light" /> 78 + <hr className="mt-1 mb-1 border-border-light" /> 79 79 <a 80 80 href={`https://${props.pubRecord.base_path}`} 81 81 className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!"
+13 -1
app/(home-pages)/notifications/NotificationList.tsx
··· 7 7 import { ReplyNotification } from "./ReplyNotification"; 8 8 import { useIdentityData } from "components/IdentityProvider"; 9 9 import { FollowNotification } from "./FollowNotification"; 10 + import { QuoteNotification } from "./QuoteNotification"; 11 + import { MentionNotification } from "./MentionNotification"; 12 + import { CommentMentionNotification } from "./CommentMentionNotification"; 10 13 11 14 export function NotificationList({ 12 15 notifications, ··· 23 26 }, 500); 24 27 }, []); 25 28 26 - if (notifications.length !== 0) 29 + if (notifications.length === 0) 27 30 return ( 28 31 <div className="w-full text-sm flex flex-col gap-1 container italic text-tertiary text-center sm:p-4 p-3"> 29 32 <div className="text-base font-bold">no notifications yet...</div> ··· 41 44 } 42 45 if (n.type === "subscribe") { 43 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} />; 44 56 } 45 57 })} 46 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 34 props.parentData?.bsky_profiles?.handle || 35 35 "Someone"; 36 36 37 - let rkey = new AtUri(props.commentData.documents?.uri!).rkey; 37 + let docUri = new AtUri(props.commentData.documents?.uri!); 38 + let rkey = docUri.rkey; 39 + let did = docUri.host; 38 40 const pubRecord = props.commentData.documents?.documents_in_publications[0] 39 - ?.publications?.record as PubLeafletPublication.Record; 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`; 40 46 41 47 return ( 42 48 <Notification 43 49 timestamp={props.commentData.indexed_at} 44 - href={`https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`} 50 + href={href} 45 51 icon={<ReplyTiny />} 46 52 actionText={`${displayName} replied to your comment`} 47 53 content={
+7 -192
app/(home-pages)/reader/ReaderContent.tsx
··· 1 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 2 import { ButtonPrimary } from "components/Buttons"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 3 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 4 import type { Cursor, Post } from "./getReaderFeed"; 18 5 import useSWRInfinite from "swr/infinite"; 19 6 import { getReaderFeed } from "./getReaderFeed"; 20 7 import { useEffect, useRef } from "react"; 21 - import { useRouter } from "next/navigation"; 22 8 import Link from "next/link"; 23 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 9 + import { PostListing } from "components/PostListing"; 24 10 25 11 export const ReaderContent = (props: { 26 12 posts: Post[]; ··· 28 14 }) => { 29 15 const getKey = ( 30 16 pageIndex: number, 31 - previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 32 21 ) => { 33 22 // Reached the end 34 23 if (previousPageData && !previousPageData.nextCursor) return null; ··· 40 29 return ["reader-feed", previousPageData?.nextCursor] as const; 41 30 }; 42 31 43 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 44 33 getKey, 45 34 ([_, cursor]) => getReaderFeed(cursor), 46 35 { ··· 79 68 return ( 80 69 <div className="flex flex-col gap-3 relative"> 81 70 {allPosts.map((p) => ( 82 - <Post {...p} key={p.documents.uri} /> 71 + <PostListing {...p} key={p.documents.uri} /> 83 72 ))} 84 73 {/* Trigger element for loading more posts */} 85 74 <div ··· 96 85 ); 97 86 }; 98 87 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 88 export const ReaderEmpty = () => { 274 89 return ( 275 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">
+68
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 + // Query documents that have this tag 14 + const { data: documents, error } = await supabaseServerClient 15 + .from("documents") 16 + .select( 17 + `*, 18 + comments_on_documents(count), 19 + document_mentions_in_bsky(count), 20 + documents_in_publications(publications(*))`, 21 + ) 22 + .contains("data->tags", `["${tag}"]`) 23 + .order("indexed_at", { ascending: false }) 24 + .limit(50); 25 + 26 + if (error) { 27 + console.error("Error fetching documents by tag:", error); 28 + return { posts: [] }; 29 + } 30 + 31 + const posts = await Promise.all( 32 + documents.map(async (doc) => { 33 + const pub = doc.documents_in_publications[0]?.publications; 34 + 35 + // Skip if document doesn't have a publication 36 + if (!pub) { 37 + return null; 38 + } 39 + 40 + const uri = new AtUri(doc.uri); 41 + const handle = await idResolver.did.resolve(uri.host); 42 + 43 + const post: Post = { 44 + publication: { 45 + href: getPublicationURL(pub), 46 + pubRecord: pub?.record || null, 47 + uri: pub?.uri || "", 48 + }, 49 + author: handle?.alsoKnownAs?.[0] 50 + ? `@${handle.alsoKnownAs[0].slice(5)}` 51 + : null, 52 + documents: { 53 + comments_on_documents: doc.comments_on_documents, 54 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 55 + data: doc.data, 56 + uri: doc.uri, 57 + indexed_at: doc.indexed_at, 58 + }, 59 + }; 60 + return post; 61 + }), 62 + ); 63 + 64 + // Filter out null entries (documents without publications) 65 + return { 66 + posts: posts.filter((p): p is Post => p !== null), 67 + }; 68 + }
+75
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 + cardBorderHidden={false} 18 + currentPage="tag" 19 + defaultTab="default" 20 + actions={null} 21 + tabs={{ 22 + default: { 23 + controls: null, 24 + content: <TagContent tag={decodedTag} posts={posts} />, 25 + }, 26 + }} 27 + /> 28 + ); 29 + } 30 + 31 + const TagContent = (props: { 32 + tag: string; 33 + posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"]; 34 + }) => { 35 + return ( 36 + <div className="max-w-prose mx-auto w-full grow shrink-0"> 37 + <div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4"> 38 + <TagHeader tag={props.tag} postCount={props.posts.length} /> 39 + </div> 40 + <div className="pt-6 flex flex-col gap-3"> 41 + {props.posts.length === 0 ? ( 42 + <EmptyState tag={props.tag} /> 43 + ) : ( 44 + props.posts.map((post) => ( 45 + <PostListing key={post.documents.uri} {...post} /> 46 + )) 47 + )} 48 + </div> 49 + </div> 50 + ); 51 + }; 52 + 53 + const TagHeader = (props: { tag: string; postCount: number }) => { 54 + return ( 55 + <div className="flex flex-col leading-tight items-center"> 56 + <div className="flex items-center gap-3 text-xl font-bold text-primary"> 57 + <TagTiny className="scale-150" /> 58 + <h1>{props.tag}</h1> 59 + </div> 60 + <div className="text-tertiary text-sm"> 61 + {props.postCount} {props.postCount === 1 ? "post" : "posts"} 62 + </div> 63 + </div> 64 + ); 65 + }; 66 + 67 + const EmptyState = (props: { tag: string }) => { 68 + return ( 69 + <div className="flex flex-col gap-2 items-center justify-center p-8 text-center"> 70 + <div className="text-tertiary"> 71 + No posts found with the tag "{props.tag}" 72 + </div> 73 + </div> 74 + ); 75 + };
-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 4 import { Media } from "components/Media"; 5 5 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 6 6 import { Toolbar } from "components/Toolbar"; 7 - import { ShareOptions } from "components/ShareOptions"; 8 - import { HomeButton } from "components/HomeButton"; 7 + import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 8 + import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 + import { PublishButton } from "./actions/PublishButton"; 9 10 import { useEntitySetContext } from "components/EntitySetProvider"; 10 - import { HelpPopover } from "components/HelpPopover"; 11 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 11 12 import { Watermark } from "components/Watermark"; 12 - import { BackToPubButton, PublishButton } from "./Actions"; 13 + import { BackToPubButton } from "./actions/BackToPubButton"; 13 14 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 14 15 import { useIdentityData } from "components/IdentityProvider"; 15 16 ··· 36 37 /> 37 38 </div> 38 39 ) : entity_set.permissions.write ? ( 39 - pub?.publications && 40 - identity?.atp_did && 41 - pub.publications.identity_did === identity.atp_did ? ( 42 - <ActionFooter> 40 + <ActionFooter> 41 + {pub?.publications && 42 + identity?.atp_did && 43 + pub.publications.identity_did === identity.atp_did ? ( 43 44 <BackToPubButton publication={pub.publications} /> 44 - <PublishButton /> 45 - <ShareOptions /> 46 - <HelpPopover /> 47 - <ThemePopover entityID={props.entityID} /> 48 - </ActionFooter> 49 - ) : ( 50 - <ActionFooter> 45 + ) : ( 51 46 <HomeButton /> 52 - <ShareOptions /> 53 - <HelpPopover /> 54 - <ThemePopover entityID={props.entityID} /> 55 - </ActionFooter> 56 - ) 47 + )} 48 + 49 + <PublishButton entityID={props.entityID} /> 50 + <ShareOptions /> 51 + <ThemePopover entityID={props.entityID} /> 52 + </ActionFooter> 57 53 ) : ( 58 54 <div className="pb-2 px-2 z-10 flex justify-end"> 59 55 <Watermark mobile />
+12 -28
app/[leaflet_id]/Sidebar.tsx
··· 1 1 "use client"; 2 - import { ActionButton } from "components/ActionBar/ActionButton"; 3 2 import { Sidebar } from "components/ActionBar/Sidebar"; 4 3 import { useEntitySetContext } from "components/EntitySetProvider"; 5 - import { HelpPopover } from "components/HelpPopover"; 6 - import { HomeButton } from "components/HomeButton"; 4 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 5 + import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 7 6 import { Media } from "components/Media"; 8 7 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 9 - import { ShareOptions } from "components/ShareOptions"; 8 + import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 10 9 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 10 + import { PublishButton } from "./actions/PublishButton"; 11 11 import { Watermark } from "components/Watermark"; 12 - import { useUIState } from "src/useUIState"; 13 - import { BackToPubButton, PublishButton } from "./Actions"; 12 + import { BackToPubButton } from "./actions/BackToPubButton"; 14 13 import { useIdentityData } from "components/IdentityProvider"; 15 14 import { useReplicache } from "src/replicache"; 16 15 ··· 29 28 <div className="sidebarContainer flex flex-col justify-end h-full w-16 relative"> 30 29 {entity_set.permissions.write && ( 31 30 <Sidebar> 31 + <PublishButton entityID={rootEntity} /> 32 + <ShareOptions /> 33 + <ThemePopover entityID={rootEntity} /> 34 + <HelpButton /> 35 + <hr className="text-border" /> 32 36 {pub?.publications && 33 37 identity?.atp_did && 34 38 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 - </> 39 + <BackToPubButton publication={pub.publications} /> 43 40 ) : ( 44 - <> 45 - <ShareOptions /> 46 - <ThemePopover entityID={rootEntity} /> 47 - <HelpPopover /> 48 - <hr className="text-border" /> 49 - <HomeButton /> 50 - </> 41 + <HomeButton /> 51 42 )} 52 43 </Sidebar> 53 44 )} ··· 59 50 </Media> 60 51 ); 61 52 } 62 - 63 - const blurPage = () => { 64 - useUIState.setState(() => ({ 65 - focusedEntity: null, 66 - selectedBlocks: [], 67 - })); 68 - };
+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 24 process.env.SUPABASE_SERVICE_ROLE_KEY as string, 25 25 { cookies: {} }, 26 26 ); 27 - export default async function Icon(props: { params: { leaflet_id: string } }) { 27 + export default async function Icon(props: { 28 + params: Promise<{ leaflet_id: string }>; 29 + }) { 28 30 let res = await supabase 29 31 .from("permission_tokens") 30 32 .select("*, permission_token_rights(*)") 31 - .eq("id", props.params.leaflet_id) 33 + .eq("id", (await props.params).leaflet_id) 32 34 .single(); 33 35 let rootEntity = res.data?.root_entity; 34 36 let outlineColor, fillColor;
+3 -2
app/[leaflet_id]/opengraph-image.tsx
··· 4 4 export const revalidate = 60; 5 5 6 6 export default async function OpenGraphImage(props: { 7 - params: { leaflet_id: string }; 7 + params: Promise<{ leaflet_id: string }>; 8 8 }) { 9 - return getMicroLinkOgImage(`/${props.params.leaflet_id}`); 9 + let params = await props.params; 10 + return getMicroLinkOgImage(`/${params.leaflet_id}`); 10 11 }
+3 -6
app/[leaflet_id]/page.tsx
··· 4 4 5 5 import type { Fact } from "src/replicache"; 6 6 import type { Attribute } from "src/replicache/attributes"; 7 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 7 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 8 8 import { Leaflet } from "./Leaflet"; 9 9 import { scanIndexLocal } from "src/replicache/utils"; 10 10 import { getRSVPData } from "actions/getRSVPData"; ··· 13 13 import { supabaseServerClient } from "supabase/serverClient"; 14 14 import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 15 15 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 16 + import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 16 17 17 18 export const preferredRegion = ["sfo1"]; 18 19 export const dynamic = "force-dynamic"; ··· 70 71 ); 71 72 let rootEntity = res.data?.root_entity; 72 73 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]; 74 + let publication_data = getPublicationMetadataFromLeafletData(res.data); 78 75 if (publication_data) { 79 76 return { 80 77 title: publication_data.title || "Untitled",
+170 -296
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 1 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"; 2 + import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 + import { useEffect, useState, useCallback, useRef, useLayoutEffect } from "react"; 4 + import { EditorState } from "prosemirror-state"; 14 5 import { EditorView } from "prosemirror-view"; 15 6 import { Schema, MarkSpec, Mark } from "prosemirror-model"; 16 7 import { baseKeymap } from "prosemirror-commands"; ··· 19 10 import { inputRules, InputRule } from "prosemirror-inputrules"; 20 11 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21 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"; 22 15 23 16 // Schema with only links, mentions, and hashtags marks 24 17 const bskyPostSchema = new Schema({ ··· 134 127 return tr; 135 128 }); 136 129 } 137 - 138 130 export function BlueskyPostEditorProsemirror(props: { 139 - editorStateRef: React.MutableRefObject<EditorState | null>; 131 + editorStateRef: React.RefObject<EditorState | null>; 140 132 initialContent?: string; 141 133 onCharCountChange?: (count: number) => void; 142 134 }) { 143 135 const mountRef = useRef<HTMLDivElement | null>(null); 144 136 const viewRef = useRef<EditorView | null>(null); 145 137 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 }); 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 + }, []); 151 170 152 171 const handleMentionSelect = useCallback( 153 - ( 154 - mention: { handle: string; did: string }, 155 - range: { from: number; to: number }, 156 - ) => { 157 - if (!viewRef.current) return; 172 + (mention: Mention) => { 173 + if (!viewRef.current || mentionInsertPos === null) return; 158 174 const view = viewRef.current; 159 - const { from, to } = range; 175 + const from = mentionInsertPos - 1; 176 + const to = mentionInsertPos; 160 177 const tr = view.state.tr; 161 178 162 - // Delete the query text (keep the @) 163 - tr.delete(from + 1, to); 179 + // Delete the @ symbol 180 + tr.delete(from, to); 164 181 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); 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 + } 178 213 179 214 view.dispatch(tr); 180 215 view.focus(); 181 216 }, 182 - [], 217 + [mentionInsertPos], 183 218 ); 184 219 185 - const mentionStateRef = useRef(mentionState); 186 - mentionStateRef.current = mentionState; 220 + const handleMentionOpenChange = useCallback((open: boolean) => { 221 + setMentionOpen(open); 222 + if (!open) { 223 + setMentionCoords(null); 224 + setMentionInsertPos(null); 225 + } 226 + }, []); 187 227 188 228 useLayoutEffect(() => { 189 229 if (!mountRef.current) return; 190 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 + 191 240 const initialState = EditorState.create({ 192 241 schema: bskyPostSchema, 193 242 doc: props.initialContent ··· 200 249 }) 201 250 : undefined, 202 251 plugins: [ 203 - inputRules({ rules: [createHashtagInputRule()] }), 252 + inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }), 204 253 keymap({ 205 254 "Mod-z": undo, 206 255 "Mod-y": redo, 207 256 "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 257 }), 226 258 keymap(baseKeymap), 227 259 autolink({ ··· 245 277 view.updateState(newState); 246 278 setEditorState(newState); 247 279 props.editorStateRef.current = newState; 248 - props.onCharCountChange?.(newState.doc.textContent.length); 280 + props.onCharCountChange?.( 281 + newState.doc.textContent.length + newState.doc.children.length - 1, 282 + ); 249 283 }, 250 284 }, 251 285 ); ··· 256 290 view.destroy(); 257 291 viewRef.current = null; 258 292 }; 259 - }, [handleMentionSelect]); 293 + }, [openMentionAutocomplete]); 294 + 295 + const haveContent = (editorState?.doc.textContent.length ?? 0) > 0 296 + 297 + // Warn if there's content in the editor on page change, unload or reload. 298 + useWarnOnUnsavedChanges(haveContent); 260 299 261 300 return ( 262 301 <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 && ( 302 + <MentionAutocomplete 303 + open={mentionOpen} 304 + onOpenChange={handleMentionOpenChange} 305 + view={viewRef} 306 + onSelect={handleMentionSelect} 307 + coords={mentionCoords} 308 + placeholder="Search people..." 309 + /> 310 + {!haveContent && ( 274 311 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 275 312 Write a post to share your writing! 276 313 </div> 277 314 )} 278 315 <div 279 316 ref={mountRef} 280 - className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 317 + className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm" 281 318 style={{ 282 319 wordWrap: "break-word", 283 320 overflowWrap: "break-word", ··· 288 325 ); 289 326 } 290 327 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 328 /** 513 329 * Converts a ProseMirror editor state to Bluesky post facets. 514 330 * Extracts mentions, links, and hashtags from the editor state and returns them ··· 593 409 594 410 return features; 595 411 } 412 + 413 + export const addMentionToEditor = ( 414 + mention: Mention, 415 + range: { from: number; to: number }, 416 + view: EditorView, 417 + ) => { 418 + console.log("view", view); 419 + if (!view) return; 420 + const { from, to } = range; 421 + const tr = view.state.tr; 422 + 423 + if (mention.type == "did") { 424 + // Delete the @ and any query text 425 + tr.delete(from, to); 426 + // Insert didMention inline node 427 + const mentionText = "@" + mention.handle; 428 + const didMentionNode = schema.nodes.didMention.create({ 429 + did: mention.did, 430 + text: mentionText, 431 + }); 432 + tr.insert(from, didMentionNode); 433 + } 434 + if (mention.type === "publication" || mention.type === "post") { 435 + // Delete the @ and any query text 436 + tr.delete(from, to); 437 + let name = mention.type == "post" ? mention.title : mention.name; 438 + // Insert atMention inline node 439 + const atMentionNode = schema.nodes.atMention.create({ 440 + atURI: mention.uri, 441 + text: name, 442 + }); 443 + tr.insert(from, atMentionNode); 444 + } 445 + console.log("yo", mention); 446 + 447 + // Add a space after the mention 448 + tr.insertText(" ", from + 1); 449 + 450 + view.dispatch(tr); 451 + view.focus(); 452 + }; 453 + 454 + 455 + function useWarnOnUnsavedChanges(hasUnsavedContent: boolean) { 456 + useEffect(() => { 457 + const handleBeforeUnload = (e: BeforeUnloadEvent) => { 458 + if (hasUnsavedContent) { 459 + e.preventDefault(); 460 + // Chrome requires returnValue to be set 461 + e.returnValue = ""; 462 + } 463 + }; 464 + window.addEventListener("beforeunload", handleBeforeUnload); 465 + return () => { 466 + window.removeEventListener("beforeunload", handleBeforeUnload); 467 + }; 468 + }, [hasUnsavedContent]); 469 + }
+202 -91
app/[leaflet_id]/publish/PublishPost.tsx
··· 6 6 import { Radio } from "components/Checkbox"; 7 7 import { useParams } from "next/navigation"; 8 8 import Link from "next/link"; 9 - import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 9 + 10 10 import { PubLeafletPublication } from "lexicons/api"; 11 11 import { publishPostToBsky } from "./publishBskyPost"; 12 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 13 import { AtUri } from "@atproto/syntax"; 14 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 15 import { useReplicache } from "src/replicache"; 16 + import { useSubscribe } from "src/replicache/useSubscribe"; 16 17 import { 17 18 BlueskyPostEditorProsemirror, 18 19 editorStateToFacetedText, 19 20 } from "./BskyPostEditorProsemirror"; 20 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"; 21 25 22 26 type Props = { 23 27 title: string; ··· 25 29 root_entity: string; 26 30 profile: ProfileViewDetailed; 27 31 description: string; 28 - publication_uri: string; 32 + publication_uri?: string; 29 33 record?: PubLeafletPublication.Record; 30 34 posts_in_pub?: number; 35 + entitiesToDelete?: string[]; 36 + hasDraft: boolean; 31 37 }; 32 38 33 39 export function PublishPost(props: Props) { ··· 35 41 { state: "default" } | { state: "success"; post_url: string } 36 42 >({ state: "default" }); 37 43 return ( 38 - <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 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"> 39 45 {publishState.state === "default" ? ( 40 46 <PublishPostForm setPublishState={setPublishState} {...props} /> 41 47 ) : ( ··· 55 61 setPublishState: (s: { state: "success"; post_url: string }) => void; 56 62 } & Props, 57 63 ) => { 58 - let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 59 64 let editorStateRef = useRef<EditorState | null>(null); 60 - let [isLoading, setIsLoading] = useState(false); 61 65 let [charCount, setCharCount] = useState(0); 66 + let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 67 + let [isLoading, setIsLoading] = useState(false); 62 68 let params = useParams(); 63 69 let { rep } = useReplicache(); 64 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 + 65 96 async function submit() { 66 97 if (isLoading) return; 67 98 setIsLoading(true); ··· 72 103 leaflet_id: props.leaflet_id, 73 104 title: props.title, 74 105 description: props.description, 106 + tags: currentTags, 107 + entitiesToDelete: props.entitiesToDelete, 75 108 }); 76 109 if (!doc) return; 77 110 78 - let post_url = `https://${props.record?.base_path}/${doc.rkey}`; 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 + 79 116 let [text, facets] = editorStateRef.current 80 117 ? editorStateToFacetedText(editorStateRef.current) 81 118 : []; ··· 94 131 } 95 132 96 133 return ( 97 - <div className="flex flex-col gap-4 w-[640px] max-w-full sm:px-4 px-3"> 98 - <h3>Publish Options</h3> 134 + <div className="flex flex-col gap-4 w-[640px] max-w-full sm:px-4 px-3 text-primary"> 99 135 <form 100 136 onSubmit={(e) => { 101 137 e.preventDefault(); 102 138 submit(); 103 139 }} 104 140 > 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> 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" /> 142 164 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="h-[260px] w-full bg-test" /> */} 165 - <div className="flex flex-col p-2"> 166 - <div className="font-bold">{props.title}</div> 167 - <div className="text-tertiary">{props.description}</div> 168 - <hr className="border-border-light mt-2 mb-1" /> 169 - <p className="text-xs text-tertiary"> 170 - {props.record?.base_path} 171 - </p> 172 - </div> 173 - </div> 174 - <div className="text-xs text-secondary italic place-self-end pt-2"> 175 - {charCount}/300 176 - </div> 177 - </div> 178 - </div> 179 - </div> 180 - </div> 181 165 <div className="flex justify-between"> 182 166 <Link 183 167 className="hover:no-underline! font-bold" ··· 199 183 ); 200 184 }; 201 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 + 202 304 const PublishPostSuccess = (props: { 203 305 post_url: string; 204 - publication_uri: string; 306 + publication_uri?: string; 205 307 record: Props["record"]; 206 308 posts_in_pub: number; 207 309 }) => { 208 - let uri = new AtUri(props.publication_uri); 310 + let uri = props.publication_uri ? new AtUri(props.publication_uri) : null; 209 311 return ( 210 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"> 211 313 <PublishIllustration posts_in_pub={props.posts_in_pub} /> 212 314 <h2 className="pt-2">Published!</h2> 213 - <Link 214 - className="hover:no-underline! font-bold place-self-center pt-2" 215 - href={`/lish/${uri.host}/${encodeURIComponent(props.record?.name || "")}/dashboard`} 216 - > 217 - <ButtonPrimary>Back to Dashboard</ButtonPrimary> 218 - </Link> 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 + )} 219 330 <a href={props.post_url}>See published post</a> 220 331 </div> 221 332 );
+69 -9
app/[leaflet_id]/publish/page.tsx
··· 13 13 type Props = { 14 14 // this is now a token id not leaflet! Should probs rename 15 15 params: Promise<{ leaflet_id: string }>; 16 + searchParams: Promise<{ 17 + publication_uri: string; 18 + title: string; 19 + description: string; 20 + entitiesToDelete: string; 21 + }>; 16 22 }; 17 23 export default async function PublishLeafletPage(props: Props) { 18 24 let leaflet_id = (await props.params).leaflet_id; ··· 27 33 *, 28 34 documents_in_publications(count) 29 35 ), 30 - documents(*))`, 36 + documents(*)), 37 + leaflets_to_documents( 38 + *, 39 + documents(*) 40 + )`, 31 41 ) 32 42 .eq("id", leaflet_id) 33 43 .single(); 34 44 let rootEntity = data?.root_entity; 35 - if (!data || !rootEntity || !data.leaflets_in_publications[0]) 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) 36 66 return ( 37 67 <div> 38 68 missin something ··· 42 72 43 73 let identity = await getIdentityData(); 44 74 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 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" }); 48 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 + 49 107 return ( 50 108 <ReplicacheProvider 51 109 rootEntity={rootEntity} ··· 57 115 leaflet_id={leaflet_id} 58 116 root_entity={rootEntity} 59 117 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} 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} 65 125 /> 66 126 </ReplicacheProvider> 67 127 );
+80 -17
app/api/inngest/functions/index_post_mention.ts
··· 3 3 import { AtpAgent, AtUri } from "@atproto/api"; 4 4 import { Json } from "supabase/database.types"; 5 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"; 6 9 7 10 export const index_post_mention = inngest.createFunction( 8 11 { id: "index_post_mention" }, ··· 11 14 let url = new URL(event.data.document_link); 12 15 let path = url.pathname.split("/").filter(Boolean); 13 16 14 - let { data: pub, error } = await supabaseServerClient 15 - .from("publications") 16 - .select("*") 17 - .eq("record->>base_path", url.host) 18 - .single(); 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 + } 19 39 20 - if (!pub) { 21 - return { 22 - message: `No publication found for ${url.host}/${path[0]}`, 23 - error, 24 - }; 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; 25 63 } 26 64 27 65 let bsky_post = await step.run("get-bsky-post-data", async () => { ··· 38 76 } 39 77 40 78 await step.run("index-bsky-post", async () => { 41 - await supabaseServerClient.from("bsky_posts").insert({ 79 + await supabaseServerClient.from("bsky_posts").upsert({ 42 80 uri: bsky_post.uri, 43 81 cid: bsky_post.cid, 44 82 post_view: bsky_post as Json, 45 83 }); 46 - await supabaseServerClient.from("document_mentions_in_bsky").insert({ 84 + await supabaseServerClient.from("document_mentions_in_bsky").upsert({ 47 85 uri: bsky_post.uri, 48 - document: AtUri.make( 49 - pub.identity_did, 50 - ids.PubLeafletDocument, 51 - path[0], 52 - ).toString(), 86 + document: documentUri, 53 87 link: event.data.document_link, 54 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 + } 55 118 }); 56 119 }, 57 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 + }
+34 -5
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
··· 4 4 import { makeRoute } from "../lib"; 5 5 import type { Env } from "./route"; 6 6 import { scanIndexLocal } from "src/replicache/utils"; 7 - import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 8 7 import * as base64 from "base64-js"; 9 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 10 9 import { applyUpdate, Doc } from "yjs"; 11 10 12 11 export const getFactsFromHomeLeaflets = makeRoute({ ··· 35 34 let scan = scanIndexLocal(facts[token]); 36 35 let [root] = scan.eav(token, "root/page"); 37 36 let rootEntity = root?.data.value || token; 38 - let [title] = getBlocksWithTypeLocal(facts[token], rootEntity).filter( 39 - (b) => b.type === "text" || b.type === "heading", 40 - ); 37 + 38 + // Check page type to determine which blocks to look up 39 + let [pageType] = scan.eav(rootEntity, "page/type"); 40 + let isCanvas = pageType?.data.value === "canvas"; 41 + 42 + // Get blocks and sort by position 43 + let rawBlocks = isCanvas 44 + ? scan.eav(rootEntity, "canvas/block").sort((a, b) => { 45 + if (a.data.position.y === b.data.position.y) 46 + return a.data.position.x - b.data.position.x; 47 + return a.data.position.y - b.data.position.y; 48 + }) 49 + : scan.eav(rootEntity, "card/block").sort((a, b) => { 50 + if (a.data.position === b.data.position) 51 + return a.id > b.id ? 1 : -1; 52 + return a.data.position > b.data.position ? 1 : -1; 53 + }); 54 + 55 + // Map to get type and filter for text/heading 56 + let blocks = rawBlocks 57 + .map((b) => { 58 + let type = scan.eav(b.data.value, "block/type")[0]; 59 + if ( 60 + !type || 61 + (type.data.value !== "text" && type.data.value !== "heading") 62 + ) 63 + return null; 64 + return b.data; 65 + }) 66 + .filter((b): b is NonNullable<typeof b> => b !== null); 67 + 68 + let title = blocks[0]; 69 + 41 70 if (!title) titles[token] = "Untitled"; 42 71 else { 43 72 let [content] = scan.eav(title.value, "block/text");
+4 -2
app/api/rpc/[command]/get_leaflet_data.ts
··· 7 7 >; 8 8 9 9 const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`; 10 + const leaflets_to_documents_query = `leaflets_to_documents(*, documents(*))`; 10 11 export const get_leaflet_data = makeRoute({ 11 12 route: "get_leaflet_data", 12 13 input: z.object({ ··· 18 19 .from("permission_tokens") 19 20 .select( 20 21 `*, 21 - permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}))), 22 + permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}, ${leaflets_to_documents_query}))), 22 23 custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), 23 - ${leaflets_in_publications_query}`, 24 + ${leaflets_in_publications_query}, 25 + ${leaflets_to_documents_query}`, 24 26 ) 25 27 .eq("id", token_id) 26 28 .single();
+6
app/api/rpc/[command]/pull.ts
··· 73 73 let publication_data = data.publications as { 74 74 description: string; 75 75 title: string; 76 + tags: string[]; 76 77 }[]; 77 78 let pub_patch = publication_data?.[0] 78 79 ? [ ··· 85 86 op: "put", 86 87 key: "publication_title", 87 88 value: publication_data[0].title, 89 + }, 90 + { 91 + op: "put", 92 + key: "publication_tags", 93 + value: publication_data[0].tags || [], 88 94 }, 89 95 ] 90 96 : [];
+4
app/api/rpc/[command]/route.ts
··· 11 11 } from "./domain_routes"; 12 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 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"; 14 16 15 17 let supabase = createClient<Database>( 16 18 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 35 37 get_leaflet_subdomain_status, 36 38 get_leaflet_data, 37 39 get_publication_data, 40 + search_publication_names, 41 + search_publication_documents, 38 42 ]; 39 43 export async function POST( 40 44 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 + });
+14
app/globals.css
··· 211 211 212 212 /* END GLOBAL STYLING */ 213 213 } 214 + 215 + img { 216 + font-size: 0; 217 + } 218 + 214 219 button:hover { 215 220 cursor: pointer; 216 221 } ··· 291 296 @apply py-[1.5px]; 292 297 } 293 298 299 + /* Underline mention nodes when selected in ProseMirror */ 300 + .ProseMirror .atMention.ProseMirror-selectednode, 301 + .ProseMirror .didMention.ProseMirror-selectednode { 302 + text-decoration: underline; 303 + } 304 + 294 305 .ProseMirror:focus-within .selection-highlight { 295 306 background-color: transparent; 296 307 } ··· 339 350 @apply focus-within:outline-offset-1; 340 351 341 352 @apply disabled:border-border-light; 353 + @apply disabled:hover:border-border-light; 342 354 @apply disabled:bg-border-light; 343 355 @apply disabled:text-tertiary; 344 356 } ··· 413 425 outline: none !important; 414 426 cursor: pointer; 415 427 background-color: transparent; 428 + display: flex; 429 + gap: 0.5rem; 416 430 417 431 :hover { 418 432 text-decoration: none !important;
+36 -206
app/lish/Subscribe.tsx
··· 23 23 import { useSearchParams } from "next/navigation"; 24 24 import LoginForm from "app/login/LoginForm"; 25 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 - import { SpeedyLink } from "components/SpeedyLink"; 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 26 183 27 export const SubscribeWithBluesky = (props: { 184 - isPost?: boolean; 185 28 pubName: string; 186 29 pub_uri: string; 187 30 base_url: string; ··· 208 51 } 209 52 return ( 210 53 <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 54 <div className="flex flex-row gap-2 place-self-center"> 217 55 <BlueskySubscribeButton 218 56 pub_uri={props.pub_uri} ··· 231 69 ); 232 70 }; 233 71 234 - const ManageSubscription = (props: { 235 - isPost?: boolean; 236 - pubName: string; 72 + export const ManageSubscription = (props: { 237 73 pub_uri: string; 238 74 subscribers: { identity: string }[]; 239 75 base_url: string; ··· 248 84 }); 249 85 }, null); 250 86 return ( 251 - <div 252 - className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 87 + <Popover 88 + trigger={ 89 + <div className="text-accent-contrast text-sm">Manage Subscription</div> 90 + } 253 91 > 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> 92 + <div className="max-w-sm flex flex-col gap-1"> 93 + <h4>Update Options</h4> 267 94 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 - 95 + {!hasFeed && ( 280 96 <a 281 - href={`${props.base_url}/rss`} 282 - className="flex" 97 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 283 98 target="_blank" 284 - aria-label="Subscribe to RSS" 99 + className=" place-self-center" 285 100 > 286 - <ButtonPrimary fullWidth compact> 287 - Get RSS 101 + <ButtonPrimary fullWidth compact className="!px-4"> 102 + View Bluesky Custom Feed 288 103 </ButtonPrimary> 289 104 </a> 105 + )} 290 106 291 - <hr className="border-border-light my-1" /> 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> 292 117 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> 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> 301 127 ); 302 128 }; 303 129 ··· 430 256 </Dialog.Root> 431 257 ); 432 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 + }
+39 -3
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 1 import { UnicodeString } from "@atproto/api"; 2 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 4 + import { AtMentionLink } from "components/AtMentionLink"; 3 5 4 6 type Facet = PubLeafletRichtextFacet.Main; 5 7 export function BaseTextBlock(props: { ··· 22 24 let isStrikethrough = segment.facet?.find( 23 25 PubLeafletRichtextFacet.isStrikethrough, 24 26 ); 27 + let isDidMention = segment.facet?.find( 28 + PubLeafletRichtextFacet.isDidMention, 29 + ); 30 + let isAtMention = segment.facet?.find( 31 + PubLeafletRichtextFacet.isAtMention, 32 + ); 25 33 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 26 34 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 27 35 let isHighlighted = segment.facet?.find( ··· 36 44 ${isStrikethrough ? "line-through decoration-tertiary" : ""} 37 45 ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 38 46 47 + // Split text by newlines and insert <br> tags 48 + const textParts = segment.text.split('\n'); 49 + const renderedText = textParts.flatMap((part, i) => 50 + i < textParts.length - 1 ? [part, <br key={`br-${counter}-${i}`} />] : [part] 51 + ); 52 + 39 53 if (isCode) { 40 54 children.push( 41 55 <code key={counter} className={className} id={id?.id}> 42 - {segment.text} 56 + {renderedText} 43 57 </code>, 44 58 ); 59 + } else if (isDidMention) { 60 + children.push( 61 + <a 62 + key={counter} 63 + href={didToBlueskyUrl(isDidMention.did)} 64 + className={`text-accent-contrast hover:underline cursor-pointer ${className}`} 65 + target="_blank" 66 + rel="noopener noreferrer" 67 + > 68 + {renderedText} 69 + </a>, 70 + ); 71 + } else if (isAtMention) { 72 + children.push( 73 + <AtMentionLink 74 + key={counter} 75 + atURI={isAtMention.atURI} 76 + className={className} 77 + > 78 + {renderedText} 79 + </AtMentionLink>, 80 + ); 45 81 } else if (link) { 46 82 children.push( 47 83 <a ··· 50 86 className={`text-accent-contrast hover:underline ${className}`} 51 87 target="_blank" 52 88 > 53 - {segment.text} 89 + {renderedText} 54 90 </a>, 55 91 ); 56 92 } else { 57 93 children.push( 58 94 <span key={counter} className={className} id={id?.id}> 59 - {segment.text} 95 + {renderedText} 60 96 </span>, 61 97 ); 62 98 }
+25 -31
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 21 21 import { PostHeader } from "./PostHeader/PostHeader"; 22 22 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 23 import { PollData } from "./fetchPollData"; 24 + import { SharedPageProps } from "./PostPages"; 25 + import { useIsMobile } from "src/hooks/isMobile"; 24 26 25 27 export function CanvasPage({ 26 - document, 27 28 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 29 pages, 40 - }: { 41 - document_uri: string; 42 - document: PostPageData; 30 + ...props 31 + }: Omit<SharedPageProps, "allPages"> & { 43 32 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 33 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 55 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; 56 51 if (!document) return null; 57 52 58 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 59 53 let isSubpage = !!pageId; 60 54 let drawer = useDrawerOpen(document_uri); 61 55 ··· 213 207 quotesCount: number | undefined; 214 208 commentsCount: number | undefined; 215 209 }) => { 210 + let isMobile = useIsMobile(); 216 211 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"> 212 + <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"> 218 213 <Interactions 219 214 quotesCount={props.quotesCount || 0} 220 215 commentsCount={props.commentsCount || 0} 221 - compact 222 216 showComments={props.preferences.showComments} 223 217 pageId={props.pageId} 224 218 /> ··· 226 220 <> 227 221 <Separator classname="h-5" /> 228 222 <Popover 229 - side="left" 230 - align="start" 231 - className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 223 + side="bottom" 224 + align="end" 225 + className={`flex flex-col gap-2 p-0! text-primary ${isMobile ? "w-full" : "max-w-sm w-[1000px] t"}`} 232 226 trigger={<InfoSmall />} 233 227 > 234 228 <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 + }
+223 -11
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 8 8 import { EditorState, TextSelection } from "prosemirror-state"; 9 9 import { EditorView } from "prosemirror-view"; 10 10 import { history, redo, undo } from "prosemirror-history"; 11 + import { InputRule, inputRules } from "prosemirror-inputrules"; 11 12 import { 12 13 MutableRefObject, 13 14 RefObject, 15 + useCallback, 14 16 useEffect, 15 17 useLayoutEffect, 16 18 useRef, ··· 36 38 import { CloseTiny } from "components/Icons/CloseTiny"; 37 39 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 38 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 + }; 39 83 40 84 export function CommentBox(props: { 41 85 doc_uri: string; ··· 50 94 commentBox: { quote }, 51 95 } = useInteractionState(props.doc_uri); 52 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; 53 112 54 - const handleSubmit = async () => { 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 () => { 55 159 if (loading || !view.current) return; 56 160 57 161 setLoading(true); ··· 114 218 "Mod-y": redo, 115 219 "Shift-Mod-z": redo, 116 220 "Ctrl-Enter": () => { 117 - handleSubmit(); 221 + handleSubmitRef.current(); 118 222 return true; 119 223 }, 120 224 "Meta-Enter": () => { 121 - handleSubmit(); 225 + handleSubmitRef.current(); 122 226 return true; 123 227 }, 124 228 }), ··· 128 232 shouldAutoLink: () => true, 129 233 defaultProtocol: "https", 130 234 }), 235 + // Input rules for @ mentions 236 + inputRules({ 237 + rules: [ 238 + // @ at start of line or after space 239 + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 240 + setTimeout(() => openMentionAutocompleteRef.current(), 0); 241 + return null; 242 + }), 243 + ], 244 + }), 131 245 history(), 132 246 ], 133 247 }), 134 248 ); 135 - let view = useRef<null | EditorView>(null); 136 249 useLayoutEffect(() => { 137 250 if (!mountRef.current) return; 138 251 view.current = new EditorView( ··· 187 300 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 188 301 if (!direct) return; 189 302 if (node.nodeSize - 2 <= _pos) return; 303 + 304 + const nodeAt1 = node.nodeAt(_pos - 1); 305 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 306 + 307 + // Check for link marks 190 308 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); 309 + nodeAt1?.marks.find( 310 + (f) => f.type === multiBlockSchema.marks.link, 311 + ) || 312 + nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link); 197 313 if (mark) { 198 314 window.open(mark.attrs.href, "_blank"); 315 + return; 316 + } 317 + 318 + // Check for didMention inline nodes 319 + if (nodeAt1?.type === multiBlockSchema.nodes.didMention) { 320 + window.open( 321 + didToBlueskyUrl(nodeAt1.attrs.did), 322 + "_blank", 323 + "noopener,noreferrer", 324 + ); 325 + return; 326 + } 327 + if (nodeAt2?.type === multiBlockSchema.nodes.didMention) { 328 + window.open( 329 + didToBlueskyUrl(nodeAt2.attrs.did), 330 + "_blank", 331 + "noopener,noreferrer", 332 + ); 333 + return; 334 + } 335 + 336 + // Check for atMention inline nodes (publications/documents) 337 + if (nodeAt1?.type === multiBlockSchema.nodes.atMention) { 338 + window.open( 339 + atUriToUrl(nodeAt1.attrs.atURI), 340 + "_blank", 341 + "noopener,noreferrer", 342 + ); 343 + return; 344 + } 345 + if (nodeAt2?.type === multiBlockSchema.nodes.atMention) { 346 + window.open( 347 + atUriToUrl(nodeAt2.attrs.atURI), 348 + "_blank", 349 + "noopener,noreferrer", 350 + ); 351 + return; 199 352 } 200 353 }, 201 354 dispatchTransaction(tr) { ··· 236 389 <div className="w-full relative group"> 237 390 <pre 238 391 ref={mountRef} 392 + onFocus={() => { 393 + // Close mention dropdown when editor gains focus (reset stale state) 394 + handleMentionOpenChange(false); 395 + }} 396 + onBlur={(e) => { 397 + // Close mention dropdown when editor loses focus 398 + // But not if focus moved to the mention autocomplete 399 + const relatedTarget = e.relatedTarget as HTMLElement | null; 400 + if (!relatedTarget?.closest(".dropdownMenu")) { 401 + handleMentionOpenChange(false); 402 + } 403 + }} 239 404 className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`} 240 405 /> 241 406 <IOSBS view={view} /> 407 + <MentionAutocomplete 408 + open={mentionOpen} 409 + onOpenChange={handleMentionOpenChange} 410 + view={view} 411 + onSelect={handleMentionSelect} 412 + coords={mentionCoords} 413 + /> 242 414 </div> 243 415 <div className="flex justify-between pt-1"> 244 416 <div className="flex gap-1"> ··· 261 433 view={view} 262 434 /> 263 435 </div> 264 - <ButtonPrimary compact onClick={handleSubmit}> 436 + <ButtonPrimary compact onClick={() => handleSubmitRef.current()}> 265 437 {loading ? <DotLoader /> : <ShareSmall />} 266 438 </ButtonPrimary> 267 439 </div> ··· 328 500 facets.push(facet); 329 501 } 330 502 } 503 + 504 + fullText += text; 505 + byteOffset += unicodeString.length; 506 + } else if (node.type.name === "didMention") { 507 + // Handle DID mention nodes 508 + const text = node.attrs.text || ""; 509 + const unicodeString = new UnicodeString(text); 510 + 511 + facets.push({ 512 + index: { 513 + byteStart: byteOffset, 514 + byteEnd: byteOffset + unicodeString.length, 515 + }, 516 + features: [ 517 + { 518 + $type: "pub.leaflet.richtext.facet#didMention", 519 + did: node.attrs.did, 520 + }, 521 + ], 522 + }); 523 + 524 + fullText += text; 525 + byteOffset += unicodeString.length; 526 + } else if (node.type.name === "atMention") { 527 + // Handle AT-URI mention nodes (publications and documents) 528 + const text = node.attrs.text || ""; 529 + const unicodeString = new UnicodeString(text); 530 + 531 + facets.push({ 532 + index: { 533 + byteStart: byteOffset, 534 + byteEnd: byteOffset + unicodeString.length, 535 + }, 536 + features: [ 537 + { 538 + $type: "pub.leaflet.richtext.facet#atMention", 539 + atURI: node.attrs.atURI, 540 + }, 541 + ], 542 + }); 331 543 332 544 fullText += text; 333 545 byteOffset += unicodeString.length;
+98 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 10 10 import { Json } from "supabase/database.types"; 11 11 import { 12 12 Notification, 13 + NotificationData, 13 14 pingIdentityToUpdateNotification, 14 15 } from "src/notifications"; 15 16 import { v7 } from "uuid"; ··· 84 85 parent_uri: args.comment.replyTo, 85 86 }, 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) { 87 100 // SOMEDAY: move this out the action with inngest or workflows 88 101 await supabaseServerClient.from("notifications").insert(notifications); 89 - await pingIdentityToUpdateNotification(recipient); 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 + ); 90 108 } 91 109 92 110 return { ··· 95 113 uri: uri.toString(), 96 114 }; 97 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 + }
+208 -30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 9 9 import { useContext } from "react"; 10 10 import { PostPageContext } from "../PostPageContext"; 11 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"; 12 15 import { PostPageData } from "../getPostPageData"; 13 - import { PubLeafletComment } from "lexicons/api"; 16 + import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 14 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"; 15 22 16 23 export type InteractionState = { 17 24 drawerOpen: undefined | boolean; ··· 99 106 export const Interactions = (props: { 100 107 quotesCount: number; 101 108 commentsCount: number; 102 - compact?: boolean; 103 109 className?: string; 104 110 showComments?: boolean; 105 111 pageId?: string; 106 112 }) => { 107 113 const data = useContext(PostPageContext); 108 114 const document_uri = data?.uri; 115 + let { identity } = useIdentityData(); 109 116 if (!document_uri) 110 117 throw new Error("document_uri not available in PostPageContext"); 111 118 ··· 117 124 } 118 125 }; 119 126 127 + const tags = (data?.data as any)?.tags as string[] | undefined; 128 + const tagCount = tags?.length || 0; 129 + 120 130 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> 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 + )} 142 149 {props.showComments === false ? null : ( 143 150 <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"}`} 151 + className="flex gap-2 items-center w-fit" 145 152 onClick={() => { 146 153 if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 147 154 openInteractionDrawer("comments", document_uri, props.pageId); ··· 149 156 }} 150 157 aria-label="Post comments" 151 158 > 152 - <CommentTiny aria-hidden /> {props.commentsCount}{" "} 153 - {!props.compact && ( 154 - <span 155 - aria-hidden 156 - >{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span> 157 - )} 159 + <CommentTiny aria-hidden /> {props.commentsCount} 158 160 </button> 159 161 )} 160 162 </div> 161 163 ); 162 164 }; 163 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 + }; 164 322 export function getQuoteCount(document: PostPageData, pageId?: string) { 165 323 if (!document) return; 166 324 return getQuoteCountFromArray(document.quotesAndMentions, pageId); ··· 198 356 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 199 357 ).length; 200 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 -66
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 11 11 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 12 import { EditTiny } from "components/Icons/EditTiny"; 13 13 import { 14 + ExpandedInteractions, 14 15 getCommentCount, 15 16 getQuoteCount, 16 17 Interactions, ··· 23 24 import { PageWrapper } from "components/Pages/Page"; 24 25 import { decodeQuotePosition } from "./quotePosition"; 25 26 import { PollData } from "./fetchPollData"; 27 + import { SharedPageProps } from "./PostPages"; 26 28 27 29 export function LinearDocumentPage({ 28 - document, 29 30 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; 31 + ...props 32 + }: Omit<SharedPageProps, "allPages"> & { 44 33 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 34 }) { 56 - let { identity } = useIdentityData(); 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; 57 51 let drawer = useDrawerOpen(document_uri); 58 52 59 - if (!document || !document.documents_in_publications[0].publications) 60 - return null; 53 + if (!document) return null; 61 54 62 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 63 55 let record = document.data as PubLeafletDocument.Record; 64 56 65 57 const isSubpage = !!pageId; ··· 92 84 did={did} 93 85 prerenderedCodeBlocks={prerenderedCodeBlocks} 94 86 /> 95 - <Interactions 87 + 88 + <ExpandedInteractions 96 89 pageId={pageId} 97 90 showComments={preferences.showComments} 98 91 commentsCount={getCommentCount(document, pageId) || 0} 99 92 quotesCount={getQuoteCount(document, pageId) || 0} 100 93 /> 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 - )} 94 + {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 137 95 </PageWrapper> 138 96 </> 139 97 );
+11 -8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 59 59 return ( 60 60 <div 61 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}`} 62 + className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`} 63 63 > 64 64 {blocks.map((b, index) => { 65 65 return ( ··· 293 293 } 294 294 case PubLeafletBlocksImage.isMain(b.block): { 295 295 return ( 296 - <div className={`relative flex ${alignment}`} {...blockProps}> 296 + <div 297 + className={`imageBlock relative flex ${alignment}`} 298 + {...blockProps} 299 + > 297 300 <img 298 301 alt={b.block.alt} 299 302 height={b.block.aspectRatio?.height} ··· 321 324 return ( 322 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. 323 326 <blockquote 324 - className={` blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 327 + className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 325 328 {...blockProps} 326 329 > 327 330 <TextBlock ··· 336 339 } 337 340 case PubLeafletBlocksText.isMain(b.block): 338 341 return ( 339 - <p className={` ${className}`} {...blockProps}> 342 + <p className={`textBlock ${className}`} {...blockProps}> 340 343 <TextBlock 341 344 facets={b.block.facets} 342 345 plaintext={b.block.plaintext} ··· 349 352 case PubLeafletBlocksHeader.isMain(b.block): { 350 353 if (b.block.level === 1) 351 354 return ( 352 - <h2 className={`${className}`} {...blockProps}> 355 + <h2 className={`h1Block ${className}`} {...blockProps}> 353 356 <TextBlock 354 357 {...b.block} 355 358 index={index} ··· 360 363 ); 361 364 if (b.block.level === 2) 362 365 return ( 363 - <h3 className={`${className}`} {...blockProps}> 366 + <h3 className={`h2Block ${className}`} {...blockProps}> 364 367 <TextBlock 365 368 {...b.block} 366 369 index={index} ··· 371 374 ); 372 375 if (b.block.level === 3) 373 376 return ( 374 - <h4 className={`${className}`} {...blockProps}> 377 + <h4 className={`h3Block ${className}`} {...blockProps}> 375 378 <TextBlock 376 379 {...b.block} 377 380 index={index} ··· 383 386 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 384 387 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 385 388 return ( 386 - <h6 className={`${className}`} {...blockProps}> 389 + <h6 className={`h6Block ${className}`} {...blockProps}> 387 390 <TextBlock 388 391 {...b.block} 389 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 - // };
+75 -52
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 16 16 import { EditTiny } from "components/Icons/EditTiny"; 17 17 import { SpeedyLink } from "components/SpeedyLink"; 18 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"; 19 21 20 22 export function PostHeader(props: { 21 23 data: PostPageData; ··· 27 29 28 30 let record = document?.data as PubLeafletDocument.Record; 29 31 let profile = props.profile; 30 - let pub = props.data?.documents_in_publications[0].publications; 31 - let pubRecord = pub?.record as PubLeafletPublication.Record; 32 + let pub = props.data?.documents_in_publications[0]?.publications; 32 33 33 34 const formattedDate = useLocalizedDate( 34 35 record.publishedAt || new Date().toISOString(), ··· 36 37 year: "numeric", 37 38 month: "long", 38 39 day: "2-digit", 39 - } 40 + }, 40 41 ); 41 42 42 - if (!document?.data || !document.documents_in_publications[0].publications) 43 - return; 43 + if (!document?.data) return; 44 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> 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 + )} 62 56 {identity && 63 - identity.atp_did === 64 - document.documents_in_publications[0]?.publications 65 - .identity_did && 57 + pub && 58 + identity.atp_did === pub.identity_did && 66 59 document.leaflets_in_publications[0] && ( 67 60 <a 68 61 className=" rounded-full flex place-items-center" ··· 71 64 <EditTiny className="shrink-0" /> 72 65 </a> 73 66 )} 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 - |{" "} 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> 98 91 <Interactions 99 92 showComments={props.preferences.showComments} 100 - compact 101 93 quotesCount={getQuoteCount(document) || 0} 102 94 commentsCount={getCommentCount(document) || 0} 103 95 /> 104 - </div> 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 + <p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1"> 123 + {props.postDescription} 124 + </p> 125 + ) : null} 126 + <div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between"> 127 + {props.postInfo} 105 128 </div> 106 129 </div> 107 130 ); 108 - } 131 + };
+110 -75
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 98 98 }; 99 99 }); 100 100 101 + // Shared props type for both page components 102 + export type SharedPageProps = { 103 + document: PostPageData; 104 + did: string; 105 + profile: ProfileViewDetailed; 106 + preferences: { showComments?: boolean }; 107 + pubRecord?: PubLeafletPublication.Record; 108 + theme?: PubLeafletPublication.Theme | null; 109 + prerenderedCodeBlocks?: Map<string, string>; 110 + bskyPostData: AppBskyFeedDefs.PostView[]; 111 + pollData: PollData[]; 112 + document_uri: string; 113 + fullPageScroll: boolean; 114 + hasPageBackground: boolean; 115 + pageId?: string; 116 + pageOptions?: React.ReactNode; 117 + allPages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 118 + }; 119 + 120 + // Component that renders either Canvas or Linear page based on page type 121 + function PageRenderer({ 122 + page, 123 + ...sharedProps 124 + }: { 125 + page: PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main; 126 + } & SharedPageProps) { 127 + const isCanvas = PubLeafletPagesCanvas.isMain(page); 128 + 129 + if (isCanvas) { 130 + return ( 131 + <CanvasPage 132 + {...sharedProps} 133 + blocks={(page as PubLeafletPagesCanvas.Main).blocks || []} 134 + pages={sharedProps.allPages} 135 + /> 136 + ); 137 + } 138 + 139 + return ( 140 + <LinearDocumentPage 141 + {...sharedProps} 142 + blocks={(page as PubLeafletPagesLinearDocument.Main).blocks || []} 143 + /> 144 + ); 145 + } 146 + 101 147 export function PostPages({ 102 148 document, 103 - blocks, 104 149 did, 105 150 profile, 106 151 preferences, ··· 112 157 }: { 113 158 document_uri: string; 114 159 document: PostPageData; 115 - blocks: PubLeafletPagesLinearDocument.Block[]; 116 160 profile: ProfileViewDetailed; 117 - pubRecord: PubLeafletPublication.Record; 161 + pubRecord?: PubLeafletPublication.Record; 118 162 did: string; 119 163 prerenderedCodeBlocks?: Map<string, string>; 120 164 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 123 167 }) { 124 168 let drawer = useDrawerOpen(document_uri); 125 169 useInitializeOpenPages(); 126 - let pages = useOpenPages(); 127 - if (!document || !document.documents_in_publications[0].publications) 128 - return null; 170 + let openPageIds = useOpenPages(); 171 + if (!document) return null; 129 172 130 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 131 173 let record = document.data as PubLeafletDocument.Record; 174 + let theme = pubRecord?.theme || record.theme || null; 175 + // For publication posts, respect the publication's showPageBackground setting 176 + // For standalone documents, default to showing page background 177 + let isInPublication = !!pubRecord; 178 + let hasPageBackground = isInPublication ? !!theme?.showPageBackground : true; 132 179 let quotesAndMentions = document.quotesAndMentions; 133 180 134 - let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; 181 + let firstPage = record.pages[0] as 182 + | PubLeafletPagesLinearDocument.Main 183 + | PubLeafletPagesCanvas.Main; 184 + 185 + // Canvas pages don't support fullPageScroll well due to fixed 1272px width 186 + let firstPageIsCanvas = PubLeafletPagesCanvas.isMain(firstPage); 187 + 188 + // Shared props used for all pages 189 + const sharedProps: SharedPageProps = { 190 + document, 191 + did, 192 + profile, 193 + preferences, 194 + pubRecord, 195 + theme, 196 + prerenderedCodeBlocks, 197 + bskyPostData, 198 + pollData, 199 + document_uri, 200 + hasPageBackground, 201 + allPages: record.pages as ( 202 + | PubLeafletPagesLinearDocument.Main 203 + | PubLeafletPagesCanvas.Main 204 + )[], 205 + fullPageScroll: 206 + !hasPageBackground && 207 + !drawer && 208 + openPageIds.length === 0 && 209 + !firstPageIsCanvas, 210 + }; 211 + 135 212 return ( 136 213 <> 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 - /> 214 + {!sharedProps.fullPageScroll && <BookendSpacer />} 215 + 216 + <PageRenderer page={firstPage} {...sharedProps} /> 151 217 152 218 {drawer && !drawer.pageId && ( 153 219 <InteractionDrawer 154 220 document_uri={document.uri} 155 221 comments={ 156 - pubRecord.preferences?.showComments === false 222 + pubRecord?.preferences?.showComments === false 157 223 ? [] 158 224 : document.comments_on_documents 159 225 } ··· 162 228 /> 163 229 )} 164 230 165 - {pages.map((p) => { 231 + {openPageIds.map((pageId) => { 166 232 let page = record.pages.find( 167 - (page) => 233 + (p) => 168 234 ( 169 - page as 235 + p as 170 236 | PubLeafletPagesLinearDocument.Main 171 237 | PubLeafletPagesCanvas.Main 172 - ).id === p, 238 + ).id === pageId, 173 239 ) as 174 240 | PubLeafletPagesLinearDocument.Main 175 241 | PubLeafletPagesCanvas.Main 176 242 | undefined; 177 - if (!page) return null; 178 243 179 - const isCanvas = PubLeafletPagesCanvas.isMain(page); 244 + if (!page) return null; 180 245 181 246 return ( 182 - <Fragment key={p}> 247 + <Fragment key={pageId}> 183 248 <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 - )} 249 + <PageRenderer 250 + page={page} 251 + {...sharedProps} 252 + fullPageScroll={false} 253 + pageId={page.id} 254 + pageOptions={ 255 + <PageOptions 256 + onClick={() => closePage(page.id!)} 257 + hasPageBackground={hasPageBackground} 258 + /> 259 + } 260 + /> 227 261 {drawer && drawer.pageId === page.id && ( 228 262 <InteractionDrawer 229 263 pageId={page.id} 230 264 document_uri={document.uri} 231 265 comments={ 232 - pubRecord.preferences?.showComments === false 266 + pubRecord?.preferences?.showComments === false 233 267 ? [] 234 268 : document.comments_on_documents 235 269 } ··· 240 274 </Fragment> 241 275 ); 242 276 })} 243 - {!fullPageScroll && <BookendSpacer />} 277 + 278 + {!sharedProps.fullPageScroll && <BookendSpacer />} 244 279 </> 245 280 ); 246 281 }
+4 -5
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 106 106 <div className="grow"> 107 107 {title && ( 108 108 <div 109 - className={`pageBlockOne outline-none resize-none align-top flex gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 109 + className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 110 110 > 111 111 <TextBlock 112 112 facets={title.facets} ··· 118 118 )} 119 119 {description && ( 120 120 <div 121 - className={`pageBlockLineTwo outline-none resize-none align-top flex gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 121 + className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 122 122 > 123 123 <TextBlock 124 124 facets={description.facets} ··· 151 151 let previewRef = useRef<HTMLDivElement | null>(null); 152 152 let { rootEntity } = useReplicache(); 153 153 let data = useContext(PostPageContext); 154 - let theme = data?.documents_in_publications[0]?.publications 155 - ?.record as PubLeafletPublication.Record; 154 + let theme = data?.theme; 156 155 let pageWidth = `var(--page-width-unitless)`; 157 - let cardBorderHidden = !theme.theme?.showPageBackground; 156 + let cardBorderHidden = !theme?.showPageBackground; 158 157 return ( 159 158 <div 160 159 ref={previewRef}
+3 -2
app/lish/[did]/[publication]/[rkey]/extractCodeBlocks.ts
··· 1 1 import { 2 2 PubLeafletDocument, 3 3 PubLeafletPagesLinearDocument, 4 + PubLeafletPagesCanvas, 4 5 PubLeafletBlocksCode, 5 6 } from "lexicons/api"; 6 7 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 7 8 8 9 export async function extractCodeBlocks( 9 - blocks: PubLeafletPagesLinearDocument.Block[], 10 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[], 10 11 ): Promise<Map<string, string>> { 11 12 const codeBlocks = new Map<string, string>(); 12 13 13 - // Process all pages in the document 14 + // Process all blocks (works for both linear and canvas) 14 15 for (let i = 0; i < blocks.length; i++) { 15 16 const block = blocks[i]; 16 17 const currentIndex = [i];
+12 -3
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletPublication } from "lexicons/api"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 4 5 5 export async function getPostPageData(uri: string) { 6 6 let { data: document } = await supabaseServerClient ··· 23 23 // Fetch constellation backlinks for mentions 24 24 const pubRecord = document.documents_in_publications[0]?.publications 25 25 ?.record as PubLeafletPublication.Record; 26 - const rkey = new AtUri(uri).rkey; 27 - const postUrl = `https://${pubRecord?.base_path}/${rkey}`; 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}`; 28 30 const constellationBacklinks = await getConstellationBacklinks(postUrl); 29 31 30 32 // Deduplicate constellation backlinks (same post could appear in both links and embeds) ··· 43 45 ...uniqueBacklinks, 44 46 ]; 45 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 + 46 54 return { 47 55 ...document, 48 56 quotesAndMentions, 57 + theme, 49 58 }; 50 59 } 51 60
+4 -3
app/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts
··· 5 5 export const revalidate = 60; 6 6 7 7 export default async function OpenGraphImage(props: { 8 - params: { publication: string; did: string; rkey: string; quote: string }; 8 + params: Promise<{ publication: string; did: string; rkey: string; quote: string }>; 9 9 }) { 10 - let quotePosition = decodeQuotePosition(props.params.quote); 10 + let params = await props.params; 11 + let quotePosition = decodeQuotePosition(params.quote); 11 12 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 + `/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/l-quote/${params.quote}#${quotePosition?.pageId ? `${quotePosition.pageId}~` : ""}${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`, 13 14 { 14 15 width: 620, 15 16 height: 324,
+3 -2
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
··· 4 4 export const revalidate = 60; 5 5 6 6 export default async function OpenGraphImage(props: { 7 - params: { publication: string; did: string; rkey: string }; 7 + params: Promise<{ publication: string; did: string; rkey: string }>; 8 8 }) { 9 + let params = await props.params; 9 10 return getMicroLinkOgImage( 10 - `/lish/${decodeURIComponent(props.params.did)}/${decodeURIComponent(props.params.publication)}/${props.params.rkey}/`, 11 + `/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`, 11 12 ); 12 13 }
+14 -156
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { ids } from "lexicons/api/lexicons"; 4 - import { 5 - PubLeafletBlocksBskyPost, 6 - PubLeafletDocument, 7 - PubLeafletPagesLinearDocument, 8 - PubLeafletPublication, 9 - } from "lexicons/api"; 4 + import { PubLeafletDocument } from "lexicons/api"; 10 5 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"; 6 + import { DocumentPageRenderer } from "./DocumentPageRenderer"; 24 7 25 8 export async function generateMetadata(props: { 26 9 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 42 25 43 26 return { 44 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 + }, 45 36 other: { 46 37 rel: "alternate", 47 38 url: document.uri, ··· 57 48 export default async function Post(props: { 58 49 params: Promise<{ publication: string; did: string; rkey: string }>; 59 50 }) { 60 - let did = decodeURIComponent((await props.params).did); 51 + let params = await props.params; 52 + let did = decodeURIComponent(params.did); 53 + 61 54 if (!did) 62 55 return ( 63 56 <div className="p-4 text-lg text-center flex flex-col gap-4"> ··· 68 61 </p> 69 62 </div> 70 63 ); 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 64 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 - ); 65 + return <DocumentPageRenderer did={did} rkey={params.rkey} />; 208 66 }
+3 -1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 26 26 cardBorderHidden={!props.showPageBackground} 27 27 leaflets={leaflets_in_publications 28 28 .filter((l) => !l.documents) 29 + .filter((l) => !l.archived) 29 30 .map((l) => { 30 31 return { 32 + archived: l.archived, 33 + added_at: "", 31 34 token: { 32 35 ...l.permission_tokens!, 33 36 leaflets_in_publications: [ ··· 39 42 }, 40 43 ], 41 44 }, 42 - added_at: "", 43 45 }; 44 46 })} 45 47 initialFacts={pub_data.leaflet_data.facts || {}}
+22 -2
app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
··· 2 2 3 3 import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 4 4 import { callRPC } from "app/api/rpc/client"; 5 - import { createContext, useContext } from "react"; 6 - import useSWR, { SWRConfig } from "swr"; 5 + import { createContext, useContext, useEffect } from "react"; 6 + import useSWR, { SWRConfig, KeyedMutator, mutate } from "swr"; 7 + import { produce, Draft } from "immer"; 8 + 9 + export type PublicationData = GetPublicationDataReturnType["result"]; 7 10 8 11 const PublicationContext = createContext({ name: "", did: "" }); 9 12 export function PublicationSWRDataProvider(props: { ··· 13 16 children: React.ReactNode; 14 17 }) { 15 18 let key = `publication-data-${props.publication_did}-${props.publication_rkey}`; 19 + useEffect(() => { 20 + console.log("UPDATING"); 21 + mutate(key, props.publication_data); 22 + }, [props.publication_data]); 16 23 return ( 17 24 <PublicationContext 18 25 value={{ name: props.publication_rkey, did: props.publication_did }} ··· 41 48 ); 42 49 return { data, mutate }; 43 50 } 51 + 52 + export function mutatePublicationData( 53 + mutate: KeyedMutator<PublicationData>, 54 + recipe: (draft: Draft<NonNullable<PublicationData>>) => void, 55 + ) { 56 + mutate( 57 + (data) => { 58 + if (!data) return data; 59 + return produce(data, recipe); 60 + }, 61 + { revalidate: false }, 62 + ); 63 + }
+131 -136
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument } from "lexicons/api"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 4 import { EditTiny } from "components/Icons/EditTiny"; 5 5 6 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 13 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 14 14 import { DeleteSmall } from "components/Icons/DeleteSmall"; 15 15 import { ShareSmall } from "components/Icons/ShareSmall"; 16 - import { ShareButton } from "components/ShareOptions"; 16 + import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; 17 17 import { SpeedyLink } from "components/SpeedyLink"; 18 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 + import { InteractionPreview } from "components/InteractionsPreview"; 20 21 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 22 + import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 23 + import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 21 24 22 25 export function PublishedPostsList(props: { 23 26 searchValue: string; ··· 26 29 let { data } = usePublicationData(); 27 30 let params = useParams(); 28 31 let { publication } = data!; 32 + let pubRecord = publication?.record as PubLeafletPublication.Record; 33 + 29 34 if (!publication) return null; 30 35 if (publication.documents_in_publications.length === 0) 31 36 return ( ··· 53 58 (l) => doc.documents && l.doc === doc.documents.uri, 54 59 ); 55 60 let uri = new AtUri(doc.documents.uri); 56 - let record = doc.documents.data as PubLeafletDocument.Record; 61 + let postRecord = doc.documents.data as PubLeafletDocument.Record; 57 62 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 58 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}` 68 + : ""; 59 69 60 70 return ( 61 71 <Fragment key={doc.documents?.uri}> ··· 75 85 href={`${getPublicationURL(publication)}/${uri.rkey}`} 76 86 > 77 87 <h3 className="text-primary grow leading-snug"> 78 - {record.title} 88 + {postRecord.title} 79 89 </h3> 80 90 </a> 81 91 <div className="flex justify-start align-top flex-row gap-1"> 82 - {leaflet && ( 83 - <SpeedyLink 84 - className="pt-[6px]" 85 - href={`/${leaflet.leaflet}`} 86 - > 87 - <EditTiny /> 88 - </SpeedyLink> 92 + {leaflet && leaflet.permission_tokens && ( 93 + <> 94 + <SpeedyLink 95 + className="pt-[6px]" 96 + href={`/${leaflet.leaflet}`} 97 + > 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 + </> 89 125 )} 90 - <Options document_uri={doc.documents.uri} /> 91 126 </div> 92 127 </div> 93 128 94 - {record.description ? ( 129 + {postRecord.description ? ( 95 130 <p className="italic text-secondary"> 96 - {record.description} 131 + {postRecord.description} 97 132 </p> 98 133 ) : null} 99 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3"> 100 - {record.publishedAt ? ( 101 - <PublishedDate dateString={record.publishedAt} /> 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} /> 102 137 ) : null} 103 - {(comments > 0 || quotes > 0) && record.publishedAt 104 - ? " | " 105 - : ""} 106 - {quotes > 0 && ( 107 - <SpeedyLink 108 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 109 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 110 - > 111 - <QuoteTiny /> {quotes} 112 - </SpeedyLink> 113 - )} 114 - {comments > 0 && quotes > 0 ? " " : ""} 115 - {comments > 0 && ( 116 - <SpeedyLink 117 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 118 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 119 - > 120 - <CommentTiny /> {comments} 121 - </SpeedyLink> 122 - )} 138 + <InteractionPreview 139 + quotesCount={quotes} 140 + commentsCount={comments} 141 + tags={tags} 142 + showComments={pubRecord?.preferences?.showComments} 143 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 + /> 123 145 </div> 124 146 </div> 125 147 </div> ··· 133 155 ); 134 156 } 135 157 136 - let Options = (props: { document_uri: string }) => { 137 - return ( 138 - <Menu 139 - align="end" 140 - alignOffset={20} 141 - asChild 142 - trigger={ 143 - <button className="text-secondary rounded-md selected-outline border-transparent! hover:border-border! h-min"> 144 - <MoreOptionsVerticalTiny /> 145 - </button> 146 - } 147 - > 148 - <> 149 - <OptionsMenu document_uri={props.document_uri} /> 150 - </> 151 - </Menu> 152 - ); 153 - }; 158 + // function OptionsMenu(props: { document_uri: string }) { 159 + // let { mutate, data } = usePublicationData(); 160 + // let [state, setState] = useState<"normal" | "confirm">("normal"); 154 161 155 - function OptionsMenu(props: { document_uri: string }) { 156 - let { mutate, data } = usePublicationData(); 157 - let [state, setState] = useState<"normal" | "confirm">("normal"); 162 + // if (state === "normal") { 163 + // return ( 164 + // <> 165 + // <ShareButton 166 + // className="justify-end" 167 + // text={ 168 + // <div className="flex gap-2"> 169 + // Share Post Link 170 + // <ShareSmall /> 171 + // </div> 172 + // } 173 + // subtext="" 174 + // smokerText="Post link copied!" 175 + // id="get-post-link" 176 + // fullLink={postLink?.includes("https") ? postLink : undefined} 177 + // link={postLink} 178 + // /> 158 179 159 - let postLink = data?.publication 160 - ? `${getPublicationURL(data?.publication)}/${new AtUri(props.document_uri).rkey}` 161 - : null; 162 - 163 - if (state === "normal") { 164 - return ( 165 - <> 166 - <ShareButton 167 - className="justify-end" 168 - text={ 169 - <div className="flex gap-2"> 170 - Share Post Link 171 - <ShareSmall /> 172 - </div> 173 - } 174 - subtext="" 175 - smokerText="Post link copied!" 176 - id="get-post-link" 177 - fullLink={postLink?.includes("https") ? postLink : undefined} 178 - link={postLink} 179 - /> 180 - 181 - <hr className="border-border-light" /> 182 - <MenuItem 183 - className="justify-end" 184 - onSelect={async (e) => { 185 - e.preventDefault(); 186 - setState("confirm"); 187 - return; 188 - }} 189 - > 190 - Delete Post 191 - <DeleteSmall /> 192 - </MenuItem> 193 - </> 194 - ); 195 - } 196 - if (state === "confirm") { 197 - return ( 198 - <div className="flex flex-col items-center font-bold text-secondary px-2 py-1"> 199 - Are you sure? 200 - <div className="text-sm text-tertiary font-normal"> 201 - This action cannot be undone! 202 - </div> 203 - <ButtonPrimary 204 - className="mt-2" 205 - onClick={async () => { 206 - await mutate((data) => { 207 - if (!data) return data; 208 - return { 209 - ...data, 210 - publication: { 211 - ...data.publication!, 212 - leaflets_in_publications: 213 - data.publication?.leaflets_in_publications.filter( 214 - (l) => l.doc !== props.document_uri, 215 - ) || [], 216 - documents_in_publications: 217 - data.publication?.documents_in_publications.filter( 218 - (d) => d.documents?.uri !== props.document_uri, 219 - ) || [], 220 - }, 221 - }; 222 - }, false); 223 - await deletePost(props.document_uri); 224 - }} 225 - > 226 - Delete 227 - </ButtonPrimary> 228 - </div> 229 - ); 230 - } 231 - } 180 + // <hr className="border-border-light" /> 181 + // <MenuItem 182 + // className="justify-end" 183 + // onSelect={async (e) => { 184 + // e.preventDefault(); 185 + // setState("confirm"); 186 + // return; 187 + // }} 188 + // > 189 + // Delete Post 190 + // <DeleteSmall /> 191 + // </MenuItem> 192 + // </> 193 + // ); 194 + // } 195 + // if (state === "confirm") { 196 + // return ( 197 + // <div className="flex flex-col items-center font-bold text-secondary px-2 py-1"> 198 + // Are you sure? 199 + // <div className="text-sm text-tertiary font-normal"> 200 + // This action cannot be undone! 201 + // </div> 202 + // <ButtonPrimary 203 + // className="mt-2" 204 + // onClick={async () => { 205 + // await mutate((data) => { 206 + // if (!data) return data; 207 + // return { 208 + // ...data, 209 + // publication: { 210 + // ...data.publication!, 211 + // leaflets_in_publications: 212 + // data.publication?.leaflets_in_publications.filter( 213 + // (l) => l.doc !== props.document_uri, 214 + // ) || [], 215 + // documents_in_publications: 216 + // data.publication?.documents_in_publications.filter( 217 + // (d) => d.documents?.uri !== props.document_uri, 218 + // ) || [], 219 + // }, 220 + // }; 221 + // }, false); 222 + // await deletePost(props.document_uri); 223 + // }} 224 + // > 225 + // Delete 226 + // </ButtonPrimary> 227 + // </div> 228 + // ); 229 + // } 230 + //} 232 231 233 232 function PublishedDate(props: { dateString: string }) { 234 233 const formattedDate = useLocalizedDate(props.dateString, { ··· 237 236 day: "2-digit", 238 237 }); 239 238 240 - return ( 241 - <p className="text-sm text-tertiary"> 242 - Published {formattedDate} 243 - </p> 244 - ); 239 + return <p className="text-sm text-tertiary">Published {formattedDate}</p>; 245 240 }
+23
app/lish/[did]/[publication]/dashboard/deletePost.ts
··· 30 30 .delete() 31 31 .eq("doc", document_uri), 32 32 ]); 33 + 34 + return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 35 + } 36 + 37 + export async function unpublishPost(document_uri: string) { 38 + let identity = await getIdentityData(); 39 + if (!identity || !identity.atp_did) throw new Error("No Identity"); 40 + 41 + const oauthClient = await createOauthClient(); 42 + let credentialSession = await oauthClient.restore(identity.atp_did); 43 + let agent = new AtpBaseClient( 44 + credentialSession.fetchHandler.bind(credentialSession), 45 + ); 46 + let uri = new AtUri(document_uri); 47 + if (uri.host !== identity.atp_did) return; 48 + 49 + await Promise.all([ 50 + agent.pub.leaflet.document.delete({ 51 + repo: credentialSession.did, 52 + rkey: uri.rkey, 53 + }), 54 + supabaseServerClient.from("documents").delete().eq("uri", document_uri), 55 + ]); 33 56 return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 34 57 }
+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 47 title: pubRecord?.name || "Untitled Publication", 48 48 description: pubRecord?.description || "", 49 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 + }, 50 58 other: { 51 59 rel: "alternate", 52 60 url: publication.uri,
+3 -2
app/lish/[did]/[publication]/opengraph-image.ts
··· 4 4 export const revalidate = 60; 5 5 6 6 export default async function OpenGraphImage(props: { 7 - params: { publication: string; did: string }; 7 + params: Promise<{ publication: string; did: string }>; 8 8 }) { 9 + let params = await props.params; 9 10 return getMicroLinkOgImage( 10 - `/lish/${encodeURIComponent(props.params.did)}/${encodeURIComponent(props.params.publication)}/`, 11 + `/lish/${encodeURIComponent(params.did)}/${encodeURIComponent(params.publication)}/`, 11 12 ); 12 13 }
+108 -120
app/lish/[did]/[publication]/page.tsx
··· 14 14 import { SpeedyLink } from "components/SpeedyLink"; 15 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { InteractionPreview } from "components/InteractionsPreview"; 17 18 import { LocalizedDate } from "./LocalizedDate"; 19 + import { PublicationHomeLayout } from "./PublicationHomeLayout"; 18 20 19 21 export default async function Publication(props: { 20 22 params: Promise<{ publication: string; did: string }>; ··· 59 61 try { 60 62 return ( 61 63 <PublicationThemeProvider 62 - record={record} 64 + theme={record?.theme} 63 65 pub_creator={publication.identity_did} 64 66 > 65 67 <PublicationBackgroundProvider 66 - record={record} 68 + theme={record?.theme} 67 69 pub_creator={publication.identity_did} 68 70 > 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 + <PublicationHomeLayout 72 + uri={publication.uri} 73 + showPageBackground={!!showPageBackground} 71 74 > 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}{" "} 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> 92 102 </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> 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 + /> 112 111 </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; 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) || []; 140 139 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> 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> 153 152 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> 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 + /> 186 174 </div> 187 - <hr className="last:hidden border-border-light" /> 188 - </React.Fragment> 189 - ); 190 - })} 191 - </div> 175 + </div> 176 + <hr className="last:hidden border-border-light" /> 177 + </React.Fragment> 178 + ); 179 + })} 192 180 </div> 193 - </div> 181 + </PublicationHomeLayout> 194 182 </PublicationBackgroundProvider> 195 183 </PublicationThemeProvider> 196 184 );
+6 -7
app/lish/createPub/CreatePubForm.tsx
··· 127 127 onChange={(e) => setShowInDiscover(e.target.checked)} 128 128 > 129 129 <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 130 - <p className="font-bold italic"> 131 - Show In{" "} 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{" "} 132 133 <a href="/discover" target="_blank"> 133 134 Discover 134 - </a> 135 - </p> 136 - <p className="text-sm text-tertiary font-normal"> 137 - You'll be able to change this later! 135 + </a>{" "} 136 + page. You can change this at any time! 138 137 </p> 139 138 </div> 140 139 </Checkbox> 141 140 <hr className="border-border-light" /> 142 141 143 - <div className="flex w-full justify-center"> 142 + <div className="flex w-full justify-end"> 144 143 <ButtonPrimary 145 144 type="submit" 146 145 disabled={
+5 -2
app/lish/createPub/UpdatePubForm.tsx
··· 66 66 if (!pubData) return; 67 67 e.preventDefault(); 68 68 props.setLoadingAction(true); 69 - console.log("step 1:update"); 70 69 let data = await updatePublication({ 71 70 uri: pubData.uri, 72 71 name: nameValue, ··· 171 170 </a> 172 171 </p> 173 172 <p className="text-xs text-tertiary font-normal"> 174 - This publication will appear on our public Discover page 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! 175 178 </p> 176 179 </div> 177 180 </Checkbox>
+1
app/lish/createPub/createPublication.ts
··· 101 101 await supabaseServerClient 102 102 .from("custom_domains") 103 103 .insert({ domain, confirmed: true, identity: null }); 104 + 104 105 await supabaseServerClient 105 106 .from("publication_domains") 106 107 .insert({ domain, publication: result.uri, identity: identity.atp_did });
+3 -11
app/lish/createPub/getPublicationURL.ts
··· 3 3 import { isProductionDomain } from "src/utils/isProductionDeployment"; 4 4 import { Json } from "supabase/database.types"; 5 5 6 - export function getPublicationURL(pub: { 7 - uri: string; 8 - name: string; 9 - record: Json; 10 - }) { 6 + export function getPublicationURL(pub: { uri: string; record: Json }) { 11 7 let record = pub.record as PubLeafletPublication.Record; 12 8 if (isProductionDomain() && record?.base_path) 13 9 return `https://${record.base_path}`; 14 10 else return getBasePublicationURL(pub); 15 11 } 16 12 17 - export function getBasePublicationURL(pub: { 18 - uri: string; 19 - name: string; 20 - record: Json; 21 - }) { 13 + export function getBasePublicationURL(pub: { uri: string; record: Json }) { 22 14 let record = pub.record as PubLeafletPublication.Record; 23 15 let aturi = new AtUri(pub.uri); 24 - return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name || pub.name)}`; 16 + return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`; 25 17 }
+1 -1
app/lish/createPub/page.tsx
··· 26 26 <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto"> 27 27 <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 28 28 <h2 className="text-center">Create Your Publication!</h2> 29 - <div className="container w-full p-3"> 29 + <div className="opaque-container w-full sm:py-4 p-3"> 30 30 <CreatePubForm /> 31 31 </div> 32 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 213 </ButtonPrimary> 214 214 <button 215 215 type="button" 216 - className={`${props.compact ? "text-xs" : "text-sm"} text-accent-contrast place-self-center mt-[6px]`} 216 + className={`${props.compact ? "text-xs mt-0.5" : "text-sm mt-[6px]"} text-accent-contrast place-self-center`} 217 217 onClick={() => setSigningWithHandle(true)} 218 218 > 219 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 + }
+90
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 + 9 + export async function generateMetadata(props: { 10 + params: Promise<{ didOrHandle: string; rkey: string }>; 11 + }): Promise<Metadata> { 12 + let params = await props.params; 13 + let didOrHandle = decodeURIComponent(params.didOrHandle); 14 + 15 + // Resolve handle to DID if necessary 16 + let did = didOrHandle; 17 + if (!didOrHandle.startsWith("did:")) { 18 + try { 19 + let resolved = await idResolver.handle.resolve(didOrHandle); 20 + if (resolved) did = resolved; 21 + } catch (e) { 22 + return { title: "404" }; 23 + } 24 + } 25 + 26 + let { data: document } = await supabaseServerClient 27 + .from("documents") 28 + .select("*, documents_in_publications(publications(*))") 29 + .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) 30 + .single(); 31 + 32 + if (!document) return { title: "404" }; 33 + 34 + let docRecord = document.data as PubLeafletDocument.Record; 35 + 36 + // For documents in publications, include publication name 37 + let publicationName = document.documents_in_publications[0]?.publications?.name; 38 + 39 + return { 40 + icons: { 41 + other: { 42 + rel: "alternate", 43 + url: document.uri, 44 + }, 45 + }, 46 + title: publicationName 47 + ? `${docRecord.title} - ${publicationName}` 48 + : docRecord.title, 49 + description: docRecord?.description || "", 50 + }; 51 + } 52 + 53 + export default async function StandaloneDocumentPage(props: { 54 + params: Promise<{ didOrHandle: string; rkey: string }>; 55 + }) { 56 + let params = await props.params; 57 + let didOrHandle = decodeURIComponent(params.didOrHandle); 58 + 59 + // Resolve handle to DID if necessary 60 + let did = didOrHandle; 61 + if (!didOrHandle.startsWith("did:")) { 62 + try { 63 + let resolved = await idResolver.handle.resolve(didOrHandle); 64 + if (!resolved) { 65 + return ( 66 + <div className="p-4 text-lg text-center flex flex-col gap-4"> 67 + <p>Sorry, can&apos;t resolve handle.</p> 68 + <p> 69 + This may be a glitch on our end. If the issue persists please{" "} 70 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 71 + </p> 72 + </div> 73 + ); 74 + } 75 + did = resolved; 76 + } catch (e) { 77 + return ( 78 + <div className="p-4 text-lg text-center flex flex-col gap-4"> 79 + <p>Sorry, can&apos;t resolve handle.</p> 80 + <p> 81 + This may be a glitch on our end. If the issue persists please{" "} 82 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 83 + </p> 84 + </div> 85 + ); 86 + } 87 + } 88 + 89 + return <DocumentPageRenderer did={did} rkey={params.rkey} />; 90 + }
-159
app/templates/TemplateList.tsx
··· 1 - "use client"; 2 - 3 - import { ButtonPrimary } from "components/Buttons"; 4 - import Image from "next/image"; 5 - import Link from "next/link"; 6 - import { createNewLeafletFromTemplate } from "actions/createNewLeafletFromTemplate"; 7 - import { AddTiny } from "components/Icons/AddTiny"; 8 - 9 - export function LeafletTemplate(props: { 10 - title: string; 11 - description?: string; 12 - image: string; 13 - alt: string; 14 - templateID: string; // readonly id for the leaflet that will be duplicated 15 - }) { 16 - return ( 17 - <div className="flex flex-col gap-4"> 18 - <div className="flex flex-col gap-2"> 19 - <div className="max-w-[274px] h-[154px] relative"> 20 - <Image 21 - className="absolute top-0 left-0 rounded-md w-full h-full object-cover" 22 - src={props.image} 23 - alt={props.alt} 24 - width={274} 25 - height={154} 26 - /> 27 - </div> 28 - </div> 29 - <div className={`flex flex-col ${props.description ? "gap-4" : "gap-2"}`}> 30 - <div className="gap-0"> 31 - <h3 className="font-bold text-center text-secondary"> 32 - {props.title} 33 - </h3> 34 - {props.description && ( 35 - <div className="text-tertiary text-sm font-normal text-center"> 36 - {props.description} 37 - </div> 38 - )} 39 - </div> 40 - <div className="flex sm:flex-row flex-col gap-2 justify-center items-center bottom-4"> 41 - <Link 42 - href={`https://leaflet.pub/` + props.templateID} 43 - target="_blank" 44 - className="no-underline hover:no-underline" 45 - > 46 - <ButtonPrimary className="bg-primary hover:outline-hidden! hover:scale-105 hover:rotate-3 transition-all"> 47 - Preview 48 - </ButtonPrimary> 49 - </Link> 50 - <ButtonPrimary 51 - className=" hover:outline-hidden! hover:scale-105 hover:-rotate-2 transition-all" 52 - onClick={async () => { 53 - let id = await createNewLeafletFromTemplate( 54 - props.templateID, 55 - false, 56 - ); 57 - window.open(`/${id}`, "_blank"); 58 - }} 59 - > 60 - Create 61 - <AddTiny /> 62 - </ButtonPrimary> 63 - </div> 64 - </div> 65 - </div> 66 - ); 67 - } 68 - 69 - export function TemplateList(props: { 70 - name: string; 71 - description?: string; 72 - children: React.ReactNode; 73 - }) { 74 - return ( 75 - <div className="templateLeafletGrid flex flex-col gap-6"> 76 - <div className="flex flex-col gap-0 text-center"> 77 - <h3 className="text-[24px]">{props.name}</h3> 78 - <p className="text-secondary">{props.description}</p> 79 - </div> 80 - <div className="grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-8 gap-x-6 sm:gap-6 grow pb-8"> 81 - {props.children} 82 - </div> 83 - </div> 84 - ); 85 - } 86 - 87 - export function TemplateListThemes() { 88 - return ( 89 - <> 90 - <TemplateList 91 - name="Themes" 92 - description="A small sampling of Leaflet's infinite theme possibilities!" 93 - > 94 - <LeafletTemplate 95 - title="Foliage" 96 - image="/templates/template-foliage-548x308.jpg" 97 - alt="preview image of Foliage theme, with lots of green and leafy bg" 98 - templateID="e4323c1d-15c1-407d-afaf-e5d772a35f0e" 99 - /> 100 - <LeafletTemplate 101 - title="Lunar" 102 - image="/templates/template-lunar-548x308.jpg" 103 - alt="preview image of Lunar theme, with dark grey, red, and moon bg" 104 - templateID="219d14ab-096c-4b48-83ee-36446e335c3e" 105 - /> 106 - <LeafletTemplate 107 - title="Paper" 108 - image="/templates/template-paper-548x308.jpg" 109 - alt="preview image of Paper theme, with red, gold, green and marbled paper bg" 110 - templateID="9b28ceea-0220-42ac-87e6-3976d156f653" 111 - /> 112 - <LeafletTemplate 113 - title="Oceanic" 114 - image="/templates/template-oceanic-548x308.jpg" 115 - alt="preview image of Oceanic theme, with dark and light blue and ocean bg" 116 - templateID="a65a56d7-713d-437e-9c42-f18bdc6fe2a7" 117 - /> 118 - </TemplateList> 119 - </> 120 - ); 121 - } 122 - 123 - export function TemplateListExamples() { 124 - return ( 125 - <TemplateList 126 - name="Examples" 127 - description="Creative documents you can make and share with Leaflet" 128 - > 129 - <LeafletTemplate 130 - title="Reading List" 131 - description="Make a list for your own reading, or share recs with friends!" 132 - image="/templates/template-reading-548x308.jpg" 133 - alt="preview image of Reading List template, with a few sections and example books as sub-pages" 134 - templateID="a5655b68-fe7a-4494-bda6-c9847523b2f6" 135 - /> 136 - <LeafletTemplate 137 - title="Travel Plan" 138 - description="Organize a trip โ€” notes, logistics, itinerary, even a shared scrapbook" 139 - image="/templates/template-travel-548x308.jpg" 140 - alt="preview image of a Travel Plan template, with pages for itinerary, logistics, research, and a travel diary canvas" 141 - templateID="4d6f1392-dfd3-4015-925d-df55b7da5566" 142 - /> 143 - <LeafletTemplate 144 - title="Gift Guide" 145 - description="Share your favorite things โ€” products, restaurants, moviesโ€ฆ" 146 - image="/templates/template-gift-548x308.jpg" 147 - alt="preview image for a Gift Guide template, with three blank canvases for different categories" 148 - templateID="de73df29-35d9-4a43-a441-7ce45ad3b498" 149 - /> 150 - <LeafletTemplate 151 - title="Event Page" 152 - description="Host an event โ€” from a single meetup, to a whole conference!" 153 - image="/templates/template-event-548x308.jpg" 154 - alt="preview image for an Event Page template, with an event info section and linked pages / canvases for more info" 155 - templateID="23d8a4ec-b2f6-438a-933d-726d2188974d" 156 - /> 157 - </TemplateList> 158 - ); 159 - }
-108
app/templates/icon.tsx
··· 1 - // NOTE: duplicated from home/icon.tsx 2 - // we could make it different so it's clear it's not your personal colors? 3 - 4 - import { ImageResponse } from "next/og"; 5 - import type { Fact } from "src/replicache"; 6 - import type { Attribute } from "src/replicache/attributes"; 7 - import { Database } from "../../supabase/database.types"; 8 - import { createServerClient } from "@supabase/ssr"; 9 - import { parseHSBToRGB } from "src/utils/parseHSB"; 10 - import { cookies } from "next/headers"; 11 - 12 - // Route segment config 13 - export const revalidate = 0; 14 - export const preferredRegion = ["sfo1"]; 15 - export const dynamic = "force-dynamic"; 16 - export const fetchCache = "force-no-store"; 17 - 18 - // Image metadata 19 - export const size = { 20 - width: 32, 21 - height: 32, 22 - }; 23 - export const contentType = "image/png"; 24 - 25 - // Image generation 26 - let supabase = createServerClient<Database>( 27 - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 28 - process.env.SUPABASE_SERVICE_ROLE_KEY as string, 29 - { cookies: {} }, 30 - ); 31 - export default async function Icon() { 32 - let cookieStore = await cookies(); 33 - let identity = cookieStore.get("identity"); 34 - let rootEntity: string | null = null; 35 - if (identity) { 36 - let res = await supabase 37 - .from("identities") 38 - .select( 39 - `*, 40 - permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), 41 - permission_token_on_homepage( 42 - *, permission_tokens(*, permission_token_rights(*)) 43 - ) 44 - `, 45 - ) 46 - .eq("id", identity?.value) 47 - .single(); 48 - rootEntity = res.data?.permission_tokens?.root_entity || null; 49 - } 50 - let outlineColor, fillColor; 51 - if (rootEntity) { 52 - let { data } = await supabase.rpc("get_facts", { 53 - root: rootEntity, 54 - }); 55 - let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 56 - let themePageBG = initialFacts.find( 57 - (f) => f.attribute === "theme/card-background", 58 - ) as Fact<"theme/card-background"> | undefined; 59 - 60 - let themePrimary = initialFacts.find( 61 - (f) => f.attribute === "theme/primary", 62 - ) as Fact<"theme/primary"> | undefined; 63 - 64 - outlineColor = parseHSBToRGB(`hsba(${themePageBG?.data.value})`); 65 - 66 - fillColor = parseHSBToRGB(`hsba(${themePrimary?.data.value})`); 67 - } 68 - 69 - return new ImageResponse( 70 - ( 71 - // ImageResponse JSX element 72 - <div style={{ display: "flex" }}> 73 - <svg 74 - width="32" 75 - height="32" 76 - viewBox="0 0 32 32" 77 - fill="none" 78 - xmlns="http://www.w3.org/2000/svg" 79 - > 80 - {/* outline */} 81 - <path 82 - fillRule="evenodd" 83 - clipRule="evenodd" 84 - d="M3.09628 21.8809C2.1044 23.5376 1.19806 25.3395 0.412496 27.2953C-0.200813 28.8223 0.539843 30.5573 2.06678 31.1706C3.59372 31.7839 5.32873 31.0433 5.94204 29.5163C6.09732 29.1297 6.24696 28.7489 6.39151 28.3811L6.39286 28.3777C6.94334 26.9769 7.41811 25.7783 7.99246 24.6987C8.63933 24.6636 9.37895 24.6582 10.2129 24.6535L10.3177 24.653C11.8387 24.6446 13.6711 24.6345 15.2513 24.3147C16.8324 23.9947 18.789 23.2382 19.654 21.2118C19.8881 20.6633 20.1256 19.8536 19.9176 19.0311C19.98 19.0311 20.044 19.031 20.1096 19.031C20.1447 19.031 20.1805 19.0311 20.2169 19.0311C21.0513 19.0316 22.2255 19.0324 23.2752 18.7469C24.5 18.4137 25.7878 17.6248 26.3528 15.9629C26.557 15.3624 26.5948 14.7318 26.4186 14.1358C26.4726 14.1262 26.528 14.1165 26.5848 14.1065C26.6121 14.1018 26.6398 14.0969 26.6679 14.092C27.3851 13.9667 28.3451 13.7989 29.1653 13.4921C29.963 13.1936 31.274 12.5268 31.6667 10.9987C31.8906 10.1277 31.8672 9.20568 31.3642 8.37294C31.1551 8.02669 30.889 7.75407 30.653 7.55302C30.8728 7.27791 31.1524 6.89517 31.345 6.47292C31.6791 5.74032 31.8513 4.66394 31.1679 3.61078C30.3923 2.4155 29.0623 2.2067 28.4044 2.1526C27.7203 2.09635 26.9849 2.15644 26.4564 2.2042C26.3846 2.02839 26.2858 1.84351 26.1492 1.66106C25.4155 0.681263 24.2775 0.598914 23.6369 0.61614C22.3428 0.650943 21.3306 1.22518 20.5989 1.82076C20.2149 2.13334 19.8688 2.48545 19.5698 2.81786C18.977 2.20421 18.1625 1.90193 17.3552 1.77751C15.7877 1.53594 14.5082 2.58853 13.6056 3.74374C12.4805 5.18375 11.7295 6.8566 10.7361 8.38059C10.3814 8.14984 9.83685 7.89945 9.16529 7.93065C8.05881 7.98204 7.26987 8.73225 6.79424 9.24551C5.96656 10.1387 5.46273 11.5208 5.10424 12.7289C4.71615 14.0368 4.38077 15.5845 4.06569 17.1171C3.87054 18.0664 3.82742 18.5183 4.01638 20.2489C3.43705 21.1826 3.54993 21.0505 3.09628 21.8809Z" 85 - fill={outlineColor ? outlineColor : "#FFFFFF"} 86 - /> 87 - 88 - {/* fill */} 89 - <path 90 - fillRule="evenodd" 91 - clipRule="evenodd" 92 - d="M9.86889 10.2435C10.1927 10.528 10.5723 10.8615 11.3911 10.5766C11.9265 10.3903 12.6184 9.17682 13.3904 7.82283C14.5188 5.84367 15.8184 3.56431 17.0505 3.7542C18.5368 3.98325 18.4453 4.80602 18.3749 5.43886C18.3255 5.88274 18.2866 6.23317 18.8098 6.21972C19.3427 6.20601 19.8613 5.57971 20.4632 4.8529C21.2945 3.84896 22.2847 2.65325 23.6906 2.61544C24.6819 2.58879 24.6663 3.01595 24.6504 3.44913C24.6403 3.72602 24.63 4.00537 24.8826 4.17024C25.1314 4.33266 25.7571 4.2759 26.4763 4.21065C27.6294 4.10605 29.023 3.97963 29.4902 4.6995C29.9008 5.33235 29.3776 5.96135 28.8762 6.56423C28.4514 7.07488 28.0422 7.56679 28.2293 8.02646C28.3819 8.40149 28.6952 8.61278 29.0024 8.81991C29.5047 9.15866 29.9905 9.48627 29.7297 10.5009C29.4539 11.5737 27.7949 11.8642 26.2398 12.1366C24.937 12.3647 23.7072 12.5801 23.4247 13.2319C23.2475 13.6407 23.5414 13.8311 23.8707 14.0444C24.2642 14.2992 24.7082 14.5869 24.4592 15.3191C23.8772 17.031 21.9336 17.031 20.1095 17.0311C18.5438 17.0311 17.0661 17.0311 16.6131 18.1137C16.3515 18.7387 16.7474 18.849 17.1818 18.9701C17.7135 19.1183 18.3029 19.2826 17.8145 20.4267C16.8799 22.6161 13.3934 22.6357 10.2017 22.6536C9.03136 22.6602 7.90071 22.6665 6.95003 22.7795C6.84152 22.7924 6.74527 22.8547 6.6884 22.948C5.81361 24.3834 5.19318 25.9622 4.53139 27.6462C4.38601 28.0162 4.23862 28.3912 4.08611 28.7709C3.88449 29.2729 3.31413 29.5163 2.81217 29.3147C2.31021 29.1131 2.06673 28.5427 2.26834 28.0408C3.01927 26.1712 3.88558 24.452 4.83285 22.8739C6.37878 20.027 9.42621 16.5342 12.6488 13.9103C15.5162 11.523 18.2544 9.73614 21.4413 8.38026C21.8402 8.21054 21.7218 7.74402 21.3053 7.86437C18.4789 8.68119 15.9802 10.3013 13.3904 11.9341C10.5735 13.71 8.21288 16.1115 6.76027 17.8575C6.50414 18.1653 5.94404 17.9122 6.02468 17.5199C6.65556 14.4512 7.30668 11.6349 8.26116 10.605C9.16734 9.62708 9.47742 9.8995 9.86889 10.2435Z" 93 - fill={fillColor ? fillColor : "#272727"} 94 - /> 95 - </svg> 96 - </div> 97 - ), 98 - // ImageResponse options 99 - { 100 - // For convenience, we can re-use the exported icons size metadata 101 - // config to also set the ImageResponse's width and height. 102 - ...size, 103 - headers: { 104 - "Cache-Control": "no-cache", 105 - }, 106 - }, 107 - ); 108 - }
-29
app/templates/page.tsx
··· 1 - import Link from "next/link"; 2 - import { TemplateListExamples, TemplateListThemes } from "./TemplateList"; 3 - import { ActionButton } from "components/ActionBar/ActionButton"; 4 - import { HomeSmall } from "components/Icons/HomeSmall"; 5 - 6 - export const metadata = { 7 - title: "Leaflet Templates", 8 - description: "example themes and documents you can use!", 9 - }; 10 - 11 - export default function Templates() { 12 - return ( 13 - <div className="flex h-full bg-bg-leaflet"> 14 - <div className="home relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col-reverse px-4 sm:px-6 "> 15 - <div className="homeOptions z-10 shrink-0 sm:static absolute bottom-0 place-self-end sm:place-self-start flex sm:flex-col flex-row-reverse gap-2 sm:w-fit w-full items-center pb-2 pt-1 sm:pt-7"> 16 - {/* NOT using <HomeButton /> b/c it does a permission check we don't need */} 17 - <Link href="/home"> 18 - <ActionButton icon={<HomeSmall />} label="Go Home" /> 19 - </Link> 20 - </div> 21 - <div className="flex flex-col gap-10 py-6 pt-3 sm:pt-6 sm:pb-12 sm:pl-6 grow w-full h-full overflow-y-scroll no-scrollbar"> 22 - <h1 className="text-center">Template Library</h1> 23 - <TemplateListThemes /> 24 - <TemplateListExamples /> 25 - </div> 26 - </div> 27 - </div> 28 - ); 29 - }
+20 -17
appview/index.ts
··· 104 104 data: record.value as Json, 105 105 }); 106 106 if (docResult.error) console.log(docResult.error); 107 - let publicationURI = new AtUri(record.value.publication); 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()); 108 125 109 - if (publicationURI.host !== evt.uri.host) { 110 - console.log("Unauthorized to create post!"); 111 - return; 126 + if (docInPublicationResult.error) 127 + console.log(docInPublicationResult.error); 112 128 } 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 129 } 127 130 if (evt.event === "delete") { 128 131 await supabase.from("documents").delete().eq("uri", evt.uri.toString());
+1 -1
components/ActionBar/ActionButton.tsx
··· 3 3 import { useContext, useEffect } from "react"; 4 4 import { SidebarContext } from "./Sidebar"; 5 5 import React, { forwardRef, type JSX } from "react"; 6 - import { PopoverOpenContext } from "components/Popover"; 6 + import { PopoverOpenContext } from "components/Popover/PopoverContext"; 7 7 8 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9 9
+15 -12
components/ActionBar/Navigation.tsx
··· 18 18 import { SpeedyLink } from "components/SpeedyLink"; 19 19 import { Separator } from "components/Layout"; 20 20 21 - export type navPages = "home" | "reader" | "pub" | "discover" | "notifications"; 21 + export type navPages = 22 + | "home" 23 + | "reader" 24 + | "pub" 25 + | "discover" 26 + | "notifications" 27 + | "looseleafs" 28 + | "tag"; 22 29 23 30 export const DesktopNavigation = (props: { 24 31 currentPage: navPages; ··· 47 54 publication?: string; 48 55 }) => { 49 56 let { identity } = useIdentityData(); 50 - let thisPublication = identity?.publications?.find( 51 - (pub) => pub.uri === props.publication, 52 - ); 57 + 53 58 return ( 54 59 <div className="flex gap-1 "> 55 60 <Popover ··· 100 105 <DiscoverButton current={props.currentPage === "discover"} /> 101 106 102 107 <hr className="border-border-light my-1" /> 103 - <PublicationButtons currentPubUri={thisPublication?.uri} /> 108 + <PublicationButtons 109 + currentPage={props.currentPage} 110 + currentPubUri={thisPublication?.uri} 111 + /> 104 112 </> 105 113 ); 106 114 }; ··· 119 127 }; 120 128 121 129 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 122 - let readerUnreads = false; 123 - 124 130 if (!props.subs) return; 125 131 return ( 126 132 <SpeedyLink href={"/reader"} className="hover:no-underline!"> 127 133 <ActionButton 128 134 nav 129 - icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 135 + icon={<ReaderUnreadSmall />} 130 136 label="Reader" 131 - className={` 132 - ${readerUnreads && "text-accent-contrast!"} 133 - ${props.current && "border-accent-contrast!"} 134 - `} 137 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 135 138 /> 136 139 </SpeedyLink> 137 140 );
+49 -24
components/ActionBar/Publications.tsx
··· 12 12 import { PublishSmall } from "components/Icons/PublishSmall"; 13 13 import { Popover } from "components/Popover"; 14 14 import { BlueskyLogin } from "app/login/LoginForm"; 15 - import { ButtonPrimary } from "components/Buttons"; 15 + import { ButtonSecondary } from "components/Buttons"; 16 16 import { useIsMobile } from "src/hooks/isMobile"; 17 17 import { useState } from "react"; 18 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 19 + import { navPages } from "./Navigation"; 18 20 19 21 export const PublicationButtons = (props: { 22 + currentPage: navPages; 20 23 currentPubUri: string | undefined; 21 24 }) => { 22 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 + ); 23 31 24 32 // don't show pub list button if not logged in or no pub list 25 33 // we show a "start a pub" banner instead 26 34 if (!identity || !identity.atp_did || identity.publications.length === 0) 27 35 return <PubListEmpty />; 36 + 28 37 return ( 29 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 + 30 62 {identity.publications?.map((d) => { 31 63 return ( 32 64 <PublicationOption 33 65 {...d} 34 66 key={d.uri} 35 67 record={d.record} 36 - asActionButton 37 68 current={d.uri === props.currentPubUri} 38 69 /> 39 70 ); ··· 52 83 uri: string; 53 84 name: string; 54 85 record: Json; 55 - asActionButton?: boolean; 56 86 current?: boolean; 57 87 }) => { 58 88 let record = props.record as PubLeafletPublication.Record | null; ··· 63 93 href={`${getBasePublicationURL(props)}/dashboard`} 64 94 className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 65 95 > 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 - )} 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 + /> 79 102 </SpeedyLink> 80 103 ); 81 104 }; 82 105 83 106 const PubListEmpty = () => { 84 - let { identity } = useIdentityData(); 85 107 let isMobile = useIsMobile(); 86 108 87 109 let [state, setState] = useState<"default" | "info">("default"); ··· 98 120 /> 99 121 ); 100 122 101 - if (isMobile && state === "info") return <PublishPopoverContent />; 123 + if (isMobile && state === "info") return <PubListEmptyContent />; 102 124 else 103 125 return ( 104 126 <Popover 105 127 side="right" 106 128 align="start" 107 129 className="p-1! max-w-56" 130 + asChild 108 131 trigger={ 109 132 <ActionButton 110 133 label="Publish" ··· 114 137 /> 115 138 } 116 139 > 117 - <PublishPopoverContent /> 140 + <PubListEmptyContent /> 118 141 </Popover> 119 142 ); 120 143 }; 121 144 122 - const PublishPopoverContent = () => { 145 + export const PubListEmptyContent = (props: { compact?: boolean }) => { 123 146 let { identity } = useIdentityData(); 124 147 125 148 return ( 126 - <div className="bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm"> 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 + > 127 152 <div className="mx-auto pt-2 scale-90"> 128 153 <PubListEmptyIllo /> 129 154 </div> ··· 136 161 on AT Proto 137 162 </div> 138 163 <SpeedyLink href={`lish/createPub`} className=" hover:no-underline!"> 139 - <ButtonPrimary className="text-sm mx-auto" compact> 164 + <ButtonSecondary className="text-sm mx-auto" compact> 140 165 Start a Publication! 141 - </ButtonPrimary> 166 + </ButtonSecondary> 142 167 </SpeedyLink> 143 168 </> 144 169 ) : ( 145 170 // no ATProto account and no pubs 146 171 <> 147 172 <div className="pb-2 text-secondary text-xs"> 148 - Link a Bluesky account to start a new publication on AT Proto 173 + Link a Bluesky account to start <br /> a new publication on AT Proto 149 174 </div> 150 175 151 176 <BlueskyLogin compact />
+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-5 h-5 rounded-full mr-1 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={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`} 41 + > 42 + {icon} 43 + {children} 44 + </a> 45 + ); 46 + }
+43 -6
components/Blocks/BaseTextareaBlock.tsx
··· 5 5 import { BlockProps } from "./Block"; 6 6 import { getCoordinatesInTextarea } from "src/utils/getCoordinatesInTextarea"; 7 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"; 8 13 9 - export function BaseTextareaBlock( 10 - props: AutosizeTextareaProps & { 11 - block: Pick<BlockProps, "previousBlock" | "nextBlock">; 12 - }, 13 - ) { 14 - let { block, ...passDownProps } = props; 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; 15 25 return ( 16 26 <AsyncValueAutosizeTextarea 17 27 {...passDownProps} 18 28 noWrap 19 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 + 20 57 if (e.key === "ArrowUp") { 21 58 let selection = e.currentTarget.selectionStart; 22 59
+4 -2
components/Blocks/BlockCommandBar.tsx
··· 37 37 const clearCommandSearchText = () => { 38 38 if (!props.entityID) return; 39 39 const entityID = props.entityID; 40 - 40 + 41 41 const existingState = useEditorStates.getState().editorStates[entityID]; 42 42 if (!existingState) return; 43 43 ··· 69 69 setHighlighted(commandResults[0].name); 70 70 } 71 71 }, [commandResults, setHighlighted, highlighted]); 72 + 72 73 useEffect(() => { 73 74 let listener = async (e: KeyboardEvent) => { 74 75 let reverseDir = ref.current?.dataset.side === "top"; ··· 118 119 return; 119 120 } 120 121 }; 122 + 121 123 window.addEventListener("keydown", listener); 122 124 123 125 return () => window.removeEventListener("keydown", listener); ··· 200 202 201 203 return ( 202 204 <button 203 - className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`} 205 + className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`} 204 206 onMouseOver={() => { 205 207 props.setHighlighted(props.name); 206 208 }}
+13 -4
components/Blocks/BlockCommands.tsx
··· 2 2 import { useUIState } from "src/useUIState"; 3 3 4 4 import { generateKeyBetween } from "fractional-indexing"; 5 - import { focusPage } from "components/Pages"; 5 + import { focusPage } from "src/utils/focusPage"; 6 6 import { v7 } from "uuid"; 7 7 import { Replicache } from "replicache"; 8 8 import { useEditorStates } from "src/state/useEditorState"; 9 9 import { elementId } from "src/utils/elementId"; 10 10 import { UndoManager } from "src/undoManager"; 11 11 import { focusBlock } from "src/utils/focusBlock"; 12 - import { usePollBlockUIState } from "./PollBlock"; 13 - import { focusElement } from "components/Input"; 12 + import { usePollBlockUIState } from "./PollBlock/pollBlockState"; 13 + import { focusElement } from "src/utils/focusElement"; 14 14 import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; 15 15 import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; 16 16 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; ··· 32 32 import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 33 33 import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 34 34 import { QuoteSmall } from "components/Icons/QuoteSmall"; 35 + import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 35 36 36 37 type Props = { 37 38 parent: string; ··· 310 311 type: "block", 311 312 hiddenInPublication: false, 312 313 onSelect: async (rep, props) => { 313 - createBlockWithType(rep, props, "code"); 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 + } 314 323 }, 315 324 }, 316 325
+6 -1
components/Blocks/CodeBlock.tsx
··· 13 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 14 import { flushSync } from "react-dom"; 15 15 import { elementId } from "src/utils/elementId"; 16 + import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 16 17 17 18 export function CodeBlock(props: BlockProps) { 18 19 let { rep, rootEntity } = useReplicache(); ··· 25 26 let focusedBlock = useUIState( 26 27 (s) => s.focusedEntity?.entityID === props.entityID, 27 28 ); 28 - let { permissions } = useEntitySetContext(); 29 + let entity_set = useEntitySetContext(); 30 + let { permissions } = entity_set; 29 31 const [html, setHTML] = useState<string | null>(null); 30 32 31 33 useLayoutEffect(() => { ··· 100 102 }} 101 103 value={lang} 102 104 onChange={async (e) => { 105 + localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value); 103 106 await rep?.mutate.assertFact({ 104 107 attribute: "block/code-language", 105 108 entity: props.entityID, ··· 123 126 data-entityid={props.entityID} 124 127 id={elementId.block(props.entityID).input} 125 128 block={props} 129 + rep={rep} 130 + permissionSet={entity_set.set} 126 131 spellCheck={false} 127 132 autoCapitalize="none" 128 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"; 1 + import { Fact, useReplicache } from "src/replicache"; 12 2 import { ButtonPrimary } from "components/Buttons"; 13 3 import { CloseTiny } from "components/Icons/CloseTiny"; 4 + import { deleteBlock } from "src/utils/deleteBlock"; 14 5 15 6 export const AreYouSure = (props: { 16 7 entityID: string[] | string; ··· 82 73 ); 83 74 }; 84 75 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 - }
+65 -16
components/Blocks/EmbedBlock.tsx
··· 10 10 import { Input } from "components/Input"; 11 11 import { isUrl } from "src/utils/isURL"; 12 12 import { elementId } from "src/utils/elementId"; 13 - import { deleteBlock } from "./DeleteBlock"; 14 13 import { focusBlock } from "src/utils/focusBlock"; 15 14 import { useDrag } from "src/hooks/useDrag"; 16 15 import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall"; 17 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"; 18 22 19 23 export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => { 20 24 let { permissions } = useEntitySetContext(); ··· 132 136 133 137 let entity_set = useEntitySetContext(); 134 138 let [linkValue, setLinkValue] = useState(""); 139 + let [loading, setLoading] = useState(false); 135 140 let { rep } = useReplicache(); 136 141 let submit = async () => { 137 142 let entity = props.entityID; ··· 149 154 } 150 155 let link = linkValue; 151 156 if (!linkValue.startsWith("http")) link = `https://${linkValue}`; 152 - // these mutations = simpler subset of addLinkBlock 153 157 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 - }); 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 + } 167 213 }; 168 214 let smoker = useSmoker(); 169 215 ··· 171 217 <form 172 218 onSubmit={(e) => { 173 219 e.preventDefault(); 220 + if (loading) return; 174 221 let rect = document 175 222 .getElementById("embed-block-submit") 176 223 ?.getBoundingClientRect(); ··· 212 259 <button 213 260 type="submit" 214 261 id="embed-block-submit" 262 + disabled={loading} 215 263 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 216 264 onMouseDown={(e) => { 217 265 e.preventDefault(); 266 + if (loading) return; 218 267 if (!linkValue || linkValue === "") { 219 268 smoker({ 220 269 error: true, ··· 234 283 submit(); 235 284 }} 236 285 > 237 - <CheckTiny /> 286 + {loading ? <DotLoader /> : <CheckTiny />} 238 287 </button> 239 288 </div> 240 289 </form>
+2 -1
components/Blocks/ExternalLinkBlock.tsx
··· 8 8 import { v7 } from "uuid"; 9 9 import { useSmoker } from "components/Toast"; 10 10 import { Separator } from "components/Layout"; 11 - import { focusElement, Input } from "components/Input"; 11 + import { Input } from "components/Input"; 12 + import { focusElement } from "src/utils/focusElement"; 12 13 import { isUrl } from "src/utils/isURL"; 13 14 import { elementId } from "src/utils/elementId"; 14 15 import { focusBlock } from "src/utils/focusBlock";
+1 -1
components/Blocks/MailboxBlock.tsx
··· 9 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 11 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 - import { focusPage } from "components/Pages"; 12 + import { focusPage } from "src/utils/focusPage"; 13 13 import { v7 } from "uuid"; 14 14 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 15 import { getBlocksWithType } from "src/hooks/queries/useBlocks";
+1 -1
components/Blocks/PageLinkBlock.tsx
··· 2 2 import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 3 import { focusBlock } from "src/utils/focusBlock"; 4 4 5 - import { focusPage } from "components/Pages"; 5 + import { focusPage } from "src/utils/focusPage"; 6 6 import { useEntity, useReplicache } from "src/replicache"; 7 7 import { useUIState } from "src/useUIState"; 8 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 1 import { useUIState } from "src/useUIState"; 2 2 import { BlockProps } from "./Block"; 3 3 import { useMemo } from "react"; 4 - import { focusElement, AsyncValueInput } from "components/Input"; 4 + import { AsyncValueInput } from "components/Input"; 5 + import { focusElement } from "src/utils/focusElement"; 5 6 import { useEntitySetContext } from "components/EntitySetProvider"; 6 7 import { useEntity, useReplicache } from "src/replicache"; 7 8 import { v7 } from "uuid";
+2 -2
components/Blocks/RSVPBlock/SendUpdate.tsx
··· 9 9 import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; 10 10 import { useReplicache } from "src/replicache"; 11 11 import { Checkbox } from "components/Checkbox"; 12 - import { usePublishLink } from "components/ShareOptions"; 12 + import { useReadOnlyShareLink } from "app/[leaflet_id]/actions/ShareOptions"; 13 13 14 14 export function SendUpdateButton(props: { entityID: string }) { 15 - let publishLink = usePublishLink(); 15 + let publishLink = useReadOnlyShareLink(); 16 16 let { permissions } = useEntitySetContext(); 17 17 let { permission_token } = useReplicache(); 18 18 let [input, setInput] = useState("");
+36 -32
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 3 3 import { CSSProperties, Fragment } from "react"; 4 4 import { theme } from "tailwind.config"; 5 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"; 6 9 7 10 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 8 11 export function RenderYJSFragment({ ··· 27 30 return ( 28 31 <BlockWrapper wrapper={wrapper} attrs={attrs}> 29 32 {children.length === 0 ? ( 30 - <div /> 33 + <br /> 31 34 ) : ( 32 35 node.toArray().map((node, index) => { 33 36 if (node.constructor === XmlText) { ··· 60 63 ); 61 64 } 62 65 66 + if (node.constructor === XmlElement && node.nodeName === "hard_break") { 67 + return <br key={index} />; 68 + } 69 + 70 + // Handle didMention inline nodes 71 + if (node.constructor === XmlElement && node.nodeName === "didMention") { 72 + const did = node.getAttribute("did") || ""; 73 + const text = node.getAttribute("text") || ""; 74 + return ( 75 + <a 76 + href={didToBlueskyUrl(did)} 77 + target="_blank" 78 + rel="noopener noreferrer" 79 + key={index} 80 + className="text-accent-contrast hover:underline cursor-pointer" 81 + > 82 + {text} 83 + </a> 84 + ); 85 + } 86 + 87 + // Handle atMention inline nodes 88 + if (node.constructor === XmlElement && node.nodeName === "atMention") { 89 + const atURI = node.getAttribute("atURI") || ""; 90 + const text = node.getAttribute("text") || ""; 91 + return ( 92 + <AtMentionLink key={index} atURI={atURI}> 93 + {text} 94 + </AtMentionLink> 95 + ); 96 + } 97 + 63 98 return null; 64 99 }) 65 100 )} ··· 97 132 } 98 133 }; 99 134 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 135 function attributesToStyle(d: Delta) { 114 136 let props = { 115 137 style: {}, ··· 140 162 return props; 141 163 } 142 164 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 - }
+109 -14
components/Blocks/TextBlock/index.tsx
··· 1 - import { useRef, useEffect, useState } from "react"; 1 + import { useRef, useEffect, useState, useCallback } from "react"; 2 2 import { elementId } from "src/utils/elementId"; 3 3 import { useReplicache, useEntity } from "src/replicache"; 4 4 import { isVisible } from "src/utils/isVisible"; 5 5 import { EditorState, TextSelection } from "prosemirror-state"; 6 + import { EditorView } from "prosemirror-view"; 6 7 import { RenderYJSFragment } from "./RenderYJSFragment"; 7 8 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 8 9 import { BlockProps } from "../Block"; ··· 23 24 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 24 25 import { DotLoader } from "components/utils/DotLoader"; 25 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"; 26 31 27 32 const HeadingStyle = { 28 33 1: "text-xl font-bold", ··· 183 188 let editorState = useEditorStates( 184 189 (s) => s.editorStates[props.entityID], 185 190 )?.editor; 191 + const { 192 + viewRef, 193 + mentionOpen, 194 + mentionCoords, 195 + openMentionAutocomplete, 196 + handleMentionSelect, 197 + handleMentionOpenChange, 198 + } = useMentionState(props.entityID); 186 199 187 200 let { mountRef, actionTimeout } = useMountProsemirror({ 188 201 props, 202 + openMentionAutocomplete, 189 203 }); 190 204 191 205 return ( ··· 199 213 ? "blockquote pt-3" 200 214 : "blockquote" 201 215 : "" 202 - } 203 - 204 - `} 216 + }`} 205 217 > 206 218 <pre 207 219 data-entityid={props.entityID} ··· 224 236 } 225 237 }} 226 238 onFocus={() => { 239 + handleMentionOpenChange(false); 227 240 setTimeout(() => { 228 241 useUIState.getState().setSelectedBlock(props); 229 242 useUIState.setState(() => ({ ··· 249 262 ${props.className}`} 250 263 ref={mountRef} 251 264 /> 265 + {focused && ( 266 + <MentionAutocomplete 267 + open={mentionOpen} 268 + onOpenChange={handleMentionOpenChange} 269 + view={viewRef} 270 + onSelect={handleMentionSelect} 271 + coords={mentionCoords} 272 + /> 273 + )} 252 274 {editorState?.doc.textContent.length === 0 && 253 275 props.previousBlock === null && 254 276 props.nextBlock === null ? ( ··· 439 461 ); 440 462 }; 441 463 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 }; 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 + }; 452 547 };
+32 -3
components/Blocks/TextBlock/inputRules.ts
··· 11 11 import { schema } from "./schema"; 12 12 import { useUIState } from "src/useUIState"; 13 13 import { flushSync } from "react-dom"; 14 + import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 14 15 export const inputrules = ( 15 16 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 16 17 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 18 + openMentionAutocomplete?: () => void, 17 19 ) => 18 20 inputRules({ 19 21 //Strikethrough ··· 108 110 109 111 // Code Block 110 112 new InputRule(/^```\s$/, (state, match) => { 111 - flushSync(() => 113 + flushSync(() => { 112 114 repRef.current?.mutate.assertFact({ 113 115 entity: propsRef.current.entityID, 114 116 attribute: "block/type", 115 117 data: { type: "block-type-union", value: "code" }, 116 - }), 117 - ); 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 + }); 118 128 setTimeout(() => { 119 129 focusBlock({ ...propsRef.current, type: "code" }, { type: "start" }); 120 130 }, 20); ··· 180 190 data: { type: "number", value: headingLevel }, 181 191 }); 182 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 183 212 }), 184 213 ], 185 214 });
+10 -13
components/Blocks/TextBlock/keymap.ts
··· 17 17 import { schema } from "./schema"; 18 18 import { useUIState } from "src/useUIState"; 19 19 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20 - import { focusPage } from "components/Pages"; 20 + import { focusPage } from "src/utils/focusPage"; 21 21 import { v7 } from "uuid"; 22 22 import { scanIndex } from "src/replicache/utils"; 23 23 import { indent, outdent } from "src/utils/list-operations"; 24 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 26 import { UndoManager } from "src/undoManager"; 27 - 28 27 type PropsRef = RefObject< 29 28 BlockProps & { 30 29 entity_set: { set: string }; ··· 35 34 propsRef: PropsRef, 36 35 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 37 36 um: UndoManager, 38 - multiLine?: boolean, 37 + openMentionAutocomplete: () => void, 39 38 ) => 40 39 ({ 41 40 "Meta-b": toggleMark(schema.marks.strong), ··· 138 137 ), 139 138 "Shift-Backspace": backspace(propsRef, repRef), 140 139 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 - ); 140 + return um.withUndoGroup(() => { 141 + return enter(propsRef, repRef)(state, dispatch, view); 142 + }); 146 143 }, 147 144 "Shift-Enter": (state, dispatch, view) => { 148 - if (multiLine) { 149 - return baseKeymap.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()); 150 149 } 151 - return um.withUndoGroup(() => 152 - enter(propsRef, repRef)(state, dispatch, view), 153 - ); 150 + return true; 154 151 }, 155 152 "Ctrl-Enter": CtrlEnter(propsRef, repRef), 156 153 "Meta-Enter": CtrlEnter(propsRef, repRef),
+48 -12
components/Blocks/TextBlock/mountProsemirror.ts
··· 23 23 import { useHandlePaste } from "./useHandlePaste"; 24 24 import { BlockProps } from "../Block"; 25 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 26 27 27 - export function useMountProsemirror({ props }: { props: BlockProps }) { 28 + export function useMountProsemirror({ 29 + props, 30 + openMentionAutocomplete, 31 + }: { 32 + props: BlockProps; 33 + openMentionAutocomplete: () => void; 34 + }) { 28 35 let { entityID, parent } = props; 29 36 let rep = useReplicache(); 30 37 let mountRef = useRef<HTMLPreElement | null>(null); ··· 44 51 useLayoutEffect(() => { 45 52 if (!mountRef.current) return; 46 53 47 - const km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 54 + const km = TextBlockKeymap( 55 + propsRef, 56 + repRef, 57 + rep.undoManager, 58 + openMentionAutocomplete, 59 + ); 48 60 const editor = EditorState.create({ 49 61 schema: schema, 50 62 plugins: [ 51 63 ySyncPlugin(value), 52 64 keymap(km), 53 - inputrules(propsRef, repRef), 65 + inputrules(propsRef, repRef, openMentionAutocomplete), 54 66 keymap(baseKeymap), 55 67 highlightSelectionPlugin, 56 68 autolink({ ··· 69 81 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 70 82 if (!direct) return; 71 83 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"); 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; 81 117 } 82 118 }, 83 119 dispatchTransaction,
+107 -1
components/Blocks/TextBlock/schema.ts
··· 1 - import { Schema, Node, MarkSpec } from "prosemirror-model"; 1 + import { AtUri } from "@atproto/api"; 2 + import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; 2 3 import { marks } from "prosemirror-schema-basic"; 3 4 import { theme } from "tailwind.config"; 4 5 ··· 115 116 text: { 116 117 group: "inline", 117 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 text-accent-contrast"; 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: "inline-block w-5 h-5 rounded-full mr-1 align-text-top", 172 + alt: "", 173 + width: "16", 174 + height: "16", 175 + loading: "lazy", 176 + }, 177 + ], 178 + node.attrs.text, 179 + ]; 180 + } 181 + 182 + return [ 183 + "span", 184 + { 185 + class: className, 186 + "data-at-uri": node.attrs.atURI, 187 + }, 188 + node.attrs.text, 189 + ]; 190 + }, 191 + } as NodeSpec, 192 + didMention: { 193 + attrs: { 194 + did: {}, 195 + text: { default: "" }, 196 + }, 197 + group: "inline", 198 + inline: true, 199 + atom: true, 200 + selectable: true, 201 + draggable: true, 202 + parseDOM: [ 203 + { 204 + tag: "span.didMention", 205 + getAttrs(dom: HTMLElement) { 206 + return { 207 + did: dom.getAttribute("data-did"), 208 + text: dom.textContent || "", 209 + }; 210 + }, 211 + }, 212 + ], 213 + toDOM(node) { 214 + return [ 215 + "span", 216 + { 217 + class: "didMention text-accent-contrast", 218 + "data-did": node.attrs.did, 219 + }, 220 + node.attrs.text, 221 + ]; 222 + }, 223 + } as NodeSpec, 118 224 }, 119 225 }; 120 226 export const schema = new Schema(baseSchema);
+1 -1
components/Blocks/useBlockKeyboardHandlers.ts
··· 12 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 14 import { Replicache } from "replicache"; 15 - import { deleteBlock } from "./DeleteBlock"; 15 + import { deleteBlock } from "src/utils/deleteBlock"; 16 16 import { entities } from "drizzle/schema"; 17 17 import { scanIndex } from "src/replicache/utils"; 18 18
+1 -1
components/Blocks/useBlockMouseHandlers.ts
··· 1 - import { useSelectingMouse } from "components/SelectionManager"; 1 + import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 2 import { MouseEvent, useCallback, useRef } from "react"; 3 3 import { useUIState } from "src/useUIState"; 4 4 import { Block } from "./Block";
+35 -21
components/Buttons.tsx
··· 10 10 import { PopoverArrow } from "./Icons/PopoverArrow"; 11 11 12 12 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 13 + 13 14 export const ButtonPrimary = forwardRef< 14 15 HTMLButtonElement, 15 16 ButtonProps & { ··· 35 36 m-0 h-max 36 37 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 37 38 ${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 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 40 43 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 44 ${className} 44 45 `} 45 46 > ··· 70 71 <button 71 72 {...buttonProps} 72 73 ref={ref} 73 - className={`m-0 h-max 74 + className={` 75 + m-0 h-max 74 76 ${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 - `} 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 + `} 84 85 > 85 86 {props.children} 86 87 </button> ··· 92 93 HTMLButtonElement, 93 94 { 94 95 fullWidth?: boolean; 96 + fullWidthOnMobile?: boolean; 95 97 children: React.ReactNode; 96 98 compact?: boolean; 97 99 } & ButtonProps 98 100 >((props, ref) => { 99 - let { fullWidth, children, compact, ...buttonProps } = props; 101 + let { 102 + className, 103 + fullWidth, 104 + fullWidthOnMobile, 105 + compact, 106 + children, 107 + ...buttonProps 108 + } = props; 100 109 return ( 101 110 <button 102 111 {...buttonProps} 103 112 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 - `} 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 + `} 110 124 > 111 125 {children} 112 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 - };
-76
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 - created_at: new Date().toISOString(), 56 - permission_tokens: { 57 - ...permission_token, 58 - leaflets_in_publications: [], 59 - }, 60 - }, 61 - ], 62 - }; 63 - }); 64 - smoker({ 65 - position: { 66 - x: e.clientX + 64, 67 - y: e.clientY, 68 - }, 69 - text: "Leaflet added to your home!", 70 - }); 71 - }} 72 - icon={<AddToHomeSmall />} 73 - label="Add to Home" 74 - /> 75 - ); 76 - };
+21
components/Icons/ArchiveSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const ArchiveSmall = (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 + fillRule="evenodd" 15 + clipRule="evenodd" 16 + d="M14.3935 2.33729C14.4781 2.30741 14.5682 2.29611 14.6576 2.30415C14.7774 2.31514 14.897 2.32836 15.0165 2.34211C15.2401 2.36784 15.5571 2.40755 15.9337 2.46375C16.6844 2.57577 17.6834 2.755 18.6552 3.02334C20.043 3.40654 21.1623 4.08204 21.9307 4.65549C22.3161 4.94319 22.6172 5.20811 22.8237 5.40315C22.9788 5.5496 23.0813 5.6572 23.1271 5.70673C23.3287 5.92633 23.375 6.26081 23.1986 6.51162C23.0315 6.74906 22.723 6.84022 22.4537 6.73167C22.0456 6.56715 21.4938 6.48314 21.0486 6.65428C20.807 6.74717 20.531 6.94113 20.3218 7.3713L20.6009 7.19094C20.7969 7.06426 21.0472 7.05737 21.2499 7.17306C21.4527 7.28875 21.574 7.50775 21.5646 7.74096L21.2277 16.1284C21.2197 16.3285 21.1162 16.5127 20.9494 16.6237L11.9336 22.6232C11.7666 22.7343 11.5564 22.7585 11.3685 22.6883L2.23473 19.2743C2.00112 19.187 1.84179 18.9692 1.82933 18.7201L1.40252 10.1857C1.39041 9.94356 1.5194 9.71628 1.73347 9.60253L2.89319 8.98631C3.19801 8.82434 3.57642 8.94015 3.73838 9.24497C3.8855 9.52184 3.80344 9.85944 3.55872 10.0404L4.46834 10.3669C4.529 10.1684 4.63256 9.64884 4.57793 9.06783C4.51992 8.45086 4.29459 7.8533 3.74994 7.45779C3.09256 6.98978 2.55044 6.51789 2.315 6.27264C2.07596 6.02363 2.08403 5.62799 2.33304 5.38894C2.58204 5.14989 2.97769 5.15797 3.21674 5.40697C3.38499 5.58224 3.87255 6.01278 4.49863 6.45635C5.12762 6.90198 5.83958 7.31975 6.4589 7.5144C7.00579 7.68628 7.7553 7.62969 8.5369 7.43649C9.3015 7.24751 10.0054 6.95105 10.4074 6.74228C10.5756 6.65494 10.7743 6.64864 10.9477 6.72514C12.2233 7.28795 12.9191 8.50607 13.2891 9.66169C13.5067 10.3415 13.6259 11.0415 13.6803 11.6632L15.3414 10.5898C15.3412 10.5032 15.3407 10.4155 15.3403 10.3268C15.3336 9.034 15.3259 7.52674 16.0328 6.1972C15.7338 6.16682 15.3912 6.12949 15.0302 6.08539C13.9285 5.95083 12.5649 5.74352 11.7833 5.45362C11.0189 5.17008 10.3102 4.75223 9.80152 4.41446C9.6696 4.32685 9.54977 4.24371 9.4444 4.16843C9.26969 4.41598 9.11811 4.6909 8.99766 4.9675C8.79907 5.42358 8.71173 5.82238 8.71173 6.05267C8.71173 6.39784 8.43191 6.67767 8.08673 6.67767C7.74155 6.67767 7.46173 6.39784 7.46173 6.05267C7.46173 5.58769 7.61509 5.01162 7.8516 4.46846C8.09203 3.91632 8.44552 3.33542 8.89963 2.8725C9.12701 2.64071 9.4943 2.62192 9.74446 2.82883L9.74577 2.8299C9.80956 2.88191 9.87475 2.93223 9.94039 2.98188C10.0714 3.08094 10.2612 3.21923 10.493 3.37315C10.9612 3.68404 11.5799 4.04492 12.218 4.28164C12.8391 4.512 14.0548 4.70696 15.1817 4.84461C15.7313 4.91174 16.2384 4.96292 16.6084 4.99732C16.8076 5.01584 17.007 5.03362 17.2065 5.04896C17.4444 5.06698 17.6512 5.21883 17.7397 5.44036C17.8282 5.66191 17.7828 5.9145 17.6228 6.09143C16.7171 7.09276 16.6045 8.33681 16.5923 9.78143L18.8039 8.35222C18.7998 8.30706 18.8006 8.26075 18.8068 8.21391C19.0047 6.71062 19.6821 5.84043 20.6001 5.48753C20.6783 5.45746 20.7569 5.4317 20.8356 5.40989C20.1821 4.96625 19.3286 4.50604 18.3225 4.22826C17.4178 3.97844 16.4732 3.80809 15.7493 3.70006C15.3886 3.64625 15.0857 3.60832 14.8736 3.58392C14.8084 3.57642 14.7519 3.57021 14.705 3.56521C14.6894 3.57354 14.6728 3.58282 14.6556 3.59303C14.5489 3.65657 14.4711 3.72644 14.4347 3.7856C14.2538 4.07957 13.8688 4.17123 13.5749 3.99032C13.2809 3.80941 13.1892 3.42445 13.3701 3.13047C13.5575 2.82606 13.8293 2.63024 14.0162 2.51897C14.1352 2.44809 14.2601 2.38531 14.3906 2.33829L14.3921 2.33776L14.3935 2.33729ZM12.4675 12.447C12.4635 11.7846 12.3687 10.8866 12.0986 10.0428C11.8096 9.1402 11.353 8.39584 10.6886 7.99621C10.209 8.21933 9.54785 8.47423 8.83684 8.64998C7.98278 8.86108 6.96103 8.98249 6.08412 8.70689C5.98146 8.67463 5.87826 8.63824 5.77495 8.59834C5.79615 8.71819 5.81166 8.83611 5.82244 8.95081C5.89602 9.73333 5.75996 10.4455 5.64541 10.7895L11.68 12.9559L12.4675 12.447ZM4.77065 13.1487C4.60756 13.0891 4.43494 13.2099 4.43494 13.3835V14.9513C4.43494 15.1613 4.5662 15.3489 4.76351 15.421L8.55169 16.8036C8.71479 16.8631 8.88741 16.7423 8.88741 16.5687V15.001C8.88741 14.7909 8.75614 14.6033 8.55884 14.5313L4.77065 13.1487ZM2.69778 11.0594L11.1256 14.085L11.0552 17.5412C11.0482 17.8863 11.3222 18.1718 11.6673 18.1788C12.0124 18.1859 12.2979 17.9118 12.3049 17.5667L12.3778 13.9933L20.2673 8.89485L19.9915 15.7596L12.2366 20.9201L12.2469 20.4127C12.254 20.0676 11.9799 19.7821 11.6348 19.7751C11.2897 19.768 11.0042 20.0421 10.9972 20.3872L10.9804 21.2088L3.05725 18.2473L2.69778 11.0594Z" 17 + fill="currentColor" 18 + /> 19 + </svg> 20 + ); 21 + };
+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 + };
+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 + };
-21
components/Icons/TemplateRemoveSmall.tsx
··· 1 - import { Props } from "./Props"; 2 - 3 - export const TemplateRemoveSmall = (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 - fillRule="evenodd" 15 - clipRule="evenodd" 16 - d="M21.6598 1.22969C22.0503 0.839167 22.6835 0.839167 23.074 1.22969C23.4646 1.62021 23.4646 2.25338 23.074 2.6439L21.9991 3.71887C22 3.72121 22.001 3.72355 22.002 3.7259L21.0348 4.69374C21.0347 4.69033 21.0345 4.68693 21.0344 4.68353L17.2882 8.42972L17.2977 8.43313L16.3813 9.35011L16.3714 9.34656L15.5955 10.1224L15.6058 10.1261L14.6894 11.0431L14.6787 11.0393L14.3959 11.3221L14.4067 11.326L13.4903 12.2429L13.479 12.2389L12.8919 12.8261L12.9034 12.8302L10.2156 15.5198L10.2028 15.5152L9.35969 16.3583C9.36255 16.3614 9.36541 16.3645 9.36826 16.3676L7.20585 18.5314C7.19871 18.5321 7.19159 18.5328 7.18448 18.5335L6.26611 19.4519C6.27069 19.4539 6.27528 19.4559 6.27989 19.4579L5.40679 20.3316C5.40244 20.3291 5.39809 20.3267 5.39376 20.3242L2.54817 23.1698C2.15765 23.5603 1.52448 23.5603 1.13396 23.1698C0.743434 22.7793 0.743433 22.1461 1.13396 21.7556L4.57518 18.3144C4.5862 18.296 4.59778 18.2779 4.6099 18.2599C4.72342 18.0917 4.86961 17.964 5.02393 17.8656L6.39488 16.4947C6.25376 16.4822 6.10989 16.4734 5.96441 16.4685C5.20904 16.4433 4.461 16.5264 3.88183 16.7201C3.2818 16.9207 2.99485 17.1912 2.91069 17.4452C2.80892 17.7525 2.47737 17.919 2.17013 17.8173C1.8629 17.7155 1.69634 17.3839 1.79811 17.0767C2.05627 16.2973 2.78206 15.852 3.51019 15.6085C4.2592 15.3581 5.15477 15.2689 6.00346 15.2972C6.48903 15.3133 6.97583 15.3686 7.42782 15.4617L8.11942 14.7701L7.89431 14.6896C7.7838 14.6501 7.69213 14.5705 7.63742 14.4667L5.91365 11.1952C5.86162 11.0964 5.84836 10.9944 5.86434 10.9002L5.85245 10.9196L5.11563 9.4308C4.96523 9.11293 5.04515 8.78343 5.24544 8.56361L5.25054 8.55806C5.25749 8.55058 5.26457 8.54323 5.2718 8.53601L6.43022 7.3457C6.6445 7.11834 6.97346 7.03892 7.26837 7.14439L9.80363 8.05107L12.9624 7.10485C13.1067 7.02062 13.2859 6.99834 13.4555 7.05901L14.4322 7.40831C14.7942 6.69891 14.93 5.89897 15.0777 5.02873L15.0777 5.02872L15.0958 4.9222C15.2586 3.96572 15.4529 2.86736 16.1798 2.04515C17.0056 1.11114 18.7307 0.837125 20.2663 1.83615C20.4285 1.94168 20.5821 2.05061 20.7266 2.16294L21.6598 1.22969ZM19.8899 2.99965C19.8075 2.93935 19.72 2.87895 19.6271 2.81856C18.4897 2.07854 17.4326 2.39759 17.0579 2.82147C16.5869 3.3541 16.4234 4.10723 16.2512 5.11887L16.2231 5.28522L16.2231 5.28523C16.1304 5.83581 16.0274 6.44661 15.8342 7.05527L19.8899 2.99965ZM14.288 8.60148L13.2682 8.23675L11.6654 8.71688L13.5122 9.37736L14.288 8.60148ZM12.5953 10.2942L9.59692 9.22187L9.58424 9.21734L7.10654 8.33124L6.82935 8.61605L12.3125 10.577L12.5953 10.2942ZM11.3957 11.4938L6.56005 9.76447L6.04788 10.6006C6.16458 10.5123 6.32269 10.4767 6.48628 10.5352L10.8085 12.081L11.3957 11.4938ZM17.0099 12.2569L16.2294 11.9778L15.313 12.8948L16.8798 13.4551L18.7426 16.9905L18.0747 17.8398L19.1912 18.2615C19.6607 18.4294 20.1033 18.1358 20.2179 17.728L20.7391 16.3648C20.824 16.1511 20.8112 15.9108 20.7039 15.7071L19.124 12.7086L18.8949 11.321L18.8931 11.3104L18.8904 11.2969C18.8874 11.234 18.8742 11.1705 18.8497 11.1087L18.3522 9.8537L16.5121 11.6949L16.5482 11.7078L16.5582 11.7115L17.1419 11.9202L17.0099 12.2569ZM12.0382 16.1716L14.7261 13.482L16.0553 13.9574C16.1658 13.9969 16.2575 14.0764 16.3122 14.1803L18.0359 17.4518C18.2352 17.83 17.8658 18.2557 17.4633 18.1118L12.0382 16.1716ZM8.44038 19.7717L7.26492 20.9479C7.80247 21.0274 8.35468 21.0252 8.82243 20.8811C9.24804 20.7499 9.52382 20.5096 9.73008 20.285C9.79978 20.2091 9.87046 20.1246 9.92979 20.0536L9.92981 20.0536L9.92999 20.0534L9.9306 20.0527C9.95072 20.0286 9.96953 20.0061 9.98653 19.9861C10.0618 19.8973 10.1248 19.8281 10.1905 19.7694C10.307 19.6651 10.4472 19.579 10.6908 19.5395C10.9182 19.5027 11.2529 19.5041 11.7567 19.6004C11.6943 19.6815 11.6359 19.764 11.5823 19.8476C11.3276 20.2439 11.1352 20.7322 11.2038 21.2293C11.3097 21.9955 11.8139 22.4463 12.3522 22.6544C12.8626 22.8518 13.4377 22.8513 13.8631 22.731C14.7279 22.4863 15.6213 21.724 15.4107 20.664C15.3105 20.1591 14.9656 19.7211 14.4516 19.3701C14.3677 19.3128 14.2783 19.2571 14.1833 19.203C14.5987 19.0436 14.9889 19.0051 15.2828 19.1025C15.59 19.2042 15.9215 19.0377 16.0233 18.7304C16.1251 18.4232 15.9585 18.0916 15.6513 17.9899C14.6724 17.6656 13.5751 18.0821 12.7766 18.6397C12.6141 18.5938 12.4436 18.5504 12.265 18.5097C11.5394 18.3444 10.9698 18.307 10.5035 18.3825C10.018 18.4612 9.67586 18.657 9.40877 18.8961C9.28262 19.009 9.17853 19.1268 9.09296 19.2277C9.06342 19.2625 9.03731 19.2937 9.0131 19.3227L9.01295 19.3228C8.9605 19.3856 8.91697 19.4377 8.86686 19.4922C8.73917 19.6313 8.63185 19.7134 8.47726 19.761C8.46519 19.7648 8.45289 19.7683 8.44038 19.7717ZM12.5683 20.4811C12.3863 20.7644 12.3505 20.965 12.3648 21.0689C12.4003 21.3259 12.5445 21.4722 12.7749 21.5613C13.0331 21.6611 13.3469 21.659 13.544 21.6032C14.1554 21.4302 14.2952 21.0637 14.2612 20.8923C14.2391 20.7814 14.1422 20.578 13.7907 20.338C13.6005 20.2082 13.347 20.076 13.0173 19.9508C12.8341 20.1242 12.681 20.3057 12.5683 20.4811Z" 17 - fill="currentColor" 18 - /> 19 - </svg> 20 - ); 21 - };
-25
components/Icons/TemplateSmall.tsx
··· 1 - import { Props } from "./Props"; 2 - 3 - export const TemplateSmall = (props: Props & { fill?: string }) => { 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="M14.1876 3.5073C14.3657 2.68428 14.8409 1.80449 15.1974 1.39941L15.2085 1.38682C15.5258 1.02605 16.1664 0.297788 17.7348 0.0551971C19.7272 -0.252968 22.338 1.22339 23.1781 3.53026C23.9464 5.63998 22.4863 7.65134 21.1778 8.49107C20.443 8.96256 19.8776 9.29865 19.5389 9.6655C19.6381 9.88024 19.8755 10.4623 19.9945 10.8588C20.1304 11.312 20.1356 11.8263 20.2444 12.3342C20.6412 13.1008 21.4615 14.6122 21.6483 14.9894C21.9441 15.5868 22.0637 16.0554 21.901 16.59C21.7793 16.99 21.3809 18.0037 21.2098 18.4064C21.1134 18.6333 20.6741 19.1794 20.165 19.3516C19.5207 19.5694 19.2 19.533 18.2867 19.1682C17.9231 19.3768 17.3068 19.3194 17.0874 19.2128C16.9902 19.5392 16.6234 19.8695 16.4353 20.0055C16.5008 20.1749 16.6684 20.619 16.5759 21.4191C16.4257 22.7176 14.6119 24.4819 12.2763 23.8544C10.5744 23.3971 10.2099 22.1002 10.0744 21.5462C8.16651 22.8209 5.74592 21.9772 4.43632 21.1133C3.44653 20.4603 3.16063 19.4467 3.2199 18.7888C2.57837 19.147 1.33433 19.2159 0.756062 17.9729C0.320217 17.036 0.838862 15.6535 2.49397 14.7706C3.56898 14.1971 5.01017 14.061 6.14456 14.136C5.47545 12.9417 4.17774 10.4051 3.97777 9.74456C3.72779 8.91889 3.94746 8.3129 4.30348 7.88113C4.6595 7.44936 5.21244 6.90396 5.75026 6.38129C6.28808 5.85862 7.06074 5.85862 7.7349 6.07072C8.27424 6.2404 9.36352 6.65146 9.84074 6.83578C10.5069 6.63086 11.9689 6.18102 12.4877 6.02101C13.0065 5.861 13.184 5.78543 13.7188 5.90996C13.8302 5.37643 14.0045 4.35336 14.1876 3.5073Z" 15 - fill={props.fill || "transparent"} 16 - /> 17 - <path 18 - fillRule="evenodd" 19 - clipRule="evenodd" 20 - d="M19.6271 2.81856C18.4896 2.07854 17.4326 2.39759 17.0578 2.82147C16.5869 3.3541 16.4234 4.10723 16.2512 5.11887L16.2231 5.28522L16.2231 5.28523C16.0919 6.06363 15.9405 6.96241 15.5423 7.80533L17.4557 8.48962C18.0778 7.71969 18.7304 7.28473 19.2974 6.92363L19.3687 6.87829C20.0258 6.46022 20.473 6.17579 20.7913 5.5972C21.0667 5.09643 21.0978 4.64884 20.9415 4.23092C20.7767 3.79045 20.3738 3.3044 19.6271 2.81856ZM15.0777 5.02873C14.9299 5.89897 14.7941 6.69891 14.4321 7.4083L13.4555 7.05901C13.2858 6.99834 13.1067 7.02061 12.9624 7.10485L9.80359 8.05107L7.26833 7.14438C6.97342 7.03892 6.64447 7.11834 6.43018 7.3457L5.27176 8.53601C5.26453 8.54323 5.25745 8.55058 5.2505 8.55806L5.2454 8.56361C5.04511 8.78343 4.9652 9.11292 5.1156 9.43079L5.85241 10.9196L5.8643 10.9002C5.84832 10.9944 5.86158 11.0964 5.91361 11.1952L7.63738 14.4667C7.6921 14.5705 7.78376 14.6501 7.89428 14.6896L17.4633 18.1118C17.8658 18.2557 18.2352 17.83 18.0359 17.4518L16.3121 14.1803C16.2574 14.0764 16.1657 13.9969 16.0552 13.9574L6.48624 10.5352C6.32266 10.4767 6.16454 10.5123 6.04784 10.6006L6.56002 9.76447L16.8798 13.4551L18.7426 16.9905L18.0747 17.8398L19.1912 18.2615C19.6606 18.4294 20.1033 18.1358 20.2179 17.728L20.7391 16.3648C20.8239 16.1511 20.8112 15.9108 20.7039 15.7071L19.124 12.7086L18.8949 11.321C18.8935 11.3129 18.892 11.3049 18.8904 11.2969C18.8874 11.234 18.8741 11.1705 18.8496 11.1087L18.1936 9.45372C18.7455 8.68856 19.3357 8.28878 19.927 7.9122C19.9681 7.88603 20.0096 7.85977 20.0514 7.83331C20.6663 7.44436 21.3511 7.01112 21.8182 6.16211C22.2345 5.40522 22.3314 4.60167 22.0392 3.82037C21.7555 3.06161 21.1334 2.40034 20.2662 1.83615C18.7307 0.837123 17.0056 1.11114 16.1798 2.04515C15.4528 2.86736 15.2586 3.96572 15.0958 4.92219L15.0777 5.02872L15.0777 5.02873ZM13.2681 8.23675L11.6653 8.71688L16.3567 10.3947L16.6254 9.4374L13.2681 8.23675ZM16.5481 11.7078L16.5582 11.7114L17.1419 11.9202L17.0098 12.2569L6.82932 8.61605L7.1065 8.33124L9.5842 9.21734L9.59688 9.22187L16.5481 11.7078ZM12.5683 20.4811C12.3863 20.7644 12.3505 20.965 12.3648 21.0689C12.4003 21.3259 12.5444 21.4722 12.7748 21.5613C13.0331 21.6611 13.3469 21.659 13.544 21.6032C14.1553 21.4302 14.2952 21.0637 14.2611 20.8923C14.2391 20.7814 14.1421 20.578 13.7906 20.338C13.6004 20.2082 13.3469 20.076 13.0173 19.9508C12.834 20.1242 12.681 20.3057 12.5683 20.4811ZM11.7567 19.6004C11.6942 19.6815 11.6359 19.764 11.5822 19.8476C11.3276 20.2439 11.1351 20.7322 11.2038 21.2293C11.3096 21.9955 11.8139 22.4463 12.3521 22.6544C12.8626 22.8518 13.4377 22.8513 13.863 22.731C14.7279 22.4863 15.6213 21.724 15.4107 20.664C15.3104 20.1591 14.9656 19.7211 14.4515 19.3701C14.3677 19.3128 14.2783 19.2571 14.1833 19.203C14.5987 19.0436 14.9889 19.0051 15.2827 19.1025C15.59 19.2042 15.9215 19.0377 16.0233 18.7304C16.125 18.4232 15.9585 18.0916 15.6513 17.9899C14.6724 17.6656 13.5751 18.0821 12.7766 18.6397C12.6141 18.5938 12.4436 18.5504 12.265 18.5097C11.5393 18.3444 10.9698 18.307 10.5034 18.3825C10.018 18.4612 9.67582 18.657 9.40873 18.8961C9.28258 19.009 9.17849 19.1268 9.09292 19.2277C9.06338 19.2625 9.03727 19.2937 9.01306 19.3227L9.01291 19.3228C8.96046 19.3856 8.91693 19.4377 8.86682 19.4922C8.73913 19.6313 8.63181 19.7134 8.47722 19.761C8.03942 19.896 7.30137 19.8237 6.60705 19.5851C6.27195 19.4699 5.98787 19.3293 5.79222 19.1916C5.64379 19.0871 5.59428 19.019 5.58047 19L5.58045 19C5.57827 18.997 5.57698 18.9952 5.57634 18.9947C5.57144 18.9579 5.57397 18.938 5.57539 18.9305C5.57674 18.9233 5.57829 18.9201 5.58128 18.9156C5.59031 18.9023 5.63142 18.8546 5.76375 18.7965C6.04383 18.6735 6.48291 18.6061 7.03421 18.5487C7.12534 18.5392 7.22003 18.5299 7.31675 18.5205L7.31734 18.5205L7.31774 18.5204C7.75337 18.478 8.22986 18.4315 8.60602 18.3399C8.83695 18.2837 9.10046 18.1956 9.31444 18.0333C9.55604 17.8501 9.73703 17.5659 9.72457 17.1949C9.71117 16.7955 9.50249 16.4807 9.2559 16.2553C9.01235 16.0327 8.69774 15.863 8.36729 15.7333C7.70363 15.4729 6.85166 15.3254 6.00343 15.2972C5.15473 15.2689 4.25916 15.3581 3.51015 15.6085C2.78202 15.852 2.05623 16.2973 1.79807 17.0767C1.6963 17.3839 1.86287 17.7155 2.1701 17.8173C2.47733 17.919 2.80889 17.7525 2.91065 17.4452C2.99481 17.1912 3.28176 16.9207 3.8818 16.7201C4.46096 16.5264 5.209 16.4433 5.96437 16.4685C6.7202 16.4937 7.43275 16.6256 7.93908 16.8243C8.19363 16.9243 8.36538 17.0292 8.46519 17.1204C8.4773 17.1315 8.4878 17.1419 8.49689 17.1515C8.45501 17.1668 8.39992 17.1838 8.3287 17.2012C8.04154 17.2711 7.67478 17.3072 7.24492 17.3496L7.24413 17.3497L7.24246 17.3498C7.13635 17.3603 7.02639 17.3711 6.91284 17.3829C6.38763 17.4376 5.76632 17.5153 5.29238 17.7234C5.0477 17.8309 4.78839 17.9954 4.60986 18.2599C4.42009 18.541 4.36482 18.8707 4.42432 19.213C4.49899 19.6426 4.83826 19.9534 5.11763 20.15C5.42736 20.368 5.81812 20.5533 6.22607 20.6935C7.01783 20.9656 8.03865 21.1226 8.82239 20.8811C9.248 20.7499 9.52379 20.5096 9.73004 20.285C9.79974 20.2091 9.87042 20.1246 9.92975 20.0536L9.92977 20.0536L9.92995 20.0534C9.9503 20.0291 9.96932 20.0063 9.98649 19.9861C10.0618 19.8973 10.1248 19.8281 10.1905 19.7694C10.3069 19.6651 10.4472 19.579 10.6908 19.5395C10.9181 19.5027 11.2529 19.5041 11.7567 19.6004Z" 21 - fill="currentColor" 22 - /> 23 - </svg> 24 - ); 25 - };
+19
components/Icons/UnpublishSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const UnpublishSmall = (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="M15.5207 11.5526C15.9624 11.2211 16.5896 11.3101 16.9211 11.7518L18.9162 14.411L21.5754 12.4158C22.017 12.0845 22.6433 12.1735 22.9748 12.6151C23.306 13.0568 23.2172 13.684 22.7756 14.0155L20.1164 16.0106L22.1115 18.6698C22.4425 19.1114 22.3537 19.7378 21.9123 20.0692C21.4707 20.4006 20.8434 20.3114 20.5119 19.87L18.5168 17.2108L15.8576 19.2059C15.416 19.537 14.7897 19.4479 14.4582 19.0067C14.1267 18.565 14.2158 17.9378 14.6574 17.6063L17.3166 15.6112L15.3215 12.952C14.9902 12.5103 15.0792 11.8841 15.5207 11.5526ZM12.2062 4.29378C13.7932 3.59008 15.5128 3.49569 16.9767 4.29769C19.1391 5.48261 19.9471 8.15954 19.5314 10.8885C19.4793 11.2296 19.1606 11.4638 18.8195 11.4119C18.4786 11.3598 18.2444 11.042 18.2961 10.701C18.669 8.25384 17.8985 6.22855 16.3761 5.39436C15.5192 4.92484 14.4833 4.85746 13.4006 5.1805C13.3522 5.21491 13.3004 5.24633 13.2414 5.26644C13.0411 5.33451 12.8498 5.39707 12.6662 5.45686C12.6176 5.47894 12.5684 5.50065 12.5197 5.52425C11.1279 6.19898 9.77207 7.47892 8.81657 9.22249C7.86108 10.9662 7.51225 12.7985 7.69254 14.3348C7.87314 15.8723 8.57043 17.0593 9.65739 17.6551C10.3281 18.0226 11.1012 18.1431 11.9211 18.0272C12.2625 17.9791 12.5786 18.2161 12.6271 18.5575C12.6754 18.8992 12.4375 19.216 12.0959 19.2645C11.0448 19.4131 9.99397 19.2653 9.0568 18.7518C7.96346 18.1527 7.21589 17.1633 6.79801 15.9862C6.74111 15.914 6.69783 15.829 6.67692 15.7332C6.5875 15.3237 6.4571 14.8734 6.30387 14.4188C6.00205 14.7748 5.69607 15.0308 5.37419 15.1834C5.04355 15.3401 4.70719 15.3838 4.38102 15.327C4.06576 15.272 3.79527 15.129 3.57145 14.9696C2.96057 14.5342 2.36597 14.0627 1.89274 13.5487C1.4209 13.036 1.0333 12.4423 0.8986 11.7596C0.842171 11.4736 0.768809 11.1336 0.89274 10.5985C0.997303 10.1475 1.23987 9.57405 1.69059 8.73226L1.60758 8.66585C1.60246 8.66173 1.59696 8.65743 1.59196 8.65315C1.16612 8.2884 1.07023 7.69032 1.08708 7.21468C1.1054 6.69843 1.25893 6.12189 1.54411 5.6014C1.81576 5.10576 2.17253 4.65997 2.58903 4.35433C3.00424 4.04981 3.53772 3.84664 4.10661 3.97737C4.12165 3.98084 4.13775 3.98453 4.15251 3.98909L5.22575 4.3221C5.62556 4.21028 6.05447 4.1958 6.48747 4.32015L6.54801 4.34065L6.54997 4.34163C6.55156 4.34227 6.55431 4.34319 6.55778 4.34456C6.56529 4.34752 6.57742 4.35226 6.59294 4.35823C6.62402 4.3702 6.67024 4.3877 6.72868 4.40901C6.84618 4.45186 7.01173 4.50951 7.20133 4.56819C7.59399 4.6897 8.04168 4.79978 8.382 4.81624C9.99154 4.89405 10.8568 4.72942 12.2062 4.29378ZM12.5441 6.13655C13.7669 5.47408 15.1231 5.29219 16.256 5.91292C17.1747 6.41641 17.7296 7.33256 17.9572 8.39729C18.0148 8.66723 17.8433 8.93322 17.5734 8.99104C17.3035 9.04869 17.0375 8.8771 16.9797 8.60726C16.7956 7.74535 16.3745 7.11819 15.7756 6.78987C15.0408 6.38732 14.0621 6.45197 13.0216 7.01546C12.7704 7.15159 12.5186 7.31527 12.2716 7.50472C13.0464 8.19627 13.6187 8.92334 13.9347 9.64632C14.2881 10.4549 14.3328 11.2901 13.9328 12.0203C13.5333 12.7492 12.7922 13.1542 11.9211 13.2918C11.1394 13.4153 10.2177 13.3313 9.2277 13.0614C9.20118 13.3705 9.19947 13.6697 9.21989 13.9539C9.30483 15.1342 9.77626 15.9936 10.5109 16.3963C10.8983 16.6086 11.346 16.6898 11.8351 16.6405C12.1098 16.6128 12.3552 16.8131 12.383 17.0877C12.4107 17.3624 12.2103 17.6077 11.9357 17.6356C11.2725 17.7026 10.6177 17.5951 10.0304 17.2733C8.89778 16.6525 8.32161 15.4121 8.22184 14.0252C8.12182 12.6321 8.49018 11.0188 9.32243 9.49983C10.1548 7.98089 11.316 6.80199 12.5441 6.13655ZM2.67204 9.54866C2.32412 10.2204 2.17134 10.6184 2.11051 10.8807C2.04887 11.1469 2.07605 11.2695 2.12516 11.5184C2.19851 11.8898 2.4242 12.2809 2.81169 12.702C3.1981 13.1217 3.71082 13.5349 4.29606 13.952C4.42383 14.043 4.52152 14.0826 4.59489 14.0955C4.65746 14.1064 4.73234 14.1036 4.83805 14.0535C5.04286 13.9565 5.35376 13.6844 5.76383 13.035C5.42543 12.2826 5.08809 11.7185 4.84391 11.4735C4.57886 11.2075 4.20518 10.9304 3.87907 10.7108C3.71974 10.6035 3.57875 10.514 3.4777 10.452C3.42724 10.421 3.3866 10.3967 3.35954 10.3807C3.34614 10.3728 3.33581 10.366 3.32926 10.3621L3.32047 10.3582C3.29879 10.3457 3.278 10.3312 3.25797 10.3162C2.98299 10.1101 2.79521 9.83996 2.67204 9.54866ZM11.5216 8.17561C11.0336 8.67806 10.5807 9.28455 10.1994 9.9803C9.81804 10.6763 9.54956 11.3844 9.38883 12.0662C10.3261 12.3341 11.1364 12.4037 11.7648 12.3045C12.4323 12.1991 12.8487 11.9177 13.0558 11.5399C13.2683 11.1518 13.2832 10.6541 13.0177 10.0467C12.7657 9.47024 12.2702 8.82723 11.5216 8.17561ZM9.63883 6.07112C9.45477 6.07962 9.26355 6.08427 9.06266 6.08382C9.01613 6.11598 8.96536 6.1545 8.91032 6.20003C8.71163 6.36444 8.4977 6.58912 8.28434 6.84651C7.85781 7.36118 7.46925 7.96403 7.24626 8.37093C6.99703 8.82575 6.71681 9.39869 6.51969 9.97542C6.34987 10.4725 6.25688 10.9316 6.26969 11.3055C6.3691 11.4655 6.46736 11.6376 6.56266 11.8182C6.76355 10.7536 7.14751 9.66653 7.71989 8.6219C8.25537 7.64475 8.9105 6.78559 9.63883 6.07112ZM6.12516 5.51741C5.92665 5.46415 5.72213 5.47396 5.50895 5.54378C5.15736 5.78936 4.57147 6.28659 4.28727 6.81136C3.94853 7.43736 3.7629 8.31657 3.71598 8.67561C3.71568 8.67793 3.71436 8.68015 3.71403 8.68245C3.72929 8.72056 3.74152 8.76064 3.74919 8.80257C3.79805 9.07007 3.89591 9.222 3.99626 9.30354L3.99723 9.3055C4.02922 9.32447 4.07496 9.35213 4.13102 9.38655C4.24364 9.45571 4.40052 9.5546 4.57731 9.67366C4.82014 9.83722 5.11483 10.0498 5.39079 10.283C5.44136 10.068 5.50384 9.85578 5.5734 9.65218C5.79598 9.00089 6.10514 8.37255 6.3693 7.89046C6.61869 7.4354 7.0422 6.77704 7.51481 6.20686C7.57748 6.13127 7.64175 6.05648 7.70719 5.98323C7.39142 5.92263 7.08276 5.84103 6.83219 5.76351C6.61847 5.69737 6.43222 5.63106 6.29997 5.58284C6.23424 5.55887 6.1809 5.53953 6.14372 5.52522C6.13705 5.52265 6.1308 5.51963 6.12516 5.51741ZM3.81559 5.19319C3.71663 5.17448 3.55572 5.19609 3.32926 5.36214C3.09558 5.53353 2.84889 5.82236 2.64079 6.20198C2.4462 6.55708 2.34736 6.94361 2.3361 7.25862C2.3235 7.61435 2.42004 7.7163 2.40446 7.70296L2.81657 8.03304C2.92255 7.54286 3.11192 6.88062 3.40739 6.33479C3.61396 5.95324 3.91707 5.60514 4.21794 5.31722L3.81559 5.19319Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+18 -1
components/IdentityProvider.tsx
··· 4 4 import useSWR, { KeyedMutator, mutate } from "swr"; 5 5 import { DashboardState } from "./PageLayouts/DashboardLayout"; 6 6 import { supabaseBrowserClient } from "supabase/browserClient"; 7 + import { produce, Draft } from "immer"; 7 8 8 9 export type InterfaceState = { 9 10 dashboards: { [id: string]: DashboardState | undefined }; 10 11 }; 11 - type Identity = Awaited<ReturnType<typeof getIdentityData>>; 12 + export type Identity = Awaited<ReturnType<typeof getIdentityData>>; 12 13 let IdentityContext = createContext({ 13 14 identity: null as Identity, 14 15 mutate: (() => {}) as KeyedMutator<Identity>, 15 16 }); 16 17 export const useIdentityData = () => useContext(IdentityContext); 18 + 19 + export function mutateIdentityData( 20 + mutate: KeyedMutator<Identity>, 21 + recipe: (draft: Draft<NonNullable<Identity>>) => void, 22 + ) { 23 + mutate( 24 + (data) => { 25 + if (!data) return data; 26 + return produce(data, recipe); 27 + }, 28 + { revalidate: false }, 29 + ); 30 + } 17 31 export function IdentityContextProvider(props: { 18 32 children: React.ReactNode; 19 33 initialValue: Identity; ··· 21 35 let { data: identity, mutate } = useSWR("identity", () => getIdentityData(), { 22 36 fallbackData: props.initialValue, 23 37 }); 38 + useEffect(() => { 39 + mutate(props.initialValue); 40 + }, [props.initialValue]); 24 41 useEffect(() => { 25 42 if (!identity?.atp_did) return; 26 43 let supabase = supabaseBrowserClient();
+11 -36
components/Input.tsx
··· 2 2 import { useEffect, useRef, useState, type JSX } from "react"; 3 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 4 import { isIOS } from "src/utils/isDevice"; 5 + import { focusElement } from "src/utils/focusElement"; 5 6 6 7 export const Input = ( 7 8 props: { ··· 58 59 ); 59 60 }; 60 61 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 62 export const InputWithLabel = ( 96 63 props: { 97 64 label: string; ··· 100 67 JSX.IntrinsicElements["textarea"], 101 68 ) => { 102 69 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`; 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}`; 104 77 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]!"> 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 + > 106 81 {props.label} 107 82 {textarea ? ( 108 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 + };
+3 -4
components/Layout.tsx
··· 1 + "use client"; 1 2 import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 2 3 import { theme } from "tailwind.config"; 3 4 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 4 5 import { PopoverArrow } from "./Icons/PopoverArrow"; 5 - import { PopoverOpenContext } from "./Popover"; 6 + import { PopoverOpenContext } from "./Popover/PopoverContext"; 6 7 import { useState } from "react"; 7 8 8 9 export const Separator = (props: { classname?: string }) => { 9 - return ( 10 - <div className={`min-h-full border-r border-border ${props.classname}`} /> 11 - ); 10 + return <div className={`h-full border-r border-border ${props.classname}`} />; 12 11 }; 13 12 14 13 export const Menu = (props: {
+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 + }
+27 -23
components/PageLayouts/DashboardLayout.tsx
··· 33 33 drafts: boolean; 34 34 published: boolean; 35 35 docs: boolean; 36 - templates: boolean; 36 + archived: boolean; 37 37 }; 38 38 }; 39 39 ··· 45 45 const defaultDashboardState: DashboardState = { 46 46 display: undefined, 47 47 sort: undefined, 48 - filter: { drafts: false, published: false, docs: false, templates: false }, 48 + filter: { 49 + drafts: false, 50 + published: false, 51 + docs: false, 52 + archived: false, 53 + }, 49 54 }; 50 55 51 56 export const useDashboardStore = create<DashboardStore>((set, get) => ({ ··· 255 260 hasBackgroundImage: boolean; 256 261 defaultDisplay: Exclude<DashboardState["display"], undefined>; 257 262 hasPubs: boolean; 258 - hasTemplates: boolean; 263 + hasArchived: boolean; 259 264 }) => { 260 265 let { display, sort } = useDashboardState(); 261 266 display = display || props.defaultDisplay; ··· 276 281 <DisplayToggle setState={setState} display={display} /> 277 282 <Separator classname="h-4 min-h-4!" /> 278 283 279 - {props.hasPubs || props.hasTemplates ? ( 284 + {props.hasPubs ? ( 280 285 <> 281 - {props.hasPubs} 282 - {props.hasTemplates} 283 286 <FilterOptions 284 287 hasPubs={props.hasPubs} 285 - hasTemplates={props.hasTemplates} 288 + hasArchived={props.hasArchived} 286 289 /> 287 290 <Separator classname="h-4 min-h-4!" />{" "} 288 291 </> ··· 369 372 ); 370 373 } 371 374 372 - const FilterOptions = (props: { hasPubs: boolean; hasTemplates: boolean }) => { 375 + const FilterOptions = (props: { 376 + hasPubs: boolean; 377 + hasArchived: boolean; 378 + }) => { 373 379 let { filter } = useDashboardState(); 374 380 let setState = useSetDashboardState(); 375 381 let filterCount = Object.values(filter).filter(Boolean).length; ··· 406 412 </> 407 413 )} 408 414 409 - {props.hasTemplates && ( 410 - <> 411 - <Checkbox 412 - small 413 - checked={filter.templates} 414 - onChange={(e) => 415 - setState({ 416 - filter: { ...filter, templates: !!e.target.checked }, 417 - }) 418 - } 419 - > 420 - Templates 421 - </Checkbox> 422 - </> 415 + {props.hasArchived && ( 416 + <Checkbox 417 + small 418 + checked={filter.archived} 419 + onChange={(e) => 420 + setState({ 421 + filter: { ...filter, archived: !!e.target.checked }, 422 + }) 423 + } 424 + > 425 + Archived 426 + </Checkbox> 423 427 )} 424 428 <Checkbox 425 429 small ··· 441 445 docs: false, 442 446 published: false, 443 447 drafts: false, 444 - templates: false, 448 + archived: false, 445 449 }, 446 450 }); 447 451 }}
+52 -6
components/PageSWRDataProvider.tsx
··· 7 7 import { getPollData } from "actions/pollActions"; 8 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 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"; 10 13 11 14 export const StaticLeafletDataContext = createContext< 12 15 null | GetLeafletDataReturnType["result"]["data"] ··· 66 69 }; 67 70 export function useLeafletPublicationData() { 68 71 let { data, mutate } = useLeafletData(); 72 + 73 + // First check for leaflets in publications 74 + let pubData = getPublicationMetadataFromLeafletData(data); 75 + 69 76 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, 77 + data: pubData || null, 76 78 mutate, 77 79 }; 78 80 } ··· 80 82 let { data, mutate } = useLeafletData(); 81 83 return { data: data?.custom_domain_routes, mutate: mutate }; 82 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 + }
+5 -2
components/Pages/Page.tsx
··· 12 12 import { Blocks } from "components/Blocks"; 13 13 import { PublicationMetadata } from "./PublicationMetadata"; 14 14 import { useCardBorderHidden } from "./useCardBorderHidden"; 15 - import { focusPage } from "."; 15 + import { focusPage } from "src/utils/focusPage"; 16 16 import { PageOptions } from "./PageOptions"; 17 17 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 18 18 import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; 19 + import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 19 20 20 21 export function Page(props: { 21 22 entityID: string; ··· 60 61 /> 61 62 } 62 63 > 63 - {props.first && ( 64 + {props.first && pageType === "doc" && ( 64 65 <> 65 66 <PublicationMetadata /> 66 67 </> ··· 83 84 pageType: "canvas" | "doc"; 84 85 drawerOpen: boolean | undefined; 85 86 }) => { 87 + let { ref } = usePreserveScroll<HTMLDivElement>(props.id); 86 88 return ( 87 89 // this div wraps the contents AND the page options. 88 90 // it needs to be its own div because this container does NOT scroll, and therefore doesn't clip the absolutely positioned pageOptions ··· 95 97 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 98 */} 97 99 <div 100 + ref={ref} 98 101 onClick={props.onClickAction} 99 102 id={props.id} 100 103 className={`
+7 -6
components/Pages/PageShareMenu.tsx
··· 1 1 import { useLeafletDomains } from "components/PageSWRDataProvider"; 2 - import { ShareButton, usePublishLink } from "components/ShareOptions"; 2 + import { 3 + ShareButton, 4 + useReadOnlyShareLink, 5 + } from "app/[leaflet_id]/actions/ShareOptions"; 3 6 import { useEffect, useState } from "react"; 4 7 5 8 export const PageShareMenu = (props: { entityID: string }) => { 6 - let publishLink = usePublishLink(); 9 + let publishLink = useReadOnlyShareLink(); 7 10 let { data: domains } = useLeafletDomains(); 8 11 let [collabLink, setCollabLink] = useState<null | string>(null); 9 12 useEffect(() => { ··· 14 17 <div> 15 18 <ShareButton 16 19 text="Share Edit Link" 17 - subtext="" 18 - helptext="recipients can edit the full Leaflet" 20 + subtext="Recipients can edit the full Leaflet" 19 21 smokerText="Collab link copied!" 20 22 id="get-page-collab-link" 21 23 link={`${collabLink}?page=${props.entityID}`} 22 24 /> 23 25 <ShareButton 24 26 text="Share View Link" 25 - subtext="" 26 - helptext="recipients can view the full Leaflet" 27 + subtext="Recipients can view the full Leaflet" 27 28 smokerText="Publish link copied!" 28 29 id="get-page-publish-link" 29 30 fullLink={
+158 -80
components/Pages/PublicationMetadata.tsx
··· 1 1 import Link from "next/link"; 2 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 - import { useRef } from "react"; 3 + import { useRef, useState } from "react"; 4 4 import { useReplicache } from "src/replicache"; 5 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 6 import { Separator } from "components/Layout"; 7 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument } from "lexicons/api"; 8 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 9 import { 10 10 getBasePublicationURL, 11 11 getPublicationURL, ··· 13 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 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"; 16 21 import { useIdentityData } from "components/IdentityProvider"; 22 + import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 17 23 export const PublicationMetadata = () => { 18 24 let { rep } = useReplicache(); 19 25 let { data: pub } = useLeafletPublicationData(); ··· 23 29 tx.get<string>("publication_description"), 24 30 ); 25 31 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 32 + let pubRecord = pub?.publications?.record as 33 + | PubLeafletPublication.Record 34 + | undefined; 26 35 let publishedAt = record?.publishedAt; 27 36 28 - if (!pub || !pub.publications) return null; 37 + if (!pub) return null; 29 38 30 39 if (typeof title !== "string") { 31 40 title = pub?.title || ""; ··· 33 42 if (typeof description !== "string") { 34 43 description = pub?.description || ""; 35 44 } 45 + let tags = true; 46 + 36 47 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 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> 51 66 </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> 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 + /> 93 136 ); 94 137 }; 95 138 ··· 169 212 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 170 213 let publishedAt = record?.publishedAt; 171 214 172 - if (!pub || !pub.publications) return null; 215 + if (!pub) return null; 173 216 174 217 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> 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 + ); 179 246 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> 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 + }; 188 262 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> 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"} 194 272 </div> 195 - ) : ( 196 - <p className="text-sm text-tertiary pt-2">Draft</p> 197 - )} 198 - </div> 273 + } 274 + > 275 + <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 276 + </Popover> 199 277 ); 200 278 };
+2 -75
components/Pages/index.tsx
··· 4 4 import { useUIState } from "src/useUIState"; 5 5 import { useSearchParams } from "next/navigation"; 6 6 7 - import { focusBlock } from "src/utils/focusBlock"; 8 - import { elementId } from "src/utils/elementId"; 7 + import { useEntity } from "src/replicache"; 9 8 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 9 import { useCardBorderHidden } from "./useCardBorderHidden"; 17 10 import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 18 11 import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; ··· 62 55 ); 63 56 } 64 57 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 = () => { 58 + const blurPage = () => { 132 59 useUIState.setState(() => ({ 133 60 focusedEntity: null, 134 61 selectedBlocks: [],
+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 - };
+132
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 PubLeafletPublication.Record; 19 + 20 + let postRecord = props.documents.data as PubLeafletDocument.Record; 21 + let postUri = new AtUri(props.documents.uri); 22 + 23 + let theme = usePubTheme(pubRecord.theme); 24 + let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 25 + ? blobRefToSrc( 26 + pubRecord?.theme?.backgroundImage?.image?.ref, 27 + new AtUri(props.publication.uri).host, 28 + ) 29 + : null; 30 + 31 + let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 32 + let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 33 + 34 + let showPageBackground = pubRecord.theme?.showPageBackground; 35 + 36 + let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 37 + let comments = 38 + pubRecord.preferences?.showComments === false 39 + ? 0 40 + : props.documents.comments_on_documents?.[0]?.count || 0; 41 + let tags = (postRecord?.tags as string[] | undefined) || []; 42 + 43 + return ( 44 + <BaseThemeProvider {...theme} local> 45 + <div 46 + style={{ 47 + backgroundImage: `url(${backgroundImage})`, 48 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 49 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 50 + }} 51 + className={`no-underline! flex flex-row gap-2 w-full relative 52 + bg-bg-leaflet 53 + border border-border-light rounded-lg 54 + sm:p-2 p-2 selected-outline 55 + hover:outline-accent-contrast hover:border-accent-contrast 56 + `} 57 + > 58 + <Link 59 + className="h-full w-full absolute top-0 left-0" 60 + href={`${props.publication.href}/${postUri.rkey}`} 61 + /> 62 + <div 63 + className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 64 + style={{ 65 + backgroundColor: showPageBackground 66 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 67 + : "transparent", 68 + }} 69 + > 70 + <h3 className="text-primary truncate">{postRecord.title}</h3> 71 + 72 + <p className="text-secondary italic">{postRecord.description}</p> 73 + <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"> 74 + <PubInfo 75 + href={props.publication.href} 76 + pubRecord={pubRecord} 77 + uri={props.publication.uri} 78 + /> 79 + <div className="flex flex-row justify-between gap-2 items-center w-full"> 80 + <PostInfo publishedAt={postRecord.publishedAt} /> 81 + <InteractionPreview 82 + postUrl={`${props.publication.href}/${postUri.rkey}`} 83 + quotesCount={quotes} 84 + commentsCount={comments} 85 + tags={tags} 86 + showComments={pubRecord.preferences?.showComments} 87 + share 88 + /> 89 + </div> 90 + </div> 91 + </div> 92 + </div> 93 + </BaseThemeProvider> 94 + ); 95 + }; 96 + 97 + const PubInfo = (props: { 98 + href: string; 99 + pubRecord: PubLeafletPublication.Record; 100 + uri: string; 101 + }) => { 102 + return ( 103 + <div className="flex flex-col md:w-auto shrink-0 w-full"> 104 + <hr className="md:hidden block border-border-light mb-2" /> 105 + <Link 106 + href={props.href} 107 + className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0" 108 + > 109 + <PubIcon small record={props.pubRecord} uri={props.uri} /> 110 + {props.pubRecord.name} 111 + </Link> 112 + </div> 113 + ); 114 + }; 115 + 116 + const PostInfo = (props: { publishedAt: string | undefined }) => { 117 + return ( 118 + <div className="flex gap-2 items-center shrink-0 self-start"> 119 + {props.publishedAt && ( 120 + <> 121 + <div className="shrink-0"> 122 + {new Date(props.publishedAt).toLocaleDateString("en-US", { 123 + year: "numeric", 124 + month: "short", 125 + day: "numeric", 126 + })} 127 + </div> 128 + </> 129 + )} 130 + </div> 131 + ); 132 + };
-1
components/Providers/RequestHeadersProvider.tsx
··· 19 19 timezone: string | null; 20 20 children: React.ReactNode; 21 21 }) => { 22 - console.log(props); 23 22 return ( 24 23 <RequestHeadersContext.Provider 25 24 value={{
+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 - }
-284
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 { useTemplateState } from "app/(home-pages)/home/Actions/CreateNewButton"; 10 - import LoginForm from "app/login/LoginForm"; 11 - import { CustomDomainMenu } from "./DomainOptions"; 12 - import { useIdentityData } from "components/IdentityProvider"; 13 - import { 14 - useLeafletDomains, 15 - useLeafletPublicationData, 16 - } from "components/PageSWRDataProvider"; 17 - import { ShareSmall } from "components/Icons/ShareSmall"; 18 - import { PubLeafletDocument } from "lexicons/api"; 19 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 20 - import { AtUri } from "@atproto/syntax"; 21 - import { useIsMobile } from "src/hooks/isMobile"; 22 - 23 - export type ShareMenuStates = "default" | "login" | "domain"; 24 - 25 - export let usePublishLink = () => { 26 - let { permission_token, rootEntity } = useReplicache(); 27 - let entity_set = useEntitySetContext(); 28 - let { data: publishLink } = useSWR( 29 - "publishLink-" + permission_token.id, 30 - async () => { 31 - if ( 32 - !permission_token.permission_token_rights.find( 33 - (s) => s.entity_set === entity_set.set && s.create_token, 34 - ) 35 - ) 36 - return; 37 - let shareLink = await getShareLink( 38 - { id: permission_token.id, entity_set: entity_set.set }, 39 - rootEntity, 40 - ); 41 - return shareLink?.id; 42 - }, 43 - ); 44 - return publishLink; 45 - }; 46 - 47 - export function ShareOptions() { 48 - let [menuState, setMenuState] = useState<ShareMenuStates>("default"); 49 - let { data: pub } = useLeafletPublicationData(); 50 - let isMobile = useIsMobile(); 51 - 52 - return ( 53 - <Menu 54 - asChild 55 - side={isMobile ? "top" : "right"} 56 - align={isMobile ? "center" : "start"} 57 - className="max-w-xs" 58 - onOpenChange={() => { 59 - setMenuState("default"); 60 - }} 61 - trigger={ 62 - <ActionButton 63 - icon=<ShareSmall /> 64 - primary={!!!pub} 65 - secondary={!!pub} 66 - label={`Share ${pub ? "Draft" : ""}`} 67 - /> 68 - } 69 - > 70 - {menuState === "login" ? ( 71 - <div className="px-3 py-1"> 72 - <LoginForm text="Save your Leaflets and access them on multiple devices!" /> 73 - </div> 74 - ) : menuState === "domain" ? ( 75 - <CustomDomainMenu setShareMenuState={setMenuState} /> 76 - ) : ( 77 - <ShareMenu 78 - setMenuState={setMenuState} 79 - domainConnected={false} 80 - isPub={!!pub} 81 - /> 82 - )} 83 - </Menu> 84 - ); 85 - } 86 - 87 - const ShareMenu = (props: { 88 - setMenuState: (state: ShareMenuStates) => void; 89 - domainConnected: boolean; 90 - isPub?: boolean; 91 - }) => { 92 - let { permission_token } = useReplicache(); 93 - let { data: pub } = useLeafletPublicationData(); 94 - 95 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 96 - 97 - let postLink = 98 - pub?.publications && pub.documents 99 - ? `${getPublicationURL(pub.publications)}/${new AtUri(pub?.documents.uri).rkey}` 100 - : null; 101 - let publishLink = usePublishLink(); 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 - let isTemplate = useTemplateState( 110 - (s) => !!s.templates.find((t) => t.id === permission_token.id), 111 - ); 112 - 113 - return ( 114 - <> 115 - {isTemplate && ( 116 - <> 117 - <ShareButton 118 - text="Share Template" 119 - subtext="Let others make new Leaflets as copies of this template" 120 - smokerText="Template link copied!" 121 - id="get-template-link" 122 - link={`template/${publishLink}` || ""} 123 - /> 124 - <hr className="border-border my-1" /> 125 - </> 126 - )} 127 - 128 - <ShareButton 129 - text={`Share ${postLink ? "Draft" : ""} Edit Link`} 130 - subtext="" 131 - smokerText="Edit link copied!" 132 - id="get-edit-link" 133 - link={collabLink} 134 - /> 135 - <ShareButton 136 - text={`Share ${postLink ? "Draft" : ""} View Link`} 137 - subtext=<> 138 - {domains?.[0] ? ( 139 - <> 140 - This Leaflet is published on{" "} 141 - <span className="italic underline"> 142 - {domains[0].domain} 143 - {domains[0].route} 144 - </span> 145 - </> 146 - ) : ( 147 - "" 148 - )} 149 - </> 150 - smokerText="View link copied!" 151 - id="get-view-link" 152 - fullLink={ 153 - domains?.[0] 154 - ? `https://${domains[0].domain}${domains[0].route}` 155 - : undefined 156 - } 157 - link={publishLink || ""} 158 - /> 159 - {postLink && ( 160 - <> 161 - <hr className="border-border-light" /> 162 - 163 - <ShareButton 164 - text="Share Published Link" 165 - subtext="" 166 - smokerText="Post link copied!" 167 - id="get-post-link" 168 - fullLink={postLink.includes("http") ? postLink : undefined} 169 - link={postLink} 170 - /> 171 - </> 172 - )} 173 - {!props.isPub && ( 174 - <> 175 - <hr className="border-border mt-1" /> 176 - <DomainMenuItem setMenuState={props.setMenuState} /> 177 - </> 178 - )} 179 - </> 180 - ); 181 - }; 182 - 183 - export const ShareButton = (props: { 184 - text: React.ReactNode; 185 - subtext: React.ReactNode; 186 - helptext?: string; 187 - smokerText: string; 188 - id: string; 189 - link: null | string; 190 - fullLink?: string; 191 - className?: string; 192 - }) => { 193 - let smoker = useSmoker(); 194 - 195 - return ( 196 - <MenuItem 197 - id={props.id} 198 - onSelect={(e) => { 199 - e.preventDefault(); 200 - let rect = document.getElementById(props.id)?.getBoundingClientRect(); 201 - if (props.link || props.fullLink) { 202 - navigator.clipboard.writeText( 203 - props.fullLink 204 - ? props.fullLink 205 - : `${location.protocol}//${location.host}/${props.link}`, 206 - ); 207 - smoker({ 208 - position: { 209 - x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 210 - y: rect ? rect.top + 26 : 0, 211 - }, 212 - text: props.smokerText, 213 - }); 214 - } 215 - }} 216 - > 217 - <div className={`group/${props.id} ${props.className}`}> 218 - <div className={`group-hover/${props.id}:text-accent-contrast`}> 219 - {props.text} 220 - </div> 221 - <div 222 - className={`text-sm font-normal text-tertiary group-hover/${props.id}:text-accent-contrast`} 223 - > 224 - {props.subtext} 225 - </div> 226 - {/* optional help text */} 227 - {props.helptext && ( 228 - <div 229 - className={`text-sm italic font-normal text-tertiary group-hover/${props.id}:text-accent-contrast`} 230 - > 231 - {props.helptext} 232 - </div> 233 - )} 234 - </div> 235 - </MenuItem> 236 - ); 237 - }; 238 - 239 - const DomainMenuItem = (props: { 240 - setMenuState: (state: ShareMenuStates) => void; 241 - }) => { 242 - let { identity } = useIdentityData(); 243 - let { data: domains } = useLeafletDomains(); 244 - 245 - if (identity === null) 246 - return ( 247 - <div className="text-tertiary font-normal text-sm px-3 py-1"> 248 - <button 249 - className="text-accent-contrast hover:font-bold" 250 - onClick={() => { 251 - props.setMenuState("login"); 252 - }} 253 - > 254 - Log In 255 - </button>{" "} 256 - to publish on a custom domain! 257 - </div> 258 - ); 259 - else 260 - return ( 261 - <> 262 - {domains?.[0] ? ( 263 - <button 264 - className="px-3 py-1 text-accent-contrast text-sm hover:font-bold w-fit text-left" 265 - onMouseDown={() => { 266 - props.setMenuState("domain"); 267 - }} 268 - > 269 - Edit custom domain 270 - </button> 271 - ) : ( 272 - <MenuItem 273 - className="font-normal text-tertiary text-sm" 274 - onSelect={(e) => { 275 - e.preventDefault(); 276 - props.setMenuState("domain"); 277 - }} 278 - > 279 - Publish on a custom domain 280 - </MenuItem> 281 - )} 282 - </> 283 - ); 284 - };
+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 16 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 17 import { Separator } from "components/Layout"; 18 18 import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; 19 + import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; 19 20 20 21 export type ImageState = { 21 22 src: string; ··· 39 40 theme: localPubTheme, 40 41 setTheme, 41 42 changes, 42 - } = useLocalPubTheme(record, showPageBackground); 43 + } = useLocalPubTheme(record?.theme, showPageBackground); 43 44 let [image, setImage] = useState<ImageState | null>( 44 45 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) 45 46 ? { ··· 343 344 </div> 344 345 ); 345 346 }; 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 - }
+39 -27
components/ThemeManager/PublicationThemeProvider.tsx
··· 2 2 import { useMemo, useState } from "react"; 3 3 import { parseColor } from "react-aria-components"; 4 4 import { useEntity } from "src/replicache"; 5 - import { getColorContrast } from "./ThemeProvider"; 5 + import { getColorContrast } from "./themeUtils"; 6 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 7 import { BaseThemeProvider } from "./ThemeProvider"; 8 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; ··· 16 16 accentText: "#FFFFFF", 17 17 accentBackground: "#0000FF", 18 18 }; 19 + 20 + // Default page background for standalone leaflets (matches editor default) 21 + const StandalonePageBackground = "#FFFFFF"; 19 22 function parseThemeColor( 20 23 c: PubLeafletThemeColor.Rgb | PubLeafletThemeColor.Rgba, 21 24 ) { ··· 26 29 } 27 30 28 31 let useColor = ( 29 - record: PubLeafletPublication.Record | null | undefined, 32 + theme: PubLeafletPublication.Record["theme"] | null | undefined, 30 33 c: keyof typeof PubThemeDefaults, 31 34 ) => { 32 35 return useMemo(() => { 33 - let v = record?.theme?.[c]; 36 + let v = theme?.[c]; 34 37 if (isColor(v)) { 35 38 return parseThemeColor(v); 36 39 } else return parseColor(PubThemeDefaults[c]); 37 - }, [record?.theme?.[c]]); 40 + }, [theme?.[c]]); 38 41 }; 39 42 let isColor = ( 40 43 c: any, ··· 53 56 return ( 54 57 <PublicationThemeProvider 55 58 pub_creator={pub?.identity_did || ""} 56 - record={pub?.record as PubLeafletPublication.Record} 59 + theme={(pub?.record as PubLeafletPublication.Record)?.theme} 57 60 > 58 61 <PublicationBackgroundProvider 59 - record={pub?.record as PubLeafletPublication.Record} 62 + theme={(pub?.record as PubLeafletPublication.Record)?.theme} 60 63 pub_creator={pub?.identity_did || ""} 61 64 > 62 65 {props.children} ··· 66 69 } 67 70 68 71 export function PublicationBackgroundProvider(props: { 69 - record?: PubLeafletPublication.Record | null; 72 + theme?: PubLeafletPublication.Record["theme"] | null; 70 73 pub_creator: string; 71 74 className?: string; 72 75 children: React.ReactNode; 73 76 }) { 74 - let backgroundImage = props.record?.theme?.backgroundImage?.image?.ref 75 - ? blobRefToSrc( 76 - props.record?.theme?.backgroundImage?.image?.ref, 77 - props.pub_creator, 78 - ) 77 + let backgroundImage = props.theme?.backgroundImage?.image?.ref 78 + ? blobRefToSrc(props.theme?.backgroundImage?.image?.ref, props.pub_creator) 79 79 : null; 80 80 81 - let backgroundImageRepeat = props.record?.theme?.backgroundImage?.repeat; 82 - let backgroundImageSize = props.record?.theme?.backgroundImage?.width || 500; 81 + let backgroundImageRepeat = props.theme?.backgroundImage?.repeat; 82 + let backgroundImageSize = props.theme?.backgroundImage?.width || 500; 83 83 return ( 84 84 <div 85 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 86 style={{ 87 - backgroundImage: `url(${backgroundImage})`, 87 + backgroundImage: backgroundImage 88 + ? `url(${backgroundImage})` 89 + : undefined, 88 90 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 89 91 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 90 92 }} ··· 96 98 export function PublicationThemeProvider(props: { 97 99 local?: boolean; 98 100 children: React.ReactNode; 99 - record?: PubLeafletPublication.Record | null; 101 + theme?: PubLeafletPublication.Record["theme"] | null; 100 102 pub_creator: string; 103 + isStandalone?: boolean; 101 104 }) { 102 - let colors = usePubTheme(props.record); 105 + let colors = usePubTheme(props.theme, props.isStandalone); 103 106 return ( 104 107 <BaseThemeProvider local={props.local} {...colors}> 105 108 {props.children} ··· 107 110 ); 108 111 } 109 112 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; 113 + export const usePubTheme = ( 114 + theme?: PubLeafletPublication.Record["theme"] | null, 115 + isStandalone?: boolean, 116 + ) => { 117 + let bgLeaflet = useColor(theme, "backgroundColor"); 118 + let bgPage = useColor(theme, "pageBackground"); 119 + // For standalone documents, use the editor default page background (#FFFFFF) 120 + // For publications without explicit pageBackground, use bgLeaflet 121 + if (isStandalone && !theme?.pageBackground) { 122 + bgPage = parseColor(StandalonePageBackground); 123 + } else if (theme && !theme.pageBackground) { 124 + bgPage = bgLeaflet; 125 + } 126 + let showPageBackground = theme?.showPageBackground; 115 127 116 - let primary = useColor(record, "primary"); 128 + let primary = useColor(theme, "primary"); 117 129 118 - let accent1 = useColor(record, "accentBackground"); 119 - let accent2 = useColor(record, "accentText"); 130 + let accent1 = useColor(theme, "accentBackground"); 131 + let accent2 = useColor(theme, "accentText"); 120 132 121 133 let highlight1 = useEntity(null, "theme/highlight-1")?.data.value; 122 134 let highlight2 = useColorAttribute(null, "theme/highlight-2"); ··· 136 148 }; 137 149 138 150 export const useLocalPubTheme = ( 139 - record: PubLeafletPublication.Record | undefined, 151 + theme: PubLeafletPublication.Record["theme"] | undefined, 140 152 showPageBackground?: boolean, 141 153 ) => { 142 - const pubTheme = usePubTheme(record); 154 + const pubTheme = usePubTheme(theme); 143 155 const [localOverrides, setTheme] = useState<Partial<typeof pubTheme>>({}); 144 156 145 157 const mergedTheme = useMemo(() => {
+5 -42
components/ThemeManager/ThemeProvider.tsx
··· 5 5 CSSProperties, 6 6 useContext, 7 7 useEffect, 8 - useMemo, 9 - useState, 10 8 } from "react"; 11 9 import { 12 10 colorToString, ··· 14 12 useColorAttributeNullable, 15 13 } from "./useColorAttribute"; 16 14 import { Color as AriaColor, parseColor } from "react-aria-components"; 17 - import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 18 15 19 16 import { useEntity } from "src/replicache"; 20 17 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 23 20 PublicationThemeProvider, 24 21 } from "./PublicationThemeProvider"; 25 22 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 - }; 23 + import { getColorContrast } from "./themeUtils"; 54 24 55 25 // define a function to set an Aria Color to a CSS Variable in RGB 56 26 function setCSSVariableToColor( ··· 73 43 return ( 74 44 <PublicationThemeProvider 75 45 {...props} 76 - record={pub.publications?.record as PubLeafletPublication.Record} 46 + theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme} 77 47 pub_creator={pub.publications?.identity_did} 78 48 /> 79 49 ); ··· 339 309 return ( 340 310 <PublicationBackgroundProvider 341 311 pub_creator={pub?.publications.identity_did || ""} 342 - record={pub?.publications.record as PubLeafletPublication.Record} 312 + theme={ 313 + (pub.publications?.record as PubLeafletPublication.Record)?.theme 314 + } 343 315 > 344 316 {props.children} 345 317 </PublicationBackgroundProvider> ··· 366 338 ); 367 339 }; 368 340 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 - }
+2 -2
components/ThemeManager/ThemeSetter.tsx
··· 70 70 }, [rep, props.entityID]); 71 71 72 72 if (!permission) return null; 73 - if (pub) return null; 73 + if (pub?.publications) return null; 74 74 75 75 return ( 76 76 <> ··· 111 111 }, [rep, props.entityID]); 112 112 113 113 if (!permission) return null; 114 - if (pub) return null; 114 + if (pub?.publications) return null; 115 115 return ( 116 116 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 117 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 2 import { Color, parseColor } from "react-aria-components"; 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 4 import { FilterAttributes } from "src/replicache/attributes"; 5 - import { ThemeDefaults } from "./ThemeProvider"; 5 + import { ThemeDefaults } from "./themeUtils"; 6 6 7 7 export function useColorAttribute( 8 8 entity: string | null,
+5 -14
components/Toolbar/BlockToolbar.tsx
··· 2 2 import { ToolbarButton } from "."; 3 3 import { Separator, ShortcutKey } from "components/Layout"; 4 4 import { metaKey } from "src/utils/metaKey"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 5 import { useUIState } from "src/useUIState"; 7 6 import { LockBlockButton } from "./LockBlockButton"; 8 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 9 8 import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 10 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 11 12 12 export const BlockToolbar = (props: { 13 13 setToolbarState: ( ··· 66 66 67 67 const MoveBlockButtons = () => { 68 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 69 return ( 81 70 <> 82 71 <ToolbarButton 83 72 hiddenOnCanvas 84 73 onClick={async () => { 85 - let [sortedBlocks, siblings] = await getSortedSelection(); 74 + if (!rep) return; 75 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 86 76 if (sortedBlocks.length > 1) return; 87 77 let block = sortedBlocks[0]; 88 78 let previousBlock = ··· 139 129 <ToolbarButton 140 130 hiddenOnCanvas 141 131 onClick={async () => { 142 - let [sortedBlocks, siblings] = await getSortedSelection(); 132 + if (!rep) return; 133 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 143 134 if (sortedBlocks.length > 1) return; 144 135 let block = sortedBlocks[0]; 145 136 let nextBlock = siblings
+1 -1
components/Toolbar/MultiSelectToolbar.tsx
··· 8 8 import { LockBlockButton } from "./LockBlockButton"; 9 9 import { Props } from "components/Icons/Props"; 10 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { getSortedSelection } from "components/SelectionManager"; 11 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 12 12 13 13 export const MultiselectToolbar = (props: { 14 14 setToolbarState: (
+2 -1
components/Toolbar/index.tsx
··· 13 13 import { TextToolbar } from "./TextToolbar"; 14 14 import { BlockToolbar } from "./BlockToolbar"; 15 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 - import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock"; 16 + import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 + import { deleteBlock } from "src/utils/deleteBlock"; 17 18 import { TooltipButton } from "components/Buttons"; 18 19 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 19 20 import { useIsMobile } from "src/hooks/isMobile";
+1 -1
components/utils/UpdateLeafletTitle.tsx
··· 8 8 import { useEntity, useReplicache } from "src/replicache"; 9 9 import * as Y from "yjs"; 10 10 import * as base64 from "base64-js"; 11 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 11 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 12 12 import { useParams, useRouter, useSearchParams } from "next/navigation"; 13 13 import { focusBlock } from "src/utils/focusBlock"; 14 14 import { useIsMobile } from "src/hooks/isMobile";
+16 -8
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema"; 2 + import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema"; 3 3 4 - export const publicationsRelations = relations(publications, ({one, many}) => ({ 4 + export const notificationsRelations = relations(notifications, ({one}) => ({ 5 5 identity: one(identities, { 6 - fields: [publications.identity_did], 6 + fields: [notifications.recipient], 7 7 references: [identities.atp_did] 8 8 }), 9 - subscribers_to_publications: many(subscribers_to_publications), 10 - documents_in_publications: many(documents_in_publications), 11 - publication_domains: many(publication_domains), 12 - leaflets_in_publications: many(leaflets_in_publications), 13 - publication_subscriptions: many(publication_subscriptions), 14 9 })); 15 10 16 11 export const identitiesRelations = relations(identities, ({one, many}) => ({ 12 + notifications: many(notifications), 17 13 publications: many(publications), 18 14 email_auth_tokens: many(email_auth_tokens), 19 15 bsky_profiles: many(bsky_profiles), ··· 36 32 subscribers_to_publications: many(subscribers_to_publications), 37 33 permission_token_on_homepages: many(permission_token_on_homepage), 38 34 publication_domains: many(publication_domains), 35 + publication_subscriptions: many(publication_subscriptions), 36 + })); 37 + 38 + export const publicationsRelations = relations(publications, ({one, many}) => ({ 39 + identity: one(identities, { 40 + fields: [publications.identity_did], 41 + references: [identities.atp_did] 42 + }), 43 + subscribers_to_publications: many(subscribers_to_publications), 44 + documents_in_publications: many(documents_in_publications), 45 + publication_domains: many(publication_domains), 46 + leaflets_in_publications: many(leaflets_in_publications), 39 47 publication_subscriptions: many(publication_subscriptions), 40 48 })); 41 49
+10 -2
drizzle/schema.ts
··· 1 - import { pgTable, pgEnum, text, jsonb, index, foreignKey, timestamp, uuid, bigint, boolean, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 1 + import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 2 2 import { sql } from "drizzle-orm" 3 3 4 4 export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) ··· 15 15 export const rsvp_status = pgEnum("rsvp_status", ['GOING', 'NOT_GOING', 'MAYBE']) 16 16 export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'ERROR']) 17 17 export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) 18 - export const buckettype = pgEnum("buckettype", ['STANDARD', 'ANALYTICS']) 18 + export const buckettype = pgEnum("buckettype", ['STANDARD', 'ANALYTICS', 'VECTOR']) 19 19 20 20 21 21 export const oauth_state_store = pgTable("oauth_state_store", { 22 22 key: text("key").primaryKey().notNull(), 23 23 state: jsonb("state").notNull(), 24 + }); 25 + 26 + export const notifications = pgTable("notifications", { 27 + recipient: text("recipient").notNull().references(() => identities.atp_did, { onDelete: "cascade", onUpdate: "cascade" } ), 28 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 29 + read: boolean("read").default(false).notNull(), 30 + data: jsonb("data").notNull(), 31 + id: uuid("id").primaryKey().notNull(), 24 32 }); 25 33 26 34 export const publications = pgTable("publications", {
+1 -1
feeds/index.ts
··· 92 92 .from("documents") 93 93 .select( 94 94 `*, 95 - documents_in_publications!inner(publications!inner(*))`, 95 + documents_in_publications(publications(*))`, 96 96 ) 97 97 .or( 98 98 "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true",
+36 -1
lexicons/api/lexicons.ts
··· 1408 1408 description: 'Record containing a document', 1409 1409 record: { 1410 1410 type: 'object', 1411 - required: ['pages', 'author', 'title', 'publication'], 1411 + required: ['pages', 'author', 'title'], 1412 1412 properties: { 1413 1413 title: { 1414 1414 type: 'string', ··· 1435 1435 author: { 1436 1436 type: 'string', 1437 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 + }, 1438 1449 }, 1439 1450 pages: { 1440 1451 type: 'array', ··· 1861 1872 type: 'union', 1862 1873 refs: [ 1863 1874 'lex:pub.leaflet.richtext.facet#link', 1875 + 'lex:pub.leaflet.richtext.facet#didMention', 1876 + 'lex:pub.leaflet.richtext.facet#atMention', 1864 1877 'lex:pub.leaflet.richtext.facet#code', 1865 1878 'lex:pub.leaflet.richtext.facet#highlight', 1866 1879 'lex:pub.leaflet.richtext.facet#underline', ··· 1897 1910 properties: { 1898 1911 uri: { 1899 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', 1900 1935 }, 1901 1936 }, 1902 1937 },
+4 -1
lexicons/api/types/pub/leaflet/document.ts
··· 6 6 import { validate as _validate } from '../../../lexicons' 7 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 8 import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 + import type * as PubLeafletPublication from './publication' 9 10 import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 10 11 import type * as PubLeafletPagesCanvas from './pages/canvas' 11 12 ··· 19 20 postRef?: ComAtprotoRepoStrongRef.Main 20 21 description?: string 21 22 publishedAt?: string 22 - publication: string 23 + publication?: string 23 24 author: string 25 + theme?: PubLeafletPublication.Theme 26 + tags?: string[] 24 27 pages: ( 25 28 | $Typed<PubLeafletPagesLinearDocument.Main> 26 29 | $Typed<PubLeafletPagesCanvas.Main>
+34
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 20 20 index: ByteSlice 21 21 features: ( 22 22 | $Typed<Link> 23 + | $Typed<DidMention> 24 + | $Typed<AtMention> 23 25 | $Typed<Code> 24 26 | $Typed<Highlight> 25 27 | $Typed<Underline> ··· 72 74 73 75 export function validateLink<V>(v: V) { 74 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) 75 109 } 76 110 77 111 /** Facet feature for inline code. */
+12 -2
lexicons/pub/leaflet/document.json
··· 13 13 "required": [ 14 14 "pages", 15 15 "author", 16 - "title", 17 - "publication" 16 + "title" 18 17 ], 19 18 "properties": { 20 19 "title": { ··· 42 41 "author": { 43 42 "type": "string", 44 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 + } 45 55 }, 46 56 "pages": { 47 57 "type": "array",
+28
lexicons/pub/leaflet/richtext/facet.json
··· 20 20 "type": "union", 21 21 "refs": [ 22 22 "#link", 23 + "#didMention", 24 + "#atMention", 23 25 "#code", 24 26 "#highlight", 25 27 "#underline", ··· 59 61 "properties": { 60 62 "uri": { 61 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" 62 90 } 63 91 } 64 92 },
+3 -1
lexicons/src/document.ts
··· 14 14 description: "Record containing a document", 15 15 record: { 16 16 type: "object", 17 - required: ["pages", "author", "title", "publication"], 17 + required: ["pages", "author", "title"], 18 18 properties: { 19 19 title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, 20 20 postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, ··· 22 22 publishedAt: { type: "string", format: "datetime" }, 23 23 publication: { type: "string", format: "at-uri" }, 24 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 } }, 25 27 pages: { 26 28 type: "array", 27 29 items: {
+12
lexicons/src/facet.ts
··· 9 9 uri: { type: "string" }, 10 10 }, 11 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 + }, 12 24 code: { 13 25 type: "object", 14 26 description: "Facet feature for inline code.",
+1 -1
next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 - /// <reference path="./.next/types/routes.d.ts" /> 3 + import "./.next/dev/types/routes.d.ts"; 4 4 5 5 // NOTE: This file should not be edited 6 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -2
next.config.js
··· 21 21 }, 22 22 ]; 23 23 }, 24 - serverExternalPackages: ["yjs"], 24 + serverExternalPackages: ["yjs", "pino"], 25 25 pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"], 26 26 images: { 27 27 loader: "custom", ··· 31 31 { protocol: "https", hostname: "bdefzwcumgzjwllsnaej.supabase.co" }, 32 32 ], 33 33 }, 34 + reactCompiler: true, 34 35 experimental: { 35 - reactCompiler: true, 36 36 serverActions: { 37 37 bodySizeLimit: "5mb", 38 38 },
+2337 -453
package-lock.json
··· 21 21 "@hono/node-server": "^1.14.3", 22 22 "@mdx-js/loader": "^3.1.0", 23 23 "@mdx-js/react": "^3.1.0", 24 - "@next/bundle-analyzer": "^15.3.2", 25 - "@next/mdx": "15.3.2", 24 + "@next/bundle-analyzer": "16.0.3", 25 + "@next/mdx": "16.0.3", 26 26 "@radix-ui/react-dialog": "^1.1.15", 27 27 "@radix-ui/react-dropdown-menu": "^2.1.16", 28 28 "@radix-ui/react-popover": "^1.1.15", ··· 48 48 "inngest": "^3.40.1", 49 49 "ioredis": "^5.6.1", 50 50 "katex": "^0.16.22", 51 + "l": "^0.6.0", 51 52 "linkifyjs": "^4.2.0", 52 53 "luxon": "^3.7.2", 53 54 "multiformats": "^13.3.2", 54 - "next": "^15.5.3", 55 + "next": "^16.0.7", 55 56 "pg": "^8.16.3", 56 57 "prosemirror-commands": "^1.5.2", 57 58 "prosemirror-inputrules": "^1.4.0", ··· 59 60 "prosemirror-model": "^1.21.0", 60 61 "prosemirror-schema-basic": "^1.2.2", 61 62 "prosemirror-state": "^1.4.3", 62 - "react": "^19.1.1", 63 + "react": "19.2.1", 63 64 "react-aria-components": "^1.8.0", 64 65 "react-day-picker": "^9.3.0", 65 - "react-dom": "^19.1.1", 66 + "react-dom": "19.2.1", 66 67 "react-use-measure": "^2.1.1", 67 68 "redlock": "^5.0.0-beta.2", 68 69 "rehype-parse": "^9.0.0", ··· 92 93 "@types/katex": "^0.16.7", 93 94 "@types/luxon": "^3.7.1", 94 95 "@types/node": "^22.15.17", 95 - "@types/react": "19.1.3", 96 - "@types/react-dom": "19.1.3", 96 + "@types/react": "19.2.6", 97 + "@types/react-dom": "19.2.3", 97 98 "@types/uuid": "^10.0.0", 98 99 "drizzle-kit": "^0.21.2", 99 100 "esbuild": "^0.25.4", 100 - "eslint": "8.57.0", 101 - "eslint-config-next": "^15.5.3", 101 + "eslint": "^9.39.1", 102 + "eslint-config-next": "16.0.3", 102 103 "postcss": "^8.4.38", 103 104 "prettier": "3.2.5", 104 105 "supabase": "^1.187.3", ··· 567 568 "node": ">=18.7.0" 568 569 } 569 570 }, 571 + "node_modules/@babel/code-frame": { 572 + "version": "7.27.1", 573 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", 574 + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", 575 + "dev": true, 576 + "dependencies": { 577 + "@babel/helper-validator-identifier": "^7.27.1", 578 + "js-tokens": "^4.0.0", 579 + "picocolors": "^1.1.1" 580 + }, 581 + "engines": { 582 + "node": ">=6.9.0" 583 + } 584 + }, 585 + "node_modules/@babel/compat-data": { 586 + "version": "7.28.5", 587 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", 588 + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", 589 + "dev": true, 590 + "engines": { 591 + "node": ">=6.9.0" 592 + } 593 + }, 594 + "node_modules/@babel/core": { 595 + "version": "7.28.5", 596 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", 597 + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", 598 + "dev": true, 599 + "dependencies": { 600 + "@babel/code-frame": "^7.27.1", 601 + "@babel/generator": "^7.28.5", 602 + "@babel/helper-compilation-targets": "^7.27.2", 603 + "@babel/helper-module-transforms": "^7.28.3", 604 + "@babel/helpers": "^7.28.4", 605 + "@babel/parser": "^7.28.5", 606 + "@babel/template": "^7.27.2", 607 + "@babel/traverse": "^7.28.5", 608 + "@babel/types": "^7.28.5", 609 + "@jridgewell/remapping": "^2.3.5", 610 + "convert-source-map": "^2.0.0", 611 + "debug": "^4.1.0", 612 + "gensync": "^1.0.0-beta.2", 613 + "json5": "^2.2.3", 614 + "semver": "^6.3.1" 615 + }, 616 + "engines": { 617 + "node": ">=6.9.0" 618 + }, 619 + "funding": { 620 + "type": "opencollective", 621 + "url": "https://opencollective.com/babel" 622 + } 623 + }, 624 + "node_modules/@babel/core/node_modules/json5": { 625 + "version": "2.2.3", 626 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", 627 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", 628 + "dev": true, 629 + "bin": { 630 + "json5": "lib/cli.js" 631 + }, 632 + "engines": { 633 + "node": ">=6" 634 + } 635 + }, 636 + "node_modules/@babel/core/node_modules/semver": { 637 + "version": "6.3.1", 638 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 639 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 640 + "dev": true, 641 + "bin": { 642 + "semver": "bin/semver.js" 643 + } 644 + }, 645 + "node_modules/@babel/generator": { 646 + "version": "7.28.5", 647 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", 648 + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", 649 + "dev": true, 650 + "dependencies": { 651 + "@babel/parser": "^7.28.5", 652 + "@babel/types": "^7.28.5", 653 + "@jridgewell/gen-mapping": "^0.3.12", 654 + "@jridgewell/trace-mapping": "^0.3.28", 655 + "jsesc": "^3.0.2" 656 + }, 657 + "engines": { 658 + "node": ">=6.9.0" 659 + } 660 + }, 661 + "node_modules/@babel/helper-compilation-targets": { 662 + "version": "7.27.2", 663 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", 664 + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", 665 + "dev": true, 666 + "dependencies": { 667 + "@babel/compat-data": "^7.27.2", 668 + "@babel/helper-validator-option": "^7.27.1", 669 + "browserslist": "^4.24.0", 670 + "lru-cache": "^5.1.1", 671 + "semver": "^6.3.1" 672 + }, 673 + "engines": { 674 + "node": ">=6.9.0" 675 + } 676 + }, 677 + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { 678 + "version": "5.1.1", 679 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", 680 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", 681 + "dev": true, 682 + "dependencies": { 683 + "yallist": "^3.0.2" 684 + } 685 + }, 686 + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { 687 + "version": "6.3.1", 688 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 689 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 690 + "dev": true, 691 + "bin": { 692 + "semver": "bin/semver.js" 693 + } 694 + }, 695 + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { 696 + "version": "3.1.1", 697 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", 698 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", 699 + "dev": true 700 + }, 701 + "node_modules/@babel/helper-globals": { 702 + "version": "7.28.0", 703 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", 704 + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", 705 + "dev": true, 706 + "engines": { 707 + "node": ">=6.9.0" 708 + } 709 + }, 710 + "node_modules/@babel/helper-module-imports": { 711 + "version": "7.27.1", 712 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", 713 + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", 714 + "dev": true, 715 + "dependencies": { 716 + "@babel/traverse": "^7.27.1", 717 + "@babel/types": "^7.27.1" 718 + }, 719 + "engines": { 720 + "node": ">=6.9.0" 721 + } 722 + }, 723 + "node_modules/@babel/helper-module-transforms": { 724 + "version": "7.28.3", 725 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", 726 + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", 727 + "dev": true, 728 + "dependencies": { 729 + "@babel/helper-module-imports": "^7.27.1", 730 + "@babel/helper-validator-identifier": "^7.27.1", 731 + "@babel/traverse": "^7.28.3" 732 + }, 733 + "engines": { 734 + "node": ">=6.9.0" 735 + }, 736 + "peerDependencies": { 737 + "@babel/core": "^7.0.0" 738 + } 739 + }, 570 740 "node_modules/@babel/helper-string-parser": { 571 741 "version": "7.27.1", 572 742 "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", ··· 577 747 } 578 748 }, 579 749 "node_modules/@babel/helper-validator-identifier": { 750 + "version": "7.28.5", 751 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", 752 + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", 753 + "engines": { 754 + "node": ">=6.9.0" 755 + } 756 + }, 757 + "node_modules/@babel/helper-validator-option": { 580 758 "version": "7.27.1", 581 - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", 582 - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", 583 - "license": "MIT", 759 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", 760 + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", 761 + "dev": true, 762 + "engines": { 763 + "node": ">=6.9.0" 764 + } 765 + }, 766 + "node_modules/@babel/helpers": { 767 + "version": "7.28.4", 768 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", 769 + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", 770 + "dev": true, 771 + "dependencies": { 772 + "@babel/template": "^7.27.2", 773 + "@babel/types": "^7.28.4" 774 + }, 775 + "engines": { 776 + "node": ">=6.9.0" 777 + } 778 + }, 779 + "node_modules/@babel/parser": { 780 + "version": "7.28.5", 781 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", 782 + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", 783 + "dev": true, 784 + "dependencies": { 785 + "@babel/types": "^7.28.5" 786 + }, 787 + "bin": { 788 + "parser": "bin/babel-parser.js" 789 + }, 790 + "engines": { 791 + "node": ">=6.0.0" 792 + } 793 + }, 794 + "node_modules/@babel/template": { 795 + "version": "7.27.2", 796 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", 797 + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", 798 + "dev": true, 799 + "dependencies": { 800 + "@babel/code-frame": "^7.27.1", 801 + "@babel/parser": "^7.27.2", 802 + "@babel/types": "^7.27.1" 803 + }, 804 + "engines": { 805 + "node": ">=6.9.0" 806 + } 807 + }, 808 + "node_modules/@babel/traverse": { 809 + "version": "7.28.5", 810 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", 811 + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", 812 + "dev": true, 813 + "dependencies": { 814 + "@babel/code-frame": "^7.27.1", 815 + "@babel/generator": "^7.28.5", 816 + "@babel/helper-globals": "^7.28.0", 817 + "@babel/parser": "^7.28.5", 818 + "@babel/template": "^7.27.2", 819 + "@babel/types": "^7.28.5", 820 + "debug": "^4.3.1" 821 + }, 584 822 "engines": { 585 823 "node": ">=6.9.0" 586 824 } 587 825 }, 588 826 "node_modules/@babel/types": { 589 - "version": "7.27.1", 590 - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", 591 - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", 592 - "license": "MIT", 827 + "version": "7.28.5", 828 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", 829 + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", 593 830 "dependencies": { 594 831 "@babel/helper-string-parser": "^7.27.1", 595 - "@babel/helper-validator-identifier": "^7.27.1" 832 + "@babel/helper-validator-identifier": "^7.28.5" 596 833 }, 597 834 "engines": { 598 835 "node": ">=6.9.0" ··· 714 951 "source-map-support": "^0.5.21" 715 952 } 716 953 }, 954 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { 955 + "version": "0.18.20", 956 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", 957 + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", 958 + "cpu": [ 959 + "arm" 960 + ], 961 + "dev": true, 962 + "optional": true, 963 + "os": [ 964 + "android" 965 + ], 966 + "engines": { 967 + "node": ">=12" 968 + } 969 + }, 970 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { 971 + "version": "0.18.20", 972 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", 973 + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", 974 + "cpu": [ 975 + "arm64" 976 + ], 977 + "dev": true, 978 + "optional": true, 979 + "os": [ 980 + "android" 981 + ], 982 + "engines": { 983 + "node": ">=12" 984 + } 985 + }, 986 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { 987 + "version": "0.18.20", 988 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", 989 + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", 990 + "cpu": [ 991 + "x64" 992 + ], 993 + "dev": true, 994 + "optional": true, 995 + "os": [ 996 + "android" 997 + ], 998 + "engines": { 999 + "node": ">=12" 1000 + } 1001 + }, 1002 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { 1003 + "version": "0.18.20", 1004 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", 1005 + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", 1006 + "cpu": [ 1007 + "arm64" 1008 + ], 1009 + "dev": true, 1010 + "optional": true, 1011 + "os": [ 1012 + "darwin" 1013 + ], 1014 + "engines": { 1015 + "node": ">=12" 1016 + } 1017 + }, 1018 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { 1019 + "version": "0.18.20", 1020 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", 1021 + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", 1022 + "cpu": [ 1023 + "x64" 1024 + ], 1025 + "dev": true, 1026 + "optional": true, 1027 + "os": [ 1028 + "darwin" 1029 + ], 1030 + "engines": { 1031 + "node": ">=12" 1032 + } 1033 + }, 1034 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { 1035 + "version": "0.18.20", 1036 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", 1037 + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", 1038 + "cpu": [ 1039 + "arm64" 1040 + ], 1041 + "dev": true, 1042 + "optional": true, 1043 + "os": [ 1044 + "freebsd" 1045 + ], 1046 + "engines": { 1047 + "node": ">=12" 1048 + } 1049 + }, 1050 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { 1051 + "version": "0.18.20", 1052 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", 1053 + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", 1054 + "cpu": [ 1055 + "x64" 1056 + ], 1057 + "dev": true, 1058 + "optional": true, 1059 + "os": [ 1060 + "freebsd" 1061 + ], 1062 + "engines": { 1063 + "node": ">=12" 1064 + } 1065 + }, 1066 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { 1067 + "version": "0.18.20", 1068 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", 1069 + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", 1070 + "cpu": [ 1071 + "arm" 1072 + ], 1073 + "dev": true, 1074 + "optional": true, 1075 + "os": [ 1076 + "linux" 1077 + ], 1078 + "engines": { 1079 + "node": ">=12" 1080 + } 1081 + }, 1082 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { 1083 + "version": "0.18.20", 1084 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", 1085 + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", 1086 + "cpu": [ 1087 + "arm64" 1088 + ], 1089 + "dev": true, 1090 + "optional": true, 1091 + "os": [ 1092 + "linux" 1093 + ], 1094 + "engines": { 1095 + "node": ">=12" 1096 + } 1097 + }, 1098 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { 1099 + "version": "0.18.20", 1100 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", 1101 + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", 1102 + "cpu": [ 1103 + "ia32" 1104 + ], 1105 + "dev": true, 1106 + "optional": true, 1107 + "os": [ 1108 + "linux" 1109 + ], 1110 + "engines": { 1111 + "node": ">=12" 1112 + } 1113 + }, 1114 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { 1115 + "version": "0.18.20", 1116 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", 1117 + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", 1118 + "cpu": [ 1119 + "loong64" 1120 + ], 1121 + "dev": true, 1122 + "optional": true, 1123 + "os": [ 1124 + "linux" 1125 + ], 1126 + "engines": { 1127 + "node": ">=12" 1128 + } 1129 + }, 1130 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { 1131 + "version": "0.18.20", 1132 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", 1133 + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", 1134 + "cpu": [ 1135 + "mips64el" 1136 + ], 1137 + "dev": true, 1138 + "optional": true, 1139 + "os": [ 1140 + "linux" 1141 + ], 1142 + "engines": { 1143 + "node": ">=12" 1144 + } 1145 + }, 1146 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { 1147 + "version": "0.18.20", 1148 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", 1149 + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", 1150 + "cpu": [ 1151 + "ppc64" 1152 + ], 1153 + "dev": true, 1154 + "optional": true, 1155 + "os": [ 1156 + "linux" 1157 + ], 1158 + "engines": { 1159 + "node": ">=12" 1160 + } 1161 + }, 1162 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { 1163 + "version": "0.18.20", 1164 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", 1165 + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", 1166 + "cpu": [ 1167 + "riscv64" 1168 + ], 1169 + "dev": true, 1170 + "optional": true, 1171 + "os": [ 1172 + "linux" 1173 + ], 1174 + "engines": { 1175 + "node": ">=12" 1176 + } 1177 + }, 1178 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { 1179 + "version": "0.18.20", 1180 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", 1181 + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", 1182 + "cpu": [ 1183 + "s390x" 1184 + ], 1185 + "dev": true, 1186 + "optional": true, 1187 + "os": [ 1188 + "linux" 1189 + ], 1190 + "engines": { 1191 + "node": ">=12" 1192 + } 1193 + }, 717 1194 "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { 718 1195 "version": "0.18.20", 719 1196 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", ··· 730 1207 "node": ">=12" 731 1208 } 732 1209 }, 1210 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { 1211 + "version": "0.18.20", 1212 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", 1213 + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", 1214 + "cpu": [ 1215 + "x64" 1216 + ], 1217 + "dev": true, 1218 + "optional": true, 1219 + "os": [ 1220 + "netbsd" 1221 + ], 1222 + "engines": { 1223 + "node": ">=12" 1224 + } 1225 + }, 1226 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { 1227 + "version": "0.18.20", 1228 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", 1229 + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", 1230 + "cpu": [ 1231 + "x64" 1232 + ], 1233 + "dev": true, 1234 + "optional": true, 1235 + "os": [ 1236 + "openbsd" 1237 + ], 1238 + "engines": { 1239 + "node": ">=12" 1240 + } 1241 + }, 1242 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { 1243 + "version": "0.18.20", 1244 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", 1245 + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", 1246 + "cpu": [ 1247 + "x64" 1248 + ], 1249 + "dev": true, 1250 + "optional": true, 1251 + "os": [ 1252 + "sunos" 1253 + ], 1254 + "engines": { 1255 + "node": ">=12" 1256 + } 1257 + }, 1258 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { 1259 + "version": "0.18.20", 1260 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", 1261 + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", 1262 + "cpu": [ 1263 + "arm64" 1264 + ], 1265 + "dev": true, 1266 + "optional": true, 1267 + "os": [ 1268 + "win32" 1269 + ], 1270 + "engines": { 1271 + "node": ">=12" 1272 + } 1273 + }, 1274 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { 1275 + "version": "0.18.20", 1276 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", 1277 + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", 1278 + "cpu": [ 1279 + "ia32" 1280 + ], 1281 + "dev": true, 1282 + "optional": true, 1283 + "os": [ 1284 + "win32" 1285 + ], 1286 + "engines": { 1287 + "node": ">=12" 1288 + } 1289 + }, 1290 + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { 1291 + "version": "0.18.20", 1292 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", 1293 + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", 1294 + "cpu": [ 1295 + "x64" 1296 + ], 1297 + "dev": true, 1298 + "optional": true, 1299 + "os": [ 1300 + "win32" 1301 + ], 1302 + "engines": { 1303 + "node": ">=12" 1304 + } 1305 + }, 733 1306 "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { 734 1307 "version": "0.18.20", 735 1308 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", ··· 799 1372 "esbuild": "*" 800 1373 } 801 1374 }, 1375 + "node_modules/@esbuild/aix-ppc64": { 1376 + "version": "0.25.4", 1377 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", 1378 + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", 1379 + "cpu": [ 1380 + "ppc64" 1381 + ], 1382 + "dev": true, 1383 + "optional": true, 1384 + "os": [ 1385 + "aix" 1386 + ], 1387 + "engines": { 1388 + "node": ">=18" 1389 + } 1390 + }, 1391 + "node_modules/@esbuild/android-arm": { 1392 + "version": "0.25.4", 1393 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", 1394 + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", 1395 + "cpu": [ 1396 + "arm" 1397 + ], 1398 + "dev": true, 1399 + "optional": true, 1400 + "os": [ 1401 + "android" 1402 + ], 1403 + "engines": { 1404 + "node": ">=18" 1405 + } 1406 + }, 1407 + "node_modules/@esbuild/android-arm64": { 1408 + "version": "0.25.4", 1409 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", 1410 + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", 1411 + "cpu": [ 1412 + "arm64" 1413 + ], 1414 + "dev": true, 1415 + "optional": true, 1416 + "os": [ 1417 + "android" 1418 + ], 1419 + "engines": { 1420 + "node": ">=18" 1421 + } 1422 + }, 1423 + "node_modules/@esbuild/android-x64": { 1424 + "version": "0.25.4", 1425 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", 1426 + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", 1427 + "cpu": [ 1428 + "x64" 1429 + ], 1430 + "dev": true, 1431 + "optional": true, 1432 + "os": [ 1433 + "android" 1434 + ], 1435 + "engines": { 1436 + "node": ">=18" 1437 + } 1438 + }, 1439 + "node_modules/@esbuild/darwin-x64": { 1440 + "version": "0.25.4", 1441 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", 1442 + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", 1443 + "cpu": [ 1444 + "x64" 1445 + ], 1446 + "dev": true, 1447 + "optional": true, 1448 + "os": [ 1449 + "darwin" 1450 + ], 1451 + "engines": { 1452 + "node": ">=18" 1453 + } 1454 + }, 1455 + "node_modules/@esbuild/freebsd-arm64": { 1456 + "version": "0.25.4", 1457 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", 1458 + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", 1459 + "cpu": [ 1460 + "arm64" 1461 + ], 1462 + "dev": true, 1463 + "optional": true, 1464 + "os": [ 1465 + "freebsd" 1466 + ], 1467 + "engines": { 1468 + "node": ">=18" 1469 + } 1470 + }, 1471 + "node_modules/@esbuild/freebsd-x64": { 1472 + "version": "0.25.4", 1473 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", 1474 + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", 1475 + "cpu": [ 1476 + "x64" 1477 + ], 1478 + "dev": true, 1479 + "optional": true, 1480 + "os": [ 1481 + "freebsd" 1482 + ], 1483 + "engines": { 1484 + "node": ">=18" 1485 + } 1486 + }, 1487 + "node_modules/@esbuild/linux-arm": { 1488 + "version": "0.25.4", 1489 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", 1490 + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", 1491 + "cpu": [ 1492 + "arm" 1493 + ], 1494 + "dev": true, 1495 + "optional": true, 1496 + "os": [ 1497 + "linux" 1498 + ], 1499 + "engines": { 1500 + "node": ">=18" 1501 + } 1502 + }, 1503 + "node_modules/@esbuild/linux-arm64": { 1504 + "version": "0.25.4", 1505 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", 1506 + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", 1507 + "cpu": [ 1508 + "arm64" 1509 + ], 1510 + "dev": true, 1511 + "optional": true, 1512 + "os": [ 1513 + "linux" 1514 + ], 1515 + "engines": { 1516 + "node": ">=18" 1517 + } 1518 + }, 1519 + "node_modules/@esbuild/linux-ia32": { 1520 + "version": "0.25.4", 1521 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", 1522 + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", 1523 + "cpu": [ 1524 + "ia32" 1525 + ], 1526 + "dev": true, 1527 + "optional": true, 1528 + "os": [ 1529 + "linux" 1530 + ], 1531 + "engines": { 1532 + "node": ">=18" 1533 + } 1534 + }, 1535 + "node_modules/@esbuild/linux-loong64": { 1536 + "version": "0.25.4", 1537 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", 1538 + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", 1539 + "cpu": [ 1540 + "loong64" 1541 + ], 1542 + "dev": true, 1543 + "optional": true, 1544 + "os": [ 1545 + "linux" 1546 + ], 1547 + "engines": { 1548 + "node": ">=18" 1549 + } 1550 + }, 1551 + "node_modules/@esbuild/linux-mips64el": { 1552 + "version": "0.25.4", 1553 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", 1554 + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", 1555 + "cpu": [ 1556 + "mips64el" 1557 + ], 1558 + "dev": true, 1559 + "optional": true, 1560 + "os": [ 1561 + "linux" 1562 + ], 1563 + "engines": { 1564 + "node": ">=18" 1565 + } 1566 + }, 1567 + "node_modules/@esbuild/linux-ppc64": { 1568 + "version": "0.25.4", 1569 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", 1570 + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", 1571 + "cpu": [ 1572 + "ppc64" 1573 + ], 1574 + "dev": true, 1575 + "optional": true, 1576 + "os": [ 1577 + "linux" 1578 + ], 1579 + "engines": { 1580 + "node": ">=18" 1581 + } 1582 + }, 1583 + "node_modules/@esbuild/linux-riscv64": { 1584 + "version": "0.25.4", 1585 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", 1586 + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", 1587 + "cpu": [ 1588 + "riscv64" 1589 + ], 1590 + "dev": true, 1591 + "optional": true, 1592 + "os": [ 1593 + "linux" 1594 + ], 1595 + "engines": { 1596 + "node": ">=18" 1597 + } 1598 + }, 1599 + "node_modules/@esbuild/linux-s390x": { 1600 + "version": "0.25.4", 1601 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", 1602 + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", 1603 + "cpu": [ 1604 + "s390x" 1605 + ], 1606 + "dev": true, 1607 + "optional": true, 1608 + "os": [ 1609 + "linux" 1610 + ], 1611 + "engines": { 1612 + "node": ">=18" 1613 + } 1614 + }, 802 1615 "node_modules/@esbuild/linux-x64": { 803 1616 "version": "0.25.4", 804 1617 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", ··· 816 1629 "node": ">=18" 817 1630 } 818 1631 }, 1632 + "node_modules/@esbuild/netbsd-arm64": { 1633 + "version": "0.25.4", 1634 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", 1635 + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", 1636 + "cpu": [ 1637 + "arm64" 1638 + ], 1639 + "dev": true, 1640 + "optional": true, 1641 + "os": [ 1642 + "netbsd" 1643 + ], 1644 + "engines": { 1645 + "node": ">=18" 1646 + } 1647 + }, 1648 + "node_modules/@esbuild/netbsd-x64": { 1649 + "version": "0.25.4", 1650 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", 1651 + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", 1652 + "cpu": [ 1653 + "x64" 1654 + ], 1655 + "dev": true, 1656 + "optional": true, 1657 + "os": [ 1658 + "netbsd" 1659 + ], 1660 + "engines": { 1661 + "node": ">=18" 1662 + } 1663 + }, 1664 + "node_modules/@esbuild/openbsd-arm64": { 1665 + "version": "0.25.4", 1666 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", 1667 + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", 1668 + "cpu": [ 1669 + "arm64" 1670 + ], 1671 + "dev": true, 1672 + "optional": true, 1673 + "os": [ 1674 + "openbsd" 1675 + ], 1676 + "engines": { 1677 + "node": ">=18" 1678 + } 1679 + }, 1680 + "node_modules/@esbuild/openbsd-x64": { 1681 + "version": "0.25.4", 1682 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", 1683 + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", 1684 + "cpu": [ 1685 + "x64" 1686 + ], 1687 + "dev": true, 1688 + "optional": true, 1689 + "os": [ 1690 + "openbsd" 1691 + ], 1692 + "engines": { 1693 + "node": ">=18" 1694 + } 1695 + }, 1696 + "node_modules/@esbuild/sunos-x64": { 1697 + "version": "0.25.4", 1698 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", 1699 + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", 1700 + "cpu": [ 1701 + "x64" 1702 + ], 1703 + "dev": true, 1704 + "optional": true, 1705 + "os": [ 1706 + "sunos" 1707 + ], 1708 + "engines": { 1709 + "node": ">=18" 1710 + } 1711 + }, 1712 + "node_modules/@esbuild/win32-arm64": { 1713 + "version": "0.25.4", 1714 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", 1715 + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", 1716 + "cpu": [ 1717 + "arm64" 1718 + ], 1719 + "dev": true, 1720 + "optional": true, 1721 + "os": [ 1722 + "win32" 1723 + ], 1724 + "engines": { 1725 + "node": ">=18" 1726 + } 1727 + }, 1728 + "node_modules/@esbuild/win32-ia32": { 1729 + "version": "0.25.4", 1730 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", 1731 + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", 1732 + "cpu": [ 1733 + "ia32" 1734 + ], 1735 + "dev": true, 1736 + "optional": true, 1737 + "os": [ 1738 + "win32" 1739 + ], 1740 + "engines": { 1741 + "node": ">=18" 1742 + } 1743 + }, 1744 + "node_modules/@esbuild/win32-x64": { 1745 + "version": "0.25.4", 1746 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", 1747 + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", 1748 + "cpu": [ 1749 + "x64" 1750 + ], 1751 + "dev": true, 1752 + "optional": true, 1753 + "os": [ 1754 + "win32" 1755 + ], 1756 + "engines": { 1757 + "node": ">=18" 1758 + } 1759 + }, 819 1760 "node_modules/@eslint-community/eslint-utils": { 820 - "version": "4.7.0", 821 - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", 822 - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", 1761 + "version": "4.9.0", 1762 + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", 1763 + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", 823 1764 "dev": true, 824 - "license": "MIT", 825 1765 "dependencies": { 826 1766 "eslint-visitor-keys": "^3.4.3" 827 1767 }, ··· 835 1775 "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" 836 1776 } 837 1777 }, 1778 + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { 1779 + "version": "3.4.3", 1780 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", 1781 + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", 1782 + "dev": true, 1783 + "engines": { 1784 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 1785 + }, 1786 + "funding": { 1787 + "url": "https://opencollective.com/eslint" 1788 + } 1789 + }, 838 1790 "node_modules/@eslint-community/regexpp": { 839 - "version": "4.10.0", 840 - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", 841 - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", 1791 + "version": "4.12.2", 1792 + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", 1793 + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", 842 1794 "dev": true, 843 1795 "engines": { 844 1796 "node": "^12.0.0 || ^14.0.0 || >=16.0.0" 845 1797 } 846 1798 }, 1799 + "node_modules/@eslint/config-array": { 1800 + "version": "0.21.1", 1801 + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", 1802 + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", 1803 + "dev": true, 1804 + "dependencies": { 1805 + "@eslint/object-schema": "^2.1.7", 1806 + "debug": "^4.3.1", 1807 + "minimatch": "^3.1.2" 1808 + }, 1809 + "engines": { 1810 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1811 + } 1812 + }, 1813 + "node_modules/@eslint/config-helpers": { 1814 + "version": "0.4.2", 1815 + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", 1816 + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", 1817 + "dev": true, 1818 + "dependencies": { 1819 + "@eslint/core": "^0.17.0" 1820 + }, 1821 + "engines": { 1822 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1823 + } 1824 + }, 1825 + "node_modules/@eslint/core": { 1826 + "version": "0.17.0", 1827 + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", 1828 + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", 1829 + "dev": true, 1830 + "dependencies": { 1831 + "@types/json-schema": "^7.0.15" 1832 + }, 1833 + "engines": { 1834 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1835 + } 1836 + }, 847 1837 "node_modules/@eslint/eslintrc": { 848 - "version": "2.1.4", 849 - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", 850 - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", 1838 + "version": "3.3.1", 1839 + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", 1840 + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", 851 1841 "dev": true, 852 1842 "dependencies": { 853 1843 "ajv": "^6.12.4", 854 1844 "debug": "^4.3.2", 855 - "espree": "^9.6.0", 856 - "globals": "^13.19.0", 1845 + "espree": "^10.0.1", 1846 + "globals": "^14.0.0", 857 1847 "ignore": "^5.2.0", 858 1848 "import-fresh": "^3.2.1", 859 1849 "js-yaml": "^4.1.0", ··· 861 1851 "strip-json-comments": "^3.1.1" 862 1852 }, 863 1853 "engines": { 864 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 1854 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 865 1855 }, 866 1856 "funding": { 867 1857 "url": "https://opencollective.com/eslint" 868 1858 } 869 1859 }, 870 1860 "node_modules/@eslint/js": { 871 - "version": "8.57.0", 872 - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", 873 - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", 1861 + "version": "9.39.1", 1862 + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", 1863 + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", 1864 + "dev": true, 1865 + "engines": { 1866 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1867 + }, 1868 + "funding": { 1869 + "url": "https://eslint.org/donate" 1870 + } 1871 + }, 1872 + "node_modules/@eslint/object-schema": { 1873 + "version": "2.1.7", 1874 + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", 1875 + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", 1876 + "dev": true, 1877 + "engines": { 1878 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1879 + } 1880 + }, 1881 + "node_modules/@eslint/plugin-kit": { 1882 + "version": "0.4.1", 1883 + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", 1884 + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", 874 1885 "dev": true, 1886 + "dependencies": { 1887 + "@eslint/core": "^0.17.0", 1888 + "levn": "^0.4.1" 1889 + }, 875 1890 "engines": { 876 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 1891 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 877 1892 } 878 1893 }, 879 1894 "node_modules/@fastify/busboy": { ··· 1017 2032 "hono": "^4" 1018 2033 } 1019 2034 }, 1020 - "node_modules/@humanwhocodes/config-array": { 1021 - "version": "0.11.14", 1022 - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", 1023 - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", 2035 + "node_modules/@humanfs/core": { 2036 + "version": "0.19.1", 2037 + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", 2038 + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", 2039 + "dev": true, 2040 + "engines": { 2041 + "node": ">=18.18.0" 2042 + } 2043 + }, 2044 + "node_modules/@humanfs/node": { 2045 + "version": "0.16.7", 2046 + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", 2047 + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", 1024 2048 "dev": true, 1025 2049 "dependencies": { 1026 - "@humanwhocodes/object-schema": "^2.0.2", 1027 - "debug": "^4.3.1", 1028 - "minimatch": "^3.0.5" 2050 + "@humanfs/core": "^0.19.1", 2051 + "@humanwhocodes/retry": "^0.4.0" 1029 2052 }, 1030 2053 "engines": { 1031 - "node": ">=10.10.0" 2054 + "node": ">=18.18.0" 1032 2055 } 1033 2056 }, 1034 2057 "node_modules/@humanwhocodes/module-importer": { ··· 1044 2067 "url": "https://github.com/sponsors/nzakas" 1045 2068 } 1046 2069 }, 1047 - "node_modules/@humanwhocodes/object-schema": { 1048 - "version": "2.0.3", 1049 - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", 1050 - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", 1051 - "dev": true 2070 + "node_modules/@humanwhocodes/retry": { 2071 + "version": "0.4.3", 2072 + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", 2073 + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", 2074 + "dev": true, 2075 + "engines": { 2076 + "node": ">=18.18" 2077 + }, 2078 + "funding": { 2079 + "type": "github", 2080 + "url": "https://github.com/sponsors/nzakas" 2081 + } 1052 2082 }, 1053 2083 "node_modules/@img/colour": { 1054 2084 "version": "1.0.0", ··· 1697 2727 } 1698 2728 }, 1699 2729 "node_modules/@next/bundle-analyzer": { 1700 - "version": "15.3.2", 1701 - "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-15.3.2.tgz", 1702 - "integrity": "sha512-zY5O1PNKNxWEjaFX8gKzm77z2oL0cnj+m5aiqNBgay9LPLCDO13Cf+FJONeNq/nJjeXptwHFT9EMmTecF9U4Iw==", 1703 - "license": "MIT", 2730 + "version": "16.0.3", 2731 + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.0.3.tgz", 2732 + "integrity": "sha512-6Xo8f8/ZXtASfTPa6TH1aUn+xDg9Pkyl1YHVxu+89cVdLH7MnYjxv3rPOfEJ9BwCZCU2q4Flyw5MwltfD2pGbA==", 1704 2733 "dependencies": { 1705 2734 "webpack-bundle-analyzer": "4.10.1" 1706 2735 } 1707 2736 }, 1708 2737 "node_modules/@next/env": { 1709 - "version": "15.5.3", 1710 - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz", 1711 - "integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==", 2738 + "version": "16.0.7", 2739 + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz", 2740 + "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==", 1712 2741 "license": "MIT" 1713 2742 }, 1714 2743 "node_modules/@next/eslint-plugin-next": { 1715 - "version": "15.5.3", 1716 - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.3.tgz", 1717 - "integrity": "sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==", 2744 + "version": "16.0.3", 2745 + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.3.tgz", 2746 + "integrity": "sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==", 1718 2747 "dev": true, 1719 - "license": "MIT", 1720 2748 "dependencies": { 1721 2749 "fast-glob": "3.3.1" 1722 2750 } ··· 1726 2754 "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", 1727 2755 "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", 1728 2756 "dev": true, 1729 - "license": "MIT", 1730 2757 "dependencies": { 1731 2758 "@nodelib/fs.stat": "^2.0.2", 1732 2759 "@nodelib/fs.walk": "^1.2.3", ··· 1743 2770 "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", 1744 2771 "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", 1745 2772 "dev": true, 1746 - "license": "ISC", 1747 2773 "dependencies": { 1748 2774 "is-glob": "^4.0.1" 1749 2775 }, ··· 1752 2778 } 1753 2779 }, 1754 2780 "node_modules/@next/mdx": { 1755 - "version": "15.3.2", 1756 - "resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-15.3.2.tgz", 1757 - "integrity": "sha512-D6lSSbVzn1EiPwrBKG5QzXClcgdqiNCL8a3/6oROinzgZnYSxbVmnfs0UrqygtGSOmgW7sdJJSEOy555DoAwvw==", 1758 - "license": "MIT", 2781 + "version": "16.0.3", 2782 + "resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-16.0.3.tgz", 2783 + "integrity": "sha512-uVl2JSEGAjBV+EVnpt1cZN88SK3lJ2n7Fc+iqTsgVx2g9+Y6ru+P6nuUgXd38OHPUIwzL6k2V1u4iV3kwuTySQ==", 1759 2784 "dependencies": { 1760 2785 "source-map": "^0.7.0" 1761 2786 }, ··· 1781 2806 } 1782 2807 }, 1783 2808 "node_modules/@next/swc-darwin-arm64": { 1784 - "version": "15.5.3", 1785 - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz", 1786 - "integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==", 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==", 1787 2812 "cpu": [ 1788 2813 "arm64" 1789 2814 ], ··· 1797 2822 } 1798 2823 }, 1799 2824 "node_modules/@next/swc-darwin-x64": { 1800 - "version": "15.5.3", 1801 - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz", 1802 - "integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==", 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==", 1803 2828 "cpu": [ 1804 2829 "x64" 1805 2830 ], ··· 1813 2838 } 1814 2839 }, 1815 2840 "node_modules/@next/swc-linux-arm64-gnu": { 1816 - "version": "15.5.3", 1817 - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz", 1818 - "integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==", 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==", 1819 2844 "cpu": [ 1820 2845 "arm64" 1821 2846 ], ··· 1829 2854 } 1830 2855 }, 1831 2856 "node_modules/@next/swc-linux-arm64-musl": { 1832 - "version": "15.5.3", 1833 - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz", 1834 - "integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==", 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==", 1835 2860 "cpu": [ 1836 2861 "arm64" 1837 2862 ], ··· 1845 2870 } 1846 2871 }, 1847 2872 "node_modules/@next/swc-linux-x64-gnu": { 1848 - "version": "15.5.3", 1849 - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz", 1850 - "integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==", 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==", 1851 2876 "cpu": [ 1852 2877 "x64" 1853 2878 ], ··· 1861 2886 } 1862 2887 }, 1863 2888 "node_modules/@next/swc-linux-x64-musl": { 1864 - "version": "15.5.3", 1865 - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz", 1866 - "integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==", 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==", 1867 2892 "cpu": [ 1868 2893 "x64" 1869 2894 ], ··· 1877 2902 } 1878 2903 }, 1879 2904 "node_modules/@next/swc-win32-arm64-msvc": { 1880 - "version": "15.5.3", 1881 - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz", 1882 - "integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==", 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==", 1883 2908 "cpu": [ 1884 2909 "arm64" 1885 2910 ], ··· 1893 2918 } 1894 2919 }, 1895 2920 "node_modules/@next/swc-win32-x64-msvc": { 1896 - "version": "15.5.3", 1897 - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz", 1898 - "integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==", 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==", 1899 2924 "cpu": [ 1900 2925 "x64" 1901 2926 ], ··· 5997 7022 "dev": true, 5998 7023 "license": "MIT" 5999 7024 }, 6000 - "node_modules/@rushstack/eslint-patch": { 6001 - "version": "1.10.3", 6002 - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz", 6003 - "integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==", 6004 - "dev": true 6005 - }, 6006 7025 "node_modules/@shikijs/core": { 6007 7026 "version": "3.8.1", 6008 7027 "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.8.1.tgz", ··· 6597 7616 "@types/unist": "*" 6598 7617 } 6599 7618 }, 7619 + "node_modules/@types/json-schema": { 7620 + "version": "7.0.15", 7621 + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", 7622 + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", 7623 + "dev": true 7624 + }, 6600 7625 "node_modules/@types/json5": { 6601 7626 "version": "0.0.29", 6602 7627 "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", ··· 6723 7748 "integrity": "sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==" 6724 7749 }, 6725 7750 "node_modules/@types/react": { 6726 - "version": "19.1.3", 6727 - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.3.tgz", 6728 - "integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==", 6729 - "license": "MIT", 7751 + "version": "19.2.6", 7752 + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", 7753 + "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", 6730 7754 "dependencies": { 6731 - "csstype": "^3.0.2" 7755 + "csstype": "^3.2.2" 6732 7756 } 6733 7757 }, 6734 7758 "node_modules/@types/react-dom": { 6735 - "version": "19.1.3", 6736 - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz", 6737 - "integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==", 7759 + "version": "19.2.3", 7760 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", 7761 + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", 6738 7762 "devOptional": true, 6739 - "license": "MIT", 6740 7763 "peerDependencies": { 6741 - "@types/react": "^19.0.0" 7764 + "@types/react": "^19.2.0" 6742 7765 } 6743 7766 }, 6744 7767 "node_modules/@types/shimmer": { ··· 6776 7799 } 6777 7800 }, 6778 7801 "node_modules/@typescript-eslint/eslint-plugin": { 6779 - "version": "8.32.0", 6780 - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz", 6781 - "integrity": "sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==", 7802 + "version": "8.47.0", 7803 + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", 7804 + "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", 6782 7805 "dev": true, 6783 - "license": "MIT", 6784 7806 "dependencies": { 6785 7807 "@eslint-community/regexpp": "^4.10.0", 6786 - "@typescript-eslint/scope-manager": "8.32.0", 6787 - "@typescript-eslint/type-utils": "8.32.0", 6788 - "@typescript-eslint/utils": "8.32.0", 6789 - "@typescript-eslint/visitor-keys": "8.32.0", 7808 + "@typescript-eslint/scope-manager": "8.47.0", 7809 + "@typescript-eslint/type-utils": "8.47.0", 7810 + "@typescript-eslint/utils": "8.47.0", 7811 + "@typescript-eslint/visitor-keys": "8.47.0", 6790 7812 "graphemer": "^1.4.0", 6791 - "ignore": "^5.3.1", 7813 + "ignore": "^7.0.0", 6792 7814 "natural-compare": "^1.4.0", 6793 7815 "ts-api-utils": "^2.1.0" 6794 7816 }, ··· 6800 7822 "url": "https://opencollective.com/typescript-eslint" 6801 7823 }, 6802 7824 "peerDependencies": { 6803 - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", 7825 + "@typescript-eslint/parser": "^8.47.0", 6804 7826 "eslint": "^8.57.0 || ^9.0.0", 6805 - "typescript": ">=4.8.4 <5.9.0" 7827 + "typescript": ">=4.8.4 <6.0.0" 7828 + } 7829 + }, 7830 + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { 7831 + "version": "7.0.5", 7832 + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", 7833 + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", 7834 + "dev": true, 7835 + "engines": { 7836 + "node": ">= 4" 6806 7837 } 6807 7838 }, 6808 7839 "node_modules/@typescript-eslint/parser": { 6809 - "version": "8.32.0", 6810 - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.0.tgz", 6811 - "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", 7840 + "version": "8.47.0", 7841 + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", 7842 + "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", 6812 7843 "dev": true, 6813 - "license": "MIT", 6814 7844 "dependencies": { 6815 - "@typescript-eslint/scope-manager": "8.32.0", 6816 - "@typescript-eslint/types": "8.32.0", 6817 - "@typescript-eslint/typescript-estree": "8.32.0", 6818 - "@typescript-eslint/visitor-keys": "8.32.0", 7845 + "@typescript-eslint/scope-manager": "8.47.0", 7846 + "@typescript-eslint/types": "8.47.0", 7847 + "@typescript-eslint/typescript-estree": "8.47.0", 7848 + "@typescript-eslint/visitor-keys": "8.47.0", 6819 7849 "debug": "^4.3.4" 6820 7850 }, 6821 7851 "engines": { ··· 6827 7857 }, 6828 7858 "peerDependencies": { 6829 7859 "eslint": "^8.57.0 || ^9.0.0", 6830 - "typescript": ">=4.8.4 <5.9.0" 7860 + "typescript": ">=4.8.4 <6.0.0" 7861 + } 7862 + }, 7863 + "node_modules/@typescript-eslint/project-service": { 7864 + "version": "8.47.0", 7865 + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", 7866 + "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", 7867 + "dev": true, 7868 + "dependencies": { 7869 + "@typescript-eslint/tsconfig-utils": "^8.47.0", 7870 + "@typescript-eslint/types": "^8.47.0", 7871 + "debug": "^4.3.4" 7872 + }, 7873 + "engines": { 7874 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 7875 + }, 7876 + "funding": { 7877 + "type": "opencollective", 7878 + "url": "https://opencollective.com/typescript-eslint" 7879 + }, 7880 + "peerDependencies": { 7881 + "typescript": ">=4.8.4 <6.0.0" 6831 7882 } 6832 7883 }, 6833 7884 "node_modules/@typescript-eslint/scope-manager": { 6834 - "version": "8.32.0", 6835 - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz", 6836 - "integrity": "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==", 7885 + "version": "8.47.0", 7886 + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", 7887 + "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", 6837 7888 "dev": true, 6838 - "license": "MIT", 6839 7889 "dependencies": { 6840 - "@typescript-eslint/types": "8.32.0", 6841 - "@typescript-eslint/visitor-keys": "8.32.0" 7890 + "@typescript-eslint/types": "8.47.0", 7891 + "@typescript-eslint/visitor-keys": "8.47.0" 6842 7892 }, 6843 7893 "engines": { 6844 7894 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" ··· 6848 7898 "url": "https://opencollective.com/typescript-eslint" 6849 7899 } 6850 7900 }, 7901 + "node_modules/@typescript-eslint/tsconfig-utils": { 7902 + "version": "8.47.0", 7903 + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", 7904 + "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", 7905 + "dev": true, 7906 + "engines": { 7907 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 7908 + }, 7909 + "funding": { 7910 + "type": "opencollective", 7911 + "url": "https://opencollective.com/typescript-eslint" 7912 + }, 7913 + "peerDependencies": { 7914 + "typescript": ">=4.8.4 <6.0.0" 7915 + } 7916 + }, 6851 7917 "node_modules/@typescript-eslint/type-utils": { 6852 - "version": "8.32.0", 6853 - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz", 6854 - "integrity": "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==", 7918 + "version": "8.47.0", 7919 + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz", 7920 + "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==", 6855 7921 "dev": true, 6856 - "license": "MIT", 6857 7922 "dependencies": { 6858 - "@typescript-eslint/typescript-estree": "8.32.0", 6859 - "@typescript-eslint/utils": "8.32.0", 7923 + "@typescript-eslint/types": "8.47.0", 7924 + "@typescript-eslint/typescript-estree": "8.47.0", 7925 + "@typescript-eslint/utils": "8.47.0", 6860 7926 "debug": "^4.3.4", 6861 7927 "ts-api-utils": "^2.1.0" 6862 7928 }, ··· 6869 7935 }, 6870 7936 "peerDependencies": { 6871 7937 "eslint": "^8.57.0 || ^9.0.0", 6872 - "typescript": ">=4.8.4 <5.9.0" 7938 + "typescript": ">=4.8.4 <6.0.0" 6873 7939 } 6874 7940 }, 6875 7941 "node_modules/@typescript-eslint/types": { 6876 - "version": "8.32.0", 6877 - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.0.tgz", 6878 - "integrity": "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==", 7942 + "version": "8.47.0", 7943 + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", 7944 + "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", 6879 7945 "dev": true, 6880 - "license": "MIT", 6881 7946 "engines": { 6882 7947 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 6883 7948 }, ··· 6887 7952 } 6888 7953 }, 6889 7954 "node_modules/@typescript-eslint/typescript-estree": { 6890 - "version": "8.32.0", 6891 - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz", 6892 - "integrity": "sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==", 7955 + "version": "8.47.0", 7956 + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", 7957 + "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", 6893 7958 "dev": true, 6894 - "license": "MIT", 6895 7959 "dependencies": { 6896 - "@typescript-eslint/types": "8.32.0", 6897 - "@typescript-eslint/visitor-keys": "8.32.0", 7960 + "@typescript-eslint/project-service": "8.47.0", 7961 + "@typescript-eslint/tsconfig-utils": "8.47.0", 7962 + "@typescript-eslint/types": "8.47.0", 7963 + "@typescript-eslint/visitor-keys": "8.47.0", 6898 7964 "debug": "^4.3.4", 6899 7965 "fast-glob": "^3.3.2", 6900 7966 "is-glob": "^4.0.3", ··· 6910 7976 "url": "https://opencollective.com/typescript-eslint" 6911 7977 }, 6912 7978 "peerDependencies": { 6913 - "typescript": ">=4.8.4 <5.9.0" 7979 + "typescript": ">=4.8.4 <6.0.0" 6914 7980 } 6915 7981 }, 6916 7982 "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { 6917 - "version": "2.0.1", 6918 - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", 6919 - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", 7983 + "version": "2.0.2", 7984 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", 7985 + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", 6920 7986 "dev": true, 6921 - "license": "MIT", 6922 7987 "dependencies": { 6923 7988 "balanced-match": "^1.0.0" 6924 7989 } ··· 6928 7993 "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", 6929 7994 "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 6930 7995 "dev": true, 6931 - "license": "ISC", 6932 7996 "dependencies": { 6933 7997 "brace-expansion": "^2.0.1" 6934 7998 }, ··· 6940 8004 } 6941 8005 }, 6942 8006 "node_modules/@typescript-eslint/utils": { 6943 - "version": "8.32.0", 6944 - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.0.tgz", 6945 - "integrity": "sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==", 8007 + "version": "8.47.0", 8008 + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", 8009 + "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", 6946 8010 "dev": true, 6947 - "license": "MIT", 6948 8011 "dependencies": { 6949 8012 "@eslint-community/eslint-utils": "^4.7.0", 6950 - "@typescript-eslint/scope-manager": "8.32.0", 6951 - "@typescript-eslint/types": "8.32.0", 6952 - "@typescript-eslint/typescript-estree": "8.32.0" 8013 + "@typescript-eslint/scope-manager": "8.47.0", 8014 + "@typescript-eslint/types": "8.47.0", 8015 + "@typescript-eslint/typescript-estree": "8.47.0" 6953 8016 }, 6954 8017 "engines": { 6955 8018 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" ··· 6960 8023 }, 6961 8024 "peerDependencies": { 6962 8025 "eslint": "^8.57.0 || ^9.0.0", 6963 - "typescript": ">=4.8.4 <5.9.0" 8026 + "typescript": ">=4.8.4 <6.0.0" 6964 8027 } 6965 8028 }, 6966 8029 "node_modules/@typescript-eslint/visitor-keys": { 6967 - "version": "8.32.0", 6968 - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz", 6969 - "integrity": "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==", 8030 + "version": "8.47.0", 8031 + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", 8032 + "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", 6970 8033 "dev": true, 6971 - "license": "MIT", 6972 8034 "dependencies": { 6973 - "@typescript-eslint/types": "8.32.0", 6974 - "eslint-visitor-keys": "^4.2.0" 8035 + "@typescript-eslint/types": "8.47.0", 8036 + "eslint-visitor-keys": "^4.2.1" 6975 8037 }, 6976 8038 "engines": { 6977 8039 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" ··· 6979 8041 "funding": { 6980 8042 "type": "opencollective", 6981 8043 "url": "https://opencollective.com/typescript-eslint" 6982 - } 6983 - }, 6984 - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { 6985 - "version": "4.2.0", 6986 - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", 6987 - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", 6988 - "dev": true, 6989 - "license": "Apache-2.0", 6990 - "engines": { 6991 - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 6992 - }, 6993 - "funding": { 6994 - "url": "https://opencollective.com/eslint" 6995 8044 } 6996 8045 }, 6997 8046 "node_modules/@ungap/structured-clone": { ··· 7167 8216 "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", 7168 8217 "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", 7169 8218 "dev": true, 7170 - "license": "MIT", 7171 8219 "dependencies": { 7172 8220 "fast-deep-equal": "^3.1.3", 7173 8221 "fast-uri": "^3.0.1", ··· 7264 8312 "license": "MIT" 7265 8313 }, 7266 8314 "node_modules/array-includes": { 7267 - "version": "3.1.8", 7268 - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", 7269 - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", 8315 + "version": "3.1.9", 8316 + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", 8317 + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", 7270 8318 "dev": true, 7271 8319 "dependencies": { 7272 - "call-bind": "^1.0.7", 8320 + "call-bind": "^1.0.8", 8321 + "call-bound": "^1.0.4", 7273 8322 "define-properties": "^1.2.1", 7274 - "es-abstract": "^1.23.2", 7275 - "es-object-atoms": "^1.0.0", 7276 - "get-intrinsic": "^1.2.4", 7277 - "is-string": "^1.0.7" 8323 + "es-abstract": "^1.24.0", 8324 + "es-object-atoms": "^1.1.1", 8325 + "get-intrinsic": "^1.3.0", 8326 + "is-string": "^1.1.1", 8327 + "math-intrinsics": "^1.1.0" 7278 8328 }, 7279 8329 "engines": { 7280 8330 "node": ">= 0.4" ··· 7305 8355 } 7306 8356 }, 7307 8357 "node_modules/array.prototype.findlastindex": { 7308 - "version": "1.2.5", 7309 - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", 7310 - "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", 8358 + "version": "1.2.6", 8359 + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", 8360 + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", 7311 8361 "dev": true, 7312 8362 "dependencies": { 7313 - "call-bind": "^1.0.7", 8363 + "call-bind": "^1.0.8", 8364 + "call-bound": "^1.0.4", 7314 8365 "define-properties": "^1.2.1", 7315 - "es-abstract": "^1.23.2", 8366 + "es-abstract": "^1.23.9", 7316 8367 "es-errors": "^1.3.0", 7317 - "es-object-atoms": "^1.0.0", 7318 - "es-shim-unscopables": "^1.0.2" 8368 + "es-object-atoms": "^1.1.1", 8369 + "es-shim-unscopables": "^1.1.0" 7319 8370 }, 7320 8371 "engines": { 7321 8372 "node": ">= 0.4" ··· 7325 8376 } 7326 8377 }, 7327 8378 "node_modules/array.prototype.flat": { 7328 - "version": "1.3.2", 7329 - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", 7330 - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", 8379 + "version": "1.3.3", 8380 + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", 8381 + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", 7331 8382 "dev": true, 7332 8383 "dependencies": { 7333 - "call-bind": "^1.0.2", 7334 - "define-properties": "^1.2.0", 7335 - "es-abstract": "^1.22.1", 7336 - "es-shim-unscopables": "^1.0.0" 8384 + "call-bind": "^1.0.8", 8385 + "define-properties": "^1.2.1", 8386 + "es-abstract": "^1.23.5", 8387 + "es-shim-unscopables": "^1.0.2" 7337 8388 }, 7338 8389 "engines": { 7339 8390 "node": ">= 0.4" ··· 7544 8595 } 7545 8596 ] 7546 8597 }, 8598 + "node_modules/baseline-browser-mapping": { 8599 + "version": "2.8.30", 8600 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", 8601 + "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", 8602 + "dev": true, 8603 + "bin": { 8604 + "baseline-browser-mapping": "dist/cli.js" 8605 + } 8606 + }, 7547 8607 "node_modules/bignumber.js": { 7548 8608 "version": "9.3.1", 7549 8609 "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", ··· 7662 8722 "node": ">=8" 7663 8723 } 7664 8724 }, 8725 + "node_modules/browserslist": { 8726 + "version": "4.28.0", 8727 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", 8728 + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", 8729 + "dev": true, 8730 + "funding": [ 8731 + { 8732 + "type": "opencollective", 8733 + "url": "https://opencollective.com/browserslist" 8734 + }, 8735 + { 8736 + "type": "tidelift", 8737 + "url": "https://tidelift.com/funding/github/npm/browserslist" 8738 + }, 8739 + { 8740 + "type": "github", 8741 + "url": "https://github.com/sponsors/ai" 8742 + } 8743 + ], 8744 + "dependencies": { 8745 + "baseline-browser-mapping": "^2.8.25", 8746 + "caniuse-lite": "^1.0.30001754", 8747 + "electron-to-chromium": "^1.5.249", 8748 + "node-releases": "^2.0.27", 8749 + "update-browserslist-db": "^1.1.4" 8750 + }, 8751 + "bin": { 8752 + "browserslist": "cli.js" 8753 + }, 8754 + "engines": { 8755 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" 8756 + } 8757 + }, 7665 8758 "node_modules/buffer": { 7666 8759 "version": "6.0.3", 7667 8760 "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", ··· 7765 8858 } 7766 8859 }, 7767 8860 "node_modules/caniuse-lite": { 7768 - "version": "1.0.30001717", 7769 - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz", 7770 - "integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==", 8861 + "version": "1.0.30001756", 8862 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", 8863 + "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", 7771 8864 "funding": [ 7772 8865 { 7773 8866 "type": "opencollective", ··· 7781 8874 "type": "github", 7782 8875 "url": "https://github.com/sponsors/ai" 7783 8876 } 7784 - ], 7785 - "license": "CC-BY-4.0" 8877 + ] 7786 8878 }, 7787 8879 "node_modules/canonicalize": { 7788 8880 "version": "1.0.8", ··· 8105 9197 "node": ">= 0.6" 8106 9198 } 8107 9199 }, 9200 + "node_modules/convert-source-map": { 9201 + "version": "2.0.0", 9202 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", 9203 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", 9204 + "dev": true 9205 + }, 8108 9206 "node_modules/cookie": { 8109 9207 "version": "0.5.0", 8110 9208 "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", ··· 8156 9254 } 8157 9255 }, 8158 9256 "node_modules/cross-spawn": { 8159 - "version": "7.0.3", 8160 - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", 8161 - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", 9257 + "version": "7.0.6", 9258 + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 9259 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 8162 9260 "dev": true, 8163 9261 "dependencies": { 8164 9262 "path-key": "^3.1.0", ··· 8170 9268 } 8171 9269 }, 8172 9270 "node_modules/csstype": { 8173 - "version": "3.1.3", 8174 - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", 8175 - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" 9271 + "version": "3.2.3", 9272 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", 9273 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" 8176 9274 }, 8177 9275 "node_modules/d": { 8178 9276 "version": "1.0.2", ··· 8434 9532 "node": "*" 8435 9533 } 8436 9534 }, 8437 - "node_modules/doctrine": { 8438 - "version": "3.0.0", 8439 - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", 8440 - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", 8441 - "dev": true, 8442 - "dependencies": { 8443 - "esutils": "^2.0.2" 8444 - }, 8445 - "engines": { 8446 - "node": ">=6.0.0" 8447 - } 8448 - }, 8449 9535 "node_modules/dreamopt": { 8450 9536 "version": "0.8.0", 8451 9537 "resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz", ··· 8478 9564 "drizzle-kit": "bin.cjs" 8479 9565 } 8480 9566 }, 9567 + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { 9568 + "version": "0.19.12", 9569 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", 9570 + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", 9571 + "cpu": [ 9572 + "ppc64" 9573 + ], 9574 + "dev": true, 9575 + "optional": true, 9576 + "os": [ 9577 + "aix" 9578 + ], 9579 + "engines": { 9580 + "node": ">=12" 9581 + } 9582 + }, 9583 + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { 9584 + "version": "0.19.12", 9585 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", 9586 + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", 9587 + "cpu": [ 9588 + "arm" 9589 + ], 9590 + "dev": true, 9591 + "optional": true, 9592 + "os": [ 9593 + "android" 9594 + ], 9595 + "engines": { 9596 + "node": ">=12" 9597 + } 9598 + }, 9599 + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { 9600 + "version": "0.19.12", 9601 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", 9602 + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", 9603 + "cpu": [ 9604 + "arm64" 9605 + ], 9606 + "dev": true, 9607 + "optional": true, 9608 + "os": [ 9609 + "android" 9610 + ], 9611 + "engines": { 9612 + "node": ">=12" 9613 + } 9614 + }, 9615 + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { 9616 + "version": "0.19.12", 9617 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", 9618 + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", 9619 + "cpu": [ 9620 + "x64" 9621 + ], 9622 + "dev": true, 9623 + "optional": true, 9624 + "os": [ 9625 + "android" 9626 + ], 9627 + "engines": { 9628 + "node": ">=12" 9629 + } 9630 + }, 9631 + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { 9632 + "version": "0.19.12", 9633 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", 9634 + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", 9635 + "cpu": [ 9636 + "arm64" 9637 + ], 9638 + "dev": true, 9639 + "optional": true, 9640 + "os": [ 9641 + "darwin" 9642 + ], 9643 + "engines": { 9644 + "node": ">=12" 9645 + } 9646 + }, 9647 + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { 9648 + "version": "0.19.12", 9649 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", 9650 + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", 9651 + "cpu": [ 9652 + "x64" 9653 + ], 9654 + "dev": true, 9655 + "optional": true, 9656 + "os": [ 9657 + "darwin" 9658 + ], 9659 + "engines": { 9660 + "node": ">=12" 9661 + } 9662 + }, 9663 + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { 9664 + "version": "0.19.12", 9665 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", 9666 + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", 9667 + "cpu": [ 9668 + "arm64" 9669 + ], 9670 + "dev": true, 9671 + "optional": true, 9672 + "os": [ 9673 + "freebsd" 9674 + ], 9675 + "engines": { 9676 + "node": ">=12" 9677 + } 9678 + }, 9679 + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { 9680 + "version": "0.19.12", 9681 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", 9682 + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", 9683 + "cpu": [ 9684 + "x64" 9685 + ], 9686 + "dev": true, 9687 + "optional": true, 9688 + "os": [ 9689 + "freebsd" 9690 + ], 9691 + "engines": { 9692 + "node": ">=12" 9693 + } 9694 + }, 9695 + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { 9696 + "version": "0.19.12", 9697 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", 9698 + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", 9699 + "cpu": [ 9700 + "arm" 9701 + ], 9702 + "dev": true, 9703 + "optional": true, 9704 + "os": [ 9705 + "linux" 9706 + ], 9707 + "engines": { 9708 + "node": ">=12" 9709 + } 9710 + }, 9711 + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { 9712 + "version": "0.19.12", 9713 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", 9714 + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", 9715 + "cpu": [ 9716 + "arm64" 9717 + ], 9718 + "dev": true, 9719 + "optional": true, 9720 + "os": [ 9721 + "linux" 9722 + ], 9723 + "engines": { 9724 + "node": ">=12" 9725 + } 9726 + }, 9727 + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { 9728 + "version": "0.19.12", 9729 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", 9730 + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", 9731 + "cpu": [ 9732 + "ia32" 9733 + ], 9734 + "dev": true, 9735 + "optional": true, 9736 + "os": [ 9737 + "linux" 9738 + ], 9739 + "engines": { 9740 + "node": ">=12" 9741 + } 9742 + }, 9743 + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { 9744 + "version": "0.19.12", 9745 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", 9746 + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", 9747 + "cpu": [ 9748 + "loong64" 9749 + ], 9750 + "dev": true, 9751 + "optional": true, 9752 + "os": [ 9753 + "linux" 9754 + ], 9755 + "engines": { 9756 + "node": ">=12" 9757 + } 9758 + }, 9759 + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { 9760 + "version": "0.19.12", 9761 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", 9762 + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", 9763 + "cpu": [ 9764 + "mips64el" 9765 + ], 9766 + "dev": true, 9767 + "optional": true, 9768 + "os": [ 9769 + "linux" 9770 + ], 9771 + "engines": { 9772 + "node": ">=12" 9773 + } 9774 + }, 9775 + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { 9776 + "version": "0.19.12", 9777 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", 9778 + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", 9779 + "cpu": [ 9780 + "ppc64" 9781 + ], 9782 + "dev": true, 9783 + "optional": true, 9784 + "os": [ 9785 + "linux" 9786 + ], 9787 + "engines": { 9788 + "node": ">=12" 9789 + } 9790 + }, 9791 + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { 9792 + "version": "0.19.12", 9793 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", 9794 + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", 9795 + "cpu": [ 9796 + "riscv64" 9797 + ], 9798 + "dev": true, 9799 + "optional": true, 9800 + "os": [ 9801 + "linux" 9802 + ], 9803 + "engines": { 9804 + "node": ">=12" 9805 + } 9806 + }, 9807 + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { 9808 + "version": "0.19.12", 9809 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", 9810 + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", 9811 + "cpu": [ 9812 + "s390x" 9813 + ], 9814 + "dev": true, 9815 + "optional": true, 9816 + "os": [ 9817 + "linux" 9818 + ], 9819 + "engines": { 9820 + "node": ">=12" 9821 + } 9822 + }, 8481 9823 "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { 8482 9824 "version": "0.19.12", 8483 9825 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", ··· 8495 9837 "node": ">=12" 8496 9838 } 8497 9839 }, 9840 + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { 9841 + "version": "0.19.12", 9842 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", 9843 + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", 9844 + "cpu": [ 9845 + "x64" 9846 + ], 9847 + "dev": true, 9848 + "optional": true, 9849 + "os": [ 9850 + "netbsd" 9851 + ], 9852 + "engines": { 9853 + "node": ">=12" 9854 + } 9855 + }, 9856 + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { 9857 + "version": "0.19.12", 9858 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", 9859 + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", 9860 + "cpu": [ 9861 + "x64" 9862 + ], 9863 + "dev": true, 9864 + "optional": true, 9865 + "os": [ 9866 + "openbsd" 9867 + ], 9868 + "engines": { 9869 + "node": ">=12" 9870 + } 9871 + }, 9872 + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { 9873 + "version": "0.19.12", 9874 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", 9875 + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", 9876 + "cpu": [ 9877 + "x64" 9878 + ], 9879 + "dev": true, 9880 + "optional": true, 9881 + "os": [ 9882 + "sunos" 9883 + ], 9884 + "engines": { 9885 + "node": ">=12" 9886 + } 9887 + }, 9888 + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { 9889 + "version": "0.19.12", 9890 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", 9891 + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", 9892 + "cpu": [ 9893 + "arm64" 9894 + ], 9895 + "dev": true, 9896 + "optional": true, 9897 + "os": [ 9898 + "win32" 9899 + ], 9900 + "engines": { 9901 + "node": ">=12" 9902 + } 9903 + }, 9904 + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { 9905 + "version": "0.19.12", 9906 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", 9907 + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", 9908 + "cpu": [ 9909 + "ia32" 9910 + ], 9911 + "dev": true, 9912 + "optional": true, 9913 + "os": [ 9914 + "win32" 9915 + ], 9916 + "engines": { 9917 + "node": ">=12" 9918 + } 9919 + }, 9920 + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { 9921 + "version": "0.19.12", 9922 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", 9923 + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", 9924 + "cpu": [ 9925 + "x64" 9926 + ], 9927 + "dev": true, 9928 + "optional": true, 9929 + "os": [ 9930 + "win32" 9931 + ], 9932 + "engines": { 9933 + "node": ">=12" 9934 + } 9935 + }, 8498 9936 "node_modules/drizzle-kit/node_modules/esbuild": { 8499 9937 "version": "0.19.12", 8500 9938 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", ··· 8678 10116 "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", 8679 10117 "license": "MIT" 8680 10118 }, 10119 + "node_modules/electron-to-chromium": { 10120 + "version": "1.5.258", 10121 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.258.tgz", 10122 + "integrity": "sha512-rHUggNV5jKQ0sSdWwlaRDkFc3/rRJIVnOSe9yR4zrR07m3ZxhP4N27Hlg8VeJGGYgFTxK5NqDmWI4DSH72vIJg==", 10123 + "dev": true 10124 + }, 8681 10125 "node_modules/emoji-regex": { 8682 10126 "version": "9.2.2", 8683 10127 "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", ··· 8731 10175 } 8732 10176 }, 8733 10177 "node_modules/es-abstract": { 8734 - "version": "1.23.9", 8735 - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", 8736 - "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", 10178 + "version": "1.24.0", 10179 + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", 10180 + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", 8737 10181 "dev": true, 8738 - "license": "MIT", 8739 10182 "dependencies": { 8740 10183 "array-buffer-byte-length": "^1.0.2", 8741 10184 "arraybuffer.prototype.slice": "^1.0.4", 8742 10185 "available-typed-arrays": "^1.0.7", 8743 10186 "call-bind": "^1.0.8", 8744 - "call-bound": "^1.0.3", 10187 + "call-bound": "^1.0.4", 8745 10188 "data-view-buffer": "^1.0.2", 8746 10189 "data-view-byte-length": "^1.0.2", 8747 10190 "data-view-byte-offset": "^1.0.1", 8748 10191 "es-define-property": "^1.0.1", 8749 10192 "es-errors": "^1.3.0", 8750 - "es-object-atoms": "^1.0.0", 10193 + "es-object-atoms": "^1.1.1", 8751 10194 "es-set-tostringtag": "^2.1.0", 8752 10195 "es-to-primitive": "^1.3.0", 8753 10196 "function.prototype.name": "^1.1.8", 8754 - "get-intrinsic": "^1.2.7", 8755 - "get-proto": "^1.0.0", 10197 + "get-intrinsic": "^1.3.0", 10198 + "get-proto": "^1.0.1", 8756 10199 "get-symbol-description": "^1.1.0", 8757 10200 "globalthis": "^1.0.4", 8758 10201 "gopd": "^1.2.0", ··· 8764 10207 "is-array-buffer": "^3.0.5", 8765 10208 "is-callable": "^1.2.7", 8766 10209 "is-data-view": "^1.0.2", 10210 + "is-negative-zero": "^2.0.3", 8767 10211 "is-regex": "^1.2.1", 10212 + "is-set": "^2.0.3", 8768 10213 "is-shared-array-buffer": "^1.0.4", 8769 10214 "is-string": "^1.1.1", 8770 10215 "is-typed-array": "^1.1.15", 8771 - "is-weakref": "^1.1.0", 10216 + "is-weakref": "^1.1.1", 8772 10217 "math-intrinsics": "^1.1.0", 8773 - "object-inspect": "^1.13.3", 10218 + "object-inspect": "^1.13.4", 8774 10219 "object-keys": "^1.1.1", 8775 10220 "object.assign": "^4.1.7", 8776 10221 "own-keys": "^1.0.1", 8777 - "regexp.prototype.flags": "^1.5.3", 10222 + "regexp.prototype.flags": "^1.5.4", 8778 10223 "safe-array-concat": "^1.1.3", 8779 10224 "safe-push-apply": "^1.0.0", 8780 10225 "safe-regex-test": "^1.1.0", 8781 10226 "set-proto": "^1.0.0", 10227 + "stop-iteration-iterator": "^1.1.0", 8782 10228 "string.prototype.trim": "^1.2.10", 8783 10229 "string.prototype.trimend": "^1.0.9", 8784 10230 "string.prototype.trimstart": "^1.0.8", ··· 8787 10233 "typed-array-byte-offset": "^1.0.4", 8788 10234 "typed-array-length": "^1.0.7", 8789 10235 "unbox-primitive": "^1.1.0", 8790 - "which-typed-array": "^1.1.18" 10236 + "which-typed-array": "^1.1.19" 8791 10237 }, 8792 10238 "engines": { 8793 10239 "node": ">= 0.4" ··· 8870 10316 } 8871 10317 }, 8872 10318 "node_modules/es-shim-unscopables": { 8873 - "version": "1.0.2", 8874 - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", 8875 - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", 10319 + "version": "1.1.0", 10320 + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", 10321 + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", 8876 10322 "dev": true, 8877 10323 "dependencies": { 8878 - "hasown": "^2.0.0" 10324 + "hasown": "^2.0.2" 10325 + }, 10326 + "engines": { 10327 + "node": ">= 0.4" 8879 10328 } 8880 10329 }, 8881 10330 "node_modules/es-to-primitive": { ··· 9031 10480 "esbuild": ">=0.12 <1" 9032 10481 } 9033 10482 }, 10483 + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { 10484 + "version": "0.25.4", 10485 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", 10486 + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", 10487 + "cpu": [ 10488 + "arm64" 10489 + ], 10490 + "dev": true, 10491 + "optional": true, 10492 + "os": [ 10493 + "darwin" 10494 + ], 10495 + "engines": { 10496 + "node": ">=18" 10497 + } 10498 + }, 9034 10499 "node_modules/escalade": { 9035 - "version": "3.1.2", 9036 - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", 9037 - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", 10500 + "version": "3.2.0", 10501 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", 10502 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", 9038 10503 "engines": { 9039 10504 "node": ">=6" 9040 10505 } ··· 9057 10522 } 9058 10523 }, 9059 10524 "node_modules/eslint": { 9060 - "version": "8.57.0", 9061 - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", 9062 - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", 10525 + "version": "9.39.1", 10526 + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", 10527 + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", 9063 10528 "dev": true, 9064 10529 "dependencies": { 9065 - "@eslint-community/eslint-utils": "^4.2.0", 9066 - "@eslint-community/regexpp": "^4.6.1", 9067 - "@eslint/eslintrc": "^2.1.4", 9068 - "@eslint/js": "8.57.0", 9069 - "@humanwhocodes/config-array": "^0.11.14", 10530 + "@eslint-community/eslint-utils": "^4.8.0", 10531 + "@eslint-community/regexpp": "^4.12.1", 10532 + "@eslint/config-array": "^0.21.1", 10533 + "@eslint/config-helpers": "^0.4.2", 10534 + "@eslint/core": "^0.17.0", 10535 + "@eslint/eslintrc": "^3.3.1", 10536 + "@eslint/js": "9.39.1", 10537 + "@eslint/plugin-kit": "^0.4.1", 10538 + "@humanfs/node": "^0.16.6", 9070 10539 "@humanwhocodes/module-importer": "^1.0.1", 9071 - "@nodelib/fs.walk": "^1.2.8", 9072 - "@ungap/structured-clone": "^1.2.0", 10540 + "@humanwhocodes/retry": "^0.4.2", 10541 + "@types/estree": "^1.0.6", 9073 10542 "ajv": "^6.12.4", 9074 10543 "chalk": "^4.0.0", 9075 - "cross-spawn": "^7.0.2", 10544 + "cross-spawn": "^7.0.6", 9076 10545 "debug": "^4.3.2", 9077 - "doctrine": "^3.0.0", 9078 10546 "escape-string-regexp": "^4.0.0", 9079 - "eslint-scope": "^7.2.2", 9080 - "eslint-visitor-keys": "^3.4.3", 9081 - "espree": "^9.6.1", 9082 - "esquery": "^1.4.2", 10547 + "eslint-scope": "^8.4.0", 10548 + "eslint-visitor-keys": "^4.2.1", 10549 + "espree": "^10.4.0", 10550 + "esquery": "^1.5.0", 9083 10551 "esutils": "^2.0.2", 9084 10552 "fast-deep-equal": "^3.1.3", 9085 - "file-entry-cache": "^6.0.1", 10553 + "file-entry-cache": "^8.0.0", 9086 10554 "find-up": "^5.0.0", 9087 10555 "glob-parent": "^6.0.2", 9088 - "globals": "^13.19.0", 9089 - "graphemer": "^1.4.0", 9090 10556 "ignore": "^5.2.0", 9091 10557 "imurmurhash": "^0.1.4", 9092 10558 "is-glob": "^4.0.0", 9093 - "is-path-inside": "^3.0.3", 9094 - "js-yaml": "^4.1.0", 9095 10559 "json-stable-stringify-without-jsonify": "^1.0.1", 9096 - "levn": "^0.4.1", 9097 10560 "lodash.merge": "^4.6.2", 9098 10561 "minimatch": "^3.1.2", 9099 10562 "natural-compare": "^1.4.0", 9100 - "optionator": "^0.9.3", 9101 - "strip-ansi": "^6.0.1", 9102 - "text-table": "^0.2.0" 10563 + "optionator": "^0.9.3" 9103 10564 }, 9104 10565 "bin": { 9105 10566 "eslint": "bin/eslint.js" 9106 10567 }, 9107 10568 "engines": { 9108 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 10569 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 9109 10570 }, 9110 10571 "funding": { 9111 - "url": "https://opencollective.com/eslint" 10572 + "url": "https://eslint.org/donate" 10573 + }, 10574 + "peerDependencies": { 10575 + "jiti": "*" 10576 + }, 10577 + "peerDependenciesMeta": { 10578 + "jiti": { 10579 + "optional": true 10580 + } 9112 10581 } 9113 10582 }, 9114 10583 "node_modules/eslint-config-next": { 9115 - "version": "15.5.3", 9116 - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.3.tgz", 9117 - "integrity": "sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==", 10584 + "version": "16.0.3", 10585 + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.3.tgz", 10586 + "integrity": "sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==", 9118 10587 "dev": true, 9119 - "license": "MIT", 9120 10588 "dependencies": { 9121 - "@next/eslint-plugin-next": "15.5.3", 9122 - "@rushstack/eslint-patch": "^1.10.3", 9123 - "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", 9124 - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", 10589 + "@next/eslint-plugin-next": "16.0.3", 9125 10590 "eslint-import-resolver-node": "^0.3.6", 9126 10591 "eslint-import-resolver-typescript": "^3.5.2", 9127 - "eslint-plugin-import": "^2.31.0", 10592 + "eslint-plugin-import": "^2.32.0", 9128 10593 "eslint-plugin-jsx-a11y": "^6.10.0", 9129 10594 "eslint-plugin-react": "^7.37.0", 9130 - "eslint-plugin-react-hooks": "^5.0.0" 10595 + "eslint-plugin-react-hooks": "^7.0.0", 10596 + "globals": "16.4.0", 10597 + "typescript-eslint": "^8.46.0" 9131 10598 }, 9132 10599 "peerDependencies": { 9133 - "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", 10600 + "eslint": ">=9.0.0", 9134 10601 "typescript": ">=3.3.1" 9135 10602 }, 9136 10603 "peerDependenciesMeta": { 9137 10604 "typescript": { 9138 10605 "optional": true 9139 10606 } 10607 + } 10608 + }, 10609 + "node_modules/eslint-config-next/node_modules/globals": { 10610 + "version": "16.4.0", 10611 + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", 10612 + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", 10613 + "dev": true, 10614 + "engines": { 10615 + "node": ">=18" 10616 + }, 10617 + "funding": { 10618 + "url": "https://github.com/sponsors/sindresorhus" 9140 10619 } 9141 10620 }, 9142 10621 "node_modules/eslint-import-resolver-node": { ··· 9185 10664 } 9186 10665 }, 9187 10666 "node_modules/eslint-module-utils": { 9188 - "version": "2.12.0", 9189 - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", 9190 - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", 10667 + "version": "2.12.1", 10668 + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", 10669 + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", 9191 10670 "dev": true, 9192 - "license": "MIT", 9193 10671 "dependencies": { 9194 10672 "debug": "^3.2.7" 9195 10673 }, ··· 9212 10690 } 9213 10691 }, 9214 10692 "node_modules/eslint-plugin-import": { 9215 - "version": "2.31.0", 9216 - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", 9217 - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", 10693 + "version": "2.32.0", 10694 + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", 10695 + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", 9218 10696 "dev": true, 9219 - "license": "MIT", 9220 10697 "dependencies": { 9221 10698 "@rtsao/scc": "^1.1.0", 9222 - "array-includes": "^3.1.8", 9223 - "array.prototype.findlastindex": "^1.2.5", 9224 - "array.prototype.flat": "^1.3.2", 9225 - "array.prototype.flatmap": "^1.3.2", 10699 + "array-includes": "^3.1.9", 10700 + "array.prototype.findlastindex": "^1.2.6", 10701 + "array.prototype.flat": "^1.3.3", 10702 + "array.prototype.flatmap": "^1.3.3", 9226 10703 "debug": "^3.2.7", 9227 10704 "doctrine": "^2.1.0", 9228 10705 "eslint-import-resolver-node": "^0.3.9", 9229 - "eslint-module-utils": "^2.12.0", 10706 + "eslint-module-utils": "^2.12.1", 9230 10707 "hasown": "^2.0.2", 9231 - "is-core-module": "^2.15.1", 10708 + "is-core-module": "^2.16.1", 9232 10709 "is-glob": "^4.0.3", 9233 10710 "minimatch": "^3.1.2", 9234 10711 "object.fromentries": "^2.0.8", 9235 10712 "object.groupby": "^1.0.3", 9236 - "object.values": "^1.2.0", 10713 + "object.values": "^1.2.1", 9237 10714 "semver": "^6.3.1", 9238 - "string.prototype.trimend": "^1.0.8", 10715 + "string.prototype.trimend": "^1.0.9", 9239 10716 "tsconfig-paths": "^3.15.0" 9240 10717 }, 9241 10718 "engines": { ··· 9339 10816 } 9340 10817 }, 9341 10818 "node_modules/eslint-plugin-react-hooks": { 9342 - "version": "5.2.0", 9343 - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", 9344 - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", 10819 + "version": "7.0.1", 10820 + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", 10821 + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", 9345 10822 "dev": true, 9346 - "license": "MIT", 10823 + "dependencies": { 10824 + "@babel/core": "^7.24.4", 10825 + "@babel/parser": "^7.24.4", 10826 + "hermes-parser": "^0.25.1", 10827 + "zod": "^3.25.0 || ^4.0.0", 10828 + "zod-validation-error": "^3.5.0 || ^4.0.0" 10829 + }, 9347 10830 "engines": { 9348 - "node": ">=10" 10831 + "node": ">=18" 9349 10832 }, 9350 10833 "peerDependencies": { 9351 10834 "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" 9352 10835 } 9353 10836 }, 10837 + "node_modules/eslint-plugin-react-hooks/node_modules/zod": { 10838 + "version": "4.1.12", 10839 + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", 10840 + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", 10841 + "dev": true, 10842 + "funding": { 10843 + "url": "https://github.com/sponsors/colinhacks" 10844 + } 10845 + }, 10846 + "node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": { 10847 + "version": "4.0.2", 10848 + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", 10849 + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", 10850 + "dev": true, 10851 + "engines": { 10852 + "node": ">=18.0.0" 10853 + }, 10854 + "peerDependencies": { 10855 + "zod": "^3.25.0 || ^4.0.0" 10856 + } 10857 + }, 9354 10858 "node_modules/eslint-plugin-react/node_modules/doctrine": { 9355 10859 "version": "2.1.0", 9356 10860 "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", ··· 9393 10897 } 9394 10898 }, 9395 10899 "node_modules/eslint-scope": { 9396 - "version": "7.2.2", 9397 - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", 9398 - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", 10900 + "version": "8.4.0", 10901 + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", 10902 + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", 9399 10903 "dev": true, 9400 10904 "dependencies": { 9401 10905 "esrecurse": "^4.3.0", 9402 10906 "estraverse": "^5.2.0" 9403 10907 }, 9404 10908 "engines": { 9405 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 10909 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 9406 10910 }, 9407 10911 "funding": { 9408 10912 "url": "https://opencollective.com/eslint" 9409 10913 } 9410 10914 }, 9411 10915 "node_modules/eslint-visitor-keys": { 9412 - "version": "3.4.3", 9413 - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", 9414 - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", 10916 + "version": "4.2.1", 10917 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", 10918 + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", 9415 10919 "dev": true, 9416 10920 "engines": { 9417 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 10921 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 9418 10922 }, 9419 10923 "funding": { 9420 10924 "url": "https://opencollective.com/eslint" ··· 9436 10940 } 9437 10941 }, 9438 10942 "node_modules/espree": { 9439 - "version": "9.6.1", 9440 - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", 9441 - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", 10943 + "version": "10.4.0", 10944 + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", 10945 + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", 9442 10946 "dev": true, 9443 10947 "dependencies": { 9444 - "acorn": "^8.9.0", 10948 + "acorn": "^8.15.0", 9445 10949 "acorn-jsx": "^5.3.2", 9446 - "eslint-visitor-keys": "^3.4.1" 10950 + "eslint-visitor-keys": "^4.2.1" 9447 10951 }, 9448 10952 "engines": { 9449 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 10953 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 9450 10954 }, 9451 10955 "funding": { 9452 10956 "url": "https://opencollective.com/eslint" ··· 9802 11306 } 9803 11307 }, 9804 11308 "node_modules/fast-uri": { 9805 - "version": "3.0.5", 9806 - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz", 9807 - "integrity": "sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==", 11309 + "version": "3.1.0", 11310 + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", 11311 + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", 9808 11312 "dev": true, 9809 11313 "funding": [ 9810 11314 { ··· 9815 11319 "type": "opencollective", 9816 11320 "url": "https://opencollective.com/fastify" 9817 11321 } 9818 - ], 9819 - "license": "BSD-3-Clause" 11322 + ] 9820 11323 }, 9821 11324 "node_modules/fastq": { 9822 11325 "version": "1.17.1", ··· 9864 11367 } 9865 11368 }, 9866 11369 "node_modules/file-entry-cache": { 9867 - "version": "6.0.1", 9868 - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", 9869 - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", 11370 + "version": "8.0.0", 11371 + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", 11372 + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", 9870 11373 "dev": true, 9871 11374 "dependencies": { 9872 - "flat-cache": "^3.0.4" 11375 + "flat-cache": "^4.0.0" 9873 11376 }, 9874 11377 "engines": { 9875 - "node": "^10.12.0 || >=12.0.0" 11378 + "node": ">=16.0.0" 9876 11379 } 9877 11380 }, 9878 11381 "node_modules/fill-range": { ··· 9937 11440 } 9938 11441 }, 9939 11442 "node_modules/flat-cache": { 9940 - "version": "3.2.0", 9941 - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", 9942 - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", 11443 + "version": "4.0.1", 11444 + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", 11445 + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", 9943 11446 "dev": true, 9944 11447 "dependencies": { 9945 11448 "flatted": "^3.2.9", 9946 - "keyv": "^4.5.3", 9947 - "rimraf": "^3.0.2" 11449 + "keyv": "^4.5.4" 9948 11450 }, 9949 11451 "engines": { 9950 - "node": "^10.12.0 || >=12.0.0" 11452 + "node": ">=16" 9951 11453 } 9952 11454 }, 9953 11455 "node_modules/flatted": { 9954 - "version": "3.3.1", 9955 - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", 9956 - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", 11456 + "version": "3.3.3", 11457 + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", 11458 + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", 9957 11459 "dev": true 9958 11460 }, 9959 11461 "node_modules/follow-redirects": { ··· 10155 11657 "node": ">=14" 10156 11658 } 10157 11659 }, 11660 + "node_modules/gensync": { 11661 + "version": "1.0.0-beta.2", 11662 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", 11663 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", 11664 + "dev": true, 11665 + "engines": { 11666 + "node": ">=6.9.0" 11667 + } 11668 + }, 10158 11669 "node_modules/get-caller-file": { 10159 11670 "version": "2.0.5", 10160 11671 "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", ··· 10316 11827 } 10317 11828 }, 10318 11829 "node_modules/globals": { 10319 - "version": "13.24.0", 10320 - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", 10321 - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", 11830 + "version": "14.0.0", 11831 + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", 11832 + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", 10322 11833 "dev": true, 10323 - "dependencies": { 10324 - "type-fest": "^0.20.2" 10325 - }, 10326 11834 "engines": { 10327 - "node": ">=8" 11835 + "node": ">=18" 10328 11836 }, 10329 11837 "funding": { 10330 11838 "url": "https://github.com/sponsors/sindresorhus" ··· 10784 12292 "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", 10785 12293 "dev": true 10786 12294 }, 12295 + "node_modules/hermes-estree": { 12296 + "version": "0.25.1", 12297 + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", 12298 + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", 12299 + "dev": true 12300 + }, 12301 + "node_modules/hermes-parser": { 12302 + "version": "0.25.1", 12303 + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", 12304 + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", 12305 + "dev": true, 12306 + "dependencies": { 12307 + "hermes-estree": "0.25.1" 12308 + } 12309 + }, 10787 12310 "node_modules/hono": { 10788 12311 "version": "4.7.11", 10789 12312 "resolved": "https://registry.npmjs.org/hono/-/hono-4.7.11.tgz", ··· 10888 12411 } 10889 12412 }, 10890 12413 "node_modules/import-fresh": { 10891 - "version": "3.3.0", 10892 - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", 10893 - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", 12414 + "version": "3.3.1", 12415 + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", 12416 + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", 10894 12417 "dev": true, 10895 12418 "dependencies": { 10896 12419 "parent-module": "^1.0.0", ··· 11375 12898 "url": "https://github.com/sponsors/ljharb" 11376 12899 } 11377 12900 }, 12901 + "node_modules/is-negative-zero": { 12902 + "version": "2.0.3", 12903 + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", 12904 + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", 12905 + "dev": true, 12906 + "engines": { 12907 + "node": ">= 0.4" 12908 + }, 12909 + "funding": { 12910 + "url": "https://github.com/sponsors/ljharb" 12911 + } 12912 + }, 11378 12913 "node_modules/is-number": { 11379 12914 "version": "7.0.0", 11380 12915 "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", ··· 11399 12934 }, 11400 12935 "funding": { 11401 12936 "url": "https://github.com/sponsors/ljharb" 11402 - } 11403 - }, 11404 - "node_modules/is-path-inside": { 11405 - "version": "3.0.3", 11406 - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", 11407 - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", 11408 - "dev": true, 11409 - "engines": { 11410 - "node": ">=8" 11411 12937 } 11412 12938 }, 11413 12939 "node_modules/is-plain-obj": { ··· 11666 13192 "license": "MIT" 11667 13193 }, 11668 13194 "node_modules/js-yaml": { 11669 - "version": "4.1.0", 11670 - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", 11671 - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", 13195 + "version": "4.1.1", 13196 + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", 13197 + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", 11672 13198 "dev": true, 11673 13199 "dependencies": { 11674 13200 "argparse": "^2.0.1" 11675 13201 }, 11676 13202 "bin": { 11677 13203 "js-yaml": "bin/js-yaml.js" 13204 + } 13205 + }, 13206 + "node_modules/jsesc": { 13207 + "version": "3.1.0", 13208 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", 13209 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", 13210 + "dev": true, 13211 + "bin": { 13212 + "jsesc": "bin/jsesc" 13213 + }, 13214 + "engines": { 13215 + "node": ">=6" 11678 13216 } 11679 13217 }, 11680 13218 "node_modules/json-bigint": { ··· 11713 13251 "version": "1.0.0", 11714 13252 "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", 11715 13253 "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", 11716 - "dev": true, 11717 - "license": "MIT" 13254 + "dev": true 11718 13255 }, 11719 13256 "node_modules/json-stable-stringify-without-jsonify": { 11720 13257 "version": "1.0.1", ··· 11832 13369 "dependencies": { 11833 13370 "json-buffer": "3.0.1" 11834 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" 11835 13378 }, 11836 13379 "node_modules/language-subtag-registry": { 11837 13380 "version": "0.3.23", ··· 13581 15124 } 13582 15125 }, 13583 15126 "node_modules/next": { 13584 - "version": "15.5.3", 13585 - "resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz", 13586 - "integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==", 15127 + "version": "16.0.7", 15128 + "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", 15129 + "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", 13587 15130 "license": "MIT", 13588 15131 "dependencies": { 13589 - "@next/env": "15.5.3", 15132 + "@next/env": "16.0.7", 13590 15133 "@swc/helpers": "0.5.15", 13591 15134 "caniuse-lite": "^1.0.30001579", 13592 15135 "postcss": "8.4.31", ··· 13596 15139 "next": "dist/bin/next" 13597 15140 }, 13598 15141 "engines": { 13599 - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" 15142 + "node": ">=20.9.0" 13600 15143 }, 13601 15144 "optionalDependencies": { 13602 - "@next/swc-darwin-arm64": "15.5.3", 13603 - "@next/swc-darwin-x64": "15.5.3", 13604 - "@next/swc-linux-arm64-gnu": "15.5.3", 13605 - "@next/swc-linux-arm64-musl": "15.5.3", 13606 - "@next/swc-linux-x64-gnu": "15.5.3", 13607 - "@next/swc-linux-x64-musl": "15.5.3", 13608 - "@next/swc-win32-arm64-msvc": "15.5.3", 13609 - "@next/swc-win32-x64-msvc": "15.5.3", 13610 - "sharp": "^0.34.3" 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" 13611 15154 }, 13612 15155 "peerDependencies": { 13613 15156 "@opentelemetry/api": "^1.1.0", ··· 13731 15274 "node-gyp-build-optional-packages-optional": "optional.js", 13732 15275 "node-gyp-build-optional-packages-test": "build-test.js" 13733 15276 } 15277 + }, 15278 + "node_modules/node-releases": { 15279 + "version": "2.0.27", 15280 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", 15281 + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", 15282 + "dev": true 13734 15283 }, 13735 15284 "node_modules/normalize-path": { 13736 15285 "version": "3.0.0", ··· 14099 15648 "dev": true, 14100 15649 "engines": { 14101 15650 "node": ">=8" 14102 - } 14103 - }, 14104 - "node_modules/path-is-absolute": { 14105 - "version": "1.0.1", 14106 - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 14107 - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", 14108 - "dev": true, 14109 - "engines": { 14110 - "node": ">=0.10.0" 14111 15651 } 14112 15652 }, 14113 15653 "node_modules/path-key": { ··· 14798 16338 } 14799 16339 }, 14800 16340 "node_modules/react": { 14801 - "version": "19.1.1", 14802 - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", 14803 - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", 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==", 14804 16344 "license": "MIT", 14805 16345 "engines": { 14806 16346 "node": ">=0.10.0" ··· 14920 16460 } 14921 16461 }, 14922 16462 "node_modules/react-dom": { 14923 - "version": "19.1.1", 14924 - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", 14925 - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", 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==", 14926 16466 "license": "MIT", 14927 16467 "dependencies": { 14928 - "scheduler": "^0.26.0" 16468 + "scheduler": "^0.27.0" 14929 16469 }, 14930 16470 "peerDependencies": { 14931 - "react": "^19.1.1" 16471 + "react": "^19.2.1" 14932 16472 } 14933 16473 }, 14934 16474 "node_modules/react-is": { ··· 15441 16981 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", 15442 16982 "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", 15443 16983 "dev": true, 15444 - "license": "MIT", 15445 16984 "engines": { 15446 16985 "node": ">=0.10.0" 15447 16986 } ··· 15513 17052 "node": ">=0.10.0" 15514 17053 } 15515 17054 }, 15516 - "node_modules/rimraf": { 15517 - "version": "3.0.2", 15518 - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", 15519 - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", 15520 - "deprecated": "Rimraf versions prior to v4 are no longer supported", 15521 - "dev": true, 15522 - "dependencies": { 15523 - "glob": "^7.1.3" 15524 - }, 15525 - "bin": { 15526 - "rimraf": "bin.js" 15527 - }, 15528 - "funding": { 15529 - "url": "https://github.com/sponsors/isaacs" 15530 - } 15531 - }, 15532 - "node_modules/rimraf/node_modules/glob": { 15533 - "version": "7.2.3", 15534 - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", 15535 - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", 15536 - "deprecated": "Glob versions prior to v9 are no longer supported", 15537 - "dev": true, 15538 - "dependencies": { 15539 - "fs.realpath": "^1.0.0", 15540 - "inflight": "^1.0.4", 15541 - "inherits": "2", 15542 - "minimatch": "^3.1.1", 15543 - "once": "^1.3.0", 15544 - "path-is-absolute": "^1.0.0" 15545 - }, 15546 - "engines": { 15547 - "node": "*" 15548 - }, 15549 - "funding": { 15550 - "url": "https://github.com/sponsors/isaacs" 15551 - } 15552 - }, 15553 17055 "node_modules/rollup-plugin-inject": { 15554 17056 "version": "3.0.2", 15555 17057 "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", ··· 15707 17209 "license": "ISC" 15708 17210 }, 15709 17211 "node_modules/scheduler": { 15710 - "version": "0.26.0", 15711 - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", 15712 - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", 15713 - "license": "MIT" 17212 + "version": "0.27.0", 17213 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", 17214 + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" 15714 17215 }, 15715 17216 "node_modules/scmp": { 15716 17217 "version": "2.1.0", ··· 16155 17656 "node": ">= 0.8" 16156 17657 } 16157 17658 }, 17659 + "node_modules/stop-iteration-iterator": { 17660 + "version": "1.1.0", 17661 + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", 17662 + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", 17663 + "dev": true, 17664 + "dependencies": { 17665 + "es-errors": "^1.3.0", 17666 + "internal-slot": "^1.1.0" 17667 + }, 17668 + "engines": { 17669 + "node": ">= 0.4" 17670 + } 17671 + }, 16158 17672 "node_modules/stoppable": { 16159 17673 "version": "1.1.0", 16160 17674 "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", ··· 16482 17996 "integrity": "sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==", 16483 17997 "license": "ISC" 16484 17998 }, 16485 - "node_modules/text-table": { 16486 - "version": "0.2.0", 16487 - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", 16488 - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", 16489 - "dev": true 16490 - }, 16491 17999 "node_modules/thread-stream": { 16492 18000 "version": "2.7.0", 16493 18001 "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz", ··· 16643 18151 "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", 16644 18152 "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", 16645 18153 "dev": true, 16646 - "license": "MIT", 16647 18154 "engines": { 16648 18155 "node": ">=18.12" 16649 18156 }, ··· 16761 18268 "node": ">= 0.8.0" 16762 18269 } 16763 18270 }, 16764 - "node_modules/type-fest": { 16765 - "version": "0.20.2", 16766 - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", 16767 - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", 16768 - "dev": true, 16769 - "engines": { 16770 - "node": ">=10" 16771 - }, 16772 - "funding": { 16773 - "url": "https://github.com/sponsors/sindresorhus" 16774 - } 16775 - }, 16776 18271 "node_modules/type-is": { 16777 18272 "version": "1.6.18", 16778 18273 "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", ··· 16875 18370 }, 16876 18371 "engines": { 16877 18372 "node": ">=14.17" 18373 + } 18374 + }, 18375 + "node_modules/typescript-eslint": { 18376 + "version": "8.47.0", 18377 + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.47.0.tgz", 18378 + "integrity": "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==", 18379 + "dev": true, 18380 + "dependencies": { 18381 + "@typescript-eslint/eslint-plugin": "8.47.0", 18382 + "@typescript-eslint/parser": "8.47.0", 18383 + "@typescript-eslint/typescript-estree": "8.47.0", 18384 + "@typescript-eslint/utils": "8.47.0" 18385 + }, 18386 + "engines": { 18387 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 18388 + }, 18389 + "funding": { 18390 + "type": "opencollective", 18391 + "url": "https://opencollective.com/typescript-eslint" 18392 + }, 18393 + "peerDependencies": { 18394 + "eslint": "^8.57.0 || ^9.0.0", 18395 + "typescript": ">=4.8.4 <6.0.0" 16878 18396 } 16879 18397 }, 16880 18398 "node_modules/uc.micro": { ··· 17048 18566 "node": ">= 0.8" 17049 18567 } 17050 18568 }, 18569 + "node_modules/update-browserslist-db": { 18570 + "version": "1.1.4", 18571 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", 18572 + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", 18573 + "dev": true, 18574 + "funding": [ 18575 + { 18576 + "type": "opencollective", 18577 + "url": "https://opencollective.com/browserslist" 18578 + }, 18579 + { 18580 + "type": "tidelift", 18581 + "url": "https://tidelift.com/funding/github/npm/browserslist" 18582 + }, 18583 + { 18584 + "type": "github", 18585 + "url": "https://github.com/sponsors/ai" 18586 + } 18587 + ], 18588 + "dependencies": { 18589 + "escalade": "^3.2.0", 18590 + "picocolors": "^1.1.1" 18591 + }, 18592 + "bin": { 18593 + "update-browserslist-db": "cli.js" 18594 + }, 18595 + "peerDependencies": { 18596 + "browserslist": ">= 4.21.0" 18597 + } 18598 + }, 17051 18599 "node_modules/use-callback-ref": { 17052 18600 "version": "1.3.3", 17053 18601 "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", ··· 17457 19005 } 17458 19006 } 17459 19007 }, 19008 + "node_modules/wrangler/node_modules/@esbuild/android-arm": { 19009 + "version": "0.17.19", 19010 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", 19011 + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", 19012 + "cpu": [ 19013 + "arm" 19014 + ], 19015 + "dev": true, 19016 + "optional": true, 19017 + "os": [ 19018 + "android" 19019 + ], 19020 + "engines": { 19021 + "node": ">=12" 19022 + } 19023 + }, 19024 + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { 19025 + "version": "0.17.19", 19026 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", 19027 + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", 19028 + "cpu": [ 19029 + "arm64" 19030 + ], 19031 + "dev": true, 19032 + "optional": true, 19033 + "os": [ 19034 + "android" 19035 + ], 19036 + "engines": { 19037 + "node": ">=12" 19038 + } 19039 + }, 19040 + "node_modules/wrangler/node_modules/@esbuild/android-x64": { 19041 + "version": "0.17.19", 19042 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", 19043 + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", 19044 + "cpu": [ 19045 + "x64" 19046 + ], 19047 + "dev": true, 19048 + "optional": true, 19049 + "os": [ 19050 + "android" 19051 + ], 19052 + "engines": { 19053 + "node": ">=12" 19054 + } 19055 + }, 19056 + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { 19057 + "version": "0.17.19", 19058 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", 19059 + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", 19060 + "cpu": [ 19061 + "arm64" 19062 + ], 19063 + "dev": true, 19064 + "optional": true, 19065 + "os": [ 19066 + "darwin" 19067 + ], 19068 + "engines": { 19069 + "node": ">=12" 19070 + } 19071 + }, 19072 + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { 19073 + "version": "0.17.19", 19074 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", 19075 + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", 19076 + "cpu": [ 19077 + "x64" 19078 + ], 19079 + "dev": true, 19080 + "optional": true, 19081 + "os": [ 19082 + "darwin" 19083 + ], 19084 + "engines": { 19085 + "node": ">=12" 19086 + } 19087 + }, 19088 + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { 19089 + "version": "0.17.19", 19090 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", 19091 + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", 19092 + "cpu": [ 19093 + "arm64" 19094 + ], 19095 + "dev": true, 19096 + "optional": true, 19097 + "os": [ 19098 + "freebsd" 19099 + ], 19100 + "engines": { 19101 + "node": ">=12" 19102 + } 19103 + }, 19104 + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { 19105 + "version": "0.17.19", 19106 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", 19107 + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", 19108 + "cpu": [ 19109 + "x64" 19110 + ], 19111 + "dev": true, 19112 + "optional": true, 19113 + "os": [ 19114 + "freebsd" 19115 + ], 19116 + "engines": { 19117 + "node": ">=12" 19118 + } 19119 + }, 19120 + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { 19121 + "version": "0.17.19", 19122 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", 19123 + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", 19124 + "cpu": [ 19125 + "arm" 19126 + ], 19127 + "dev": true, 19128 + "optional": true, 19129 + "os": [ 19130 + "linux" 19131 + ], 19132 + "engines": { 19133 + "node": ">=12" 19134 + } 19135 + }, 19136 + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { 19137 + "version": "0.17.19", 19138 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", 19139 + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", 19140 + "cpu": [ 19141 + "arm64" 19142 + ], 19143 + "dev": true, 19144 + "optional": true, 19145 + "os": [ 19146 + "linux" 19147 + ], 19148 + "engines": { 19149 + "node": ">=12" 19150 + } 19151 + }, 19152 + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { 19153 + "version": "0.17.19", 19154 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", 19155 + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", 19156 + "cpu": [ 19157 + "ia32" 19158 + ], 19159 + "dev": true, 19160 + "optional": true, 19161 + "os": [ 19162 + "linux" 19163 + ], 19164 + "engines": { 19165 + "node": ">=12" 19166 + } 19167 + }, 19168 + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { 19169 + "version": "0.17.19", 19170 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", 19171 + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", 19172 + "cpu": [ 19173 + "loong64" 19174 + ], 19175 + "dev": true, 19176 + "optional": true, 19177 + "os": [ 19178 + "linux" 19179 + ], 19180 + "engines": { 19181 + "node": ">=12" 19182 + } 19183 + }, 19184 + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { 19185 + "version": "0.17.19", 19186 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", 19187 + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", 19188 + "cpu": [ 19189 + "mips64el" 19190 + ], 19191 + "dev": true, 19192 + "optional": true, 19193 + "os": [ 19194 + "linux" 19195 + ], 19196 + "engines": { 19197 + "node": ">=12" 19198 + } 19199 + }, 19200 + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { 19201 + "version": "0.17.19", 19202 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", 19203 + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", 19204 + "cpu": [ 19205 + "ppc64" 19206 + ], 19207 + "dev": true, 19208 + "optional": true, 19209 + "os": [ 19210 + "linux" 19211 + ], 19212 + "engines": { 19213 + "node": ">=12" 19214 + } 19215 + }, 19216 + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { 19217 + "version": "0.17.19", 19218 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", 19219 + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", 19220 + "cpu": [ 19221 + "riscv64" 19222 + ], 19223 + "dev": true, 19224 + "optional": true, 19225 + "os": [ 19226 + "linux" 19227 + ], 19228 + "engines": { 19229 + "node": ">=12" 19230 + } 19231 + }, 19232 + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { 19233 + "version": "0.17.19", 19234 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", 19235 + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", 19236 + "cpu": [ 19237 + "s390x" 19238 + ], 19239 + "dev": true, 19240 + "optional": true, 19241 + "os": [ 19242 + "linux" 19243 + ], 19244 + "engines": { 19245 + "node": ">=12" 19246 + } 19247 + }, 17460 19248 "node_modules/wrangler/node_modules/@esbuild/linux-x64": { 17461 19249 "version": "0.17.19", 17462 19250 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", ··· 17468 19256 "optional": true, 17469 19257 "os": [ 17470 19258 "linux" 19259 + ], 19260 + "engines": { 19261 + "node": ">=12" 19262 + } 19263 + }, 19264 + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { 19265 + "version": "0.17.19", 19266 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", 19267 + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", 19268 + "cpu": [ 19269 + "x64" 19270 + ], 19271 + "dev": true, 19272 + "optional": true, 19273 + "os": [ 19274 + "netbsd" 19275 + ], 19276 + "engines": { 19277 + "node": ">=12" 19278 + } 19279 + }, 19280 + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { 19281 + "version": "0.17.19", 19282 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", 19283 + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", 19284 + "cpu": [ 19285 + "x64" 19286 + ], 19287 + "dev": true, 19288 + "optional": true, 19289 + "os": [ 19290 + "openbsd" 19291 + ], 19292 + "engines": { 19293 + "node": ">=12" 19294 + } 19295 + }, 19296 + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { 19297 + "version": "0.17.19", 19298 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", 19299 + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", 19300 + "cpu": [ 19301 + "x64" 19302 + ], 19303 + "dev": true, 19304 + "optional": true, 19305 + "os": [ 19306 + "sunos" 19307 + ], 19308 + "engines": { 19309 + "node": ">=12" 19310 + } 19311 + }, 19312 + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { 19313 + "version": "0.17.19", 19314 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", 19315 + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", 19316 + "cpu": [ 19317 + "arm64" 19318 + ], 19319 + "dev": true, 19320 + "optional": true, 19321 + "os": [ 19322 + "win32" 19323 + ], 19324 + "engines": { 19325 + "node": ">=12" 19326 + } 19327 + }, 19328 + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { 19329 + "version": "0.17.19", 19330 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", 19331 + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", 19332 + "cpu": [ 19333 + "ia32" 19334 + ], 19335 + "dev": true, 19336 + "optional": true, 19337 + "os": [ 19338 + "win32" 19339 + ], 19340 + "engines": { 19341 + "node": ">=12" 19342 + } 19343 + }, 19344 + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { 19345 + "version": "0.17.19", 19346 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", 19347 + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", 19348 + "cpu": [ 19349 + "x64" 19350 + ], 19351 + "dev": true, 19352 + "optional": true, 19353 + "os": [ 19354 + "win32" 17471 19355 ], 17472 19356 "engines": { 17473 19357 "node": ">=12"
+12 -11
package.json
··· 31 31 "@hono/node-server": "^1.14.3", 32 32 "@mdx-js/loader": "^3.1.0", 33 33 "@mdx-js/react": "^3.1.0", 34 - "@next/bundle-analyzer": "^15.3.2", 35 - "@next/mdx": "15.3.2", 34 + "@next/bundle-analyzer": "16.0.3", 35 + "@next/mdx": "16.0.3", 36 36 "@radix-ui/react-dialog": "^1.1.15", 37 37 "@radix-ui/react-dropdown-menu": "^2.1.16", 38 38 "@radix-ui/react-popover": "^1.1.15", ··· 58 58 "inngest": "^3.40.1", 59 59 "ioredis": "^5.6.1", 60 60 "katex": "^0.16.22", 61 + "l": "^0.6.0", 61 62 "linkifyjs": "^4.2.0", 62 63 "luxon": "^3.7.2", 63 64 "multiformats": "^13.3.2", 64 - "next": "^15.5.3", 65 + "next": "^16.0.7", 65 66 "pg": "^8.16.3", 66 67 "prosemirror-commands": "^1.5.2", 67 68 "prosemirror-inputrules": "^1.4.0", ··· 69 70 "prosemirror-model": "^1.21.0", 70 71 "prosemirror-schema-basic": "^1.2.2", 71 72 "prosemirror-state": "^1.4.3", 72 - "react": "^19.1.1", 73 + "react": "19.2.1", 73 74 "react-aria-components": "^1.8.0", 74 75 "react-day-picker": "^9.3.0", 75 - "react-dom": "^19.1.1", 76 + "react-dom": "19.2.1", 76 77 "react-use-measure": "^2.1.1", 77 78 "redlock": "^5.0.0-beta.2", 78 79 "rehype-parse": "^9.0.0", ··· 102 103 "@types/katex": "^0.16.7", 103 104 "@types/luxon": "^3.7.1", 104 105 "@types/node": "^22.15.17", 105 - "@types/react": "19.1.3", 106 - "@types/react-dom": "19.1.3", 106 + "@types/react": "19.2.6", 107 + "@types/react-dom": "19.2.3", 107 108 "@types/uuid": "^10.0.0", 108 109 "drizzle-kit": "^0.21.2", 109 110 "esbuild": "^0.25.4", 110 - "eslint": "8.57.0", 111 - "eslint-config-next": "^15.5.3", 111 + "eslint": "^9.39.1", 112 + "eslint-config-next": "16.0.3", 112 113 "postcss": "^8.4.38", 113 114 "prettier": "3.2.5", 114 115 "supabase": "^1.187.3", ··· 120 121 "overrides": { 121 122 "ajv": "^8.17.1", 122 123 "whatwg-url": "^14.0.0", 123 - "@types/react": "19.1.3", 124 - "@types/react-dom": "19.1.3" 124 + "@types/react": "19.2.6", 125 + "@types/react-dom": "19.2.3" 125 126 } 126 127 }
+3 -2
src/hooks/useLocalizedDate.ts
··· 28 28 29 29 // On initial page load, use header timezone. After hydration, use system timezone 30 30 const effectiveTimezone = !hasPageLoaded 31 - ? timezone 31 + ? timezone || "UTC" 32 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 + 34 + console.log("tz", effectiveTimezone); 33 35 34 36 // Apply timezone if available 35 37 if (effectiveTimezone) { ··· 43 45 ? language?.split(",")[0]?.split(";")[0]?.trim() || "en-US" 44 46 : Intl.DateTimeFormat().resolvedOptions().locale; 45 47 46 - console.log({ effectiveLocale, effectiveTimezone }); 47 48 try { 48 49 return dateTime.toLocaleString(options, { locale: effectiveLocale }); 49 50 } catch (error) {
+4 -3
src/hooks/usePreserveScroll.ts
··· 6 6 useEffect(() => { 7 7 if (!ref.current || !key) return; 8 8 9 - window.requestAnimationFrame(() => { 10 - ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 11 - }); 9 + if (scrollPositions[key] !== undefined) 10 + window.requestAnimationFrame(() => { 11 + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 12 + }); 12 13 13 14 const listener = () => { 14 15 if (!ref.current?.scrollTop) return;
+287 -27
src/notifications.ts
··· 2 2 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 5 7 6 8 type NotificationRow = Tables<"notifications">; 7 9 ··· 11 13 12 14 export type NotificationData = 13 15 | { type: "comment"; comment_uri: string; parent_uri?: string } 14 - | { type: "subscribe"; subscription_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 }; 15 24 16 25 export type HydratedNotification = 17 26 | HydratedCommentNotification 18 - | HydratedSubscribeNotification; 27 + | HydratedSubscribeNotification 28 + | HydratedQuoteNotification 29 + | HydratedMentionNotification 30 + | HydratedCommentMentionNotification; 19 31 export async function hydrateNotifications( 20 32 notifications: NotificationRow[], 21 33 ): Promise<Array<HydratedNotification>> { 22 34 // Call all hydrators in parallel 23 - const [commentNotifications, subscribeNotifications] = await Promise.all([ 35 + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 24 36 hydrateCommentNotifications(notifications), 25 37 hydrateSubscribeNotifications(notifications), 38 + hydrateQuoteNotifications(notifications), 39 + hydrateMentionNotifications(notifications), 40 + hydrateCommentMentionNotifications(notifications), 26 41 ]); 27 42 28 43 // Combine all hydrated notifications 29 - const allHydrated = [...commentNotifications, ...subscribeNotifications]; 44 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 30 45 31 46 // Sort by created_at to maintain order 32 47 allHydrated.sort( ··· 70 85 ) 71 86 .in("uri", commentUris); 72 87 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 - })); 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); 86 105 } 87 106 88 107 export type HydratedSubscribeNotification = Awaited< ··· 110 129 .select("*, identities(bsky_profiles(*)), publications(*)") 111 130 .in("uri", subscriptionUris); 112 131 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 - })); 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); 123 383 } 124 384 125 385 export async function pingIdentityToUpdateNotification(did: string) {
+34 -8
src/replicache/mutations.ts
··· 609 609 }; 610 610 611 611 const updatePublicationDraft: Mutation<{ 612 - title: string; 613 - description: string; 612 + title?: string; 613 + description?: string; 614 + tags?: string[]; 614 615 }> = async (args, ctx) => { 615 616 await ctx.runOnServer(async (serverCtx) => { 616 617 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); 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 + } 621 644 }); 622 645 await ctx.runOnClient(async ({ tx }) => { 623 - await tx.set("publication_title", args.title); 624 - await tx.set("publication_description", args.description); 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); 625 651 }); 626 652 }; 627 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 + }
+3 -3
src/utils/getCurrentDeploymentDomain.ts
··· 1 - import { headers, type UnsafeUnwrappedHeaders } from "next/headers"; 2 - export function getCurrentDeploymentDomain() { 3 - const headersList = (headers() as unknown as UnsafeUnwrappedHeaders); 1 + import { headers } from "next/headers"; 2 + export async function getCurrentDeploymentDomain() { 3 + const headersList = await headers(); 4 4 const hostname = headersList.get("x-forwarded-host"); 5 5 let protocol = headersList.get("x-forwarded-proto"); 6 6 return `${protocol}://${hostname}/`;
+12 -3
src/utils/getMicroLinkOgImage.ts
··· 10 10 }, 11 11 ) { 12 12 const headersList = await headers(); 13 - const hostname = headersList.get("x-forwarded-host"); 13 + let hostname = headersList.get("x-forwarded-host"); 14 14 let protocol = headersList.get("x-forwarded-proto"); 15 + if (process.env.NODE_ENV === "development") { 16 + protocol === "https"; 17 + hostname = "leaflet.pub"; 18 + } 15 19 let full_path = `${protocol}://${hostname}${path}`; 16 - return getWebpageImage(full_path, options); 20 + return getWebpageImage(full_path, { 21 + ...options, 22 + setJavaScriptEnabled: false, 23 + }); 17 24 } 18 25 19 26 export async function getWebpageImage( 20 27 url: string, 21 28 options?: { 29 + setJavaScriptEnabled?: boolean; 22 30 width?: number; 23 31 height?: number; 24 32 deviceScaleFactor?: number; ··· 35 43 }, 36 44 body: JSON.stringify({ 37 45 url, 46 + setJavaScriptEnabled: options?.setJavaScriptEnabled, 38 47 scrollPage: true, 39 48 addStyleTag: [ 40 49 { 41 - content: `* {overflow: hidden !important; }`, 50 + content: `* {scrollbar-width:none; }`, 42 51 }, 43 52 ], 44 53 gotoOptions: {
+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 + }
-16
src/utils/isBot.ts
··· 1 - import { cookies, headers, type UnsafeUnwrappedHeaders } from "next/headers"; 2 - export function getIsBot() { 3 - const userAgent = 4 - (headers() as unknown as UnsafeUnwrappedHeaders).get("user-agent") || ""; 5 - const botPatterns = [ 6 - /bot/i, 7 - /crawler/i, 8 - /spider/i, 9 - /googlebot/i, 10 - /bingbot/i, 11 - /yahoo/i, 12 - // Add more patterns as needed 13 - ]; 14 - 15 - return botPatterns.some((pattern) => pattern.test(userAgent)); 16 - }
+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 + }
+54
supabase/database.types.ts
··· 580 580 } 581 581 leaflets_in_publications: { 582 582 Row: { 583 + archived: boolean | null 583 584 description: string 584 585 doc: string | null 585 586 leaflet: string ··· 587 588 title: string 588 589 } 589 590 Insert: { 591 + archived?: boolean | null 590 592 description?: string 591 593 doc?: string | null 592 594 leaflet: string ··· 594 596 title?: string 595 597 } 596 598 Update: { 599 + archived?: boolean | null 597 600 description?: string 598 601 doc?: string | null 599 602 leaflet?: string ··· 624 627 }, 625 628 ] 626 629 } 630 + leaflets_to_documents: { 631 + Row: { 632 + created_at: string 633 + description: string 634 + document: string 635 + leaflet: string 636 + title: string 637 + } 638 + Insert: { 639 + created_at?: string 640 + description?: string 641 + document: string 642 + leaflet: string 643 + title?: string 644 + } 645 + Update: { 646 + created_at?: string 647 + description?: string 648 + document?: string 649 + leaflet?: string 650 + title?: string 651 + } 652 + Relationships: [ 653 + { 654 + foreignKeyName: "leaflets_to_documents_document_fkey" 655 + columns: ["document"] 656 + isOneToOne: false 657 + referencedRelation: "documents" 658 + referencedColumns: ["uri"] 659 + }, 660 + { 661 + foreignKeyName: "leaflets_to_documents_leaflet_fkey" 662 + columns: ["leaflet"] 663 + isOneToOne: false 664 + referencedRelation: "permission_tokens" 665 + referencedColumns: ["id"] 666 + }, 667 + ] 668 + } 627 669 notifications: { 628 670 Row: { 629 671 created_at: string ··· 688 730 } 689 731 permission_token_on_homepage: { 690 732 Row: { 733 + archived: boolean | null 691 734 created_at: string 692 735 identity: string 693 736 token: string 694 737 } 695 738 Insert: { 739 + archived?: boolean | null 696 740 created_at?: string 697 741 identity: string 698 742 token: string 699 743 } 700 744 Update: { 745 + archived?: boolean | null 701 746 created_at?: string 702 747 identity?: string 703 748 token?: string ··· 1112 1157 client_group_id: string 1113 1158 } 1114 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 + }[] 1115 1169 } 1116 1170 } 1117 1171 Enums: {
+63
supabase/migrations/20251118185507_add_leaflets_to_documents.sql
··· 1 + create table "public"."leaflets_to_documents" ( 2 + "leaflet" uuid not null, 3 + "document" text not null, 4 + "created_at" timestamp with time zone not null default now(), 5 + "title" text not null default ''::text, 6 + "description" text not null default ''::text 7 + ); 8 + 9 + alter table "public"."leaflets_to_documents" enable row level security; 10 + 11 + CREATE UNIQUE INDEX leaflets_to_documents_pkey ON public.leaflets_to_documents USING btree (leaflet, document); 12 + 13 + alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_pkey" PRIMARY KEY using index "leaflets_to_documents_pkey"; 14 + 15 + alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid; 16 + 17 + alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_document_fkey"; 18 + 19 + alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_leaflet_fkey" FOREIGN KEY (leaflet) REFERENCES permission_tokens(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; 20 + 21 + alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_leaflet_fkey"; 22 + 23 + grant delete on table "public"."leaflets_to_documents" to "anon"; 24 + 25 + grant insert on table "public"."leaflets_to_documents" to "anon"; 26 + 27 + grant references on table "public"."leaflets_to_documents" to "anon"; 28 + 29 + grant select on table "public"."leaflets_to_documents" to "anon"; 30 + 31 + grant trigger on table "public"."leaflets_to_documents" to "anon"; 32 + 33 + grant truncate on table "public"."leaflets_to_documents" to "anon"; 34 + 35 + grant update on table "public"."leaflets_to_documents" to "anon"; 36 + 37 + grant delete on table "public"."leaflets_to_documents" to "authenticated"; 38 + 39 + grant insert on table "public"."leaflets_to_documents" to "authenticated"; 40 + 41 + grant references on table "public"."leaflets_to_documents" to "authenticated"; 42 + 43 + grant select on table "public"."leaflets_to_documents" to "authenticated"; 44 + 45 + grant trigger on table "public"."leaflets_to_documents" to "authenticated"; 46 + 47 + grant truncate on table "public"."leaflets_to_documents" to "authenticated"; 48 + 49 + grant update on table "public"."leaflets_to_documents" to "authenticated"; 50 + 51 + grant delete on table "public"."leaflets_to_documents" to "service_role"; 52 + 53 + grant insert on table "public"."leaflets_to_documents" to "service_role"; 54 + 55 + grant references on table "public"."leaflets_to_documents" to "service_role"; 56 + 57 + grant select on table "public"."leaflets_to_documents" to "service_role"; 58 + 59 + grant trigger on table "public"."leaflets_to_documents" to "service_role"; 60 + 61 + grant truncate on table "public"."leaflets_to_documents" to "service_role"; 62 + 63 + grant update on table "public"."leaflets_to_documents" to "service_role";
+1
supabase/migrations/20251119191717_add_archived_to_permission_tokens_on_homepage.sql
··· 1 + alter table "public"."permission_token_on_homepage" add column "archived" boolean;
+1
supabase/migrations/20251120215250_add_archived_col_to_leaflets_in_publications.sql
··· 1 + alter table "public"."leaflets_in_publications" add column "archived" boolean;
+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 + ;
+14 -5
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 - "lib": ["dom", "dom.iterable", "esnext"], 4 - "types": ["@cloudflare/workers-types"], 3 + "lib": [ 4 + "dom", 5 + "dom.iterable", 6 + "esnext" 7 + ], 8 + "types": [ 9 + "@cloudflare/workers-types" 10 + ], 5 11 "baseUrl": ".", 6 12 "allowJs": true, 7 13 "skipLibCheck": true, ··· 15 21 "moduleResolution": "node", 16 22 "resolveJsonModule": true, 17 23 "isolatedModules": true, 18 - "jsx": "preserve", 24 + "jsx": "react-jsx", 19 25 "plugins": [ 20 26 { 21 27 "name": "next" ··· 30 36 "**/*.js", 31 37 "**/*.ts", 32 38 "**/*.tsx", 33 - "**/*.mdx" 39 + "**/*.mdx", 40 + ".next/dev/types/**/*.ts" 34 41 ], 35 - "exclude": ["node_modules"] 42 + "exclude": [ 43 + "node_modules" 44 + ] 36 45 }