a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

+23060 -10443
+6
actions/createPublicationDraft.ts
··· 11 redirectUser: false, 12 firstBlockType: "text", 13 }); 14 15 await supabaseServerClient 16 .from("leaflets_in_publications")
··· 11 redirectUser: false, 12 firstBlockType: "text", 13 }); 14 + let { data: publication } = await supabaseServerClient 15 + .from("publications") 16 + .select("*") 17 + .eq("uri", publication_uri) 18 + .single(); 19 + if (publication?.identity_did !== identity.atp_did) return; 20 21 await supabaseServerClient 22 .from("leaflets_in_publications")
+127
actions/deleteLeaflet.ts
··· 1 "use server"; 2 3 import { drizzle } from "drizzle-orm/node-postgres"; 4 import { ··· 9 import { eq } from "drizzle-orm"; 10 import { PermissionToken } from "src/replicache"; 11 import { pool } from "supabase/pool"; 12 13 export async function deleteLeaflet(permission_token: PermissionToken) { 14 const client = await pool.connect(); 15 const db = drizzle(client); 16 await db.transaction(async (tx) => { 17 let [token] = await tx 18 .select() ··· 32 .where(eq(permission_tokens.id, permission_token.id)); 33 }); 34 client.release(); 35 return; 36 }
··· 1 "use server"; 2 + import { refresh } from "next/cache"; 3 4 import { drizzle } from "drizzle-orm/node-postgres"; 5 import { ··· 10 import { eq } from "drizzle-orm"; 11 import { PermissionToken } from "src/replicache"; 12 import { pool } from "supabase/pool"; 13 + import { getIdentityData } from "./getIdentityData"; 14 + import { supabaseServerClient } from "supabase/serverClient"; 15 16 export async function deleteLeaflet(permission_token: PermissionToken) { 17 const client = await pool.connect(); 18 const db = drizzle(client); 19 + 20 + // Get the current user's identity 21 + let identity = await getIdentityData(); 22 + 23 + // Check publication and document ownership in one query 24 + let { data: tokenData } = await supabaseServerClient 25 + .from("permission_tokens") 26 + .select( 27 + ` 28 + id, 29 + leaflets_in_publications(publication, publications!inner(identity_did)), 30 + leaflets_to_documents(document, documents!inner(uri)) 31 + `, 32 + ) 33 + .eq("id", permission_token.id) 34 + .single(); 35 + 36 + if (tokenData) { 37 + // Check if leaflet is in a publication 38 + const leafletInPubs = tokenData.leaflets_in_publications || []; 39 + if (leafletInPubs.length > 0) { 40 + if (!identity) { 41 + throw new Error( 42 + "Unauthorized: You must be logged in to delete a leaflet in a publication", 43 + ); 44 + } 45 + const isOwner = leafletInPubs.some( 46 + (pub: any) => pub.publications.identity_did === identity.atp_did, 47 + ); 48 + if (!isOwner) { 49 + throw new Error( 50 + "Unauthorized: You must own the publication to delete this leaflet", 51 + ); 52 + } 53 + } 54 + 55 + // Check if there's a standalone published document 56 + const leafletDocs = tokenData.leaflets_to_documents || []; 57 + if (leafletDocs.length > 0) { 58 + if (!identity) { 59 + throw new Error( 60 + "Unauthorized: You must be logged in to delete a published leaflet", 61 + ); 62 + } 63 + for (let leafletDoc of leafletDocs) { 64 + const docUri = leafletDoc.documents?.uri; 65 + // Extract the DID from the document URI (format: at://did:plc:xxx/...) 66 + if (docUri && identity.atp_did && !docUri.includes(identity.atp_did)) { 67 + throw new Error( 68 + "Unauthorized: You must own the published document to delete this leaflet", 69 + ); 70 + } 71 + } 72 + } 73 + } 74 + 75 await db.transaction(async (tx) => { 76 let [token] = await tx 77 .select() ··· 91 .where(eq(permission_tokens.id, permission_token.id)); 92 }); 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(); 162 return; 163 }
+10 -3
actions/getIdentityData.ts
··· 2 3 import { cookies } from "next/headers"; 4 import { supabaseServerClient } from "supabase/serverClient"; 5 - 6 - export async function getIdentityData() { 7 let cookieStore = await cookies(); 8 let auth_token = 9 cookieStore.get("auth_token")?.value || ··· 16 identities( 17 *, 18 bsky_profiles(*), 19 publication_subscriptions(*), 20 custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), 21 - home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), 22 permission_token_on_homepage( 23 created_at, 24 permission_tokens!inner( 25 id, 26 root_entity, 27 permission_token_rights(*), 28 leaflets_in_publications(*, publications(*), documents(*)) 29 ) 30 ) 31 )`, 32 ) 33 .eq("id", auth_token) 34 .eq("confirmed", true) 35 .single()
··· 2 3 import { cookies } from "next/headers"; 4 import { supabaseServerClient } from "supabase/serverClient"; 5 + import { cache } from "react"; 6 + export const getIdentityData = cache(uncachedGetIdentityData); 7 + export async function uncachedGetIdentityData() { 8 let cookieStore = await cookies(); 9 let auth_token = 10 cookieStore.get("auth_token")?.value || ··· 17 identities( 18 *, 19 bsky_profiles(*), 20 + notifications(count), 21 publication_subscriptions(*), 22 custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), 23 + home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*, 24 + entity_sets(entities(facts(*))) 25 + )), 26 permission_token_on_homepage( 27 + archived, 28 created_at, 29 permission_tokens!inner( 30 id, 31 root_entity, 32 permission_token_rights(*), 33 + leaflets_to_documents(*, documents(*)), 34 leaflets_in_publications(*, publications(*), documents(*)) 35 ) 36 ) 37 )`, 38 ) 39 + .eq("identities.notifications.read", false) 40 .eq("id", auth_token) 41 .eq("confirmed", true) 42 .single()
+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 - }
···
+750 -306
actions/publishToPublication.ts
··· 12 PubLeafletBlocksUnorderedList, 13 PubLeafletDocument, 14 PubLeafletPagesLinearDocument, 15 PubLeafletRichtextFacet, 16 PubLeafletBlocksWebsite, 17 PubLeafletBlocksCode, ··· 20 PubLeafletBlocksBskyPost, 21 PubLeafletBlocksBlockquote, 22 PubLeafletBlocksIframe, 23 } from "lexicons/api"; 24 import { Block } from "components/Blocks/Block"; 25 import { TID } from "@atproto/common"; ··· 27 import { scanIndexLocal } from "src/replicache/utils"; 28 import type { Fact } from "src/replicache"; 29 import type { Attribute } from "src/replicache/attributes"; 30 - import { 31 - Delta, 32 - YJSFragmentToString, 33 - } from "components/Blocks/TextBlock/RenderYJSFragment"; 34 import { ids } from "lexicons/api/lexicons"; 35 import { BlobRef } from "@atproto/lexicon"; 36 import { AtUri } from "@atproto/syntax"; ··· 38 import { $Typed, UnicodeString } from "@atproto/api"; 39 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 40 import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 41 42 export async function publishToPublication({ 43 root_entity, ··· 45 leaflet_id, 46 title, 47 description, 48 }: { 49 root_entity: string; 50 - publication_uri: string; 51 leaflet_id: string; 52 title?: string; 53 description?: string; 54 }) { 55 const oauthClient = await createOauthClient(); 56 let identity = await getIdentityData(); ··· 60 let agent = new AtpBaseClient( 61 credentialSession.fetchHandler.bind(credentialSession), 62 ); 63 - let { data: draft } = await supabaseServerClient 64 - .from("leaflets_in_publications") 65 - .select("*, publications(*), documents(*)") 66 - .eq("publication", publication_uri) 67 - .eq("leaflet", leaflet_id) 68 - .single(); 69 - if (!draft || identity.atp_did !== draft?.publications?.identity_did) 70 - throw new Error("No draft or not publisher"); 71 let { data } = await supabaseServerClient.rpc("get_facts", { 72 root: root_entity, 73 }); 74 let facts = (data as unknown as Fact<Attribute>[]) || []; 75 - let scan = scanIndexLocal(facts); 76 - let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 77 - if (!firstEntity) throw new Error("No root page"); 78 - let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 79 80 - let images = blocks 81 - .filter((b) => b.type === "image") 82 - .map((b) => scan.eav(b.value, "block/image")[0]); 83 - let links = blocks 84 - .filter((b) => b.type == "link") 85 - .map((b) => scan.eav(b.value, "link/preview")[0]); 86 - let imageMap = new Map<string, BlobRef>(); 87 - for (const b of [...links, ...images]) { 88 - if (!b) continue; 89 - let data = await fetch(b.data.src); 90 - if (data.status !== 200) continue; 91 - let binary = await data.blob(); 92 - try { 93 - let blob = await agent.com.atproto.repo.uploadBlob(binary, { 94 - headers: { "Content-Type": binary.type }, 95 - }); 96 - if (!blob.success) { 97 - console.log(blob); 98 - console.log("Error uploading image: " + b.data.src); 99 - throw new Error("Failed to upload image"); 100 - } 101 - imageMap.set(b.data.src, blob.data.blob); 102 - } catch (e) { 103 - console.error(e); 104 - console.log("Error uploading image: " + b.data.src); 105 - throw new Error("Failed to upload image"); 106 - } 107 - } 108 - 109 - let b: PubLeafletPagesLinearDocument.Block[] = blocksToRecord( 110 - blocks, 111 - imageMap, 112 - scan, 113 root_entity, 114 ); 115 116 let existingRecord = 117 (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 118 let record: PubLeafletDocument.Record = { 119 - $type: "pub.leaflet.document", 120 - author: credentialSession.did!, 121 - publication: publication_uri, 122 publishedAt: new Date().toISOString(), 123 ...existingRecord, 124 title: title || "Untitled", 125 description: description || "", 126 - pages: [ 127 - { 128 - $type: "pub.leaflet.pages.linearDocument", 129 - blocks: b, 130 - }, 131 - ], 132 }; 133 - let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); 134 let { data: result } = await agent.com.atproto.repo.putRecord({ 135 rkey, 136 repo: credentialSession.did!, ··· 139 validate: false, //TODO publish the lexicon so we can validate! 140 }); 141 142 await supabaseServerClient.from("documents").upsert({ 143 uri: result.uri, 144 data: record as Json, 145 }); 146 - await Promise.all([ 147 - //Optimistically put these in! 148 - supabaseServerClient.from("documents_in_publications").upsert({ 149 - publication: record.publication, 150 - document: result.uri, 151 - }), 152 - supabaseServerClient 153 - .from("leaflets_in_publications") 154 - .update({ 155 - doc: result.uri, 156 - }) 157 - .eq("leaflet", leaflet_id) 158 - .eq("publication", publication_uri), 159 - ]); 160 161 - return { rkey, record: JSON.parse(JSON.stringify(record)) }; 162 - } 163 164 - function blocksToRecord( 165 - blocks: Block[], 166 - imageMap: Map<string, BlobRef>, 167 - scan: ReturnType<typeof scanIndexLocal>, 168 - root_entity: string, 169 - ): PubLeafletPagesLinearDocument.Block[] { 170 - let parsedBlocks = parseBlocksToList(blocks); 171 - return parsedBlocks.flatMap((blockOrList) => { 172 - if (blockOrList.type === "block") { 173 - let alignmentValue = 174 - scan.eav(blockOrList.block.value, "block/text-alignment")[0]?.data 175 - .value || "left"; 176 - let alignment = 177 - alignmentValue === "center" 178 - ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 179 - : alignmentValue === "right" 180 - ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 181 - : undefined; 182 - let b = blockToRecord(blockOrList.block, imageMap, scan, root_entity); 183 - if (!b) return []; 184 - let block: PubLeafletPagesLinearDocument.Block = { 185 - $type: "pub.leaflet.pages.linearDocument#block", 186 - alignment, 187 - block: b, 188 - }; 189 - return [block]; 190 - } else { 191 - let block: PubLeafletPagesLinearDocument.Block = { 192 - $type: "pub.leaflet.pages.linearDocument#block", 193 - block: { 194 - $type: "pub.leaflet.blocks.unorderedList", 195 - children: childrenToRecord( 196 - blockOrList.children, 197 - imageMap, 198 - scan, 199 - root_entity, 200 - ), 201 - }, 202 - }; 203 - return [block]; 204 } 205 - }); 206 - } 207 208 - function childrenToRecord( 209 - children: List[], 210 - imageMap: Map<string, BlobRef>, 211 - scan: ReturnType<typeof scanIndexLocal>, 212 - root_entity: string, 213 - ) { 214 - return children.flatMap((child) => { 215 - let content = blockToRecord(child.block, imageMap, scan, root_entity); 216 - if (!content) return []; 217 - let record: PubLeafletBlocksUnorderedList.ListItem = { 218 - $type: "pub.leaflet.blocks.unorderedList#listItem", 219 - content, 220 - children: childrenToRecord(child.children, imageMap, scan, root_entity), 221 - }; 222 - return record; 223 - }); 224 } 225 - function blockToRecord( 226 - b: Block, 227 - imageMap: Map<string, BlobRef>, 228 - scan: ReturnType<typeof scanIndexLocal>, 229 root_entity: string, 230 ) { 231 - const getBlockContent = (b: string) => { 232 - let [content] = scan.eav(b, "block/text"); 233 - if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; 234 - let doc = new Y.Doc(); 235 - const update = base64.toByteArray(content.data.value); 236 - Y.applyUpdate(doc, update); 237 - let nodes = doc.getXmlElement("prosemirror").toArray(); 238 - let stringValue = YJSFragmentToString(nodes[0]); 239 - let facets = YJSFragmentToFacets(nodes[0]); 240 - return [stringValue, facets] as const; 241 - }; 242 243 - if (b.type === "bluesky-post") { 244 - let [post] = scan.eav(b.value, "block/bluesky-post"); 245 - if (!post || !post.data.value.post) return; 246 - let block: $Typed<PubLeafletBlocksBskyPost.Main> = { 247 - $type: ids.PubLeafletBlocksBskyPost, 248 - postRef: { 249 - uri: post.data.value.post.uri, 250 - cid: post.data.value.post.cid, 251 - }, 252 - }; 253 - return block; 254 - } 255 - if (b.type === "horizontal-rule") { 256 - let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { 257 - $type: ids.PubLeafletBlocksHorizontalRule, 258 - }; 259 - return block; 260 - } 261 262 - if (b.type === "heading") { 263 - let [headingLevel] = scan.eav(b.value, "block/heading-level"); 264 265 - let [stringValue, facets] = getBlockContent(b.value); 266 - let block: $Typed<PubLeafletBlocksHeader.Main> = { 267 - $type: "pub.leaflet.blocks.header", 268 - level: headingLevel?.data.value || 1, 269 - plaintext: stringValue, 270 - facets, 271 - }; 272 - return block; 273 - } 274 275 - if (b.type === "blockquote") { 276 - let [stringValue, facets] = getBlockContent(b.value); 277 - let block: $Typed<PubLeafletBlocksBlockquote.Main> = { 278 - $type: ids.PubLeafletBlocksBlockquote, 279 - plaintext: stringValue, 280 - facets, 281 - }; 282 - return block; 283 } 284 285 - if (b.type == "text") { 286 - let [stringValue, facets] = getBlockContent(b.value); 287 - let block: $Typed<PubLeafletBlocksText.Main> = { 288 - $type: ids.PubLeafletBlocksText, 289 - plaintext: stringValue, 290 - facets, 291 - }; 292 - return block; 293 - } 294 - if (b.type === "embed") { 295 - let [url] = scan.eav(b.value, "embed/url"); 296 - let [height] = scan.eav(b.value, "embed/height"); 297 - if (!url) return; 298 - let block: $Typed<PubLeafletBlocksIframe.Main> = { 299 - $type: "pub.leaflet.blocks.iframe", 300 - url: url.data.value, 301 - height: Math.floor(height?.data.value || 600), 302 - }; 303 - return block; 304 } 305 - if (b.type == "image") { 306 - let [image] = scan.eav(b.value, "block/image"); 307 - if (!image) return; 308 - let [altText] = scan.eav(b.value, "image/alt"); 309 - let blobref = imageMap.get(image.data.src); 310 - if (!blobref) return; 311 - let block: $Typed<PubLeafletBlocksImage.Main> = { 312 - $type: "pub.leaflet.blocks.image", 313 - image: blobref, 314 - aspectRatio: { 315 - height: image.data.height, 316 - width: image.data.width, 317 - }, 318 - alt: altText ? altText.data.value : undefined, 319 - }; 320 - return block; 321 } 322 - if (b.type === "link") { 323 - let [previewImage] = scan.eav(b.value, "link/preview"); 324 - let [description] = scan.eav(b.value, "link/description"); 325 - let [src] = scan.eav(b.value, "link/url"); 326 - if (!src) return; 327 - let blobref = previewImage 328 - ? imageMap.get(previewImage?.data.src) 329 - : undefined; 330 - let [title] = scan.eav(b.value, "link/title"); 331 - let block: $Typed<PubLeafletBlocksWebsite.Main> = { 332 - $type: "pub.leaflet.blocks.website", 333 - previewImage: blobref, 334 - src: src.data.value, 335 - description: description?.data.value, 336 - title: title?.data.value, 337 - }; 338 - return block; 339 } 340 - if (b.type === "code") { 341 - let [language] = scan.eav(b.value, "block/code-language"); 342 - let [code] = scan.eav(b.value, "block/code"); 343 - let [theme] = scan.eav(root_entity, "theme/code-theme"); 344 - let block: $Typed<PubLeafletBlocksCode.Main> = { 345 - $type: "pub.leaflet.blocks.code", 346 - language: language?.data.value, 347 - plaintext: code?.data.value || "", 348 - syntaxHighlightingTheme: theme?.data.value, 349 }; 350 - return block; 351 } 352 - if (b.type === "math") { 353 - let [math] = scan.eav(b.value, "block/math"); 354 - let block: $Typed<PubLeafletBlocksMath.Main> = { 355 - $type: "pub.leaflet.blocks.math", 356 - tex: math?.data.value || "", 357 - }; 358 - return block; 359 } 360 - return; 361 } 362 363 - async function sendPostToEmailSubscribers( 364 - publication_uri: string, 365 - post: { content: string; title: string }, 366 - ) { 367 - let { data: publication } = await supabaseServerClient 368 - .from("publications") 369 - .select("*, subscribers_to_publications(*)") 370 - .eq("uri", publication_uri) 371 - .single(); 372 - 373 - let res = await fetch("https://api.postmarkapp.com/email/batch", { 374 - method: "POST", 375 - headers: { 376 - "Content-Type": "application/json", 377 - "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, 378 - }, 379 - body: JSON.stringify( 380 - publication?.subscribers_to_publications.map((sub) => ({ 381 - Headers: [ 382 { 383 - Name: "List-Unsubscribe-Post", 384 - Value: "List-Unsubscribe=One-Click", 385 }, 386 { 387 - Name: "List-Unsubscribe", 388 - Value: `<${"TODO"}/mail/unsubscribe?sub_id=${sub.identity}>`, 389 }, 390 ], 391 - MessageStream: "broadcast", 392 - From: `${publication.name} <mailbox@leaflet.pub>`, 393 - Subject: post.title, 394 - To: sub.identity, 395 - HtmlBody: ` 396 - <h1>${publication.name}</h1> 397 - <hr style="margin-top: 1em; margin-bottom: 1em;"> 398 - ${post.content} 399 - <hr style="margin-top: 1em; margin-bottom: 1em;"> 400 - This is a super alpha release! Ask Jared if you want to unsubscribe (sorry) 401 - `, 402 - TextBody: post.content, 403 - })), 404 - ), 405 - }); 406 - } 407 408 - function YJSFragmentToFacets( 409 - node: Y.XmlElement | Y.XmlText | Y.XmlHook, 410 - ): PubLeafletRichtextFacet.Main[] { 411 - if (node.constructor === Y.XmlElement) { 412 - return node 413 - .toArray() 414 - .map((f) => YJSFragmentToFacets(f)) 415 - .flat(); 416 } 417 if (node.constructor === Y.XmlText) { 418 let facets: PubLeafletRichtextFacet.Main[] = []; 419 let delta = node.toDelta() as Delta[]; 420 - let byteStart = 0; 421 for (let d of delta) { 422 let unicodestring = new UnicodeString(d.insert); 423 let facet: PubLeafletRichtextFacet.Main = { ··· 450 }); 451 if (facet.features.length > 0) facets.push(facet); 452 byteStart += unicodestring.length; 453 } 454 - return facets; 455 } 456 - return []; 457 }
··· 12 PubLeafletBlocksUnorderedList, 13 PubLeafletDocument, 14 PubLeafletPagesLinearDocument, 15 + PubLeafletPagesCanvas, 16 PubLeafletRichtextFacet, 17 PubLeafletBlocksWebsite, 18 PubLeafletBlocksCode, ··· 21 PubLeafletBlocksBskyPost, 22 PubLeafletBlocksBlockquote, 23 PubLeafletBlocksIframe, 24 + PubLeafletBlocksPage, 25 + PubLeafletBlocksPoll, 26 + PubLeafletBlocksButton, 27 + PubLeafletPollDefinition, 28 } from "lexicons/api"; 29 import { Block } from "components/Blocks/Block"; 30 import { TID } from "@atproto/common"; ··· 32 import { scanIndexLocal } from "src/replicache/utils"; 33 import type { Fact } from "src/replicache"; 34 import type { Attribute } from "src/replicache/attributes"; 35 + import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 36 import { ids } from "lexicons/api/lexicons"; 37 import { BlobRef } from "@atproto/lexicon"; 38 import { AtUri } from "@atproto/syntax"; ··· 40 import { $Typed, UnicodeString } from "@atproto/api"; 41 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 42 import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 43 + import { Lock } from "src/utils/lock"; 44 + import type { PubLeafletPublication } from "lexicons/api"; 45 + import { 46 + ColorToRGB, 47 + ColorToRGBA, 48 + } from "components/ThemeManager/colorToLexicons"; 49 + import { parseColor } from "@react-stately/color"; 50 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 51 + import { v7 } from "uuid"; 52 53 export async function publishToPublication({ 54 root_entity, ··· 56 leaflet_id, 57 title, 58 description, 59 + tags, 60 + entitiesToDelete, 61 }: { 62 root_entity: string; 63 + publication_uri?: string; 64 leaflet_id: string; 65 title?: string; 66 description?: string; 67 + tags?: string[]; 68 + entitiesToDelete?: string[]; 69 }) { 70 const oauthClient = await createOauthClient(); 71 let identity = await getIdentityData(); ··· 75 let agent = new AtpBaseClient( 76 credentialSession.fetchHandler.bind(credentialSession), 77 ); 78 + 79 + // Check if we're publishing to a publication or standalone 80 + let draft: any = null; 81 + let existingDocUri: string | null = null; 82 + 83 + if (publication_uri) { 84 + // Publishing to a publication - use leaflets_in_publications 85 + let { data, error } = await supabaseServerClient 86 + .from("publications") 87 + .select("*, leaflets_in_publications(*, documents(*))") 88 + .eq("uri", publication_uri) 89 + .eq("leaflets_in_publications.leaflet", leaflet_id) 90 + .single(); 91 + console.log(error); 92 + 93 + if (!data || identity.atp_did !== data?.identity_did) 94 + throw new Error("No draft or not publisher"); 95 + draft = data.leaflets_in_publications[0]; 96 + existingDocUri = draft?.doc; 97 + } else { 98 + // Publishing standalone - use leaflets_to_documents 99 + let { data } = await supabaseServerClient 100 + .from("leaflets_to_documents") 101 + .select("*, documents(*)") 102 + .eq("leaflet", leaflet_id) 103 + .single(); 104 + draft = data; 105 + existingDocUri = draft?.document; 106 + } 107 + 108 + // Heuristic: Remove title entities if this is the first time publishing 109 + // (when coming from a standalone leaflet with entitiesToDelete passed in) 110 + if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { 111 + await supabaseServerClient 112 + .from("entities") 113 + .delete() 114 + .in("id", entitiesToDelete); 115 + } 116 + 117 let { data } = await supabaseServerClient.rpc("get_facts", { 118 root: root_entity, 119 }); 120 let facts = (data as unknown as Fact<Attribute>[]) || []; 121 122 + let { pages } = await processBlocksToPages( 123 + facts, 124 + agent, 125 root_entity, 126 + credentialSession.did!, 127 ); 128 129 let existingRecord = 130 (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 131 + 132 + // Extract theme for standalone documents (not for publications) 133 + let theme: PubLeafletPublication.Theme | undefined; 134 + if (!publication_uri) { 135 + theme = await extractThemeFromFacts(facts, root_entity, agent); 136 + } 137 + 138 let record: PubLeafletDocument.Record = { 139 publishedAt: new Date().toISOString(), 140 ...existingRecord, 141 + $type: "pub.leaflet.document", 142 + author: credentialSession.did!, 143 + ...(publication_uri && { publication: publication_uri }), 144 + ...(theme && { theme }), 145 title: title || "Untitled", 146 description: description || "", 147 + ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 148 + pages: pages.map((p) => { 149 + if (p.type === "canvas") { 150 + return { 151 + $type: "pub.leaflet.pages.canvas" as const, 152 + id: p.id, 153 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 154 + }; 155 + } else { 156 + return { 157 + $type: "pub.leaflet.pages.linearDocument" as const, 158 + id: p.id, 159 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 160 + }; 161 + } 162 + }), 163 }; 164 + 165 + // Keep the same rkey if updating an existing document 166 + let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 167 let { data: result } = await agent.com.atproto.repo.putRecord({ 168 rkey, 169 repo: credentialSession.did!, ··· 172 validate: false, //TODO publish the lexicon so we can validate! 173 }); 174 175 + // Optimistically create database entries 176 await supabaseServerClient.from("documents").upsert({ 177 uri: result.uri, 178 data: record as Json, 179 }); 180 181 + if (publication_uri) { 182 + // Publishing to a publication - update both tables 183 + await Promise.all([ 184 + supabaseServerClient.from("documents_in_publications").upsert({ 185 + publication: publication_uri, 186 + document: result.uri, 187 + }), 188 + supabaseServerClient.from("leaflets_in_publications").upsert({ 189 + doc: result.uri, 190 + leaflet: leaflet_id, 191 + publication: publication_uri, 192 + title: title, 193 + description: description, 194 + }), 195 + ]); 196 + } else { 197 + // Publishing standalone - update leaflets_to_documents 198 + await supabaseServerClient.from("leaflets_to_documents").upsert({ 199 + leaflet: leaflet_id, 200 + document: result.uri, 201 + title: title || "Untitled", 202 + description: description || "", 203 + }); 204 205 + // Heuristic: Remove title entities if this is the first time publishing standalone 206 + // (when entitiesToDelete is provided and there's no existing document) 207 + if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { 208 + await supabaseServerClient 209 + .from("entities") 210 + .delete() 211 + .in("id", entitiesToDelete); 212 } 213 + } 214 + 215 + // Create notifications for mentions (only on first publish) 216 + if (!existingDocUri) { 217 + await createMentionNotifications(result.uri, record, credentialSession.did!); 218 + } 219 220 + return { rkey, record: JSON.parse(JSON.stringify(record)) }; 221 } 222 + 223 + async function processBlocksToPages( 224 + facts: Fact<any>[], 225 + agent: AtpBaseClient, 226 root_entity: string, 227 + did: string, 228 ) { 229 + let scan = scanIndexLocal(facts); 230 + let pages: { 231 + id: string; 232 + blocks: 233 + | PubLeafletPagesLinearDocument.Block[] 234 + | PubLeafletPagesCanvas.Block[]; 235 + type: "doc" | "canvas"; 236 + }[] = []; 237 238 + // Create a lock to serialize image uploads 239 + const uploadLock = new Lock(); 240 241 + let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 242 + if (!firstEntity) throw new Error("No root page"); 243 244 + // Check if the first page is a canvas or linear document 245 + let [pageType] = scan.eav(firstEntity.data.value, "page/type"); 246 247 + if (pageType?.data.value === "canvas") { 248 + // First page is a canvas 249 + let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did); 250 + pages.unshift({ 251 + id: firstEntity.data.value, 252 + blocks: canvasBlocks, 253 + type: "canvas", 254 + }); 255 + } else { 256 + // First page is a linear document 257 + let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 258 + let b = await blocksToRecord(blocks, did); 259 + pages.unshift({ 260 + id: firstEntity.data.value, 261 + blocks: b, 262 + type: "doc", 263 + }); 264 } 265 266 + return { pages }; 267 + 268 + async function uploadImage(src: string) { 269 + let data = await fetch(src); 270 + if (data.status !== 200) return; 271 + let binary = await data.blob(); 272 + return uploadLock.withLock(async () => { 273 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 274 + headers: { "Content-Type": binary.type }, 275 + }); 276 + return blob.data.blob; 277 + }); 278 } 279 + async function blocksToRecord( 280 + blocks: Block[], 281 + did: string, 282 + ): Promise<PubLeafletPagesLinearDocument.Block[]> { 283 + let parsedBlocks = parseBlocksToList(blocks); 284 + return ( 285 + await Promise.all( 286 + parsedBlocks.map(async (blockOrList) => { 287 + if (blockOrList.type === "block") { 288 + let alignmentValue = scan.eav( 289 + blockOrList.block.value, 290 + "block/text-alignment", 291 + )[0]?.data.value; 292 + let alignment: ExcludeString< 293 + PubLeafletPagesLinearDocument.Block["alignment"] 294 + > = 295 + alignmentValue === "center" 296 + ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 297 + : alignmentValue === "right" 298 + ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 299 + : alignmentValue === "justify" 300 + ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify" 301 + : alignmentValue === "left" 302 + ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft" 303 + : undefined; 304 + let b = await blockToRecord(blockOrList.block, did); 305 + if (!b) return []; 306 + let block: PubLeafletPagesLinearDocument.Block = { 307 + $type: "pub.leaflet.pages.linearDocument#block", 308 + block: b, 309 + }; 310 + if (alignment) block.alignment = alignment; 311 + return [block]; 312 + } else { 313 + let block: PubLeafletPagesLinearDocument.Block = { 314 + $type: "pub.leaflet.pages.linearDocument#block", 315 + block: { 316 + $type: "pub.leaflet.blocks.unorderedList", 317 + children: await childrenToRecord(blockOrList.children, did), 318 + }, 319 + }; 320 + return [block]; 321 + } 322 + }), 323 + ) 324 + ).flat(); 325 } 326 + 327 + async function childrenToRecord(children: List[], did: string) { 328 + return ( 329 + await Promise.all( 330 + children.map(async (child) => { 331 + let content = await blockToRecord(child.block, did); 332 + if (!content) return []; 333 + let record: PubLeafletBlocksUnorderedList.ListItem = { 334 + $type: "pub.leaflet.blocks.unorderedList#listItem", 335 + content, 336 + children: await childrenToRecord(child.children, did), 337 + }; 338 + return record; 339 + }), 340 + ) 341 + ).flat(); 342 } 343 + async function blockToRecord(b: Block, did: string) { 344 + const getBlockContent = (b: string) => { 345 + let [content] = scan.eav(b, "block/text"); 346 + if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; 347 + let doc = new Y.Doc(); 348 + const update = base64.toByteArray(content.data.value); 349 + Y.applyUpdate(doc, update); 350 + let nodes = doc.getXmlElement("prosemirror").toArray(); 351 + let stringValue = YJSFragmentToString(nodes[0]); 352 + let { facets } = YJSFragmentToFacets(nodes[0]); 353 + return [stringValue, facets] as const; 354 }; 355 + if (b.type === "card") { 356 + let [page] = scan.eav(b.value, "block/card"); 357 + if (!page) return; 358 + let [pageType] = scan.eav(page.data.value, "page/type"); 359 + 360 + if (pageType?.data.value === "canvas") { 361 + let canvasBlocks = await canvasBlocksToRecord(page.data.value, did); 362 + pages.push({ 363 + id: page.data.value, 364 + blocks: canvasBlocks, 365 + type: "canvas", 366 + }); 367 + } else { 368 + let blocks = getBlocksWithTypeLocal(facts, page.data.value); 369 + pages.push({ 370 + id: page.data.value, 371 + blocks: await blocksToRecord(blocks, did), 372 + type: "doc", 373 + }); 374 + } 375 + 376 + let block: $Typed<PubLeafletBlocksPage.Main> = { 377 + $type: "pub.leaflet.blocks.page", 378 + id: page.data.value, 379 + }; 380 + return block; 381 + } 382 + 383 + if (b.type === "bluesky-post") { 384 + let [post] = scan.eav(b.value, "block/bluesky-post"); 385 + if (!post || !post.data.value.post) return; 386 + let block: $Typed<PubLeafletBlocksBskyPost.Main> = { 387 + $type: ids.PubLeafletBlocksBskyPost, 388 + postRef: { 389 + uri: post.data.value.post.uri, 390 + cid: post.data.value.post.cid, 391 + }, 392 + }; 393 + return block; 394 + } 395 + if (b.type === "horizontal-rule") { 396 + let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { 397 + $type: ids.PubLeafletBlocksHorizontalRule, 398 + }; 399 + return block; 400 + } 401 + 402 + if (b.type === "heading") { 403 + let [headingLevel] = scan.eav(b.value, "block/heading-level"); 404 + 405 + let [stringValue, facets] = getBlockContent(b.value); 406 + let block: $Typed<PubLeafletBlocksHeader.Main> = { 407 + $type: "pub.leaflet.blocks.header", 408 + level: Math.floor(headingLevel?.data.value || 1), 409 + plaintext: stringValue, 410 + facets, 411 + }; 412 + return block; 413 + } 414 + 415 + if (b.type === "blockquote") { 416 + let [stringValue, facets] = getBlockContent(b.value); 417 + let block: $Typed<PubLeafletBlocksBlockquote.Main> = { 418 + $type: ids.PubLeafletBlocksBlockquote, 419 + plaintext: stringValue, 420 + facets, 421 + }; 422 + return block; 423 + } 424 + 425 + if (b.type == "text") { 426 + let [stringValue, facets] = getBlockContent(b.value); 427 + let block: $Typed<PubLeafletBlocksText.Main> = { 428 + $type: ids.PubLeafletBlocksText, 429 + plaintext: stringValue, 430 + facets, 431 + }; 432 + return block; 433 + } 434 + if (b.type === "embed") { 435 + let [url] = scan.eav(b.value, "embed/url"); 436 + let [height] = scan.eav(b.value, "embed/height"); 437 + if (!url) return; 438 + let block: $Typed<PubLeafletBlocksIframe.Main> = { 439 + $type: "pub.leaflet.blocks.iframe", 440 + url: url.data.value, 441 + height: Math.floor(height?.data.value || 600), 442 + }; 443 + return block; 444 + } 445 + if (b.type == "image") { 446 + let [image] = scan.eav(b.value, "block/image"); 447 + if (!image) return; 448 + let [altText] = scan.eav(b.value, "image/alt"); 449 + let blobref = await uploadImage(image.data.src); 450 + if (!blobref) return; 451 + let block: $Typed<PubLeafletBlocksImage.Main> = { 452 + $type: "pub.leaflet.blocks.image", 453 + image: blobref, 454 + aspectRatio: { 455 + height: Math.floor(image.data.height), 456 + width: Math.floor(image.data.width), 457 + }, 458 + alt: altText ? altText.data.value : undefined, 459 + }; 460 + return block; 461 + } 462 + if (b.type === "link") { 463 + let [previewImage] = scan.eav(b.value, "link/preview"); 464 + let [description] = scan.eav(b.value, "link/description"); 465 + let [src] = scan.eav(b.value, "link/url"); 466 + if (!src) return; 467 + let blobref = previewImage 468 + ? await uploadImage(previewImage?.data.src) 469 + : undefined; 470 + let [title] = scan.eav(b.value, "link/title"); 471 + let block: $Typed<PubLeafletBlocksWebsite.Main> = { 472 + $type: "pub.leaflet.blocks.website", 473 + previewImage: blobref, 474 + src: src.data.value, 475 + description: description?.data.value, 476 + title: title?.data.value, 477 + }; 478 + return block; 479 + } 480 + if (b.type === "code") { 481 + let [language] = scan.eav(b.value, "block/code-language"); 482 + let [code] = scan.eav(b.value, "block/code"); 483 + let [theme] = scan.eav(root_entity, "theme/code-theme"); 484 + let block: $Typed<PubLeafletBlocksCode.Main> = { 485 + $type: "pub.leaflet.blocks.code", 486 + language: language?.data.value, 487 + plaintext: code?.data.value || "", 488 + syntaxHighlightingTheme: theme?.data.value, 489 + }; 490 + return block; 491 + } 492 + if (b.type === "math") { 493 + let [math] = scan.eav(b.value, "block/math"); 494 + let block: $Typed<PubLeafletBlocksMath.Main> = { 495 + $type: "pub.leaflet.blocks.math", 496 + tex: math?.data.value || "", 497 + }; 498 + return block; 499 + } 500 + if (b.type === "poll") { 501 + // Get poll options from the entity 502 + let pollOptions = scan.eav(b.value, "poll/options"); 503 + let options: PubLeafletPollDefinition.Option[] = pollOptions.map( 504 + (opt) => { 505 + let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; 506 + return { 507 + $type: "pub.leaflet.poll.definition#option", 508 + text: optionName?.data.value || "", 509 + }; 510 + }, 511 + ); 512 + 513 + // Create the poll definition record 514 + let pollRecord: PubLeafletPollDefinition.Record = { 515 + $type: "pub.leaflet.poll.definition", 516 + name: "Poll", // Default name, can be customized 517 + options, 518 + }; 519 + 520 + // Upload the poll record 521 + let { data: pollResult } = await agent.com.atproto.repo.putRecord({ 522 + //use the entity id as the rkey so we can associate it in the editor 523 + rkey: b.value, 524 + repo: did, 525 + collection: pollRecord.$type, 526 + record: pollRecord, 527 + validate: false, 528 + }); 529 + 530 + // Optimistically write poll definition to database 531 + console.log( 532 + await supabaseServerClient.from("atp_poll_records").upsert({ 533 + uri: pollResult.uri, 534 + cid: pollResult.cid, 535 + record: pollRecord as Json, 536 + }), 537 + ); 538 + 539 + // Return a poll block with reference to the poll record 540 + let block: $Typed<PubLeafletBlocksPoll.Main> = { 541 + $type: "pub.leaflet.blocks.poll", 542 + pollRef: { 543 + uri: pollResult.uri, 544 + cid: pollResult.cid, 545 + }, 546 + }; 547 + return block; 548 + } 549 + if (b.type === "button") { 550 + let [text] = scan.eav(b.value, "button/text"); 551 + let [url] = scan.eav(b.value, "button/url"); 552 + if (!text || !url) return; 553 + let block: $Typed<PubLeafletBlocksButton.Main> = { 554 + $type: "pub.leaflet.blocks.button", 555 + text: text.data.value, 556 + url: url.data.value, 557 + }; 558 + return block; 559 + } 560 + return; 561 } 562 + 563 + async function canvasBlocksToRecord( 564 + pageID: string, 565 + did: string, 566 + ): Promise<PubLeafletPagesCanvas.Block[]> { 567 + let canvasBlocks = scan.eav(pageID, "canvas/block"); 568 + return ( 569 + await Promise.all( 570 + canvasBlocks.map(async (canvasBlock) => { 571 + let blockEntity = canvasBlock.data.value; 572 + let position = canvasBlock.data.position; 573 + 574 + // Get the block content 575 + let blockType = scan.eav(blockEntity, "block/type")?.[0]; 576 + if (!blockType) return null; 577 + 578 + let block: Block = { 579 + type: blockType.data.value, 580 + value: blockEntity, 581 + parent: pageID, 582 + position: "", 583 + factID: canvasBlock.id, 584 + }; 585 + 586 + let content = await blockToRecord(block, did); 587 + if (!content) return null; 588 + 589 + // Get canvas-specific properties 590 + let width = 591 + scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; 592 + let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] 593 + ?.data.value; 594 + 595 + let canvasBlockRecord: PubLeafletPagesCanvas.Block = { 596 + $type: "pub.leaflet.pages.canvas#block", 597 + block: content, 598 + x: Math.floor(position.x), 599 + y: Math.floor(position.y), 600 + width: Math.floor(width), 601 + ...(rotation !== undefined && { rotation: Math.floor(rotation) }), 602 + }; 603 + 604 + return canvasBlockRecord; 605 + }), 606 + ) 607 + ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); 608 } 609 } 610 611 + function YJSFragmentToFacets( 612 + node: Y.XmlElement | Y.XmlText | Y.XmlHook, 613 + byteOffset: number = 0, 614 + ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 615 + if (node.constructor === Y.XmlElement) { 616 + // Handle inline mention nodes 617 + if (node.nodeName === "didMention") { 618 + const text = node.getAttribute("text") || ""; 619 + const unicodestring = new UnicodeString(text); 620 + const facet: PubLeafletRichtextFacet.Main = { 621 + index: { 622 + byteStart: byteOffset, 623 + byteEnd: byteOffset + unicodestring.length, 624 + }, 625 + features: [ 626 { 627 + $type: "pub.leaflet.richtext.facet#didMention", 628 + did: node.getAttribute("did"), 629 }, 630 + ], 631 + }; 632 + return { facets: [facet], byteLength: unicodestring.length }; 633 + } 634 + 635 + if (node.nodeName === "atMention") { 636 + const text = node.getAttribute("text") || ""; 637 + const unicodestring = new UnicodeString(text); 638 + const facet: PubLeafletRichtextFacet.Main = { 639 + index: { 640 + byteStart: byteOffset, 641 + byteEnd: byteOffset + unicodestring.length, 642 + }, 643 + features: [ 644 { 645 + $type: "pub.leaflet.richtext.facet#atMention", 646 + atURI: node.getAttribute("atURI"), 647 }, 648 ], 649 + }; 650 + return { facets: [facet], byteLength: unicodestring.length }; 651 + } 652 + 653 + if (node.nodeName === "hard_break") { 654 + const unicodestring = new UnicodeString("\n"); 655 + return { facets: [], byteLength: unicodestring.length }; 656 + } 657 658 + // For other elements (like paragraph), process children 659 + let allFacets: PubLeafletRichtextFacet.Main[] = []; 660 + let currentOffset = byteOffset; 661 + for (const child of node.toArray()) { 662 + const result = YJSFragmentToFacets(child, currentOffset); 663 + allFacets.push(...result.facets); 664 + currentOffset += result.byteLength; 665 + } 666 + return { facets: allFacets, byteLength: currentOffset - byteOffset }; 667 } 668 + 669 if (node.constructor === Y.XmlText) { 670 let facets: PubLeafletRichtextFacet.Main[] = []; 671 let delta = node.toDelta() as Delta[]; 672 + let byteStart = byteOffset; 673 + let totalLength = 0; 674 for (let d of delta) { 675 let unicodestring = new UnicodeString(d.insert); 676 let facet: PubLeafletRichtextFacet.Main = { ··· 703 }); 704 if (facet.features.length > 0) facets.push(facet); 705 byteStart += unicodestring.length; 706 + totalLength += unicodestring.length; 707 } 708 + return { facets, byteLength: totalLength }; 709 } 710 + return { facets: [], byteLength: 0 }; 711 + } 712 + 713 + type ExcludeString<T> = T extends string 714 + ? string extends T 715 + ? never 716 + : T /* maybe literal, not the whole `string` */ 717 + : T; /* not a string */ 718 + 719 + async function extractThemeFromFacts( 720 + facts: Fact<any>[], 721 + root_entity: string, 722 + agent: AtpBaseClient, 723 + ): Promise<PubLeafletPublication.Theme | undefined> { 724 + let scan = scanIndexLocal(facts); 725 + let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data 726 + .value; 727 + let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data 728 + .value; 729 + let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value; 730 + let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0] 731 + ?.data.value; 732 + let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value; 733 + let showPageBackground = !scan.eav( 734 + root_entity, 735 + "theme/card-border-hidden", 736 + )?.[0]?.data.value; 737 + let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0]; 738 + let backgroundImageRepeat = scan.eav( 739 + root_entity, 740 + "theme/background-image-repeat", 741 + )?.[0]; 742 + 743 + let theme: PubLeafletPublication.Theme = { 744 + showPageBackground: showPageBackground ?? true, 745 + }; 746 + 747 + if (pageBackground) 748 + theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`)); 749 + if (cardBackground) 750 + theme.pageBackground = ColorToRGBA(parseColor(`hsba(${cardBackground})`)); 751 + if (primary) theme.primary = ColorToRGB(parseColor(`hsba(${primary})`)); 752 + if (accentBackground) 753 + theme.accentBackground = ColorToRGB( 754 + parseColor(`hsba(${accentBackground})`), 755 + ); 756 + if (accentText) 757 + theme.accentText = ColorToRGB(parseColor(`hsba(${accentText})`)); 758 + 759 + // Upload background image if present 760 + if (backgroundImage?.data) { 761 + let imageData = await fetch(backgroundImage.data.src); 762 + if (imageData.status === 200) { 763 + let binary = await imageData.blob(); 764 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 765 + headers: { "Content-Type": binary.type }, 766 + }); 767 + 768 + theme.backgroundImage = { 769 + $type: "pub.leaflet.theme.backgroundImage", 770 + image: blob.data.blob, 771 + repeat: backgroundImageRepeat?.data.value ? true : false, 772 + ...(backgroundImageRepeat?.data.value && { 773 + width: 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 ) { 58 return; 59 } 60 - let domain = getCurrentDeploymentDomain(); 61 let res = await fetch("https://api.postmarkapp.com/email/batch", { 62 method: "POST", 63 headers: {
··· 57 ) { 58 return; 59 } 60 + let domain = await getCurrentDeploymentDomain(); 61 let res = await fetch("https://api.postmarkapp.com/email/batch", { 62 method: "POST", 63 headers: {
+1 -1
actions/subscriptions/subscribeToMailboxWithEmail.ts
··· 11 import type { Attribute } from "src/replicache/attributes"; 12 import { Database } from "supabase/database.types"; 13 import * as Y from "yjs"; 14 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 15 import { pool } from "supabase/pool"; 16 17 let supabase = createServerClient<Database>(
··· 11 import type { Attribute } from "src/replicache/attributes"; 12 import { Database } from "supabase/database.types"; 13 import * as Y from "yjs"; 14 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 15 import { pool } from "supabase/pool"; 16 17 let supabase = createServerClient<Database>(
+76
app/(home-pages)/discover/PubListing.tsx
···
··· 1 + "use client"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions"; 4 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 5 + import { PubIcon } from "components/ActionBar/Publications"; 6 + import { Separator } from "components/Layout"; 7 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 + import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 + import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 10 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + import { timeAgo } from "src/utils/timeAgo"; 12 + import { Json } from "supabase/database.types"; 13 + 14 + export const PubListing = ( 15 + props: PublicationSubscription & { 16 + resizeHeight?: boolean; 17 + }, 18 + ) => { 19 + let record = props.record as PubLeafletPublication.Record; 20 + let theme = usePubTheme(record.theme); 21 + let backgroundImage = record?.theme?.backgroundImage?.image?.ref 22 + ? blobRefToSrc( 23 + record?.theme?.backgroundImage?.image?.ref, 24 + new AtUri(props.uri).host, 25 + ) 26 + : null; 27 + 28 + let backgroundImageRepeat = record?.theme?.backgroundImage?.repeat; 29 + let backgroundImageSize = record?.theme?.backgroundImage?.width || 500; 30 + if (!record) return null; 31 + return ( 32 + <BaseThemeProvider {...theme} local> 33 + <a 34 + href={`https://${record.base_path}`} 35 + className={`no-underline! flex flex-row gap-2 36 + bg-bg-leaflet 37 + border border-border-light rounded-lg 38 + px-3 py-3 selected-outline 39 + hover:outline-accent-contrast hover:border-accent-contrast 40 + relative overflow-hidden`} 41 + style={{ 42 + backgroundImage: `url(${backgroundImage})`, 43 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 44 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 45 + }} 46 + > 47 + <div 48 + className={`flex w-full flex-col justify-center text-center max-h-48 pt-4 pb-3 px-3 rounded-lg relative z-10 ${props.resizeHeight ? "" : "sm:h-48 h-full"} ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] " : ""}`} 49 + > 50 + <div className="mx-auto pb-1"> 51 + <PubIcon record={record} uri={props.uri} large /> 52 + </div> 53 + 54 + <h4 className="truncate shrink-0 ">{record.name}</h4> 55 + {record.description && ( 56 + <p className="text-secondary text-sm max-h-full overflow-hidden pb-1"> 57 + {record.description} 58 + </p> 59 + )} 60 + <div className="flex flex-col items-center justify-center text-xs text-tertiary pt-2"> 61 + <div className="flex flex-row gap-2 items-center"> 62 + {props.authorProfile?.handle} 63 + </div> 64 + <p> 65 + Updated{" "} 66 + {timeAgo( 67 + props.documents_in_publications?.[0]?.documents?.indexed_at || 68 + "", 69 + )} 70 + </p> 71 + </div> 72 + </div> 73 + </a> 74 + </BaseThemeProvider> 75 + ); 76 + };
+97
app/(home-pages)/discover/SortButtons.tsx
···
··· 1 + "use client"; 2 + import Link from "next/link"; 3 + import { useState } from "react"; 4 + import { theme } from "tailwind.config"; 5 + 6 + export default function SortButtons(props: { order: string }) { 7 + const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( 8 + "recentlyUpdated", 9 + ); 10 + 11 + return ( 12 + <div className="flex gap-2 pt-1"> 13 + <Link href="?order=recentlyUpdated"> 14 + <SortButton selected={props.order === "recentlyUpdated"}> 15 + Recently Updated 16 + </SortButton> 17 + </Link> 18 + 19 + <Link href="?order=popular"> 20 + <SortButton selected={props.order === "popular"}>Popular</SortButton> 21 + </Link> 22 + </div> 23 + ); 24 + } 25 + 26 + const SortButton = (props: { 27 + children: React.ReactNode; 28 + selected: boolean; 29 + }) => { 30 + return ( 31 + <div className="relative"> 32 + <button 33 + style={ 34 + props.selected 35 + ? { backgroundColor: `rgba(var(--accent-1), 0.2)` } 36 + : {} 37 + } 38 + className={`text-sm rounded-md px-[8px] py-0.5 border ${props.selected ? "border-accent-contrast text-accent-1 font-bold" : "text-tertiary border-border-light"}`} 39 + > 40 + {props.children} 41 + </button> 42 + {props.selected && ( 43 + <> 44 + <div className="absolute top-0 -left-2"> 45 + <GlitterBig /> 46 + </div> 47 + <div className="absolute top-4 left-0"> 48 + <GlitterSmall /> 49 + </div> 50 + <div className="absolute -top-2 -right-1"> 51 + <GlitterSmall /> 52 + </div> 53 + </> 54 + )} 55 + </div> 56 + ); 57 + }; 58 + 59 + const GlitterBig = () => { 60 + return ( 61 + <svg 62 + width="16" 63 + height="17" 64 + viewBox="0 0 16 17" 65 + fill="none" 66 + xmlns="http://www.w3.org/2000/svg" 67 + > 68 + <path 69 + d="M8.16553 0.804321C8.5961 0.804329 8.97528 1.03925 9.22803 1.40393C9.47845 1.76546 9.6128 2.25816 9.61279 2.84338C9.61279 2.98187 9.6178 3.11647 9.62646 3.2467C9.65365 3.65499 9.72104 4.02319 9.81006 4.35022C10.0833 5.35388 10.5641 5.96726 10.7349 6.14221C10.7443 6.15184 10.7543 6.16234 10.7642 6.17249C10.9808 6.39533 11.3925 6.8162 12.0142 7.09338C12.206 7.17892 12.4177 7.2502 12.6489 7.29749C12.8402 7.3366 13.0466 7.35993 13.2681 7.35999H13.269C14.2688 7.36032 14.9747 7.96603 14.9771 8.77014C14.9793 9.57755 14.272 10.1833 13.2681 10.1832C13.0278 10.1832 12.8137 10.2034 12.6226 10.2369C12.3793 10.2796 12.1697 10.3455 11.9858 10.4254C11.4714 10.6492 11.1325 10.9918 10.7935 11.3405C10.7739 11.3605 10.7544 11.381 10.7349 11.401C10.3936 11.7507 10.0271 12.1792 9.81006 12.9352C9.72175 13.2428 9.65679 13.6119 9.63135 14.0592C9.62378 14.1924 9.61963 14.3325 9.61963 14.4801C9.61963 15.5836 9.06909 16.4876 8.17822 16.4996C7.74928 16.5053 7.36767 16.2783 7.11182 15.9147C6.85918 15.5556 6.72412 15.065 6.72412 14.4801C6.72412 14.3385 6.71808 14.2015 6.70654 14.069C6.6724 13.6774 6.59177 13.324 6.48779 13.0123C6.16402 12.0419 5.61395 11.4722 5.54443 11.401C5.54371 11.4003 5.54043 11.3977 5.53467 11.3922C5.52778 11.3857 5.51839 11.3767 5.50635 11.3658C5.4823 11.3442 5.44954 11.3158 5.40869 11.2819C5.3268 11.2139 5.21473 11.1255 5.07764 11.0289C4.80173 10.8346 4.43374 10.6113 4.01611 10.443C3.82579 10.3663 3.62728 10.3019 3.42432 10.2565C3.21687 10.21 3.00599 10.1832 2.79541 10.1832C1.79834 10.1832 1.11533 9.56575 1.11865 8.76917C1.12219 7.9773 1.80451 7.36002 2.79541 7.35999C3.01821 7.35999 3.22798 7.33422 3.42432 7.29065C3.62557 7.24597 3.81426 7.18216 3.98877 7.10608C4.6567 6.81484 5.10772 6.35442 5.3042 6.15295C5.30777 6.1493 5.31147 6.14577 5.31494 6.14221C5.51076 5.94157 6.14024 5.28964 6.48584 4.26233C6.59001 3.95264 6.66793 3.60887 6.70068 3.23303C6.71166 3.10697 6.71826 2.977 6.71826 2.84338L6.72412 2.62854C6.75331 2.13723 6.88387 1.72031 7.10303 1.40393C7.35578 1.03923 7.73495 0.804326 8.16553 0.804321Z" 70 + fill={theme.colors["accent-1"]} 71 + stroke={theme.colors["bg-leaflet"]} 72 + strokeLinecap="round" 73 + strokeLinejoin="round" 74 + /> 75 + </svg> 76 + ); 77 + }; 78 + 79 + const GlitterSmall = () => { 80 + return ( 81 + <svg 82 + width="13" 83 + height="14" 84 + viewBox="0 0 13 14" 85 + fill="none" 86 + xmlns="http://www.w3.org/2000/svg" 87 + > 88 + <path 89 + d="M6.37585 1.23596C6.7489 1.23598 7.07064 1.44034 7.28015 1.7428C7.48716 2.04187 7.59266 2.4408 7.59265 2.901C7.59266 3.00294 7.59605 3.10213 7.60242 3.19788C7.62244 3.49844 7.67183 3.76938 7.73718 4.0094C7.93813 4.74731 8.29123 5.1934 8.4071 5.31213L8.57703 5.48206C8.75042 5.64731 9.00188 5.85577 9.33777 6.00549C9.47565 6.06695 9.62723 6.11812 9.79285 6.15198C9.92991 6.18 10.0779 6.1959 10.2372 6.19592C11.0418 6.19604 11.6503 6.69195 11.6522 7.38538C11.654 8.08176 11.0444 8.57683 10.2372 8.57678C10.0618 8.57678 9.90679 8.59077 9.76941 8.61487C9.59484 8.6455 9.4456 8.69297 9.31531 8.74963C8.95055 8.9083 8.70884 9.15057 8.45203 9.41467C8.43719 9.42993 8.42207 9.44621 8.4071 9.46155C8.15582 9.71904 7.89358 10.0262 7.73718 10.5709C7.67315 10.7941 7.62512 11.064 7.60632 11.3942C7.60073 11.4925 7.59754 11.5963 7.59753 11.7057C7.59753 12.5657 7.16303 13.3455 6.38757 13.3561C6.01608 13.3611 5.6911 13.1642 5.47839 12.8619C5.26902 12.5643 5.16394 12.1657 5.16394 11.7057C5.16393 11.6022 5.15871 11.5017 5.15027 11.4049C5.1253 11.1189 5.06701 10.861 4.99109 10.6334C4.75475 9.92518 4.35324 9.51044 4.30554 9.46155C4.30554 9.46155 4.27494 9.43195 4.21179 9.37952C4.15207 9.32993 4.06961 9.26511 3.96863 9.19397C3.76515 9.05064 3.49524 8.88718 3.19031 8.76428C3.05151 8.70835 2.90782 8.66129 2.7616 8.62854C2.61218 8.59509 2.4616 8.57679 2.31238 8.57678C1.50706 8.57678 0.918891 8.07006 0.921753 7.3844C0.924612 6.70329 1.51125 6.19594 2.31238 6.19592C2.47154 6.19591 2.6213 6.17821 2.7616 6.14709C2.90554 6.11514 3.04128 6.06904 3.16687 6.01428C3.64904 5.80395 3.97684 5.47074 4.1239 5.31995C4.12648 5.3173 4.12918 5.31473 4.13171 5.31213C4.27635 5.16393 4.73656 4.68608 4.98914 3.93518C5.06511 3.70931 5.12247 3.45905 5.14636 3.18518C5.15436 3.09338 5.15905 2.99838 5.15906 2.901C5.15906 2.44078 5.26453 2.04187 5.47156 1.7428C5.68108 1.44033 6.00279 1.23595 6.37585 1.23596Z" 90 + fill={theme.colors["accent-1"]} 91 + stroke={theme.colors["bg-leaflet"]} 92 + strokeLinecap="round" 93 + strokeLinejoin="round" 94 + /> 95 + </svg> 96 + ); 97 + };
+195
app/(home-pages)/discover/SortedPublicationList.tsx
···
··· 1 + "use client"; 2 + import Link from "next/link"; 3 + import { useState, useEffect, useRef } from "react"; 4 + import { theme } from "tailwind.config"; 5 + import { PubListing } from "./PubListing"; 6 + import useSWRInfinite from "swr/infinite"; 7 + import { getPublications, type Cursor, type Publication } from "./getPublications"; 8 + 9 + export function SortedPublicationList(props: { 10 + publications: Publication[]; 11 + order: string; 12 + nextCursor: Cursor | null; 13 + }) { 14 + let [order, setOrder] = useState(props.order); 15 + 16 + const getKey = ( 17 + pageIndex: number, 18 + previousPageData: { publications: Publication[]; nextCursor: Cursor | null } | null, 19 + ) => { 20 + // Reached the end 21 + if (previousPageData && !previousPageData.nextCursor) return null; 22 + 23 + // First page, we don't have previousPageData 24 + if (pageIndex === 0) return ["discover-publications", order, null] as const; 25 + 26 + // Add the cursor to the key 27 + return ["discover-publications", order, previousPageData?.nextCursor] as const; 28 + }; 29 + 30 + const { data, error, size, setSize, isValidating } = useSWRInfinite( 31 + getKey, 32 + ([_, orderValue, cursor]) => { 33 + const orderParam = orderValue === "popular" ? "popular" : "recentlyUpdated"; 34 + return getPublications(orderParam, cursor); 35 + }, 36 + { 37 + fallbackData: order === props.order 38 + ? [{ publications: props.publications, nextCursor: props.nextCursor }] 39 + : undefined, 40 + revalidateFirstPage: false, 41 + }, 42 + ); 43 + 44 + const loadMoreRef = useRef<HTMLDivElement>(null); 45 + 46 + // Set up intersection observer to load more when trigger element is visible 47 + useEffect(() => { 48 + const observer = new IntersectionObserver( 49 + (entries) => { 50 + if (entries[0].isIntersecting && !isValidating) { 51 + const hasMore = data && data[data.length - 1]?.nextCursor; 52 + if (hasMore) { 53 + setSize(size + 1); 54 + } 55 + } 56 + }, 57 + { threshold: 0.1 }, 58 + ); 59 + 60 + if (loadMoreRef.current) { 61 + observer.observe(loadMoreRef.current); 62 + } 63 + 64 + return () => observer.disconnect(); 65 + }, [data, size, setSize, isValidating]); 66 + 67 + const allPublications = data ? data.flatMap((page) => page.publications) : []; 68 + 69 + return ( 70 + <div className="discoverHeader flex flex-col items-center "> 71 + <SortButtons 72 + order={order} 73 + setOrder={(o) => { 74 + const url = new URL(window.location.href); 75 + url.searchParams.set("order", o); 76 + window.history.pushState({}, "", url); 77 + setOrder(o); 78 + }} 79 + /> 80 + <div className="discoverPubList flex flex-col gap-3 pt-6 w-full relative"> 81 + {allPublications.map((pub) => ( 82 + <PubListing resizeHeight key={pub.uri} {...pub} /> 83 + ))} 84 + {/* Trigger element for loading more publications */} 85 + <div 86 + ref={loadMoreRef} 87 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 88 + aria-hidden="true" 89 + /> 90 + {isValidating && ( 91 + <div className="text-center text-tertiary py-4"> 92 + Loading more publications... 93 + </div> 94 + )} 95 + </div> 96 + </div> 97 + ); 98 + } 99 + 100 + export default function SortButtons(props: { 101 + order: string; 102 + setOrder: (order: string) => void; 103 + }) { 104 + const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( 105 + "recentlyUpdated", 106 + ); 107 + 108 + return ( 109 + <div className="flex gap-2 pt-1"> 110 + <SortButton 111 + selected={props.order === "recentlyUpdated"} 112 + onClick={() => props.setOrder("recentlyUpdated")} 113 + > 114 + Recently Updated 115 + </SortButton> 116 + 117 + <SortButton 118 + selected={props.order === "popular"} 119 + onClick={() => props.setOrder("popular")} 120 + > 121 + Popular 122 + </SortButton> 123 + </div> 124 + ); 125 + } 126 + 127 + const SortButton = (props: { 128 + children: React.ReactNode; 129 + onClick: () => void; 130 + selected: boolean; 131 + }) => { 132 + return ( 133 + <div className="relative"> 134 + <button 135 + onClick={props.onClick} 136 + className={`text-sm rounded-md px-[8px] font-bold py-0.5 border ${props.selected ? "border-accent-contrast bg-accent-1 text-accent-2 " : "bg-bg-page text-accent-contrast border-accent-contrast"}`} 137 + > 138 + {props.children} 139 + </button> 140 + {props.selected && ( 141 + <> 142 + <div className="absolute top-0 -left-2"> 143 + <GlitterBig /> 144 + </div> 145 + <div className="absolute top-4 left-0"> 146 + <GlitterSmall /> 147 + </div> 148 + <div className="absolute -top-2 -right-1"> 149 + <GlitterSmall /> 150 + </div> 151 + </> 152 + )} 153 + </div> 154 + ); 155 + }; 156 + 157 + const GlitterBig = () => { 158 + return ( 159 + <svg 160 + width="16" 161 + height="17" 162 + viewBox="0 0 16 17" 163 + fill="none" 164 + xmlns="http://www.w3.org/2000/svg" 165 + > 166 + <path 167 + d="M8.16553 0.804321C8.5961 0.804329 8.97528 1.03925 9.22803 1.40393C9.47845 1.76546 9.6128 2.25816 9.61279 2.84338C9.61279 2.98187 9.6178 3.11647 9.62646 3.2467C9.65365 3.65499 9.72104 4.02319 9.81006 4.35022C10.0833 5.35388 10.5641 5.96726 10.7349 6.14221C10.7443 6.15184 10.7543 6.16234 10.7642 6.17249C10.9808 6.39533 11.3925 6.8162 12.0142 7.09338C12.206 7.17892 12.4177 7.2502 12.6489 7.29749C12.8402 7.3366 13.0466 7.35993 13.2681 7.35999H13.269C14.2688 7.36032 14.9747 7.96603 14.9771 8.77014C14.9793 9.57755 14.272 10.1833 13.2681 10.1832C13.0278 10.1832 12.8137 10.2034 12.6226 10.2369C12.3793 10.2796 12.1697 10.3455 11.9858 10.4254C11.4714 10.6492 11.1325 10.9918 10.7935 11.3405C10.7739 11.3605 10.7544 11.381 10.7349 11.401C10.3936 11.7507 10.0271 12.1792 9.81006 12.9352C9.72175 13.2428 9.65679 13.6119 9.63135 14.0592C9.62378 14.1924 9.61963 14.3325 9.61963 14.4801C9.61963 15.5836 9.06909 16.4876 8.17822 16.4996C7.74928 16.5053 7.36767 16.2783 7.11182 15.9147C6.85918 15.5556 6.72412 15.065 6.72412 14.4801C6.72412 14.3385 6.71808 14.2015 6.70654 14.069C6.6724 13.6774 6.59177 13.324 6.48779 13.0123C6.16402 12.0419 5.61395 11.4722 5.54443 11.401C5.54371 11.4003 5.54043 11.3977 5.53467 11.3922C5.52778 11.3857 5.51839 11.3767 5.50635 11.3658C5.4823 11.3442 5.44954 11.3158 5.40869 11.2819C5.3268 11.2139 5.21473 11.1255 5.07764 11.0289C4.80173 10.8346 4.43374 10.6113 4.01611 10.443C3.82579 10.3663 3.62728 10.3019 3.42432 10.2565C3.21687 10.21 3.00599 10.1832 2.79541 10.1832C1.79834 10.1832 1.11533 9.56575 1.11865 8.76917C1.12219 7.9773 1.80451 7.36002 2.79541 7.35999C3.01821 7.35999 3.22798 7.33422 3.42432 7.29065C3.62557 7.24597 3.81426 7.18216 3.98877 7.10608C4.6567 6.81484 5.10772 6.35442 5.3042 6.15295C5.30777 6.1493 5.31147 6.14577 5.31494 6.14221C5.51076 5.94157 6.14024 5.28964 6.48584 4.26233C6.59001 3.95264 6.66793 3.60887 6.70068 3.23303C6.71166 3.10697 6.71826 2.977 6.71826 2.84338L6.72412 2.62854C6.75331 2.13723 6.88387 1.72031 7.10303 1.40393C7.35578 1.03923 7.73495 0.804326 8.16553 0.804321Z" 168 + fill={theme.colors["accent-1"]} 169 + stroke={theme.colors["bg-leaflet"]} 170 + strokeLinecap="round" 171 + strokeLinejoin="round" 172 + /> 173 + </svg> 174 + ); 175 + }; 176 + 177 + const GlitterSmall = () => { 178 + return ( 179 + <svg 180 + width="13" 181 + height="14" 182 + viewBox="0 0 13 14" 183 + fill="none" 184 + xmlns="http://www.w3.org/2000/svg" 185 + > 186 + <path 187 + d="M6.37585 1.23596C6.7489 1.23598 7.07064 1.44034 7.28015 1.7428C7.48716 2.04187 7.59266 2.4408 7.59265 2.901C7.59266 3.00294 7.59605 3.10213 7.60242 3.19788C7.62244 3.49844 7.67183 3.76938 7.73718 4.0094C7.93813 4.74731 8.29123 5.1934 8.4071 5.31213L8.57703 5.48206C8.75042 5.64731 9.00188 5.85577 9.33777 6.00549C9.47565 6.06695 9.62723 6.11812 9.79285 6.15198C9.92991 6.18 10.0779 6.1959 10.2372 6.19592C11.0418 6.19604 11.6503 6.69195 11.6522 7.38538C11.654 8.08176 11.0444 8.57683 10.2372 8.57678C10.0618 8.57678 9.90679 8.59077 9.76941 8.61487C9.59484 8.6455 9.4456 8.69297 9.31531 8.74963C8.95055 8.9083 8.70884 9.15057 8.45203 9.41467C8.43719 9.42993 8.42207 9.44621 8.4071 9.46155C8.15582 9.71904 7.89358 10.0262 7.73718 10.5709C7.67315 10.7941 7.62512 11.064 7.60632 11.3942C7.60073 11.4925 7.59754 11.5963 7.59753 11.7057C7.59753 12.5657 7.16303 13.3455 6.38757 13.3561C6.01608 13.3611 5.6911 13.1642 5.47839 12.8619C5.26902 12.5643 5.16394 12.1657 5.16394 11.7057C5.16393 11.6022 5.15871 11.5017 5.15027 11.4049C5.1253 11.1189 5.06701 10.861 4.99109 10.6334C4.75475 9.92518 4.35324 9.51044 4.30554 9.46155C4.30554 9.46155 4.27494 9.43195 4.21179 9.37952C4.15207 9.32993 4.06961 9.26511 3.96863 9.19397C3.76515 9.05064 3.49524 8.88718 3.19031 8.76428C3.05151 8.70835 2.90782 8.66129 2.7616 8.62854C2.61218 8.59509 2.4616 8.57679 2.31238 8.57678C1.50706 8.57678 0.918891 8.07006 0.921753 7.3844C0.924612 6.70329 1.51125 6.19594 2.31238 6.19592C2.47154 6.19591 2.6213 6.17821 2.7616 6.14709C2.90554 6.11514 3.04128 6.06904 3.16687 6.01428C3.64904 5.80395 3.97684 5.47074 4.1239 5.31995C4.12648 5.3173 4.12918 5.31473 4.13171 5.31213C4.27635 5.16393 4.73656 4.68608 4.98914 3.93518C5.06511 3.70931 5.12247 3.45905 5.14636 3.18518C5.15436 3.09338 5.15905 2.99838 5.15906 2.901C5.15906 2.44078 5.26453 2.04187 5.47156 1.7428C5.68108 1.44033 6.00279 1.23595 6.37585 1.23596Z" 188 + fill={theme.colors["accent-1"]} 189 + stroke={theme.colors["bg-leaflet"]} 190 + strokeLinecap="round" 191 + strokeLinejoin="round" 192 + /> 193 + </svg> 194 + ); 195 + };
+119
app/(home-pages)/discover/getPublications.ts
···
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + 5 + export type Cursor = { 6 + indexed_at?: string; 7 + count?: number; 8 + uri: string; 9 + }; 10 + 11 + export type Publication = Awaited< 12 + ReturnType<typeof getPublications> 13 + >["publications"][number]; 14 + 15 + export async function getPublications( 16 + order: "recentlyUpdated" | "popular" = "recentlyUpdated", 17 + cursor?: Cursor | null, 18 + ): Promise<{ publications: any[]; nextCursor: Cursor | null }> { 19 + const limit = 25; 20 + 21 + // Fetch all publications with their most recent document 22 + let { data: publications, error } = await supabaseServerClient 23 + .from("publications") 24 + .select( 25 + "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", 26 + ) 27 + .or( 28 + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 29 + ) 30 + .order("indexed_at", { 31 + referencedTable: "documents_in_publications", 32 + ascending: false, 33 + }) 34 + .limit(1, { referencedTable: "documents_in_publications" }); 35 + 36 + if (error) { 37 + console.error("Error fetching publications:", error); 38 + return { publications: [], nextCursor: null }; 39 + } 40 + 41 + // Filter out publications without documents 42 + const allPubs = (publications || []).filter( 43 + (pub) => pub.documents_in_publications.length > 0, 44 + ); 45 + 46 + // Sort on the server 47 + allPubs.sort((a, b) => { 48 + if (order === "popular") { 49 + const aCount = a.publication_subscriptions[0]?.count || 0; 50 + const bCount = b.publication_subscriptions[0]?.count || 0; 51 + if (bCount !== aCount) { 52 + return bCount - aCount; 53 + } 54 + // Secondary sort by uri for stability 55 + return b.uri.localeCompare(a.uri); 56 + } else { 57 + // recentlyUpdated 58 + const aDate = new Date( 59 + a.documents_in_publications[0]?.indexed_at || 0, 60 + ).getTime(); 61 + const bDate = new Date( 62 + b.documents_in_publications[0]?.indexed_at || 0, 63 + ).getTime(); 64 + if (bDate !== aDate) { 65 + return bDate - aDate; 66 + } 67 + // Secondary sort by uri for stability 68 + return b.uri.localeCompare(a.uri); 69 + } 70 + }); 71 + 72 + // Find cursor position and slice 73 + let startIndex = 0; 74 + if (cursor) { 75 + startIndex = allPubs.findIndex((pub) => { 76 + if (order === "popular") { 77 + const pubCount = pub.publication_subscriptions[0]?.count || 0; 78 + // Find first pub after cursor 79 + return ( 80 + pubCount < (cursor.count || 0) || 81 + (pubCount === cursor.count && pub.uri < cursor.uri) 82 + ); 83 + } else { 84 + const pubDate = pub.documents_in_publications[0]?.indexed_at || ""; 85 + // Find first pub after cursor 86 + return ( 87 + pubDate < (cursor.indexed_at || "") || 88 + (pubDate === cursor.indexed_at && pub.uri < cursor.uri) 89 + ); 90 + } 91 + }); 92 + // If not found, we're at the end 93 + if (startIndex === -1) { 94 + return { publications: [], nextCursor: null }; 95 + } 96 + } 97 + 98 + // Get the page 99 + const page = allPubs.slice(startIndex, startIndex + limit); 100 + 101 + // Create next cursor 102 + const nextCursor = 103 + page.length === limit && startIndex + limit < allPubs.length 104 + ? order === "recentlyUpdated" 105 + ? { 106 + indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at, 107 + uri: page[page.length - 1].uri, 108 + } 109 + : { 110 + count: page[page.length - 1].publication_subscriptions[0]?.count || 0, 111 + uri: page[page.length - 1].uri, 112 + } 113 + : null; 114 + 115 + return { 116 + publications: page, 117 + nextCursor, 118 + }; 119 + }
+55
app/(home-pages)/discover/page.tsx
···
··· 1 + import Link from "next/link"; 2 + import { SortedPublicationList } from "./SortedPublicationList"; 3 + import { Metadata } from "next"; 4 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 5 + import { getPublications } from "./getPublications"; 6 + 7 + export const metadata: Metadata = { 8 + title: "Leaflet Discover", 9 + description: "Explore publications on Leaflet โœจ Or make your own!", 10 + }; 11 + 12 + export default async function Discover(props: { 13 + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 14 + }) { 15 + let order = ((await props.searchParams).order as string) || "recentlyUpdated"; 16 + 17 + return ( 18 + <DashboardLayout 19 + id="discover" 20 + cardBorderHidden={false} 21 + currentPage="discover" 22 + defaultTab="default" 23 + actions={null} 24 + tabs={{ 25 + default: { 26 + controls: null, 27 + content: <DiscoverContent order={order} />, 28 + }, 29 + }} 30 + /> 31 + ); 32 + } 33 + 34 + const DiscoverContent = async (props: { order: string }) => { 35 + const orderValue = 36 + props.order === "popular" ? "popular" : "recentlyUpdated"; 37 + let { publications, nextCursor } = await getPublications(orderValue); 38 + 39 + return ( 40 + <div className="max-w-prose mx-auto w-full"> 41 + <div className="discoverHeader flex flex-col items-center text-center pt-2 px-4"> 42 + <h1>Discover</h1> 43 + <p className="text-lg text-secondary italic mb-2"> 44 + Explore publications on Leaflet โœจ Or{" "} 45 + <Link href="/lish/createPub">make your own</Link>! 46 + </p> 47 + </div> 48 + <SortedPublicationList 49 + publications={publications} 50 + order={props.order} 51 + nextCursor={nextCursor} 52 + /> 53 + </div> 54 + ); 55 + };
+134
app/(home-pages)/home/Actions/AccountSettings.tsx
···
··· 1 + "use client"; 2 + 3 + import { ActionButton } from "components/ActionBar/ActionButton"; 4 + import { mutate } from "swr"; 5 + import { AccountSmall } from "components/Icons/AccountSmall"; 6 + import { LogoutSmall } from "components/Icons/LogoutSmall"; 7 + import { Popover } from "components/Popover"; 8 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 9 + import { SpeedyLink } from "components/SpeedyLink"; 10 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 11 + import { useState } from "react"; 12 + import { ThemeSetterContent } from "components/ThemeManager/ThemeSetter"; 13 + import { useIsMobile } from "src/hooks/isMobile"; 14 + 15 + export const AccountSettings = (props: { entityID: string }) => { 16 + let [state, setState] = useState<"menu" | "general" | "theme">("menu"); 17 + let isMobile = useIsMobile(); 18 + 19 + return ( 20 + <Popover 21 + asChild 22 + onOpenChange={() => setState("menu")} 23 + side={isMobile ? "top" : "right"} 24 + align={isMobile ? "center" : "start"} 25 + className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} 26 + trigger={<ActionButton icon=<AccountSmall /> label="Settings" />} 27 + > 28 + {state === "general" ? ( 29 + <GeneralSettings backToMenu={() => setState("menu")} /> 30 + ) : state === "theme" ? ( 31 + <AccountThemeSettings 32 + entityID={props.entityID} 33 + backToMenu={() => setState("menu")} 34 + /> 35 + ) : ( 36 + <SettingsMenu state={state} setState={setState} /> 37 + )} 38 + </Popover> 39 + ); 40 + }; 41 + 42 + const SettingsMenu = (props: { 43 + state: "menu" | "general" | "theme"; 44 + setState: (s: typeof props.state) => void; 45 + }) => { 46 + let menuItemClassName = 47 + "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 48 + 49 + return ( 50 + <div className="flex flex-col gap-0.5"> 51 + <AccountSettingsHeader state={"menu"} /> 52 + <button 53 + className={menuItemClassName} 54 + type="button" 55 + onClick={() => { 56 + props.setState("general"); 57 + }} 58 + > 59 + General 60 + <ArrowRightTiny /> 61 + </button> 62 + <button 63 + className={menuItemClassName} 64 + type="button" 65 + onClick={() => props.setState("theme")} 66 + > 67 + Account Theme 68 + <ArrowRightTiny /> 69 + </button> 70 + </div> 71 + ); 72 + }; 73 + 74 + const GeneralSettings = (props: { backToMenu: () => void }) => { 75 + return ( 76 + <div className="flex flex-col gap-0.5"> 77 + <AccountSettingsHeader 78 + state={"general"} 79 + backToMenuAction={() => props.backToMenu()} 80 + /> 81 + 82 + <button 83 + className="flex gap-2 font-bold" 84 + onClick={async () => { 85 + await fetch("/api/auth/logout"); 86 + mutate("identity", null); 87 + }} 88 + > 89 + <LogoutSmall /> 90 + Logout 91 + </button> 92 + </div> 93 + ); 94 + }; 95 + const AccountThemeSettings = (props: { 96 + entityID: string; 97 + backToMenu: () => void; 98 + }) => { 99 + return ( 100 + <div className="flex flex-col gap-0.5"> 101 + <AccountSettingsHeader 102 + state={"theme"} 103 + backToMenuAction={() => props.backToMenu()} 104 + /> 105 + <ThemeSetterContent entityID={props.entityID} home /> 106 + </div> 107 + ); 108 + }; 109 + export const AccountSettingsHeader = (props: { 110 + state: "menu" | "general" | "theme"; 111 + backToMenuAction?: () => void; 112 + }) => { 113 + return ( 114 + <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 115 + {props.state === "menu" 116 + ? "Settings" 117 + : props.state === "general" 118 + ? "General" 119 + : props.state === "theme" 120 + ? "Account Theme" 121 + : ""} 122 + {props.backToMenuAction && ( 123 + <button 124 + type="button" 125 + onClick={() => { 126 + props.backToMenuAction && props.backToMenuAction(); 127 + }} 128 + > 129 + <GoBackSmall className="text-accent-contrast" /> 130 + </button> 131 + )} 132 + </div> 133 + ); 134 + };
+23
app/(home-pages)/home/Actions/Actions.tsx
···
··· 1 + "use client"; 2 + import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 3 + import { CreateNewLeafletButton } from "./CreateNewButton"; 4 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 5 + import { AccountSettings } from "./AccountSettings"; 6 + import { useIdentityData } from "components/IdentityProvider"; 7 + import { useReplicache } from "src/replicache"; 8 + import { LoginActionButton } from "components/LoginButton"; 9 + 10 + export const Actions = () => { 11 + let { identity } = useIdentityData(); 12 + let { rootEntity } = useReplicache(); 13 + return ( 14 + <> 15 + <CreateNewLeafletButton /> 16 + {identity ? ( 17 + <AccountSettings entityID={rootEntity} /> 18 + ) : ( 19 + <LoginActionButton /> 20 + )} 21 + </> 22 + ); 23 + };
+70
app/(home-pages)/home/Actions/CreateNewButton.tsx
···
··· 1 + "use client"; 2 + 3 + import { createNewLeaflet } from "actions/createNewLeaflet"; 4 + import { ActionButton } from "components/ActionBar/ActionButton"; 5 + import { AddTiny } from "components/Icons/AddTiny"; 6 + import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 7 + import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 8 + import { Menu, MenuItem } from "components/Layout"; 9 + import { useIsMobile } from "src/hooks/isMobile"; 10 + 11 + export const CreateNewLeafletButton = (props: {}) => { 12 + let isMobile = useIsMobile(); 13 + let openNewLeaflet = (id: string) => { 14 + if (isMobile) { 15 + window.location.href = `/${id}?focusFirstBlock`; 16 + } else { 17 + window.open(`/${id}?focusFirstBlock`, "_blank"); 18 + } 19 + }; 20 + return ( 21 + <Menu 22 + asChild 23 + side={isMobile ? "top" : "right"} 24 + align={isMobile ? "center" : "start"} 25 + trigger={ 26 + <ActionButton 27 + id="new-leaflet-button" 28 + primary 29 + icon=<AddTiny className="m-1 shrink-0" /> 30 + label="New" 31 + /> 32 + } 33 + > 34 + <MenuItem 35 + onSelect={async () => { 36 + let id = await createNewLeaflet({ 37 + pageType: "doc", 38 + redirectUser: false, 39 + }); 40 + openNewLeaflet(id); 41 + }} 42 + > 43 + <BlockDocPageSmall />{" "} 44 + <div className="flex flex-col"> 45 + <div>New Doc</div> 46 + <div className="text-tertiary text-sm font-normal"> 47 + A good ol&apos; text document 48 + </div> 49 + </div> 50 + </MenuItem> 51 + <MenuItem 52 + onSelect={async () => { 53 + let id = await createNewLeaflet({ 54 + pageType: "canvas", 55 + redirectUser: false, 56 + }); 57 + openNewLeaflet(id); 58 + }} 59 + > 60 + <BlockCanvasPageSmall /> 61 + <div className="flex flex-col"> 62 + New Canvas 63 + <div className="text-tertiary text-sm font-normal"> 64 + A digital whiteboard 65 + </div> 66 + </div> 67 + </MenuItem> 68 + </Menu> 69 + ); 70 + };
+27
app/(home-pages)/home/Actions/HomeHelp.tsx
···
··· 1 + "use client"; 2 + import { ActionButton } from "components/ActionBar/ActionButton"; 3 + import { HelpSmall } from "components/Icons/HelpSmall"; 4 + import { Popover } from "components/Popover"; 5 + 6 + export const HomeHelp = () => { 7 + return ( 8 + <Popover 9 + className="max-w-sm" 10 + trigger={<ActionButton icon={<HelpSmall />} label="Info" />} 11 + > 12 + <div className="flex flex-col gap-2"> 13 + <p> 14 + Leaflets are saved to home <strong>per-device / browser</strong> using 15 + cookies. 16 + </p> 17 + <p> 18 + <strong>If you clear your cookies, they&apos;ll disappear.</strong> 19 + </p> 20 + <p> 21 + Please <a href="mailto:contact@leaflet.pub">contact us</a> for help 22 + recovering Leaflets! 23 + </p> 24 + </div> 25 + </Popover> 26 + ); 27 + };
+26
app/(home-pages)/home/HomeEmpty/DiscoverIllo.tsx
···
··· 1 + import { theme } from "tailwind.config"; 2 + export const DiscoverIllo = () => { 3 + return ( 4 + <svg 5 + className="mx-auto" 6 + width="40" 7 + height="48" 8 + viewBox="0 0 40 48" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + > 12 + <path 13 + d="M27.995 7.21895C28.3796 6.88667 30.0087 5.46507 30.4 2.87748C30.7913 0.289891 32.8883 0.607118 32.497 3.19459C32.1056 5.78206 32.9051 7.53471 33.1993 8.00597C33.4935 8.47722 33.9112 9.87785 36.1378 10.2148C38.3645 10.5518 38.0167 12.8516 35.79 12.5149C33.5633 12.1782 33.0608 13.1244 32.3297 13.7561C31.5986 14.3878 30.6667 15.3456 30.2851 17.8686C29.9036 20.3915 27.7969 20.1388 28.1882 17.5515C28.5795 14.9641 27.5024 13.2044 27.3795 13.0075C27.2565 12.8106 25.9473 11.0264 24.2028 10.7626C22.4582 10.4988 22.8231 8.2013 24.5506 8.46255C26.2782 8.72381 27.6104 7.55122 27.995 7.21895Z" 14 + fill={theme.colors["accent-2"]} 15 + /> 16 + <path 17 + d="M14.0436 10.775C14.8742 10.425 15.789 10.2979 16.6757 10.4806C21.184 11.9238 25.7155 13.2951 30.2265 14.7299C33.7072 15.8375 35.1769 19.9862 33.9774 23.7289C32.7772 27.472 29.1634 30.0064 25.682 28.8996C25.0272 28.6913 24.4433 28.376 23.9352 27.9753C24.0578 29.8258 23.5966 31.7973 22.5415 33.5507C20.1175 37.5776 15.2021 39.3975 11.4706 37.17C10.3639 36.5092 9.64595 35.5804 8.92715 34.5472L2.07416 24.6709C1.57874 23.7422 1.40352 22.6478 1.46822 21.605C0.876338 21.6639 0.331032 21.2463 0.234179 20.6453C0.0915272 19.7582 0.253475 18.7713 0.683459 17.8694C1.10716 16.981 1.75459 16.2504 2.51161 15.8132C3.26248 15.3796 4.24046 15.1818 5.15852 15.6351C5.4442 15.7762 5.64396 16.0231 5.73785 16.3048C6.85704 16.0396 7.98533 16.1226 8.87394 16.3813L9.25524 16.5079L9.2715 16.5153L9.8597 16.7611C10.0213 16.6144 10.2168 16.4391 10.4346 16.2548C10.6319 16.0878 10.8582 15.906 11.0967 15.7234C10.6482 15.6994 10.2675 15.3614 10.21 14.9021C10.0757 13.8176 10.3941 12.7515 10.9253 11.951C11.4368 11.1802 12.2818 10.4694 13.3165 10.4497C13.6068 10.4444 13.8663 10.5731 14.0436 10.775ZM19.2798 23.5847C16.693 22.2682 13.0521 23.4364 11.0819 26.7095C9.04105 30.1008 9.84611 34.0029 12.4431 35.5539C15.0407 37.1046 18.8789 35.9739 20.9203 32.5822C22.8879 29.3123 22.2072 25.5671 19.8281 23.9129L19.2798 23.5847ZM12.5066 27.9296C13.8894 25.2252 16.6958 23.8876 18.7744 24.9417C20.8524 25.9961 21.1485 29.4309 20.0335 31.7462C18.9185 34.0614 15.8458 35.7888 13.7672 34.7356C11.6887 33.6815 11.1239 30.6341 12.5066 27.9296ZM18.863 29.3824C18.4624 29.2737 18.0443 29.5112 17.9305 29.9137C17.5451 31.2786 16.6274 32.2476 15.4816 32.5145C15.0747 32.6093 14.8171 33.0162 14.9067 33.4227C14.9968 33.8287 15.4001 34.0811 15.8067 33.9864C17.6111 33.5659 18.8818 32.0851 19.3832 30.3097C19.4968 29.907 19.2635 29.4916 18.863 29.3824ZM8.3153 18.1814C6.98035 17.7934 5.02731 18.0142 3.96143 19.774C3.02036 21.3284 3.35247 23.0216 3.70427 23.7054L8.0744 30.005C8.19007 28.557 8.65007 27.0839 9.45919 25.7395C10.78 23.5456 12.8405 22.0084 15.0456 21.4225L13.8072 20.8264L13.7998 20.8234L12.0263 19.9506C11.9598 19.9179 11.9009 19.8749 11.846 19.8299L9.65871 18.7201L8.57393 18.2668L8.3153 18.1814ZM19.0995 26.5799C18.8494 26.2497 18.3773 26.1876 18.0443 26.4416C17.7113 26.6963 17.6434 27.1707 17.8935 27.5013C17.9343 27.5552 17.977 27.6287 18.0132 27.7089C18.0501 27.7907 18.07 27.8577 18.0768 27.8914C18.1585 28.2993 18.5552 28.5591 18.9635 28.4728C19.3719 28.3861 19.6373 27.986 19.5562 27.5779C19.4936 27.265 19.3269 26.8805 19.0995 26.5799ZM29.6516 16.5241C27.4753 15.8319 24.7243 17.3802 23.7327 20.4717C22.7415 23.5634 24.082 26.4132 26.2584 27.1054C28.4347 27.7968 31.1846 26.2478 32.1759 23.1564C33.1666 20.0654 31.8272 17.217 29.6516 16.5241ZM25.0864 20.9088C25.8644 18.6948 27.8745 17.3801 29.5763 17.9724C31.2782 18.5655 31.8625 21.1674 31.2492 23.0563C30.6358 24.9452 28.4614 26.5857 26.7594 25.9927C25.0576 25.3994 24.3085 23.1233 25.0864 20.9088ZM30.1039 21.3842C29.7952 21.3437 29.5093 21.5623 29.4654 21.8729C29.3114 22.9627 28.7055 23.8052 27.8486 24.1307C27.5561 24.2419 27.4059 24.5693 27.5131 24.8623C27.6207 25.1552 27.9446 25.3027 28.2373 25.192C29.5689 24.6865 30.387 23.4274 30.5857 22.0201C30.6291 21.7096 30.4126 21.4251 30.1039 21.3842ZM16.2087 12.3087C15.6227 12.192 14.6928 12.3931 13.9963 12.9887C13.5732 13.3506 13.2385 13.8583 13.1584 14.5459C13.1767 14.5393 13.1948 14.5317 13.213 14.5253C13.7448 14.3389 14.2572 14.2221 14.6318 14.1515C14.8204 14.1159 14.9777 14.0907 15.09 14.0749C15.3279 14.0415 15.553 14.0285 15.7831 14.1367L20.1739 16.2018C20.4022 16.3094 20.5779 16.5046 20.6616 16.7419C20.7451 16.9794 20.7295 17.2415 20.6188 17.4676C20.0298 18.6703 19.6797 19.9843 19.6168 21.5873L20.1887 21.9303C20.3029 21.99 20.4164 22.0532 20.5286 22.1202C20.8892 22.3354 21.2207 22.5792 21.5247 22.8458C21.4865 21.8762 21.6184 20.8729 21.9311 19.8976C22.5837 17.8636 23.9491 16.1876 25.6185 15.2583L16.2087 12.3087ZM29.9842 19.2073C29.7647 18.9869 29.4064 18.9891 29.1846 19.2117C28.9637 19.4344 28.9614 19.7935 29.1802 20.0139C29.2181 20.052 29.2604 20.1041 29.2969 20.1626C29.334 20.222 29.3556 20.2728 29.3649 20.2995C29.4667 20.5942 29.7886 20.7483 30.0832 20.6439C30.378 20.5386 30.5364 20.2134 30.4349 19.9182C30.3553 19.6875 30.1879 19.4123 29.9842 19.2073ZM18.1891 18.5259C17.7543 18.6688 17.3359 18.813 17.0378 18.938C16.7617 19.0538 16.3329 19.3654 15.8895 19.7357L17.7842 20.6483C17.8606 19.8991 17.9962 19.1952 18.1891 18.5259ZM14.9836 16.0031C14.6688 16.0624 14.2552 16.1582 13.8411 16.3033C13.6711 16.3629 13.5046 16.4296 13.3475 16.5035C12.8795 16.7239 12.263 17.1843 11.71 17.6486L14.054 18.8379C14.1789 18.7233 14.3197 18.5926 14.4737 18.4596C14.9623 18.0377 15.6797 17.4632 16.3048 17.2012C16.554 17.0967 16.8576 16.9855 17.1753 16.8759L15.2333 15.9604C15.1623 15.9716 15.0778 15.9853 14.9836 16.0031Z" 18 + fill={theme.colors["accent-1"]} 19 + /> 20 + <path 21 + d="M10.8586 38.4374C11.0345 38.0999 11.7756 36.6608 11.3198 34.7706C10.8641 32.8804 12.4238 32.5044 12.8795 34.3945C13.3352 36.2847 14.3902 37.2634 14.7294 37.5041C15.0687 37.7448 15.7567 38.5896 17.4129 38.1905C19.0691 37.7913 19.4742 39.4713 17.818 39.8706C16.1619 40.27 16.0766 41.063 15.7422 41.7045C15.4079 42.346 15.0247 43.2688 15.4691 45.1118C15.9135 46.9548 14.3652 47.3779 13.9094 45.4878C13.4537 43.5978 12.2021 42.6929 12.0603 42.5923C11.9186 42.4917 10.4973 41.6358 9.19972 41.9486C7.90219 42.2615 7.50971 40.5782 8.79465 40.2684C10.0796 39.9586 10.6827 38.7749 10.8586 38.4374Z" 22 + fill={theme.colors["accent-2"]} 23 + /> 24 + </svg> 25 + ); 26 + };
+108
app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx
···
··· 1 + "use client"; 2 + 3 + import { PubListEmptyIllo } from "components/ActionBar/Publications"; 4 + import { ButtonPrimary } from "components/Buttons"; 5 + import { AddSmall } from "components/Icons/AddSmall"; 6 + import { Link } from "react-aria-components"; 7 + import { DiscoverIllo } from "./DiscoverIllo"; 8 + import { WelcomeToLeafletIllo } from "./WelcomeToLeafletIllo"; 9 + import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 10 + import { PublishSmall } from "components/Icons/PublishSmall"; 11 + import { createNewLeaflet } from "actions/createNewLeaflet"; 12 + import { useIsMobile } from "src/hooks/isMobile"; 13 + 14 + export function HomeEmptyState() { 15 + let isMobile = useIsMobile(); 16 + return ( 17 + <div className="flex flex-col gap-4 font-bold"> 18 + <div className="container p-2 flex gap-4"> 19 + <div className="w-[72px]"> 20 + <WelcomeToLeafletIllo /> 21 + </div> 22 + <div className="flex flex-col grow"> 23 + <h3 className="text-xl font-semibold pt-2">Leaflet</h3> 24 + {/*<h3>A platform for social publishing.</h3>*/} 25 + <div className="font-normal text-tertiary italic"> 26 + Write and share delightful documents! 27 + </div> 28 + <ButtonPrimary 29 + className="!text-lg my-3" 30 + onClick={async () => { 31 + let openNewLeaflet = (id: string) => { 32 + if (isMobile) { 33 + window.location.href = `/${id}?focusFirstBlock`; 34 + } else { 35 + window.open(`/${id}?focusFirstBlock`, "_blank"); 36 + } 37 + }; 38 + 39 + let id = await createNewLeaflet({ 40 + pageType: "doc", 41 + redirectUser: false, 42 + }); 43 + openNewLeaflet(id); 44 + }} 45 + > 46 + <AddSmall /> Write a Doc! 47 + </ButtonPrimary> 48 + </div> 49 + </div> 50 + <div className="flex gap-2 w-full items-center text-tertiary font-normal italic"> 51 + <hr className="border-border w-full" /> 52 + <div>or</div> 53 + <hr className="border-border w-full" /> 54 + </div> 55 + 56 + <PublicationBanner /> 57 + <DiscoverBanner /> 58 + <div className="text-tertiary italic text-sm font-normal -mt-2"> 59 + Right now docs and publications are separate. Soon you'll be able to add 60 + docs to pubs! 61 + </div> 62 + </div> 63 + ); 64 + } 65 + 66 + export const PublicationBanner = (props: { small?: boolean }) => { 67 + return ( 68 + <div 69 + className={`accent-container flex sm:py-2 items-center ${props.small ? "items-start gap-2 p-2 text-sm font-normal" : "items-center p-4 gap-4"}`} 70 + > 71 + {props.small ? ( 72 + <PublishSmall className="shrink-0 text-accent-contrast" /> 73 + ) : ( 74 + <div className="w-[64px] mx-auto"> 75 + <PubListEmptyIllo /> 76 + </div> 77 + )} 78 + <div className={`${props.small ? "pt-[2px]" : ""} grow`}> 79 + <Link href={"/lish/createPub"} className="font-bold"> 80 + Start a Publication 81 + </Link>{" "} 82 + and blog in the Atmosphere 83 + </div> 84 + </div> 85 + ); 86 + }; 87 + 88 + export const DiscoverBanner = (props: { small?: boolean }) => { 89 + return ( 90 + <div 91 + className={`accent-container flex sm:py-2 items-center ${props.small ? "items-start gap-2 p-2 text-sm font-normal" : "items-center p-4 gap-4"}`} 92 + > 93 + {props.small ? ( 94 + <DiscoverSmall className="shrink-0 text-accent-contrast" /> 95 + ) : ( 96 + <div className="w-[64px] mx-auto"> 97 + <DiscoverIllo /> 98 + </div> 99 + )} 100 + <div className={`${props.small ? "pt-[2px]" : ""} grow`}> 101 + <Link href={"/discover"} className="font-bold"> 102 + Explore Publications 103 + </Link>{" "} 104 + on art, tech, games, music & more! 105 + </div> 106 + </div> 107 + ); 108 + };
+24
app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx
···
··· 1 + import { theme } from "tailwind.config"; 2 + 3 + export const WelcomeToLeafletIllo = () => { 4 + return ( 5 + <svg 6 + width="73" 7 + height="68" 8 + viewBox="0 0 73 68" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + > 12 + <path 13 + d="M21.7639 12.9818C39.5065 8.22165 72.8274 1.9906 72.8274 11.684C72.8274 13.2356 72.5131 14.6375 71.9885 15.9193C71.422 15.1994 70.6084 14.987 69.927 14.6595C69.0312 14.3006 68.1186 14.0196 67.219 13.7347C65.9446 13.3234 64.6378 12.9769 63.4944 12.3705C63.0971 12.1482 62.6977 11.8678 62.5598 11.4935C62.4128 11.1213 62.6531 10.6704 62.9592 10.3334C63.598 9.64116 64.4397 9.13591 65.3 8.65466C64.4055 9.0767 63.5312 9.49833 62.7922 10.1713C62.4481 10.4956 62.061 11.0041 62.219 11.5961C62.3834 12.1584 62.8383 12.4935 63.2483 12.7738C64.436 13.5238 65.7311 13.9496 66.9934 14.4086C67.8817 14.7243 68.7738 15.0378 69.6096 15.4095C70.4149 15.7681 71.3704 16.291 71.4895 16.8314C71.4933 16.884 71.4915 16.9 71.3928 17.0043C71.2883 17.1047 71.1421 17.196 70.9524 17.2894C70.6123 17.455 70.1758 17.5978 69.7727 17.7084C68.9351 17.937 68.049 18.1027 67.1721 18.2513C65.4162 18.5448 63.6217 18.7708 61.8323 19.0199C60.0416 19.2716 58.2462 19.5223 56.429 19.933C55.5221 20.1485 54.6197 20.3715 53.6711 20.8383C53.2374 21.0796 52.6446 21.3532 52.2737 22.1595C52.0934 22.554 52.1241 23.1248 52.2629 23.4594C52.4237 23.8666 52.582 24.0666 52.8069 24.3275C55.2093 26.8202 58.4399 26.9744 61.1399 27.4711C61.3262 27.5015 61.5132 27.5331 61.6995 27.5638C60.4533 29.2073 60.129 30.8258 61.717 32.7035C53.3556 36.0694 40.5573 34.9986 30.1731 37.5492C27.0455 38.2875 23.9357 39.315 21.0657 41.3636C19.6745 42.3955 18.2597 43.7123 17.4534 45.7152C16.5587 47.6874 17.1166 50.6073 18.6731 52.1156C19.6636 53.1771 20.8229 53.8313 21.9641 54.3002C22.9895 54.7165 24.0905 54.774 25.1536 54.5228C25.4784 54.4319 25.7983 54.2981 26.054 54.0844C26.3095 53.8704 26.4797 53.5942 26.5325 53.2894C26.5851 52.9847 26.5177 52.6675 26.3489 52.3802C26.1797 52.0928 25.9225 51.8583 25.6467 51.6635C24.8912 51.0971 24.2127 50.692 23.5432 50.4203C22.7927 50.117 22.1473 49.7103 21.7405 49.2709C21.1437 48.5942 21.0342 48.064 21.3293 47.2084C21.6382 46.365 22.4832 45.4184 23.5022 44.6605C25.5969 43.1038 28.3033 42.084 31.094 41.3705C37.0436 39.9193 43.4459 39.2868 49.7786 38.347C52.9572 37.8792 56.1424 37.383 59.342 36.6058C60.9443 36.2072 62.5256 35.7578 64.136 35.0453C64.1883 35.0196 64.2409 34.9932 64.2942 34.9672C72.897 41.9183 73.7798 46.9821 71.0959 52.3617C68.9368 56.6893 57.6782 70.1038 45.5647 66.642C17.1652 58.5254 -0.313688 61.8818 0.983643 36.35C1.62827 23.6653 13.1137 15.3026 21.7639 12.9818ZM47.5911 44.0482C47.6939 42.4007 46.335 42.3157 46.2317 43.9633C46.1286 45.6104 45.1675 46.5931 44.9397 46.8236C44.7125 47.0535 43.9263 47.8634 42.8059 47.7933C41.6859 47.7235 41.5833 49.1871 42.7141 49.2582C43.8449 49.329 44.7847 50.3786 44.8752 50.4965C44.9656 50.6142 45.7535 51.6618 45.6506 53.309C45.5476 54.9569 46.9105 55.0009 47.011 53.3939C47.1116 51.7875 47.6534 51.1346 48.0852 50.6976C48.5171 50.2607 48.7857 49.6386 50.2297 49.7289C51.6728 49.8185 51.7638 48.3548 50.3206 48.264C48.8768 48.1734 48.5288 47.3157 48.3137 47.0355C48.0986 46.7552 47.488 45.6961 47.5911 44.0482ZM18.887 18.7377C18.8903 16.7042 17.2118 16.7019 17.2083 18.7357C17.2044 20.7693 16.0954 22.0494 15.8333 22.349C15.5704 22.6494 14.6652 23.703 13.2834 23.7006C11.901 23.6983 11.8845 25.5056 13.2805 25.5082C14.675 25.5108 15.9068 26.7289 16.0286 26.8685C16.1478 27.0058 17.1971 28.2352 17.1936 30.2689C17.1902 32.3024 18.8677 32.2546 18.8713 30.2719C18.8748 28.2891 19.4922 27.4449 19.9905 26.8754C20.4888 26.3059 20.7721 25.5208 22.554 25.5238C24.336 25.5269 24.3396 23.7198 22.5579 23.7162C20.7757 23.7129 20.2846 22.6833 19.9993 22.3549C19.7136 22.0258 18.8835 20.7706 18.887 18.7377ZM70.5559 18.5209C68.5834 21.3602 65.7167 23.5582 63.5715 25.5668C62.8433 25.4447 62.1381 25.3759 61.4397 25.2875C58.698 24.9636 55.7947 24.7314 54.1506 23.0892C53.1448 22.9109 55.2165 21.8481 56.8167 21.475C58.5129 21.0124 60.2807 20.6842 62.0471 20.3597C63.8164 20.0365 65.6066 19.7363 67.384 19.3646C68.2733 19.1761 69.1747 18.9708 70.0657 18.6898C70.2249 18.6387 70.3894 18.5819 70.5559 18.5209Z" 14 + fill="color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)" 15 + /> 16 + <path 17 + fillRule="evenodd" 18 + clipRule="evenodd" 19 + d="M33.1936 19.9644C33.8224 20.2936 34.5595 20.6793 35.7586 19.9049C36.5426 19.3985 37.1772 17.1878 37.8852 14.7211C38.9202 11.1155 40.1122 6.96302 42.1573 6.78723C44.6243 6.57519 44.7981 7.92712 44.9319 8.96695C45.0257 9.69631 45.0997 10.2721 45.9315 10.047C46.7788 9.8176 47.3647 8.61374 48.0447 7.2167C48.984 5.28694 50.1027 2.98859 52.3373 2.38086C53.9128 1.95238 54.054 2.64187 54.1973 3.34108C54.2888 3.788 54.3812 4.2389 54.8494 4.40436C55.3107 4.56736 56.2896 4.23303 57.4149 3.84869C59.219 3.23253 61.3994 2.48783 62.4269 3.45769C63.3302 4.31032 62.738 5.52027 62.1704 6.67999C61.6896 7.66228 61.2264 8.60853 61.7046 9.27112C62.0948 9.81171 62.6783 10.0278 63.2502 10.2396C64.1857 10.5861 65.0904 10.9211 65.0681 12.6458C65.0445 14.4695 62.5034 15.5801 60.1214 16.6211C58.126 17.4931 56.2423 18.3164 56.0441 19.4692C55.9197 20.1921 56.464 20.3823 57.0738 20.5954C57.8026 20.85 58.6249 21.1373 58.5116 22.4058C58.2468 25.3711 55.1373 26.1276 52.219 26.8377C49.7141 27.4471 47.3501 28.0223 47.0466 29.9306C46.8714 31.0323 47.5477 31.0548 48.2898 31.0794C49.1981 31.1095 50.205 31.1429 49.869 33.1634C49.2261 37.03 43.6557 38.4183 38.5564 39.6893C36.6866 40.1553 34.8802 40.6056 33.4032 41.1564C33.2347 41.2193 33.1049 41.3564 33.0503 41.5278C32.2094 44.1647 31.8313 46.932 31.428 49.8838C31.3394 50.5323 31.2496 51.1896 31.1534 51.8565C31.0262 52.738 29.2486 55.1385 28.3628 55.2311C27.9656 55.2726 27.8339 52.2775 27.9611 51.3959C28.4347 48.1126 29.1515 45.0249 30.0528 42.1314C31.418 36.9751 34.9339 30.201 39.0683 24.7487C42.7265 19.8134 46.4117 15.8889 50.9826 12.4792C51.5547 12.0524 51.1837 11.3521 50.5642 11.7068C46.3602 14.1137 42.9934 17.6783 39.4855 21.2985C35.6701 25.2361 32.8282 29.9969 31.1838 33.3557C30.8939 33.9479 29.8993 33.7609 29.8756 33.102C29.6905 27.9469 29.636 23.1879 30.7621 21.1685C31.8313 19.2514 32.4334 19.5665 33.1936 19.9644Z" 20 + fill={theme.colors["accent-1"]} 21 + /> 22 + </svg> 23 + ); 24 + };
+319
app/(home-pages)/home/HomeLayout.tsx
···
··· 1 + "use client"; 2 + 3 + import { getHomeDocs } from "./storage"; 4 + import useSWR from "swr"; 5 + import { 6 + Fact, 7 + PermissionToken, 8 + ReplicacheProvider, 9 + useEntity, 10 + } from "src/replicache"; 11 + import { LeafletListItem } from "./LeafletList/LeafletListItem"; 12 + import { useIdentityData } from "components/IdentityProvider"; 13 + import type { Attribute } from "src/replicache/attributes"; 14 + import { callRPC } from "app/api/rpc/client"; 15 + import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 16 + import { 17 + HomeDashboardControls, 18 + DashboardLayout, 19 + DashboardState, 20 + useDashboardState, 21 + } from "components/PageLayouts/DashboardLayout"; 22 + import { Actions } from "./Actions/Actions"; 23 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 24 + import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 25 + import { useState } from "react"; 26 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 27 + import { 28 + DiscoverBanner, 29 + HomeEmptyState, 30 + PublicationBanner, 31 + } from "./HomeEmpty/HomeEmpty"; 32 + 33 + export type Leaflet = { 34 + added_at: string; 35 + archived?: boolean | null; 36 + token: PermissionToken & { 37 + leaflets_in_publications?: Exclude< 38 + GetLeafletDataReturnType["result"]["data"], 39 + null 40 + >["leaflets_in_publications"]; 41 + leaflets_to_documents?: Exclude< 42 + GetLeafletDataReturnType["result"]["data"], 43 + null 44 + >["leaflets_to_documents"]; 45 + }; 46 + }; 47 + 48 + export const HomeLayout = (props: { 49 + entityID: string | null; 50 + titles: { [root_entity: string]: string }; 51 + initialFacts: { 52 + [root_entity: string]: Fact<Attribute>[]; 53 + }; 54 + }) => { 55 + let hasBackgroundImage = !!useEntity( 56 + props.entityID, 57 + "theme/background-image", 58 + ); 59 + let cardBorderHidden = !!useCardBorderHidden(props.entityID); 60 + 61 + let [searchValue, setSearchValue] = useState(""); 62 + let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); 63 + 64 + useDebouncedEffect( 65 + () => { 66 + setDebouncedSearchValue(searchValue); 67 + }, 68 + 200, 69 + [searchValue], 70 + ); 71 + 72 + let { identity } = useIdentityData(); 73 + 74 + let hasPubs = !identity || identity.publications.length === 0 ? false : true; 75 + let hasArchived = 76 + identity && 77 + identity.permission_token_on_homepage.filter( 78 + (leaflet) => leaflet.archived === true, 79 + ).length > 0; 80 + 81 + return ( 82 + <DashboardLayout 83 + id="home" 84 + cardBorderHidden={cardBorderHidden} 85 + currentPage="home" 86 + defaultTab="home" 87 + actions={<Actions />} 88 + tabs={{ 89 + home: { 90 + controls: ( 91 + <HomeDashboardControls 92 + defaultDisplay={"grid"} 93 + searchValue={searchValue} 94 + setSearchValueAction={setSearchValue} 95 + hasBackgroundImage={hasBackgroundImage} 96 + hasPubs={hasPubs} 97 + hasArchived={!!hasArchived} 98 + /> 99 + ), 100 + content: ( 101 + <HomeLeafletList 102 + titles={props.titles} 103 + initialFacts={props.initialFacts} 104 + cardBorderHidden={cardBorderHidden} 105 + searchValue={debouncedSearchValue} 106 + /> 107 + ), 108 + }, 109 + }} 110 + /> 111 + ); 112 + }; 113 + 114 + export function HomeLeafletList(props: { 115 + titles: { [root_entity: string]: string }; 116 + initialFacts: { 117 + [root_entity: string]: Fact<Attribute>[]; 118 + }; 119 + searchValue: string; 120 + cardBorderHidden: boolean; 121 + }) { 122 + let { identity } = useIdentityData(); 123 + let { data: initialFacts } = useSWR( 124 + "home-leaflet-data", 125 + async () => { 126 + if (identity) { 127 + let { result } = await callRPC("getFactsFromHomeLeaflets", { 128 + tokens: identity.permission_token_on_homepage.map( 129 + (ptrh) => ptrh.permission_tokens.root_entity, 130 + ), 131 + }); 132 + let titles = { 133 + ...result.titles, 134 + ...identity.permission_token_on_homepage.reduce( 135 + (acc, tok) => { 136 + let title = 137 + tok.permission_tokens.leaflets_in_publications[0]?.title || 138 + tok.permission_tokens.leaflets_to_documents[0]?.title; 139 + if (title) acc[tok.permission_tokens.root_entity] = title; 140 + return acc; 141 + }, 142 + {} as { [k: string]: string }, 143 + ), 144 + }; 145 + return { ...result, titles }; 146 + } 147 + }, 148 + { fallbackData: { facts: props.initialFacts, titles: props.titles } }, 149 + ); 150 + 151 + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { 152 + fallbackData: [], 153 + }); 154 + let leaflets: Leaflet[] = identity 155 + ? identity.permission_token_on_homepage.map((ptoh) => ({ 156 + added_at: ptoh.created_at, 157 + token: ptoh.permission_tokens as PermissionToken, 158 + archived: ptoh.archived, 159 + })) 160 + : localLeaflets 161 + .sort((a, b) => (a.added_at > b.added_at ? -1 : 1)) 162 + .filter((d) => !d.hidden) 163 + .map((ll) => ll); 164 + 165 + return leaflets.length === 0 ? ( 166 + <HomeEmptyState /> 167 + ) : ( 168 + <> 169 + <LeafletList 170 + defaultDisplay="grid" 171 + searchValue={props.searchValue} 172 + leaflets={leaflets} 173 + titles={initialFacts?.titles || {}} 174 + cardBorderHidden={props.cardBorderHidden} 175 + initialFacts={initialFacts?.facts || {}} 176 + showPreview 177 + /> 178 + <div className="spacer h-4 w-full bg-transparent shrink-0 " /> 179 + 180 + {leaflets.filter((l) => !!l.token.leaflets_in_publications).length === 181 + 0 && <PublicationBanner small />} 182 + <DiscoverBanner small /> 183 + </> 184 + ); 185 + } 186 + 187 + export function LeafletList(props: { 188 + leaflets: Leaflet[]; 189 + titles: { [root_entity: string]: string }; 190 + defaultDisplay: Exclude<DashboardState["display"], undefined>; 191 + initialFacts: { 192 + [root_entity: string]: Fact<Attribute>[]; 193 + }; 194 + searchValue: string; 195 + cardBorderHidden: boolean; 196 + showPreview?: boolean; 197 + }) { 198 + let { identity } = useIdentityData(); 199 + let { display } = useDashboardState(); 200 + 201 + display = display || props.defaultDisplay; 202 + 203 + let searchedLeaflets = useSearchedLeaflets( 204 + props.leaflets, 205 + props.titles, 206 + props.searchValue, 207 + ); 208 + 209 + return ( 210 + <div 211 + className={` 212 + leafletList 213 + w-full 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"} `} 215 + > 216 + {props.leaflets.map(({ token: leaflet, added_at, archived }, index) => ( 217 + <ReplicacheProvider 218 + disablePull 219 + initialFactsOnly={!!identity} 220 + key={leaflet.id} 221 + rootEntity={leaflet.root_entity} 222 + token={leaflet} 223 + name={leaflet.root_entity} 224 + initialFacts={props.initialFacts?.[leaflet.root_entity] || []} 225 + > 226 + <StaticLeafletDataContext 227 + value={{ 228 + ...leaflet, 229 + leaflets_in_publications: leaflet.leaflets_in_publications || [], 230 + leaflets_to_documents: leaflet.leaflets_to_documents || [], 231 + blocked_by_admin: null, 232 + custom_domain_routes: [], 233 + }} 234 + > 235 + <LeafletListItem 236 + title={props?.titles?.[leaflet.root_entity]} 237 + archived={archived} 238 + loggedIn={!!identity} 239 + display={display} 240 + added_at={added_at} 241 + cardBorderHidden={props.cardBorderHidden} 242 + index={index} 243 + showPreview={props.showPreview} 244 + isHidden={ 245 + !searchedLeaflets.some( 246 + (sl) => sl.token.root_entity === leaflet.root_entity, 247 + ) 248 + } 249 + /> 250 + </StaticLeafletDataContext> 251 + </ReplicacheProvider> 252 + ))} 253 + </div> 254 + ); 255 + } 256 + 257 + function useSearchedLeaflets( 258 + leaflets: Leaflet[], 259 + titles: { [root_entity: string]: string }, 260 + searchValue: string, 261 + ) { 262 + let { sort, filter } = useDashboardState(); 263 + 264 + let sortedLeaflets = leaflets.sort((a, b) => { 265 + if (sort === "alphabetical") { 266 + let titleA = titles[a.token.root_entity] ?? "Untitled"; 267 + let titleB = titles[b.token.root_entity] ?? "Untitled"; 268 + 269 + if (titleA === titleB) { 270 + return a.added_at > b.added_at ? -1 : 1; 271 + } else { 272 + return titleA.toLocaleLowerCase() > titleB.toLocaleLowerCase() ? 1 : -1; 273 + } 274 + } else { 275 + return a.added_at === b.added_at 276 + ? a.token.root_entity > b.token.root_entity 277 + ? -1 278 + : 1 279 + : a.added_at > b.added_at 280 + ? -1 281 + : 1; 282 + } 283 + }); 284 + 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; 301 + 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 + ); 311 + if (searchValue === "") return filteredLeaflets; 312 + let searchedLeaflets = filteredLeaflets.filter(({ token: leaflet }) => { 313 + return titles[leaflet.root_entity] 314 + ?.toLowerCase() 315 + .includes(searchValue.toLowerCase()); 316 + }); 317 + 318 + return searchedLeaflets; 319 + }
+13
app/(home-pages)/home/IdentitySetter.tsx
···
··· 1 + "use client"; 2 + 3 + import { useEffect } from "react"; 4 + 5 + export function IdentitySetter(props: { 6 + cb: () => Promise<void>; 7 + call: boolean; 8 + }) { 9 + useEffect(() => { 10 + if (props.call) props.cb(); 11 + }, [props]); 12 + return null; 13 + }
+68
app/(home-pages)/home/LeafletList/LeafletContent.tsx
···
··· 1 + "use client"; 2 + import { BlockPreview } from "components/Blocks/PageLinkBlock"; 3 + import { useEffect, useRef, useState } from "react"; 4 + import { useBlocks } from "src/hooks/queries/useBlocks"; 5 + import { useEntity } from "src/replicache"; 6 + import { CanvasContent } from "components/Canvas"; 7 + import styles from "./LeafletPreview.module.css"; 8 + import { PublicationMetadataPreview } from "components/Pages/PublicationMetadata"; 9 + 10 + export const LeafletContent = (props: { 11 + entityID: string; 12 + isOnScreen: boolean; 13 + }) => { 14 + let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; 15 + let blocks = useBlocks(props.entityID); 16 + let previewRef = useRef<HTMLDivElement | null>(null); 17 + 18 + if (type === "canvas") 19 + return ( 20 + <div 21 + className={`pageLinkBlockPreview shrink-0 h-full overflow-clip relative bg-bg-page shadow-sm rounded-md`} 22 + > 23 + <div 24 + className={`absolute top-0 left-0 origin-top-left pointer-events-none ${styles.scaleLeafletCanvasPreview}`} 25 + style={{ 26 + width: `1272px`, 27 + height: "calc(1272px * 2)", 28 + }} 29 + > 30 + {props.isOnScreen && ( 31 + <CanvasContent entityID={props.entityID} preview /> 32 + )} 33 + </div> 34 + </div> 35 + ); 36 + 37 + return ( 38 + <div 39 + ref={previewRef} 40 + className={`pageLinkBlockPreview h-full overflow-clip flex flex-col gap-0.5 no-underline relative`} 41 + > 42 + <div 43 + className={`absolute top-0 left-0 w-full h-full origin-top-left pointer-events-none ${styles.scaleLeafletDocPreview}`} 44 + style={{ 45 + width: `var(--page-width-units)`, 46 + }} 47 + > 48 + <PublicationMetadataPreview /> 49 + 50 + {props.isOnScreen && 51 + blocks.slice(0, 10).map((b, index, arr) => { 52 + return ( 53 + <BlockPreview 54 + pageType="doc" 55 + entityID={b.value} 56 + previousBlock={arr[index - 1] || null} 57 + nextBlock={arr[index + 1] || null} 58 + nextPosition={""} 59 + previewRef={previewRef} 60 + {...b} 61 + key={b.factID} 62 + /> 63 + ); 64 + })} 65 + </div> 66 + </div> 67 + ); 68 + };
+60
app/(home-pages)/home/LeafletList/LeafletInfo.tsx
···
··· 1 + "use client"; 2 + import { useEntity } from "src/replicache"; 3 + import { LeafletOptions } from "./LeafletOptions"; 4 + import { timeAgo } from "src/utils/timeAgo"; 5 + import { usePageTitle } from "components/utils/UpdateLeafletTitle"; 6 + import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 + 8 + export const LeafletInfo = (props: { 9 + title?: string; 10 + className?: string; 11 + display: "grid" | "list"; 12 + added_at: string; 13 + archived?: boolean | null; 14 + loggedIn: boolean; 15 + }) => { 16 + const pubStatus = useLeafletPublicationStatus(); 17 + let prettyCreatedAt = props.added_at ? timeAgo(props.added_at) : ""; 18 + let prettyPublishedAt = pubStatus?.publishedAt 19 + ? timeAgo(pubStatus.publishedAt) 20 + : ""; 21 + 22 + // Look up root page first, like UpdateLeafletTitle does 23 + let firstPage = useEntity(pubStatus?.leafletId ?? "", "root/page")[0]; 24 + let entityID = firstPage?.data.value || pubStatus?.leafletId || ""; 25 + let titleFromDb = usePageTitle(entityID); 26 + 27 + let title = props.title ?? titleFromDb ?? "Untitled"; 28 + 29 + return ( 30 + <div 31 + className={`leafletInfo w-full min-w-0 flex flex-col ${props.className}`} 32 + > 33 + <div className="flex justify-between items-center shrink-0 max-w-full gap-2 leading-tight overflow-hidden"> 34 + <h3 className="sm:text-lg text-base truncate w-full min-w-0"> 35 + {title} 36 + </h3> 37 + <div className="flex gap-1 shrink-0"> 38 + <LeafletOptions archived={props.archived} loggedIn={props.loggedIn} /> 39 + </div> 40 + </div> 41 + <div className="flex gap-2 items-center"> 42 + {props.archived ? ( 43 + <div className="text-xs text-tertiary truncate">Archived</div> 44 + ) : pubStatus?.draftInPublication || pubStatus?.isPublished ? ( 45 + <div 46 + className={`text-xs w-max grow truncate ${pubStatus?.isPublished ? "font-bold text-tertiary" : "text-tertiary"}`} 47 + > 48 + {pubStatus?.isPublished 49 + ? `Published ${prettyPublishedAt}` 50 + : `Draft ${prettyCreatedAt}`} 51 + </div> 52 + ) : ( 53 + <div className="text-xs text-tertiary grow w-max truncate"> 54 + {prettyCreatedAt} 55 + </div> 56 + )} 57 + </div> 58 + </div> 59 + ); 60 + };
+121
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
···
··· 1 + "use client"; 2 + import { LeafletListPreview, LeafletGridPreview } from "./LeafletPreview"; 3 + import { LeafletInfo } from "./LeafletInfo"; 4 + import { useState, useRef, useEffect } from "react"; 5 + import { SpeedyLink } from "components/SpeedyLink"; 6 + import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 + 8 + export const LeafletListItem = (props: { 9 + archived?: boolean | null; 10 + loggedIn: boolean; 11 + display: "list" | "grid"; 12 + cardBorderHidden: boolean; 13 + added_at: string; 14 + title?: string; 15 + index: number; 16 + isHidden: boolean; 17 + showPreview?: boolean; 18 + }) => { 19 + const pubStatus = useLeafletPublicationStatus(); 20 + let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); 21 + let previewRef = useRef<HTMLDivElement | null>(null); 22 + 23 + useEffect(() => { 24 + if (!previewRef.current) return; 25 + let observer = new IntersectionObserver( 26 + (entries) => { 27 + entries.forEach((entry) => { 28 + if (entry.isIntersecting) { 29 + setIsOnScreen(true); 30 + } else { 31 + setIsOnScreen(false); 32 + } 33 + }); 34 + }, 35 + { threshold: 0.1, root: null }, 36 + ); 37 + observer.observe(previewRef.current); 38 + return () => observer.disconnect(); 39 + }, [previewRef]); 40 + 41 + const tokenId = pubStatus?.shareLink ?? ""; 42 + 43 + if (props.display === "list") 44 + return ( 45 + <> 46 + <div 47 + ref={previewRef} 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"}`} 51 + style={{ 52 + backgroundColor: props.cardBorderHidden 53 + ? "transparent" 54 + : "rgba(var(--bg-page), var(--bg-page-alpha))", 55 + }} 56 + > 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 + /> 69 + </div> 70 + {props.cardBorderHidden && ( 71 + <hr 72 + className="last:hidden border-border-light" 73 + style={{ 74 + display: props.isHidden ? "none" : "block", 75 + }} 76 + /> 77 + )} 78 + </> 79 + ); 80 + return ( 81 + <div 82 + ref={previewRef} 83 + className={` 84 + relative 85 + flex flex-col gap-1 p-1 h-52 w-full 86 + block-border border-border! hover:outline-border 87 + ${props.isHidden ? "hidden" : "flex"} 88 + `} 89 + style={{ 90 + backgroundColor: props.cardBorderHidden 91 + ? "transparent" 92 + : "rgba(var(--bg-page), var(--bg-page-alpha))", 93 + }} 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 + /> 99 + <div className="grow"> 100 + <LeafletGridPreview isVisible={isOnScreen} /> 101 + </div> 102 + <LeafletInfo 103 + className="px-1 pb-0.5 shrink-0" 104 + title={props.title} 105 + display={props.display} 106 + added_at={props.added_at} 107 + archived={props.archived} 108 + loggedIn={props.loggedIn} 109 + /> 110 + </div> 111 + ); 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 + };
+351
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
···
··· 1 + "use client"; 2 + 3 + import { Menu, MenuItem } from "components/Layout"; 4 + import { useState } from "react"; 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"; 21 + import { HideSmall } from "components/Icons/HideSmall"; 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"; 34 + 35 + export const LeafletOptions = (props: { 36 + archived?: boolean | null; 37 + loggedIn?: boolean; 38 + }) => { 39 + const pubStatus = useLeafletPublicationStatus(); 40 + let [state, setState] = useState<"normal" | "areYouSure">("normal"); 41 + let [open, setOpen] = useState(false); 42 + let { identity } = useIdentityData(); 43 + let isPublicationOwner = 44 + !!identity?.atp_did && !!pubStatus?.documentUri?.includes(identity.atp_did); 45 + return ( 46 + <> 47 + <Menu 48 + open={open} 49 + align="end" 50 + onOpenChange={(o) => { 51 + setOpen(o); 52 + setState("normal"); 53 + }} 54 + trigger={ 55 + <div 56 + className="text-secondary shrink-0 relative" 57 + onClick={(e) => { 58 + e.preventDefault; 59 + e.stopPropagation; 60 + }} 61 + > 62 + <MoreOptionsVerticalTiny /> 63 + </div> 64 + } 65 + > 66 + {state === "normal" ? ( 67 + !props.loggedIn ? ( 68 + <LoggedOutOptions setState={setState} /> 69 + ) : pubStatus?.documentUri && isPublicationOwner ? ( 70 + <PublishedPostOptions setState={setState} /> 71 + ) : ( 72 + <DefaultOptions setState={setState} archived={props.archived} /> 73 + ) 74 + ) : state === "areYouSure" ? ( 75 + <DeleteAreYouSureForm backToMenu={() => setState("normal")} /> 76 + ) : null} 77 + </Menu> 78 + </> 79 + ); 80 + }; 81 + 82 + const DefaultOptions = (props: { 83 + setState: (s: "areYouSure") => void; 84 + archived?: boolean | null; 85 + }) => { 86 + const pubStatus = useLeafletPublicationStatus(); 87 + const toaster = useToaster(); 88 + const { setArchived } = useArchiveMutations(); 89 + const { identity } = useIdentityData(); 90 + const tokenId = pubStatus?.token.id; 91 + const itemType = pubStatus?.draftInPublication ? "Draft" : "Leaflet"; 92 + 93 + // Check if this is a published post/document and if user is the owner 94 + const isPublishedPostOwner = 95 + !!identity?.atp_did && !!pubStatus?.documentUri?.includes(identity.atp_did); 96 + const canDelete = !pubStatus?.documentUri || isPublishedPostOwner; 97 + 98 + return ( 99 + <> 100 + <EditLinkShareButton link={pubStatus?.shareLink ?? ""} /> 101 + <hr className="border-border-light" /> 102 + <MenuItem 103 + onSelect={async () => { 104 + if (!tokenId) return; 105 + setArchived(tokenId, !props.archived); 106 + 107 + if (!props.archived) { 108 + await archivePost(tokenId); 109 + toaster({ 110 + content: ( 111 + <div className="font-bold flex gap-2 items-center"> 112 + Archived {itemType}! 113 + <ButtonTertiary 114 + className="underline text-accent-2!" 115 + onClick={async () => { 116 + setArchived(tokenId, false); 117 + await unarchivePost(tokenId); 118 + toaster({ 119 + content: <div className="font-bold">Unarchived!</div>, 120 + type: "success", 121 + }); 122 + }} 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(); 157 + 158 + return ( 159 + <> 160 + <EditLinkShareButton link={`/${pubStatus?.shareLink ?? ""}`} /> 161 + <hr className="border-border-light" /> 162 + <MenuItem 163 + onSelect={() => { 164 + if (pubStatus?.token) hideDoc(pubStatus.token); 165 + toaster({ 166 + content: <div className="font-bold">Removed from Home!</div>, 167 + type: "success", 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 + </> 181 + ); 182 + }; 183 + 184 + const PublishedPostOptions = (props: { 185 + setState: (s: "areYouSure") => void; 186 + }) => { 187 + const pubStatus = useLeafletPublicationStatus(); 188 + const toaster = useToaster(); 189 + const postLink = pubStatus?.postShareLink ?? ""; 190 + const isFullUrl = postLink.includes("http"); 191 + 192 + return ( 193 + <> 194 + <ShareButton 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", 215 + }); 216 + }} 217 + > 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> 276 + </div> 277 + ); 278 + }; 279 + 280 + // Shared menu items 281 + const EditLinkShareButton = (props: { link: string }) => ( 282 + <ShareButton 283 + text={ 284 + <div className="flex gap-2"> 285 + <ShareSmall /> 286 + Copy Edit Link 287 + </div> 288 + } 289 + subtext="" 290 + smokerText="Link copied!" 291 + id="get-link" 292 + link={props.link} 293 + /> 294 + ); 295 + 296 + const DeleteForeverMenuItem = (props: { 297 + onSelect: (e: Event) => void; 298 + subtext?: string; 299 + }) => ( 300 + <MenuItem onSelect={props.onSelect}> 301 + <DeleteSmall /> 302 + {props.subtext ? ( 303 + <div className="flex flex-col"> 304 + Delete {props.subtext} 305 + <div className="text-tertiary text-sm font-normal!"> 306 + Unpublish AND delete 307 + </div> 308 + </div> 309 + ) : ( 310 + "Delete Forever" 311 + )} 312 + </MenuItem> 313 + ); 314 + 315 + // Helper to update archived state in both identity and publication data 316 + function useArchiveMutations() { 317 + const { mutate: mutatePub } = usePublicationData(); 318 + const { mutate: mutateIdentity } = useIdentityData(); 319 + 320 + return { 321 + setArchived: (tokenId: string, archived: boolean) => { 322 + mutateIdentityData(mutateIdentity, (data) => { 323 + const item = data.permission_token_on_homepage.find( 324 + (p) => p.permission_tokens?.id === tokenId, 325 + ); 326 + if (item) item.archived = archived; 327 + }); 328 + mutatePublicationData(mutatePub, (data) => { 329 + const item = data.publication?.leaflets_in_publications.find( 330 + (l) => l.permission_tokens?.id === tokenId, 331 + ); 332 + if (item) item.archived = archived; 333 + }); 334 + }, 335 + removeFromLists: (tokenId: string) => { 336 + mutateIdentityData(mutateIdentity, (data) => { 337 + data.permission_token_on_homepage = 338 + data.permission_token_on_homepage.filter( 339 + (p) => p.permission_tokens?.id !== tokenId, 340 + ); 341 + }); 342 + mutatePublicationData(mutatePub, (data) => { 343 + if (!data.publication) return; 344 + data.publication.leaflets_in_publications = 345 + data.publication.leaflets_in_publications.filter( 346 + (l) => l.permission_tokens?.id !== tokenId, 347 + ); 348 + }); 349 + }, 350 + }; 351 + }
+16
app/(home-pages)/home/LeafletList/LeafletPreview.module.css
···
··· 1 + .scaleLeafletDocPreview { 2 + transform: scale(calc(160 / var(--page-width-unitless))); 3 + } 4 + 5 + .scaleLeafletCanvasPreview { 6 + transform: scale(calc(160 / 1272)); 7 + } 8 + 9 + @media (min-width: 640px) { 10 + .scaleLeafletDocPreview { 11 + transform: scale(calc(192 / var(--page-width-unitless))); 12 + } 13 + .scaleLeafletCanvasPreview { 14 + transform: scale(calc(192 / 1272)); 15 + } 16 + }
+127
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
···
··· 1 + "use client"; 2 + import { 3 + ThemeBackgroundProvider, 4 + ThemeProvider, 5 + } from "components/ThemeManager/ThemeProvider"; 6 + import { useEntity, useReferenceToEntity } from "src/replicache"; 7 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 8 + import { LeafletContent } from "./LeafletContent"; 9 + import { Tooltip } from "components/Tooltip"; 10 + import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 11 + import { CSSProperties } from "react"; 12 + 13 + function useLeafletPreviewData() { 14 + const pubStatus = useLeafletPublicationStatus(); 15 + const leafletId = pubStatus?.leafletId ?? ""; 16 + const root = 17 + useReferenceToEntity("root/page", leafletId)[0]?.entity || leafletId; 18 + const firstPage = useEntity(root, "root/page")[0]; 19 + const page = firstPage?.data.value || root; 20 + 21 + const cardBorderHidden = useCardBorderHidden(root); 22 + const rootBackgroundImage = useEntity(root, "theme/card-background-image"); 23 + const rootBackgroundRepeat = useEntity( 24 + root, 25 + "theme/card-background-image-repeat", 26 + ); 27 + const rootBackgroundOpacity = useEntity( 28 + root, 29 + "theme/card-background-image-opacity", 30 + ); 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 + 59 + return ( 60 + <Tooltip 61 + open={true} 62 + delayDuration={0} 63 + side="right" 64 + trigger={ 65 + <div className="w-12 h-full py-1"> 66 + <div className="rounded-md h-full overflow-hidden"> 67 + <ThemeProvider local entityID={root} className=""> 68 + <ThemeBackgroundProvider entityID={root}> 69 + <div className="w-full h-full rounded-md p-1 border border-border"> 70 + <div 71 + className={`w-full h-full rounded-[2px]`} 72 + style={ 73 + cardBorderHidden 74 + ? { 75 + borderWidth: "2px", 76 + borderColor: "rgb(var(--primary))", 77 + } 78 + : { 79 + backgroundColor: 80 + "rgba(var(--bg-page), var(--bg-page-alpha))", 81 + } 82 + } 83 + /> 84 + </div> 85 + </ThemeBackgroundProvider> 86 + </ThemeProvider> 87 + </div> 88 + </div> 89 + } 90 + className="p-1!" 91 + > 92 + <ThemeProvider local entityID={root} className="rounded-sm"> 93 + <ThemeBackgroundProvider entityID={root}> 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] "> 95 + <div className={contentWrapperClass} style={contentWrapperStyle}> 96 + <LeafletContent entityID={page} isOnScreen={props.isVisible} /> 97 + </div> 98 + </div> 99 + </ThemeBackgroundProvider> 100 + </ThemeProvider> 101 + </Tooltip> 102 + ); 103 + }; 104 + 105 + export const LeafletGridPreview = (props: { isVisible: boolean }) => { 106 + const { root, page, contentWrapperStyle, contentWrapperClass } = 107 + useLeafletPreviewData(); 108 + 109 + return ( 110 + <ThemeProvider local entityID={root} className="w-full!"> 111 + <div className="border border-border-light rounded-md w-full h-full overflow-hidden "> 112 + <div className="w-full h-full"> 113 + <ThemeBackgroundProvider entityID={root}> 114 + <div 115 + inert 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" 117 + > 118 + <div className={contentWrapperClass} style={contentWrapperStyle}> 119 + <LeafletContent entityID={page} isOnScreen={props.isVisible} /> 120 + </div> 121 + </div> 122 + </ThemeBackgroundProvider> 123 + </div> 124 + </div> 125 + </ThemeProvider> 126 + ); 127 + };
+25
app/(home-pages)/home/LoggedOutWarning.tsx
···
··· 1 + "use client"; 2 + import { useIdentityData } from "components/IdentityProvider"; 3 + import { LoginButton } from "components/LoginButton"; 4 + 5 + export const LoggedOutWarning = (props: {}) => { 6 + let { identity } = useIdentityData(); 7 + if (identity) return null; 8 + return ( 9 + <div 10 + className={` 11 + homeWarning z-10 shrink-0 12 + bg-bg-page rounded-md 13 + absolute bottom-16 left-2 right-2 14 + sm:static sm:mr-1 sm:ml-6 sm:mt-6 border border-border-light`} 15 + > 16 + <div className="px-2 py-1 text-sm text-tertiary flex sm:flex-row flex-col sm:gap-4 gap-1 items-center sm:justify-between"> 17 + <p className="font-bold"> 18 + Log in to collect all your Leaflets and access them on multiple 19 + devices 20 + </p> 21 + <LoginButton /> 22 + </div> 23 + </div> 24 + ); 25 + };
+105
app/(home-pages)/home/icon.tsx
···
··· 1 + import { ImageResponse } from "next/og"; 2 + import type { Fact } from "src/replicache"; 3 + import type { Attribute } from "src/replicache/attributes"; 4 + import { Database } from "supabase/database.types"; 5 + import { createServerClient } from "@supabase/ssr"; 6 + import { parseHSBToRGB } from "src/utils/parseHSB"; 7 + import { cookies } from "next/headers"; 8 + 9 + // Route segment config 10 + export const revalidate = 0; 11 + export const preferredRegion = ["sfo1"]; 12 + export const dynamic = "force-dynamic"; 13 + export const fetchCache = "force-no-store"; 14 + 15 + // Image metadata 16 + export const size = { 17 + width: 32, 18 + height: 32, 19 + }; 20 + export const contentType = "image/png"; 21 + 22 + // Image generation 23 + let supabase = createServerClient<Database>( 24 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 25 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 26 + { cookies: {} }, 27 + ); 28 + export default async function Icon() { 29 + let cookieStore = await cookies(); 30 + let identity = cookieStore.get("identity"); 31 + let rootEntity: string | null = null; 32 + if (identity) { 33 + let res = await supabase 34 + .from("identities") 35 + .select( 36 + `*, 37 + permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), 38 + permission_token_on_homepage( 39 + *, permission_tokens(*, permission_token_rights(*)) 40 + ) 41 + `, 42 + ) 43 + .eq("id", identity?.value) 44 + .single(); 45 + rootEntity = res.data?.permission_tokens?.root_entity || null; 46 + } 47 + let outlineColor, fillColor; 48 + if (rootEntity) { 49 + let { data } = await supabase.rpc("get_facts", { 50 + root: rootEntity, 51 + }); 52 + let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 53 + let themePageBG = initialFacts.find( 54 + (f) => f.attribute === "theme/card-background", 55 + ) as Fact<"theme/card-background"> | undefined; 56 + 57 + let themePrimary = initialFacts.find( 58 + (f) => f.attribute === "theme/primary", 59 + ) as Fact<"theme/primary"> | undefined; 60 + 61 + outlineColor = parseHSBToRGB(`hsba(${themePageBG?.data.value})`); 62 + 63 + fillColor = parseHSBToRGB(`hsba(${themePrimary?.data.value})`); 64 + } 65 + 66 + return new ImageResponse( 67 + ( 68 + // ImageResponse JSX element 69 + <div style={{ display: "flex" }}> 70 + <svg 71 + width="32" 72 + height="32" 73 + viewBox="0 0 32 32" 74 + fill="none" 75 + xmlns="http://www.w3.org/2000/svg" 76 + > 77 + {/* outline */} 78 + <path 79 + fillRule="evenodd" 80 + clipRule="evenodd" 81 + 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" 82 + fill={outlineColor ? outlineColor : "#FFFFFF"} 83 + /> 84 + 85 + {/* fill */} 86 + <path 87 + fillRule="evenodd" 88 + clipRule="evenodd" 89 + 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" 90 + fill={fillColor ? fillColor : "#272727"} 91 + /> 92 + </svg> 93 + </div> 94 + ), 95 + // ImageResponse options 96 + { 97 + // For convenience, we can re-use the exported icons size metadata 98 + // config to also set the ImageResponse's width and height. 99 + ...size, 100 + headers: { 101 + "Cache-Control": "no-cache", 102 + }, 103 + }, 104 + ); 105 + }
+44
app/(home-pages)/home/page.tsx
···
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + 5 + import { HomeLayout } from "./HomeLayout"; 6 + 7 + export default async function Home() { 8 + let auth_res = await getIdentityData(); 9 + 10 + let [allLeafletFacts] = await Promise.all([ 11 + auth_res 12 + ? getFactsFromHomeLeaflets.handler( 13 + { 14 + tokens: auth_res.permission_token_on_homepage.map( 15 + (r) => r.permission_tokens.root_entity, 16 + ), 17 + }, 18 + { supabase: supabaseServerClient }, 19 + ) 20 + : undefined, 21 + ]); 22 + 23 + let home_docs_initialFacts = allLeafletFacts?.result || {}; 24 + 25 + return ( 26 + <HomeLayout 27 + titles={{ 28 + ...home_docs_initialFacts.titles, 29 + ...auth_res?.permission_token_on_homepage.reduce( 30 + (acc, tok) => { 31 + let title = 32 + tok.permission_tokens.leaflets_in_publications[0]?.title || 33 + tok.permission_tokens.leaflets_to_documents[0]?.title; 34 + if (title) acc[tok.permission_tokens.root_entity] = title; 35 + return acc; 36 + }, 37 + {} as { [k: string]: string }, 38 + ), 39 + }} 40 + entityID={auth_res?.home_leaflet?.root_entity || null} 41 + initialFacts={home_docs_initialFacts.facts || {}} 42 + /> 43 + ); 44 + }
+68
app/(home-pages)/home/storage.ts
···
··· 1 + import type { PermissionToken } from "src/replicache"; 2 + import { mutate } from "swr"; 3 + 4 + export type HomeDoc = { 5 + token: PermissionToken; 6 + added_at: string; 7 + hidden?: boolean; 8 + }; 9 + type HomeDocsStorage = { 10 + version: number; 11 + docs: Array<HomeDoc>; 12 + }; 13 + let defaultValue: HomeDocsStorage = { 14 + version: 1, 15 + docs: [], 16 + }; 17 + const key = "homepageDocs-v1"; 18 + let tokenCache = new Map<string, PermissionToken>(); 19 + export function getHomeDocs() { 20 + let homepageDocs: HomeDocsStorage = JSON.parse( 21 + window.localStorage.getItem(key) || JSON.stringify(defaultValue), 22 + ); 23 + return homepageDocs.docs.map((d) => { 24 + let cachedToken = tokenCache.get(d.token.id); 25 + if (!cachedToken) { 26 + cachedToken = d.token; 27 + tokenCache.set(d.token.id, d.token); 28 + } 29 + return { ...d, token: cachedToken }; 30 + }); 31 + } 32 + 33 + export function addDocToHome(doc: PermissionToken) { 34 + let homepageDocs = getHomeDocs(); 35 + if (homepageDocs.find((d) => d.token.id === doc.id)) return; 36 + homepageDocs.push({ token: doc, added_at: new Date().toISOString() }); 37 + let newValue: HomeDocsStorage = { 38 + version: 1, 39 + docs: homepageDocs, 40 + }; 41 + window.localStorage.setItem(key, JSON.stringify(newValue)); 42 + } 43 + 44 + export function removeDocFromHome(doc: PermissionToken) { 45 + let homepageDocs = getHomeDocs(); 46 + let newDocs = homepageDocs.filter((d) => d.token.id !== doc.id); 47 + let newValue: HomeDocsStorage = { 48 + version: 1, 49 + docs: newDocs, 50 + }; 51 + window.localStorage.setItem(key, JSON.stringify(newValue)); 52 + } 53 + 54 + export function hideDoc(doc: PermissionToken) { 55 + let homepageDocs = getHomeDocs(); 56 + let newDocs = homepageDocs.filter((d) => d.token.id !== doc.id); 57 + newDocs.push({ 58 + token: doc, 59 + added_at: new Date().toISOString(), 60 + hidden: true, 61 + }); 62 + let newValue: HomeDocsStorage = { 63 + version: 1, 64 + docs: newDocs, 65 + }; 66 + window.localStorage.setItem(key, JSON.stringify(newValue)); 67 + mutate("leaflets"); 68 + }
+43
app/(home-pages)/layout.tsx
···
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { EntitySetProvider } from "components/EntitySetProvider"; 3 + import { 4 + ThemeProvider, 5 + ThemeBackgroundProvider, 6 + } from "components/ThemeManager/ThemeProvider"; 7 + import { ReplicacheProvider, type Fact } from "src/replicache"; 8 + 9 + export default async function HomePagesLayout(props: { 10 + children: React.ReactNode; 11 + }) { 12 + let identityData = await getIdentityData(); 13 + if (!identityData?.home_leaflet) 14 + return ( 15 + <> 16 + <ThemeProvider entityID={""}>{props.children}</ThemeProvider> 17 + </> 18 + ); 19 + let facts = 20 + (identityData?.home_leaflet?.permission_token_rights[0].entity_sets?.entities.flatMap( 21 + (e) => e.facts, 22 + ) || []) as Fact<any>[]; 23 + 24 + let root_entity = identityData.home_leaflet.root_entity; 25 + return ( 26 + <ReplicacheProvider 27 + rootEntity={identityData.home_leaflet.root_entity} 28 + token={identityData.home_leaflet} 29 + name={identityData.home_leaflet.root_entity} 30 + initialFacts={facts} 31 + > 32 + <EntitySetProvider 33 + set={identityData.home_leaflet.permission_token_rights[0].entity_set} 34 + > 35 + <ThemeProvider entityID={root_entity}> 36 + <ThemeBackgroundProvider entityID={root_entity}> 37 + {props.children} 38 + </ThemeBackgroundProvider> 39 + </ThemeProvider> 40 + </EntitySetProvider> 41 + </ReplicacheProvider> 42 + ); 43 + }
+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 + };
+65
app/(home-pages)/notifications/CommentNotication.tsx
···
··· 1 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 2 + import { 3 + AppBskyActorProfile, 4 + PubLeafletComment, 5 + PubLeafletDocument, 6 + PubLeafletPublication, 7 + } from "lexicons/api"; 8 + import { HydratedCommentNotification } from "src/notifications"; 9 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 10 + import { Avatar } from "components/Avatar"; 11 + import { CommentTiny } from "components/Icons/CommentTiny"; 12 + import { 13 + CommentInNotification, 14 + ContentLayout, 15 + Notification, 16 + } from "./Notification"; 17 + import { AtUri } from "@atproto/api"; 18 + 19 + export const CommentNotification = (props: HydratedCommentNotification) => { 20 + let docRecord = props.commentData.documents 21 + ?.data as PubLeafletDocument.Record; 22 + let commentRecord = props.commentData.record as PubLeafletComment.Record; 23 + let profileRecord = props.commentData.bsky_profiles 24 + ?.record as AppBskyActorProfile.Record; 25 + const displayName = 26 + profileRecord.displayName || 27 + props.commentData.bsky_profiles?.handle || 28 + "Someone"; 29 + const pubRecord = props.commentData.documents?.documents_in_publications[0] 30 + ?.publications?.record as PubLeafletPublication.Record | undefined; 31 + let docUri = new AtUri(props.commentData.documents?.uri!); 32 + let rkey = docUri.rkey; 33 + let did = docUri.host; 34 + 35 + const href = pubRecord 36 + ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 37 + : `/p/${did}/${rkey}?interactionDrawer=comments`; 38 + 39 + return ( 40 + <Notification 41 + timestamp={props.commentData.indexed_at} 42 + href={href} 43 + icon={<CommentTiny />} 44 + actionText={<>{displayName} commented on your post</>} 45 + content={ 46 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 47 + <CommentInNotification 48 + className="" 49 + avatar={ 50 + profileRecord?.avatar?.ref && 51 + blobRefToSrc( 52 + profileRecord?.avatar?.ref, 53 + props.commentData.bsky_profiles?.did || "", 54 + ) 55 + } 56 + displayName={displayName} 57 + index={[]} 58 + plaintext={commentRecord.plaintext} 59 + facets={commentRecord.facets} 60 + /> 61 + </ContentLayout> 62 + } 63 + /> 64 + ); 65 + };
+35
app/(home-pages)/notifications/FollowNotification.tsx
···
··· 1 + import { Avatar } from "components/Avatar"; 2 + import { Notification } from "./Notification"; 3 + import { HydratedSubscribeNotification } from "src/notifications"; 4 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 + import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 6 + 7 + export const FollowNotification = (props: HydratedSubscribeNotification) => { 8 + const profileRecord = props.subscriptionData?.identities?.bsky_profiles 9 + ?.record as AppBskyActorProfile.Record; 10 + const displayName = 11 + profileRecord?.displayName || 12 + props.subscriptionData?.identities?.bsky_profiles?.handle || 13 + "Someone"; 14 + const pubRecord = props.subscriptionData?.publications 15 + ?.record as PubLeafletPublication.Record; 16 + const avatarSrc = 17 + profileRecord?.avatar?.ref && 18 + blobRefToSrc( 19 + profileRecord.avatar.ref, 20 + props.subscriptionData?.identity || "", 21 + ); 22 + 23 + return ( 24 + <Notification 25 + timestamp={props.created_at} 26 + href={`https://${pubRecord?.base_path}`} 27 + icon={<Avatar src={avatarSrc} displayName={displayName} tiny />} 28 + actionText={ 29 + <> 30 + {displayName} subscribed to {pubRecord?.name}! 31 + </> 32 + } 33 + /> 34 + ); 35 + };
+68
app/(home-pages)/notifications/MentionNotification.tsx
···
··· 1 + import { MentionTiny } from "components/Icons/MentionTiny"; 2 + import { ContentLayout, Notification } from "./Notification"; 3 + import { HydratedMentionNotification } from "src/notifications"; 4 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 + import { Agent, AtUri } from "@atproto/api"; 6 + 7 + export const MentionNotification = (props: HydratedMentionNotification) => { 8 + const docRecord = props.document.data as PubLeafletDocument.Record; 9 + const pubRecord = props.document.documents_in_publications?.[0]?.publications 10 + ?.record as PubLeafletPublication.Record | undefined; 11 + const docUri = new AtUri(props.document.uri); 12 + const rkey = docUri.rkey; 13 + const did = docUri.host; 14 + 15 + const href = pubRecord 16 + ? `https://${pubRecord.base_path}/${rkey}` 17 + : `/p/${did}/${rkey}`; 18 + 19 + let actionText: React.ReactNode; 20 + let mentionedItemName: string | undefined; 21 + let mentionedDocRecord = props.mentionedDocument 22 + ?.data as PubLeafletDocument.Record; 23 + 24 + const mentioner = props.documentCreatorHandle 25 + ? `@${props.documentCreatorHandle}` 26 + : "Someone"; 27 + 28 + if (props.mention_type === "did") { 29 + actionText = <>{mentioner} mentioned you</>; 30 + } else if ( 31 + props.mention_type === "publication" && 32 + props.mentionedPublication 33 + ) { 34 + const mentionedPubRecord = props.mentionedPublication 35 + .record as PubLeafletPublication.Record; 36 + mentionedItemName = mentionedPubRecord.name; 37 + actionText = ( 38 + <> 39 + {mentioner} mentioned your publication{" "} 40 + <span className="italic">{mentionedItemName}</span> 41 + </> 42 + ); 43 + } else if (props.mention_type === "document" && props.mentionedDocument) { 44 + mentionedItemName = mentionedDocRecord.title; 45 + actionText = ( 46 + <> 47 + {mentioner} mentioned your post{" "} 48 + <span className="italic">{mentionedItemName}</span> 49 + </> 50 + ); 51 + } else { 52 + actionText = <>{mentioner} mentioned you</>; 53 + } 54 + 55 + return ( 56 + <Notification 57 + timestamp={props.created_at} 58 + href={href} 59 + icon={<MentionTiny />} 60 + actionText={actionText} 61 + content={ 62 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 63 + {docRecord.description && docRecord.description} 64 + </ContentLayout> 65 + } 66 + /> 67 + ); 68 + };
+116
app/(home-pages)/notifications/Notification.tsx
···
··· 1 + "use client"; 2 + import { Avatar } from "components/Avatar"; 3 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 4 + import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; 5 + import { timeAgo } from "src/utils/timeAgo"; 6 + import { useReplicache, useEntity } from "src/replicache"; 7 + 8 + export const Notification = (props: { 9 + icon: React.ReactNode; 10 + actionText: React.ReactNode; 11 + content?: React.ReactNode; 12 + timestamp: string; 13 + href: string; 14 + }) => { 15 + let { rootEntity } = useReplicache(); 16 + let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data 17 + .value; 18 + 19 + // If compact mode, always hide border 20 + 21 + return ( 22 + <div 23 + className={`relative flex flex-col w-full pb-3 sm:pb-4 pt-2 ${ 24 + cardBorderHidden 25 + ? " first:pt-0! " 26 + : " block-border border-border! hover:outline-border sm:px-4 px-3 pl-2 sm:pl-3 " 27 + }`} 28 + style={{ 29 + backgroundColor: cardBorderHidden 30 + ? "transparent" 31 + : "rgba(var(--bg-page), var(--bg-page-alpha))", 32 + }} 33 + > 34 + <a 35 + href={props.href} 36 + className=" absolute top-0 bottom-0 left-0 right-0" 37 + /> 38 + <div className="flex justify-between items-center gap-3 w-full "> 39 + <div className={`flex flex-row gap-2 items-center grow w-full min-w-0`}> 40 + <div className="text-secondary shrink-0">{props.icon}</div> 41 + <div className={`text-secondary font-bold grow truncate min-w-0 }`}> 42 + {props.actionText} 43 + </div> 44 + </div> 45 + <div className={`text-tertiary shrink-0 min-w-8 text-sm`}> 46 + {timeAgo(props.timestamp)} 47 + </div> 48 + </div> 49 + {props.content && ( 50 + <div className="flex flex-row gap-2 mt-1 w-full"> 51 + <div className="w-4 shrink-0" /> 52 + {props.content} 53 + </div> 54 + )} 55 + </div> 56 + ); 57 + }; 58 + 59 + export const ContentLayout = (props: { 60 + children: React.ReactNode; 61 + postTitle: string; 62 + pubRecord?: PubLeafletPublication.Record; 63 + }) => { 64 + let { rootEntity } = useReplicache(); 65 + let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data 66 + .value; 67 + 68 + return ( 69 + <div 70 + className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`} 71 + > 72 + <div className="text-tertiary text-sm italic font-bold "> 73 + {props.postTitle} 74 + </div> 75 + {props.children && <div className="mb-2 text-sm">{props.children}</div>} 76 + {props.pubRecord && ( 77 + <> 78 + <hr className="mt-1 mb-1 border-border-light" /> 79 + <a 80 + href={`https://${props.pubRecord.base_path}`} 81 + className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!" 82 + > 83 + {props.pubRecord.name} 84 + </a> 85 + </> 86 + )} 87 + </div> 88 + ); 89 + }; 90 + 91 + type Facet = PubLeafletRichtextFacet.Main; 92 + export const CommentInNotification = (props: { 93 + avatar: string | undefined; 94 + displayName: string; 95 + plaintext: string; 96 + facets?: Facet[]; 97 + index: number[]; 98 + className?: string; 99 + }) => { 100 + return ( 101 + <div className=" flex gap-2 text-sm w-full "> 102 + <Avatar src={props.avatar} displayName={props.displayName} /> 103 + <pre 104 + style={{ wordBreak: "break-word" }} 105 + className={`whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6 ${props.className}`} 106 + > 107 + <BaseTextBlock 108 + preview 109 + index={props.index} 110 + plaintext={props.plaintext} 111 + facets={props.facets} 112 + /> 113 + </pre> 114 + </div> 115 + ); 116 + };
+61
app/(home-pages)/notifications/NotificationList.tsx
···
··· 1 + "use client"; 2 + 3 + import { HydratedNotification } from "src/notifications"; 4 + import { CommentNotification } from "./CommentNotication"; 5 + import { useEffect, createContext } from "react"; 6 + import { markAsRead } from "./getNotifications"; 7 + import { ReplyNotification } from "./ReplyNotification"; 8 + import { useIdentityData } from "components/IdentityProvider"; 9 + import { FollowNotification } from "./FollowNotification"; 10 + import { QuoteNotification } from "./QuoteNotification"; 11 + import { MentionNotification } from "./MentionNotification"; 12 + import { CommentMentionNotification } from "./CommentMentionNotification"; 13 + 14 + export function NotificationList({ 15 + notifications, 16 + compact, 17 + }: { 18 + notifications: HydratedNotification[]; 19 + compact?: boolean; 20 + }) { 21 + let { mutate } = useIdentityData(); 22 + useEffect(() => { 23 + setTimeout(async () => { 24 + await markAsRead(); 25 + mutate(); 26 + }, 500); 27 + }, []); 28 + 29 + if (notifications.length === 0) 30 + return ( 31 + <div className="w-full text-sm flex flex-col gap-1 container italic text-tertiary text-center sm:p-4 p-3"> 32 + <div className="text-base font-bold">no notifications yet...</div> 33 + Here, you&apos;ll find notifications about new follows, comments, 34 + mentions, and replies! 35 + </div> 36 + ); 37 + return ( 38 + <div className="max-w-prose mx-auto w-full"> 39 + <div className={`flex flex-col gap-2`}> 40 + {notifications.map((n) => { 41 + if (n.type === "comment") { 42 + if (n.parentData) return <ReplyNotification key={n.id} {...n} />; 43 + return <CommentNotification key={n.id} {...n} />; 44 + } 45 + if (n.type === "subscribe") { 46 + return <FollowNotification key={n.id} {...n} />; 47 + } 48 + if (n.type === "quote") { 49 + return <QuoteNotification key={n.id} {...n} />; 50 + } 51 + if (n.type === "mention") { 52 + return <MentionNotification key={n.id} {...n} />; 53 + } 54 + if (n.type === "comment_mention") { 55 + return <CommentMentionNotification key={n.id} {...n} />; 56 + } 57 + })} 58 + </div> 59 + </div> 60 + ); 61 + }
+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 + };
+88
app/(home-pages)/notifications/ReplyNotification.tsx
···
··· 1 + import { Avatar } from "components/Avatar"; 2 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 3 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 4 + import { 5 + CommentInNotification, 6 + ContentLayout, 7 + Notification, 8 + } from "./Notification"; 9 + import { HydratedCommentNotification } from "src/notifications"; 10 + import { 11 + PubLeafletComment, 12 + PubLeafletDocument, 13 + PubLeafletPublication, 14 + } from "lexicons/api"; 15 + import { AppBskyActorProfile, AtUri } from "@atproto/api"; 16 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 17 + 18 + export const ReplyNotification = (props: HydratedCommentNotification) => { 19 + let docRecord = props.commentData.documents 20 + ?.data as PubLeafletDocument.Record; 21 + let commentRecord = props.commentData.record as PubLeafletComment.Record; 22 + let profileRecord = props.commentData.bsky_profiles 23 + ?.record as AppBskyActorProfile.Record; 24 + const displayName = 25 + profileRecord.displayName || 26 + props.commentData.bsky_profiles?.handle || 27 + "Someone"; 28 + 29 + let parentRecord = props.parentData?.record as PubLeafletComment.Record; 30 + let parentProfile = props.parentData?.bsky_profiles 31 + ?.record as AppBskyActorProfile.Record; 32 + const parentDisplayName = 33 + parentProfile.displayName || 34 + props.parentData?.bsky_profiles?.handle || 35 + "Someone"; 36 + 37 + let docUri = new AtUri(props.commentData.documents?.uri!); 38 + let rkey = docUri.rkey; 39 + let did = docUri.host; 40 + const pubRecord = props.commentData.documents?.documents_in_publications[0] 41 + ?.publications?.record as PubLeafletPublication.Record | undefined; 42 + 43 + const href = pubRecord 44 + ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 45 + : `/p/${did}/${rkey}?interactionDrawer=comments`; 46 + 47 + return ( 48 + <Notification 49 + timestamp={props.commentData.indexed_at} 50 + href={href} 51 + icon={<ReplyTiny />} 52 + actionText={`${displayName} replied to your comment`} 53 + content={ 54 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 55 + <CommentInNotification 56 + className="" 57 + avatar={ 58 + parentProfile?.avatar?.ref && 59 + blobRefToSrc( 60 + parentProfile?.avatar?.ref, 61 + props.parentData?.bsky_profiles?.did || "", 62 + ) 63 + } 64 + displayName={parentDisplayName} 65 + index={[]} 66 + plaintext={parentRecord.plaintext} 67 + facets={parentRecord.facets} 68 + /> 69 + <div className="h-3 -mt-[1px] ml-[10px] border-l border-border" /> 70 + <CommentInNotification 71 + className="" 72 + avatar={ 73 + profileRecord?.avatar?.ref && 74 + blobRefToSrc( 75 + profileRecord?.avatar?.ref, 76 + props.commentData.bsky_profiles?.did || "", 77 + ) 78 + } 79 + displayName={displayName} 80 + index={[]} 81 + plaintext={commentRecord.plaintext} 82 + facets={commentRecord.facets} 83 + /> 84 + </ContentLayout> 85 + } 86 + /> 87 + ); 88 + };
+28
app/(home-pages)/notifications/getNotifications.ts
···
··· 1 + "use server"; 2 + import { getIdentityData } from "actions/getIdentityData"; 3 + import { hydrateNotifications } from "src/notifications"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + 6 + export async function getNotifications(limit?: number) { 7 + let identity = await getIdentityData(); 8 + if (!identity?.atp_did) return []; 9 + let query = supabaseServerClient 10 + .from("notifications") 11 + .select("*") 12 + .eq("recipient", identity.atp_did) 13 + .order("created_at", { ascending: false }); 14 + if (limit) query.limit(limit); 15 + let { data } = await query; 16 + let notifications = await hydrateNotifications(data || []); 17 + return notifications; 18 + } 19 + 20 + export async function markAsRead() { 21 + let identity = await getIdentityData(); 22 + if (!identity?.atp_did) return []; 23 + await supabaseServerClient 24 + .from("notifications") 25 + .update({ read: true }) 26 + .eq("recipient", identity.atp_did); 27 + return; 28 + }
+36
app/(home-pages)/notifications/page.tsx
···
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 + import { redirect } from "next/navigation"; 4 + import { hydrateNotifications } from "src/notifications"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { CommentNotification } from "./CommentNotication"; 7 + import { NotificationList } from "./NotificationList"; 8 + 9 + export default async function Notifications() { 10 + return ( 11 + <DashboardLayout 12 + id="discover" 13 + cardBorderHidden={true} 14 + currentPage="notifications" 15 + defaultTab="default" 16 + actions={null} 17 + tabs={{ 18 + default: { 19 + controls: null, 20 + content: <NotificationContent />, 21 + }, 22 + }} 23 + /> 24 + ); 25 + } 26 + 27 + const NotificationContent = async () => { 28 + let identity = await getIdentityData(); 29 + if (!identity?.atp_did) return redirect("/home"); 30 + let { data } = await supabaseServerClient 31 + .from("notifications") 32 + .select("*") 33 + .eq("recipient", identity.atp_did); 34 + let notifications = await hydrateNotifications(data || []); 35 + return <NotificationList notifications={notifications} />; 36 + };
+100
app/(home-pages)/reader/ReaderContent.tsx
···
··· 1 + "use client"; 2 + import { ButtonPrimary } from "components/Buttons"; 3 + import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 4 + import type { Cursor, Post } from "./getReaderFeed"; 5 + import useSWRInfinite from "swr/infinite"; 6 + import { getReaderFeed } from "./getReaderFeed"; 7 + import { useEffect, useRef } from "react"; 8 + import Link from "next/link"; 9 + import { PostListing } from "components/PostListing"; 10 + 11 + export const ReaderContent = (props: { 12 + posts: Post[]; 13 + nextCursor: Cursor | null; 14 + }) => { 15 + const getKey = ( 16 + pageIndex: number, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 21 + ) => { 22 + // Reached the end 23 + if (previousPageData && !previousPageData.nextCursor) return null; 24 + 25 + // First page, we don't have previousPageData 26 + if (pageIndex === 0) return ["reader-feed", null] as const; 27 + 28 + // Add the cursor to the key 29 + return ["reader-feed", previousPageData?.nextCursor] as const; 30 + }; 31 + 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 33 + getKey, 34 + ([_, cursor]) => getReaderFeed(cursor), 35 + { 36 + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 37 + revalidateFirstPage: false, 38 + }, 39 + ); 40 + 41 + const loadMoreRef = useRef<HTMLDivElement>(null); 42 + 43 + // Set up intersection observer to load more when trigger element is visible 44 + useEffect(() => { 45 + const observer = new IntersectionObserver( 46 + (entries) => { 47 + if (entries[0].isIntersecting && !isValidating) { 48 + const hasMore = data && data[data.length - 1]?.nextCursor; 49 + if (hasMore) { 50 + setSize(size + 1); 51 + } 52 + } 53 + }, 54 + { threshold: 0.1 }, 55 + ); 56 + 57 + if (loadMoreRef.current) { 58 + observer.observe(loadMoreRef.current); 59 + } 60 + 61 + return () => observer.disconnect(); 62 + }, [data, size, setSize, isValidating]); 63 + 64 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 65 + 66 + if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />; 67 + 68 + return ( 69 + <div className="flex flex-col gap-3 relative"> 70 + {allPosts.map((p) => ( 71 + <PostListing {...p} key={p.documents.uri} /> 72 + ))} 73 + {/* Trigger element for loading more posts */} 74 + <div 75 + ref={loadMoreRef} 76 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 77 + aria-hidden="true" 78 + /> 79 + {isValidating && ( 80 + <div className="text-center text-tertiary py-4"> 81 + Loading more posts... 82 + </div> 83 + )} 84 + </div> 85 + ); 86 + }; 87 + 88 + export const ReaderEmpty = () => { 89 + return ( 90 + <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 91 + Nothing to read yetโ€ฆ <br /> 92 + Subscribe to publications and find their posts here! 93 + <Link href={"/discover"}> 94 + <ButtonPrimary className="mx-auto place-self-center"> 95 + <DiscoverSmall /> Discover Publications 96 + </ButtonPrimary> 97 + </Link> 98 + </div> 99 + ); 100 + };
+105
app/(home-pages)/reader/SubscriptionsContent.tsx
···
··· 1 + "use client"; 2 + import { PubListing } from "app/(home-pages)/discover/PubListing"; 3 + import { ButtonPrimary } from "components/Buttons"; 4 + import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 5 + import { Json } from "supabase/database.types"; 6 + import { PublicationSubscription, getSubscriptions } from "./getSubscriptions"; 7 + import useSWRInfinite from "swr/infinite"; 8 + import { useEffect, useRef } from "react"; 9 + import { Cursor } from "./getReaderFeed"; 10 + import Link from "next/link"; 11 + 12 + export const SubscriptionsContent = (props: { 13 + publications: PublicationSubscription[]; 14 + nextCursor: Cursor | null; 15 + }) => { 16 + const getKey = ( 17 + pageIndex: number, 18 + previousPageData: { 19 + subscriptions: PublicationSubscription[]; 20 + nextCursor: Cursor | null; 21 + } | null, 22 + ) => { 23 + // Reached the end 24 + if (previousPageData && !previousPageData.nextCursor) return null; 25 + 26 + // First page, we don't have previousPageData 27 + if (pageIndex === 0) return ["subscriptions", null] as const; 28 + 29 + // Add the cursor to the key 30 + return ["subscriptions", previousPageData?.nextCursor] as const; 31 + }; 32 + 33 + const { data, error, size, setSize, isValidating } = useSWRInfinite( 34 + getKey, 35 + ([_, cursor]) => getSubscriptions(cursor), 36 + { 37 + fallbackData: [ 38 + { subscriptions: props.publications, nextCursor: props.nextCursor }, 39 + ], 40 + revalidateFirstPage: false, 41 + }, 42 + ); 43 + 44 + const loadMoreRef = useRef<HTMLDivElement>(null); 45 + 46 + // Set up intersection observer to load more when trigger element is visible 47 + useEffect(() => { 48 + const observer = new IntersectionObserver( 49 + (entries) => { 50 + if (entries[0].isIntersecting && !isValidating) { 51 + const hasMore = data && data[data.length - 1]?.nextCursor; 52 + if (hasMore) { 53 + setSize(size + 1); 54 + } 55 + } 56 + }, 57 + { threshold: 0.1 }, 58 + ); 59 + 60 + if (loadMoreRef.current) { 61 + observer.observe(loadMoreRef.current); 62 + } 63 + 64 + return () => observer.disconnect(); 65 + }, [data, size, setSize, isValidating]); 66 + 67 + const allPublications = data 68 + ? data.flatMap((page) => page.subscriptions) 69 + : []; 70 + 71 + if (allPublications.length === 0 && !isValidating) 72 + return <SubscriptionsEmpty />; 73 + 74 + return ( 75 + <div className="relative"> 76 + <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 77 + {allPublications?.map((p, index) => <PubListing key={p.uri} {...p} />)} 78 + </div> 79 + {/* Trigger element for loading more subscriptions */} 80 + <div 81 + ref={loadMoreRef} 82 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 83 + aria-hidden="true" 84 + /> 85 + {isValidating && ( 86 + <div className="text-center text-tertiary py-4"> 87 + Loading more subscriptions... 88 + </div> 89 + )} 90 + </div> 91 + ); 92 + }; 93 + 94 + export const SubscriptionsEmpty = () => { 95 + return ( 96 + <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"> 97 + You haven't subscribed to any publications yet! 98 + <Link href={"/discover"}> 99 + <ButtonPrimary className="mx-auto place-self-center"> 100 + <DiscoverSmall /> Discover Publications 101 + </ButtonPrimary> 102 + </Link> 103 + </div> 104 + ); 105 + };
+106
app/(home-pages)/reader/getReaderFeed.ts
···
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { IdResolver } from "@atproto/identity"; 7 + import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 8 + import Client from "ioredis"; 9 + import { AtUri } from "@atproto/api"; 10 + import { Json } from "supabase/database.types"; 11 + import { idResolver } from "./idResolver"; 12 + 13 + export type Cursor = { 14 + timestamp: string; 15 + uri: string; 16 + }; 17 + 18 + export async function getReaderFeed( 19 + cursor?: Cursor | null, 20 + ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 21 + let auth_res = await getIdentityData(); 22 + if (!auth_res?.atp_did) return { posts: [], nextCursor: null }; 23 + let query = supabaseServerClient 24 + .from("documents") 25 + .select( 26 + `*, 27 + comments_on_documents(count), 28 + document_mentions_in_bsky(count), 29 + documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 30 + ) 31 + .eq( 32 + "documents_in_publications.publications.publication_subscriptions.identity", 33 + auth_res.atp_did, 34 + ) 35 + .order("indexed_at", { ascending: false }) 36 + .order("uri", { ascending: false }) 37 + .limit(25); 38 + if (cursor) { 39 + query = query.or( 40 + `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 41 + ); 42 + } 43 + let { data: feed, error } = await query; 44 + 45 + let posts = await Promise.all( 46 + feed?.map(async (post) => { 47 + let pub = post.documents_in_publications[0].publications!; 48 + let uri = new AtUri(post.uri); 49 + let handle = await idResolver.did.resolve(uri.host); 50 + let p: Post = { 51 + publication: { 52 + href: getPublicationURL(pub), 53 + pubRecord: pub?.record || null, 54 + uri: pub?.uri || "", 55 + }, 56 + author: handle?.alsoKnownAs?.[0] 57 + ? `@${handle.alsoKnownAs[0].slice(5)}` 58 + : null, 59 + documents: { 60 + comments_on_documents: post.comments_on_documents, 61 + document_mentions_in_bsky: post.document_mentions_in_bsky, 62 + data: post.data, 63 + uri: post.uri, 64 + indexed_at: post.indexed_at, 65 + }, 66 + }; 67 + return p; 68 + }) || [], 69 + ); 70 + const nextCursor = 71 + posts.length > 0 72 + ? { 73 + timestamp: posts[posts.length - 1].documents.indexed_at, 74 + uri: posts[posts.length - 1].documents.uri, 75 + } 76 + : null; 77 + 78 + return { 79 + posts, 80 + nextCursor, 81 + }; 82 + } 83 + 84 + export type Post = { 85 + author: string | null; 86 + publication: { 87 + href: string; 88 + pubRecord: Json; 89 + uri: string; 90 + }; 91 + documents: { 92 + data: Json; 93 + uri: string; 94 + indexed_at: string; 95 + comments_on_documents: 96 + | { 97 + count: number; 98 + }[] 99 + | undefined; 100 + document_mentions_in_bsky: 101 + | { 102 + count: number; 103 + }[] 104 + | undefined; 105 + }; 106 + };
+70
app/(home-pages)/reader/getSubscriptions.ts
···
··· 1 + "use server"; 2 + 3 + import { AtpAgent } from "@atproto/api"; 4 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + import { Json } from "supabase/database.types"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { idResolver } from "./idResolver"; 9 + import { Cursor } from "./getReaderFeed"; 10 + 11 + export async function getSubscriptions(cursor?: Cursor | null): Promise<{ 12 + nextCursor: null | Cursor; 13 + subscriptions: PublicationSubscription[]; 14 + }> { 15 + let auth_res = await getIdentityData(); 16 + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 17 + let query = supabaseServerClient 18 + .from("publication_subscriptions") 19 + .select(`*, publications(*, documents_in_publications(*, documents(*)))`) 20 + .order(`created_at`, { ascending: false }) 21 + .order(`uri`, { ascending: false }) 22 + .order("indexed_at", { 23 + ascending: false, 24 + referencedTable: "publications.documents_in_publications", 25 + }) 26 + .limit(1, { referencedTable: "publications.documents_in_publications" }) 27 + .limit(25) 28 + .eq("identity", auth_res.atp_did); 29 + 30 + if (cursor) { 31 + query = query.or( 32 + `created_at.lt.${cursor.timestamp},and(created_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 33 + ); 34 + } 35 + let { data: pubs, error } = await query; 36 + 37 + const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( 38 + pubs?.map(async (pub) => { 39 + let id = await idResolver.did.resolve(pub.publications?.identity_did!); 40 + return { 41 + ...pub.publications!, 42 + authorProfile: id?.alsoKnownAs?.[0] 43 + ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 44 + : undefined, 45 + }; 46 + }) || [], 47 + ); 48 + 49 + const nextCursor = 50 + pubs && pubs.length > 0 51 + ? { 52 + timestamp: pubs[pubs.length - 1].created_at, 53 + uri: pubs[pubs.length - 1].uri, 54 + } 55 + : null; 56 + 57 + return { 58 + subscriptions: hydratedSubscriptions, 59 + nextCursor, 60 + }; 61 + } 62 + 63 + export type PublicationSubscription = { 64 + authorProfile?: { handle: string }; 65 + record: Json; 66 + uri: string; 67 + documents_in_publications: { 68 + documents: { data?: Json; indexed_at: string } | null; 69 + }[]; 70 + };
+78
app/(home-pages)/reader/idResolver.ts
···
··· 1 + import { IdResolver } from "@atproto/identity"; 2 + import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 3 + import Client from "ioredis"; 4 + // Create Redis client for DID caching 5 + let redisClient: Client | null = null; 6 + if (process.env.REDIS_URL) { 7 + redisClient = new Client(process.env.REDIS_URL); 8 + } 9 + 10 + // Redis-based DID cache implementation 11 + class RedisDidCache implements DidCache { 12 + private staleTTL: number; 13 + private maxTTL: number; 14 + 15 + constructor( 16 + private client: Client, 17 + staleTTL = 60 * 60, // 1 hour 18 + maxTTL = 60 * 60 * 24, // 24 hours 19 + ) { 20 + this.staleTTL = staleTTL; 21 + this.maxTTL = maxTTL; 22 + } 23 + 24 + async cacheDid(did: string, doc: DidDocument): Promise<void> { 25 + const cacheVal = { 26 + doc, 27 + updatedAt: Date.now(), 28 + }; 29 + await this.client.setex( 30 + `did:${did}`, 31 + this.maxTTL, 32 + JSON.stringify(cacheVal), 33 + ); 34 + } 35 + 36 + async checkCache(did: string): Promise<CacheResult | null> { 37 + const cached = await this.client.get(`did:${did}`); 38 + if (!cached) return null; 39 + 40 + const { doc, updatedAt } = JSON.parse(cached); 41 + const now = Date.now(); 42 + const age = now - updatedAt; 43 + 44 + return { 45 + did, 46 + doc, 47 + updatedAt, 48 + stale: age > this.staleTTL * 1000, 49 + expired: age > this.maxTTL * 1000, 50 + }; 51 + } 52 + 53 + async refreshCache( 54 + did: string, 55 + getDoc: () => Promise<DidDocument | null>, 56 + ): Promise<void> { 57 + const doc = await getDoc(); 58 + if (doc) { 59 + await this.cacheDid(did, doc); 60 + } 61 + } 62 + 63 + async clearEntry(did: string): Promise<void> { 64 + await this.client.del(`did:${did}`); 65 + } 66 + 67 + async clear(): Promise<void> { 68 + const keys = await this.client.keys("did:*"); 69 + if (keys.length > 0) { 70 + await this.client.del(...keys); 71 + } 72 + } 73 + } 74 + 75 + // Create IdResolver with Redis-based DID cache 76 + export const idResolver = new IdResolver({ 77 + didCache: redisClient ? new RedisDidCache(redisClient) : undefined, 78 + });
+38
app/(home-pages)/reader/page.tsx
···
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + 3 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 4 + import { ReaderContent } from "./ReaderContent"; 5 + import { SubscriptionsContent } from "./SubscriptionsContent"; 6 + import { getReaderFeed } from "./getReaderFeed"; 7 + import { getSubscriptions } from "./getSubscriptions"; 8 + 9 + export default async function Reader(props: {}) { 10 + let posts = await getReaderFeed(); 11 + let publications = await getSubscriptions(); 12 + return ( 13 + <DashboardLayout 14 + id="reader" 15 + cardBorderHidden={false} 16 + currentPage="reader" 17 + defaultTab="Read" 18 + actions={null} 19 + tabs={{ 20 + Read: { 21 + controls: null, 22 + content: ( 23 + <ReaderContent nextCursor={posts.nextCursor} posts={posts.posts} /> 24 + ), 25 + }, 26 + Subscriptions: { 27 + controls: null, 28 + content: ( 29 + <SubscriptionsContent 30 + publications={publications.subscriptions} 31 + nextCursor={publications.nextCursor} 32 + /> 33 + ), 34 + }, 35 + }} 36 + /> 37 + ); 38 + }
+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 - };
···
+17 -21
app/[leaflet_id]/Footer.tsx
··· 4 import { Media } from "components/Media"; 5 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 6 import { Toolbar } from "components/Toolbar"; 7 - import { ShareOptions } from "components/ShareOptions"; 8 - import { HomeButton } from "components/HomeButton"; 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 - import { HelpPopover } from "components/HelpPopover"; 11 import { Watermark } from "components/Watermark"; 12 - import { BackToPubButton, PublishButton } from "./Actions"; 13 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 14 import { useIdentityData } from "components/IdentityProvider"; 15 ··· 20 let { data: pub } = useLeafletPublicationData(); 21 22 return ( 23 - <Media mobile className="mobileFooter w-full z-10 touch-none -mt-4 "> 24 {focusedBlock && 25 focusedBlock.entityType == "block" && 26 entity_set.permissions.write ? ( ··· 36 /> 37 </div> 38 ) : entity_set.permissions.write ? ( 39 - pub?.publications && 40 - identity?.atp_did && 41 - pub.publications.identity_did === identity.atp_did ? ( 42 - <ActionFooter> 43 <BackToPubButton publication={pub.publications} /> 44 - <PublishButton /> 45 - <ShareOptions /> 46 - <HelpPopover /> 47 - <ThemePopover entityID={props.entityID} /> 48 - </ActionFooter> 49 - ) : ( 50 - <ActionFooter> 51 <HomeButton /> 52 - <ShareOptions /> 53 - <HelpPopover /> 54 - <ThemePopover entityID={props.entityID} /> 55 - </ActionFooter> 56 - ) 57 ) : ( 58 <div className="pb-2 px-2 z-10 flex justify-end"> 59 <Watermark mobile />
··· 4 import { Media } from "components/Media"; 5 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 6 import { Toolbar } from "components/Toolbar"; 7 + import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 8 + import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 + import { PublishButton } from "./actions/PublishButton"; 10 import { useEntitySetContext } from "components/EntitySetProvider"; 11 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 12 import { Watermark } from "components/Watermark"; 13 + import { BackToPubButton } from "./actions/BackToPubButton"; 14 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 15 import { useIdentityData } from "components/IdentityProvider"; 16 ··· 21 let { data: pub } = useLeafletPublicationData(); 22 23 return ( 24 + <Media mobile className="mobileFooter w-full z-10 touch-none -mt-[54px] "> 25 {focusedBlock && 26 focusedBlock.entityType == "block" && 27 entity_set.permissions.write ? ( ··· 37 /> 38 </div> 39 ) : entity_set.permissions.write ? ( 40 + <ActionFooter> 41 + {pub?.publications && 42 + identity?.atp_did && 43 + pub.publications.identity_did === identity.atp_did ? ( 44 <BackToPubButton publication={pub.publications} /> 45 + ) : ( 46 <HomeButton /> 47 + )} 48 + 49 + <PublishButton entityID={props.entityID} /> 50 + <ShareOptions /> 51 + <ThemePopover entityID={props.entityID} /> 52 + </ActionFooter> 53 ) : ( 54 <div className="pb-2 px-2 z-10 flex justify-end"> 55 <Watermark mobile />
+4 -24
app/[leaflet_id]/Leaflet.tsx
··· 12 import { AddLeafletToHomepage } from "components/utils/AddLeafletToHomepage"; 13 import { UpdateLeafletTitle } from "components/utils/UpdateLeafletTitle"; 14 import { useUIState } from "src/useUIState"; 15 - import { LeafletSidebar } from "./Sidebar"; 16 17 export function Leaflet(props: { 18 token: PermissionToken; ··· 36 <SelectionManager /> 37 {/* we need the padding bottom here because if we don't have it the mobile footer will cut off... 38 the dropshadow on the page... the padding is compensated by a negative top margin in mobile footer */} 39 - <div 40 - className="leafletContentWrapper w-full relative overflow-x-scroll snap-x snap-mandatory no-scrollbar grow items-stretch flex h-full pb-4 pwa-padding" 41 - id="page-carousel" 42 - > 43 - {/* if you adjust this padding, remember to adjust the negative margins on page in Pages/index when card borders are hidden (also applies for the pb in the parent div)*/} 44 - <div 45 - id="pages" 46 - className="pages flex pt-2 pb-1 sm:pb-8 sm:pt-6" 47 - onClick={(e) => { 48 - e.currentTarget === e.target && blurPage(); 49 - }} 50 - > 51 - <LeafletSidebar leaflet_id={props.leaflet_id} /> 52 - <Pages rootPage={props.leaflet_id} /> 53 - </div> 54 - </div> 55 <LeafletFooter entityID={props.leaflet_id} /> 56 </ThemeBackgroundProvider> 57 </ThemeProvider> ··· 59 </ReplicacheProvider> 60 ); 61 } 62 - 63 - const blurPage = () => { 64 - useUIState.setState(() => ({ 65 - focusedEntity: null, 66 - selectedBlocks: [], 67 - })); 68 - };
··· 12 import { AddLeafletToHomepage } from "components/utils/AddLeafletToHomepage"; 13 import { UpdateLeafletTitle } from "components/utils/UpdateLeafletTitle"; 14 import { useUIState } from "src/useUIState"; 15 + import { LeafletLayout } from "components/LeafletLayout"; 16 17 export function Leaflet(props: { 18 token: PermissionToken; ··· 36 <SelectionManager /> 37 {/* we need the padding bottom here because if we don't have it the mobile footer will cut off... 38 the dropshadow on the page... the padding is compensated by a negative top margin in mobile footer */} 39 + <LeafletLayout className="!pb-[64px] sm:!pb-6"> 40 + <Pages rootPage={props.leaflet_id} /> 41 + </LeafletLayout> 42 <LeafletFooter entityID={props.leaflet_id} /> 43 </ThemeBackgroundProvider> 44 </ThemeProvider> ··· 46 </ReplicacheProvider> 47 ); 48 }
+32 -50
app/[leaflet_id]/Sidebar.tsx
··· 1 "use client"; 2 - import { ActionButton } from "components/ActionBar/ActionButton"; 3 import { Sidebar } from "components/ActionBar/Sidebar"; 4 import { useEntitySetContext } from "components/EntitySetProvider"; 5 - import { HelpPopover } from "components/HelpPopover"; 6 - import { HomeButton } from "components/HomeButton"; 7 import { Media } from "components/Media"; 8 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 9 - import { ShareOptions } from "components/ShareOptions"; 10 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 11 import { Watermark } from "components/Watermark"; 12 - import { useUIState } from "src/useUIState"; 13 - import { BackToPubButton, PublishButton } from "./Actions"; 14 import { useIdentityData } from "components/IdentityProvider"; 15 16 - export function LeafletSidebar(props: { leaflet_id: string }) { 17 let entity_set = useEntitySetContext(); 18 let { data: pub } = useLeafletPublicationData(); 19 let { identity } = useIdentityData(); 20 21 return ( 22 - <div 23 - className="spacer flex justify-end items-start" 24 - style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }} 25 - onClick={(e) => { 26 - e.currentTarget === e.target && blurPage(); 27 - }} 28 - > 29 - <Media 30 - mobile={false} 31 - className="sidebarContainer relative flex flex-col justify-end h-full w-16" 32 > 33 - {entity_set.permissions.write && ( 34 - <Sidebar> 35 - {pub?.publications && 36 - identity?.atp_did && 37 - pub.publications.identity_did === identity.atp_did ? ( 38 - <> 39 - <PublishButton /> 40 - <ShareOptions /> 41 - <ThemePopover entityID={props.leaflet_id} /> 42 - <HelpPopover /> 43 - <hr className="text-border" /> 44 <BackToPubButton publication={pub.publications} /> 45 - </> 46 - ) : ( 47 - <> 48 - <ShareOptions /> 49 - <ThemePopover entityID={props.leaflet_id} /> 50 - <HelpPopover /> 51 - <hr className="text-border" /> 52 <HomeButton /> 53 - </> 54 - )} 55 - </Sidebar> 56 - )} 57 - <div className="h-full flex items-end"> 58 - <Watermark /> 59 </div> 60 - </Media> 61 - </div> 62 ); 63 } 64 - 65 - const blurPage = () => { 66 - useUIState.setState(() => ({ 67 - focusedEntity: null, 68 - selectedBlocks: [], 69 - })); 70 - };
··· 1 "use client"; 2 import { Sidebar } from "components/ActionBar/Sidebar"; 3 import { useEntitySetContext } from "components/EntitySetProvider"; 4 + import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 5 + import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 6 import { Media } from "components/Media"; 7 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 + import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 9 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 10 + import { PublishButton } from "./actions/PublishButton"; 11 import { Watermark } from "components/Watermark"; 12 + import { BackToPubButton } from "./actions/BackToPubButton"; 13 import { useIdentityData } from "components/IdentityProvider"; 14 + import { useReplicache } from "src/replicache"; 15 16 + export function LeafletSidebar() { 17 let entity_set = useEntitySetContext(); 18 + let { rootEntity } = useReplicache(); 19 let { data: pub } = useLeafletPublicationData(); 20 let { identity } = useIdentityData(); 21 22 return ( 23 + <Media mobile={false} className="w-0 h-full relative"> 24 + <div 25 + className="absolute top-0 left-0 h-full flex justify-end " 26 + style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }} 27 > 28 + <div className="sidebarContainer flex flex-col justify-end h-full w-16 relative"> 29 + {entity_set.permissions.write && ( 30 + <Sidebar> 31 + <PublishButton entityID={rootEntity} /> 32 + <ShareOptions /> 33 + <ThemePopover entityID={rootEntity} /> 34 + <HelpButton /> 35 + <hr className="text-border" /> 36 + {pub?.publications && 37 + identity?.atp_did && 38 + pub.publications.identity_did === identity.atp_did ? ( 39 <BackToPubButton publication={pub.publications} /> 40 + ) : ( 41 <HomeButton /> 42 + )} 43 + </Sidebar> 44 + )} 45 + <div className="h-full flex items-end"> 46 + <Watermark /> 47 + </div> 48 </div> 49 + </div> 50 + </Media> 51 ); 52 }
+27
app/[leaflet_id]/actions/BackToPubButton.tsx
···
··· 1 + import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; 2 + import { ActionButton } from "components/ActionBar/ActionButton"; 3 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 4 + import { SpeedyLink } from "components/SpeedyLink"; 5 + import { Json } from "supabase/database.types"; 6 + 7 + export const BackToPubButton = (props: { 8 + publication: { 9 + identity_did: string; 10 + indexed_at: string; 11 + name: string; 12 + record: Json; 13 + uri: string; 14 + }; 15 + }) => { 16 + return ( 17 + <SpeedyLink 18 + href={`${getBasePublicationURL(props.publication)}/dashboard`} 19 + className="hover:no-underline!" 20 + > 21 + <ActionButton 22 + icon={<GoBackSmall className="shrink-0" />} 23 + label="To Pub" 24 + /> 25 + </SpeedyLink> 26 + ); 27 + };
+173
app/[leaflet_id]/actions/HelpButton.tsx
···
··· 1 + "use client"; 2 + import { ShortcutKey } from "../../../components/Layout"; 3 + import { Media } from "../../../components/Media"; 4 + import { Popover } from "../../../components/Popover"; 5 + import { metaKey } from "src/utils/metaKey"; 6 + import { useEntitySetContext } from "../../../components/EntitySetProvider"; 7 + import { useState } from "react"; 8 + import { ActionButton } from "components/ActionBar/ActionButton"; 9 + import { HelpSmall } from "../../../components/Icons/HelpSmall"; 10 + import { isMac } from "src/utils/isDevice"; 11 + import { useIsMobile } from "src/hooks/isMobile"; 12 + 13 + export const HelpButton = (props: { noShortcuts?: boolean }) => { 14 + let entity_set = useEntitySetContext(); 15 + let isMobile = useIsMobile(); 16 + 17 + return entity_set.permissions.write ? ( 18 + <Popover 19 + side={isMobile ? "top" : "right"} 20 + align={isMobile ? "center" : "start"} 21 + asChild 22 + className="max-w-xs w-full" 23 + trigger={<ActionButton icon={<HelpSmall />} label="About" />} 24 + > 25 + <div className="flex flex-col text-sm gap-2 text-secondary"> 26 + {/* about links */} 27 + <HelpLink text="๐Ÿ“– Leaflet Manual" url="https://about.leaflet.pub" /> 28 + <HelpLink text="๐Ÿ’ก Make with Leaflet" url="https://make.leaflet.pub" /> 29 + <HelpLink 30 + text="โœจ Explore Publications" 31 + url="https://leaflet.pub/discover" 32 + /> 33 + <HelpLink text="๐Ÿ“ฃ Newsletter" url="https://buttondown.com/leaflet" /> 34 + {/* contact links */} 35 + <div className="columns-2 gap-2"> 36 + <HelpLink 37 + text="๐Ÿฆ‹ Bluesky" 38 + url="https://bsky.app/profile/leaflet.pub" 39 + /> 40 + <HelpLink text="๐Ÿ’Œ Email" url="mailto:contact@leaflet.pub" /> 41 + </div> 42 + {/* keyboard shortcuts: desktop only */} 43 + <Media mobile={false}> 44 + {!props.noShortcuts && ( 45 + <> 46 + <hr className="text-border my-1" /> 47 + <div className="flex flex-col gap-1"> 48 + <Label>Text Shortcuts</Label> 49 + <KeyboardShortcut name="Bold" keys={[metaKey(), "B"]} /> 50 + <KeyboardShortcut name="Italic" keys={[metaKey(), "I"]} /> 51 + <KeyboardShortcut name="Underline" keys={[metaKey(), "U"]} /> 52 + <KeyboardShortcut 53 + name="Highlight" 54 + keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "H"]} 55 + /> 56 + <KeyboardShortcut 57 + name="Strikethrough" 58 + keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]} 59 + /> 60 + <KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} /> 61 + 62 + <Label>Block Shortcuts</Label> 63 + {/* shift + up/down arrows (or click + drag): select multiple blocks */} 64 + <KeyboardShortcut 65 + name="Move Block Up" 66 + keys={["Shift", metaKey(), "โ†‘"]} 67 + /> 68 + <KeyboardShortcut 69 + name="Move Block Down" 70 + keys={["Shift", metaKey(), "โ†“"]} 71 + /> 72 + {/* cmd/ctrl-a: first selects all text in a block; again selects all blocks on page */} 73 + {/* cmd/ctrl + up/down arrows: go to beginning / end of doc */} 74 + 75 + <Label>Canvas Shortcuts</Label> 76 + <OtherShortcut name="Add Block" description="Double click" /> 77 + <OtherShortcut name="Select Block" description="Long press" /> 78 + 79 + <Label>Outliner Shortcuts</Label> 80 + <KeyboardShortcut 81 + name="Make List" 82 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "L"]} 83 + /> 84 + {/* tab / shift + tab: indent / outdent */} 85 + <KeyboardShortcut 86 + name="Toggle Checkbox" 87 + keys={[metaKey(), "Enter"]} 88 + /> 89 + <KeyboardShortcut 90 + name="Toggle Fold" 91 + keys={[metaKey(), "Shift", "Enter"]} 92 + /> 93 + <KeyboardShortcut 94 + name="Fold All" 95 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "โ†‘"]} 96 + /> 97 + <KeyboardShortcut 98 + name="Unfold All" 99 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "โ†“"]} 100 + /> 101 + </div> 102 + </> 103 + )} 104 + </Media> 105 + {/* links: terms and privacy */} 106 + <hr className="text-border my-1" /> 107 + {/* <HelpLink 108 + text="Terms and Privacy Policy" 109 + url="https://leaflet.pub/legal" 110 + /> */} 111 + <div> 112 + <a href="https://leaflet.pub/legal" target="_blank"> 113 + Terms and Privacy Policy 114 + </a> 115 + </div> 116 + </div> 117 + </Popover> 118 + ) : null; 119 + }; 120 + 121 + const KeyboardShortcut = (props: { name: string; keys: string[] }) => { 122 + return ( 123 + <div className="flex gap-2 justify-between items-center"> 124 + {props.name} 125 + <div className="flex gap-1 items-center font-bold"> 126 + {props.keys.map((key, index) => { 127 + return <ShortcutKey key={index}>{key}</ShortcutKey>; 128 + })} 129 + </div> 130 + </div> 131 + ); 132 + }; 133 + 134 + const OtherShortcut = (props: { name: string; description: string }) => { 135 + return ( 136 + <div className="flex justify-between items-center"> 137 + <span>{props.name}</span> 138 + <span> 139 + <strong>{props.description}</strong> 140 + </span> 141 + </div> 142 + ); 143 + }; 144 + 145 + const Label = (props: { children: React.ReactNode }) => { 146 + return <div className="text-tertiary font-bold pt-2 ">{props.children}</div>; 147 + }; 148 + 149 + const HelpLink = (props: { url: string; text: string }) => { 150 + const [isHovered, setIsHovered] = useState(false); 151 + const handleMouseEnter = () => { 152 + setIsHovered(true); 153 + }; 154 + const handleMouseLeave = () => { 155 + setIsHovered(false); 156 + }; 157 + return ( 158 + <a 159 + href={props.url} 160 + target="_blank" 161 + className="py-2 px-2 rounded-md flex flex-col gap-1 bg-border-light hover:bg-border hover:no-underline" 162 + style={{ 163 + backgroundColor: isHovered 164 + ? "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)" 165 + : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 166 + }} 167 + onMouseEnter={handleMouseEnter} 168 + onMouseLeave={handleMouseLeave} 169 + > 170 + <strong>{props.text}</strong> 171 + </a> 172 + ); 173 + };
+74
app/[leaflet_id]/actions/HomeButton.tsx
···
··· 1 + "use client"; 2 + import Link from "next/link"; 3 + import { useEntitySetContext } from "../../../components/EntitySetProvider"; 4 + import { ActionButton } from "components/ActionBar/ActionButton"; 5 + import { useSearchParams } from "next/navigation"; 6 + import { useIdentityData } from "../../../components/IdentityProvider"; 7 + import { useReplicache } from "src/replicache"; 8 + import { addLeafletToHome } from "actions/addLeafletToHome"; 9 + import { useSmoker } from "../../../components/Toast"; 10 + import { AddToHomeSmall } from "../../../components/Icons/AddToHomeSmall"; 11 + import { HomeSmall } from "../../../components/Icons/HomeSmall"; 12 + import { produce } from "immer"; 13 + 14 + export function HomeButton() { 15 + let { permissions } = useEntitySetContext(); 16 + let searchParams = useSearchParams(); 17 + 18 + return ( 19 + <> 20 + <Link 21 + href="/home" 22 + prefetch 23 + className="hover:no-underline" 24 + style={{ textDecorationLine: "none !important" }} 25 + > 26 + <ActionButton icon={<HomeSmall />} label="Go Home" /> 27 + </Link> 28 + {<AddToHomeButton />} 29 + </> 30 + ); 31 + } 32 + 33 + const AddToHomeButton = (props: {}) => { 34 + let { permission_token } = useReplicache(); 35 + let { identity, mutate } = useIdentityData(); 36 + let smoker = useSmoker(); 37 + if ( 38 + identity?.permission_token_on_homepage.find( 39 + (pth) => pth.permission_tokens.id === permission_token.id, 40 + ) || 41 + !identity 42 + ) 43 + return null; 44 + return ( 45 + <ActionButton 46 + onClick={async (e) => { 47 + await addLeafletToHome(permission_token.id); 48 + mutate((identity) => { 49 + if (!identity) return; 50 + return produce<typeof identity>((draft) => { 51 + draft.permission_token_on_homepage.push({ 52 + created_at: new Date().toISOString(), 53 + archived: null, 54 + permission_tokens: { 55 + ...permission_token, 56 + leaflets_to_documents: [], 57 + leaflets_in_publications: [], 58 + }, 59 + }); 60 + })(identity); 61 + }); 62 + smoker({ 63 + position: { 64 + x: e.clientX + 64, 65 + y: e.clientY, 66 + }, 67 + text: "Leaflet added to your home!", 68 + }); 69 + }} 70 + icon={<AddToHomeSmall />} 71 + label="Add to Home" 72 + /> 73 + ); 74 + };
+432
app/[leaflet_id]/actions/PublishButton.tsx
···
··· 1 + "use client"; 2 + import { publishToPublication } from "actions/publishToPublication"; 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { ActionButton } from "components/ActionBar/ActionButton"; 5 + import { 6 + PubIcon, 7 + PubListEmptyContent, 8 + PubListEmptyIllo, 9 + } from "components/ActionBar/Publications"; 10 + import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 11 + import { AddSmall } from "components/Icons/AddSmall"; 12 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 13 + import { PublishSmall } from "components/Icons/PublishSmall"; 14 + import { useIdentityData } from "components/IdentityProvider"; 15 + import { InputWithLabel } from "components/Input"; 16 + import { Menu, MenuItem } from "components/Layout"; 17 + import { 18 + useLeafletDomains, 19 + useLeafletPublicationData, 20 + } from "components/PageSWRDataProvider"; 21 + import { Popover } from "components/Popover"; 22 + import { SpeedyLink } from "components/SpeedyLink"; 23 + import { useToaster } from "components/Toast"; 24 + import { DotLoader } from "components/utils/DotLoader"; 25 + import { PubLeafletPublication } from "lexicons/api"; 26 + import { useParams, useRouter, useSearchParams } from "next/navigation"; 27 + import { useState, useMemo } from "react"; 28 + import { useIsMobile } from "src/hooks/isMobile"; 29 + import { useReplicache, useEntity } from "src/replicache"; 30 + import { useSubscribe } from "src/replicache/useSubscribe"; 31 + import { Json } from "supabase/database.types"; 32 + import { 33 + useBlocks, 34 + useCanvasBlocksWithType, 35 + } from "src/hooks/queries/useBlocks"; 36 + import * as Y from "yjs"; 37 + import * as base64 from "base64-js"; 38 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39 + import { BlueskyLogin } from "app/login/LoginForm"; 40 + import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41 + import { AddTiny } from "components/Icons/AddTiny"; 42 + 43 + export const PublishButton = (props: { entityID: string }) => { 44 + let { data: pub } = useLeafletPublicationData(); 45 + let params = useParams(); 46 + let router = useRouter(); 47 + 48 + if (!pub) return <PublishToPublicationButton entityID={props.entityID} />; 49 + if (!pub?.doc) 50 + return ( 51 + <ActionButton 52 + primary 53 + icon={<PublishSmall className="shrink-0" />} 54 + label={"Publish!"} 55 + onClick={() => { 56 + router.push(`/${params.leaflet_id}/publish`); 57 + }} 58 + /> 59 + ); 60 + 61 + return <UpdateButton />; 62 + }; 63 + 64 + const UpdateButton = () => { 65 + let [isLoading, setIsLoading] = useState(false); 66 + let { data: pub, mutate } = useLeafletPublicationData(); 67 + let { permission_token, rootEntity, rep } = useReplicache(); 68 + let { identity } = useIdentityData(); 69 + let toaster = useToaster(); 70 + 71 + // Get tags from Replicache state (same as draft editor) 72 + let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 73 + const currentTags = Array.isArray(tags) ? tags : []; 74 + 75 + return ( 76 + <ActionButton 77 + primary 78 + icon={<PublishSmall className="shrink-0" />} 79 + label={isLoading ? <DotLoader /> : "Update!"} 80 + onClick={async () => { 81 + if (!pub) return; 82 + setIsLoading(true); 83 + let doc = await publishToPublication({ 84 + root_entity: rootEntity, 85 + publication_uri: pub.publications?.uri, 86 + leaflet_id: permission_token.id, 87 + title: pub.title, 88 + description: pub.description, 89 + tags: currentTags, 90 + }); 91 + setIsLoading(false); 92 + mutate(); 93 + 94 + // Generate URL based on whether it's in a publication or standalone 95 + let docUrl = pub.publications 96 + ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` 97 + : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; 98 + 99 + toaster({ 100 + content: ( 101 + <div> 102 + {pub.doc ? "Updated! " : "Published! "} 103 + <SpeedyLink href={docUrl}>link</SpeedyLink> 104 + </div> 105 + ), 106 + type: "success", 107 + }); 108 + }} 109 + /> 110 + ); 111 + }; 112 + 113 + const PublishToPublicationButton = (props: { entityID: string }) => { 114 + let { identity } = useIdentityData(); 115 + let { permission_token } = useReplicache(); 116 + let query = useSearchParams(); 117 + let [open, setOpen] = useState(query.get("publish") !== null); 118 + 119 + let isMobile = useIsMobile(); 120 + identity && identity.atp_did && identity.publications.length > 0; 121 + let [selectedPub, setSelectedPub] = useState<string | undefined>(undefined); 122 + let router = useRouter(); 123 + let { title, entitiesToDelete } = useTitle(props.entityID); 124 + let [description, setDescription] = useState(""); 125 + 126 + return ( 127 + <Popover 128 + asChild 129 + open={open} 130 + onOpenChange={(o) => setOpen(o)} 131 + side={isMobile ? "top" : "right"} 132 + align={isMobile ? "center" : "start"} 133 + className="sm:max-w-sm w-[1000px]" 134 + trigger={ 135 + <ActionButton 136 + primary 137 + icon={<PublishSmall className="shrink-0" />} 138 + label={"Publish on ATP"} 139 + /> 140 + } 141 + > 142 + {!identity || !identity.atp_did ? ( 143 + <div className="-mx-2 -my-1"> 144 + <div 145 + className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`} 146 + > 147 + <div className="mx-auto pt-2 scale-90"> 148 + <PubListEmptyIllo /> 149 + </div> 150 + <div className="pt-1 font-bold">Publish on AT Proto</div> 151 + { 152 + <> 153 + <div className="pb-2 text-secondary text-xs"> 154 + Link a Bluesky account to start <br /> a publishing on AT 155 + Proto 156 + </div> 157 + 158 + <BlueskyLogin 159 + compact 160 + redirectRoute={`/${permission_token.id}?publish`} 161 + /> 162 + </> 163 + } 164 + </div> 165 + </div> 166 + ) : ( 167 + <div className="flex flex-col"> 168 + <PostDetailsForm 169 + title={title} 170 + description={description} 171 + setDescription={setDescription} 172 + /> 173 + <hr className="border-border-light my-3" /> 174 + <div> 175 + <PubSelector 176 + publications={identity.publications} 177 + selectedPub={selectedPub} 178 + setSelectedPub={setSelectedPub} 179 + /> 180 + </div> 181 + <hr className="border-border-light mt-3 mb-2" /> 182 + 183 + <div className="flex gap-2 items-center place-self-end"> 184 + {selectedPub !== "looseleaf" && selectedPub && ( 185 + <SaveAsDraftButton 186 + selectedPub={selectedPub} 187 + leafletId={permission_token.id} 188 + metadata={{ title: title, description }} 189 + entitiesToDelete={entitiesToDelete} 190 + /> 191 + )} 192 + <ButtonPrimary 193 + disabled={selectedPub === undefined} 194 + onClick={async (e) => { 195 + if (!selectedPub) return; 196 + e.preventDefault(); 197 + if (selectedPub === "create") return; 198 + 199 + // For looseleaf, navigate without publication_uri 200 + if (selectedPub === "looseleaf") { 201 + router.push( 202 + `${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`, 203 + ); 204 + } else { 205 + router.push( 206 + `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`, 207 + ); 208 + } 209 + }} 210 + > 211 + Next{selectedPub === "create" && ": Create Pub!"} 212 + </ButtonPrimary> 213 + </div> 214 + </div> 215 + )} 216 + </Popover> 217 + ); 218 + }; 219 + 220 + const SaveAsDraftButton = (props: { 221 + selectedPub: string | undefined; 222 + leafletId: string; 223 + metadata: { title: string; description: string }; 224 + entitiesToDelete: string[]; 225 + }) => { 226 + let { mutate } = useLeafletPublicationData(); 227 + let { rep } = useReplicache(); 228 + let [isLoading, setIsLoading] = useState(false); 229 + 230 + return ( 231 + <ButtonTertiary 232 + onClick={async (e) => { 233 + if (!props.selectedPub) return; 234 + if (props.selectedPub === "create") return; 235 + e.preventDefault(); 236 + setIsLoading(true); 237 + await moveLeafletToPublication( 238 + props.leafletId, 239 + props.selectedPub, 240 + props.metadata, 241 + props.entitiesToDelete, 242 + ); 243 + await Promise.all([rep?.pull(), mutate()]); 244 + setIsLoading(false); 245 + }} 246 + > 247 + {isLoading ? <DotLoader /> : "Save as Draft"} 248 + </ButtonTertiary> 249 + ); 250 + }; 251 + 252 + const PostDetailsForm = (props: { 253 + title: string; 254 + description: string; 255 + setDescription: (d: string) => void; 256 + }) => { 257 + return ( 258 + <div className=" flex flex-col gap-1"> 259 + <div className="text-sm text-tertiary">Post Details</div> 260 + <div className="flex flex-col gap-2"> 261 + <InputWithLabel label="Title" value={props.title} disabled /> 262 + <InputWithLabel 263 + label="Description (optional)" 264 + textarea 265 + value={props.description} 266 + className="h-[4lh]" 267 + onChange={(e) => props.setDescription(e.currentTarget.value)} 268 + /> 269 + </div> 270 + </div> 271 + ); 272 + }; 273 + 274 + const PubSelector = (props: { 275 + selectedPub: string | undefined; 276 + setSelectedPub: (s: string) => void; 277 + publications: { 278 + identity_did: string; 279 + indexed_at: string; 280 + name: string; 281 + record: Json | null; 282 + uri: string; 283 + }[]; 284 + }) => { 285 + // HEY STILL TO DO 286 + // test out logged out, logged in but no pubs, and pubbed up flows 287 + 288 + return ( 289 + <div className="flex flex-col gap-1"> 290 + <div className="text-sm text-tertiary">Publish toโ€ฆ</div> 291 + {props.publications.length === 0 || props.publications === undefined ? ( 292 + <div className="flex flex-col gap-1"> 293 + <div className="flex gap-2 menuItem"> 294 + <LooseLeafSmall className="shrink-0" /> 295 + <div className="flex flex-col leading-snug"> 296 + <div className="text-secondary font-bold"> 297 + Publish as Looseleaf 298 + </div> 299 + <div className="text-tertiary text-sm font-normal"> 300 + Publish this as a one off doc to AT Proto 301 + </div> 302 + </div> 303 + </div> 304 + <div className="flex gap-2 px-2 py-1 "> 305 + <PublishSmall className="shrink-0 text-border" /> 306 + <div className="flex flex-col leading-snug"> 307 + <div className="text-border font-bold"> 308 + Publish to Publication 309 + </div> 310 + <div className="text-border text-sm font-normal"> 311 + Publish your writing to a blog on AT Proto 312 + </div> 313 + <hr className="my-2 drashed border-border-light border-dashed" /> 314 + <div className="text-tertiary text-sm font-normal "> 315 + You don't have any Publications yet.{" "} 316 + <a target="_blank" href="/lish/createPub"> 317 + Create one 318 + </a>{" "} 319 + to get started! 320 + </div> 321 + </div> 322 + </div> 323 + </div> 324 + ) : ( 325 + <div className="flex flex-col gap-1"> 326 + <PubOption 327 + selected={props.selectedPub === "looseleaf"} 328 + onSelect={() => props.setSelectedPub("looseleaf")} 329 + > 330 + <LooseLeafSmall /> 331 + Publish as Looseleaf 332 + </PubOption> 333 + <hr className="border-border-light border-dashed " /> 334 + {props.publications.map((p) => { 335 + let pubRecord = p.record as PubLeafletPublication.Record; 336 + return ( 337 + <PubOption 338 + key={p.uri} 339 + selected={props.selectedPub === p.uri} 340 + onSelect={() => props.setSelectedPub(p.uri)} 341 + > 342 + <> 343 + <PubIcon record={pubRecord} uri={p.uri} /> 344 + {p.name} 345 + </> 346 + </PubOption> 347 + ); 348 + })} 349 + <div className="flex items-center px-2 py-1 text-accent-contrast gap-2"> 350 + <AddTiny className="m-1 shrink-0" /> 351 + 352 + <a target="_blank" href="/lish/createPub"> 353 + Start a new Publication 354 + </a> 355 + </div> 356 + </div> 357 + )} 358 + </div> 359 + ); 360 + }; 361 + 362 + const PubOption = (props: { 363 + selected: boolean; 364 + onSelect: () => void; 365 + children: React.ReactNode; 366 + }) => { 367 + return ( 368 + <button 369 + className={`flex gap-2 menuItem font-bold text-secondary ${props.selected && "bg-[var(--accent-light)]! outline! outline-offset-1! outline-accent-contrast!"}`} 370 + onClick={() => { 371 + props.onSelect(); 372 + }} 373 + > 374 + {props.children} 375 + </button> 376 + ); 377 + }; 378 + 379 + let useTitle = (entityID: string) => { 380 + let rootPage = useEntity(entityID, "root/page")[0].data.value; 381 + let canvasBlocks = useCanvasBlocksWithType(rootPage).filter( 382 + (b) => b.type === "text" || b.type === "heading", 383 + ); 384 + let blocks = useBlocks(rootPage).filter( 385 + (b) => b.type === "text" || b.type === "heading", 386 + ); 387 + let firstBlock = canvasBlocks[0] || blocks[0]; 388 + 389 + let firstBlockText = useEntity(firstBlock?.value, "block/text")?.data.value; 390 + 391 + const leafletTitle = useMemo(() => { 392 + if (!firstBlockText) return "Untitled"; 393 + let doc = new Y.Doc(); 394 + const update = base64.toByteArray(firstBlockText); 395 + Y.applyUpdate(doc, update); 396 + let nodes = doc.getXmlElement("prosemirror").toArray(); 397 + return YJSFragmentToString(nodes[0]) || "Untitled"; 398 + }, [firstBlockText]); 399 + 400 + // Only handle second block logic for linear documents, not canvas 401 + let isCanvas = canvasBlocks.length > 0; 402 + let secondBlock = !isCanvas ? blocks[1] : undefined; 403 + let secondBlockTextValue = useEntity(secondBlock?.value || null, "block/text") 404 + ?.data.value; 405 + const secondBlockText = useMemo(() => { 406 + if (!secondBlockTextValue) return ""; 407 + let doc = new Y.Doc(); 408 + const update = base64.toByteArray(secondBlockTextValue); 409 + Y.applyUpdate(doc, update); 410 + let nodes = doc.getXmlElement("prosemirror").toArray(); 411 + return YJSFragmentToString(nodes[0]) || ""; 412 + }, [secondBlockTextValue]); 413 + 414 + let entitiesToDelete = useMemo(() => { 415 + let etod: string[] = []; 416 + // Only delete first block if it's a heading type 417 + if (firstBlock?.type === "heading") { 418 + etod.push(firstBlock.value); 419 + } 420 + // Delete second block if it's empty text (only for linear documents) 421 + if ( 422 + !isCanvas && 423 + secondBlockText.trim() === "" && 424 + secondBlock?.type === "text" 425 + ) { 426 + etod.push(secondBlock.value); 427 + } 428 + return etod; 429 + }, [firstBlock, secondBlockText, secondBlock, isCanvas]); 430 + 431 + return { title: leafletTitle, entitiesToDelete }; 432 + };
+394
app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx
···
··· 1 + import { useState } from "react"; 2 + import { ButtonPrimary } from "components/Buttons"; 3 + 4 + import { useSmoker, useToaster } from "components/Toast"; 5 + import { Input, InputWithLabel } from "components/Input"; 6 + import useSWR from "swr"; 7 + import { useIdentityData } from "components/IdentityProvider"; 8 + import { addDomain } from "actions/domains/addDomain"; 9 + import { callRPC } from "app/api/rpc/client"; 10 + import { useLeafletDomains } from "components/PageSWRDataProvider"; 11 + import { useReadOnlyShareLink } from "."; 12 + import { addDomainPath } from "actions/domains/addDomainPath"; 13 + import { useReplicache } from "src/replicache"; 14 + import { deleteDomain } from "actions/domains/deleteDomain"; 15 + import { AddTiny } from "components/Icons/AddTiny"; 16 + 17 + type DomainMenuState = 18 + | { 19 + state: "default"; 20 + } 21 + | { 22 + state: "domain-settings"; 23 + domain: string; 24 + } 25 + | { 26 + state: "add-domain"; 27 + } 28 + | { 29 + state: "has-domain"; 30 + domain: string; 31 + }; 32 + export function CustomDomainMenu(props: { 33 + setShareMenuState: (s: "default") => void; 34 + }) { 35 + let { data: domains } = useLeafletDomains(); 36 + let [state, setState] = useState<DomainMenuState>( 37 + domains?.[0] 38 + ? { state: "has-domain", domain: domains[0].domain } 39 + : { state: "default" }, 40 + ); 41 + switch (state.state) { 42 + case "has-domain": 43 + case "default": 44 + return ( 45 + <DomainOptions 46 + setDomainMenuState={setState} 47 + domainConnected={false} 48 + setShareMenuState={props.setShareMenuState} 49 + /> 50 + ); 51 + case "domain-settings": 52 + return ( 53 + <DomainSettings domain={state.domain} setDomainMenuState={setState} /> 54 + ); 55 + case "add-domain": 56 + return <AddDomain setDomainMenuState={setState} />; 57 + } 58 + } 59 + 60 + export const DomainOptions = (props: { 61 + setShareMenuState: (s: "default") => void; 62 + setDomainMenuState: (state: DomainMenuState) => void; 63 + domainConnected: boolean; 64 + }) => { 65 + let { data: domains, mutate: mutateDomains } = useLeafletDomains(); 66 + let [selectedDomain, setSelectedDomain] = useState<string | undefined>( 67 + domains?.[0]?.domain, 68 + ); 69 + let [selectedRoute, setSelectedRoute] = useState( 70 + domains?.[0]?.route.slice(1) || "", 71 + ); 72 + let { identity } = useIdentityData(); 73 + let { permission_token } = useReplicache(); 74 + 75 + let toaster = useToaster(); 76 + let smoker = useSmoker(); 77 + let publishLink = useReadOnlyShareLink(); 78 + 79 + return ( 80 + <div className="px-3 py-1 flex flex-col gap-3 max-w-full w-[600px]"> 81 + <h3 className="text-secondary">Choose a Domain</h3> 82 + <div className="flex flex-col gap-1 text-secondary"> 83 + {identity?.custom_domains 84 + .filter((d) => !d.publication_domains.length) 85 + .map((domain) => { 86 + return ( 87 + <DomainOption 88 + selectedRoute={selectedRoute} 89 + setSelectedRoute={setSelectedRoute} 90 + key={domain.domain} 91 + domain={domain.domain} 92 + checked={selectedDomain === domain.domain} 93 + setChecked={setSelectedDomain} 94 + setDomainMenuState={props.setDomainMenuState} 95 + /> 96 + ); 97 + })} 98 + <button 99 + onMouseDown={() => { 100 + props.setDomainMenuState({ state: "add-domain" }); 101 + }} 102 + className="text-accent-contrast flex gap-2 items-center px-1 py-0.5" 103 + > 104 + <AddTiny /> Add a New Domain 105 + </button> 106 + </div> 107 + 108 + {/* ONLY SHOW IF A DOMAIN IS CURRENTLY CONNECTED */} 109 + <div className="flex gap-3 items-center justify-end"> 110 + {props.domainConnected && ( 111 + <button 112 + onMouseDown={() => { 113 + props.setShareMenuState("default"); 114 + toaster({ 115 + content: ( 116 + <div className="font-bold"> 117 + Unpublished from custom domain! 118 + </div> 119 + ), 120 + type: "error", 121 + }); 122 + }} 123 + > 124 + Unpublish 125 + </button> 126 + )} 127 + 128 + <ButtonPrimary 129 + id="publish-to-domain" 130 + disabled={ 131 + domains?.[0] 132 + ? domains[0].domain === selectedDomain && 133 + domains[0].route.slice(1) === selectedRoute 134 + : !selectedDomain 135 + } 136 + onClick={async () => { 137 + // let rect = document 138 + // .getElementById("publish-to-domain") 139 + // ?.getBoundingClientRect(); 140 + // smoker({ 141 + // error: true, 142 + // text: "url already in use!", 143 + // position: { 144 + // x: rect ? rect.left : 0, 145 + // y: rect ? rect.top + 26 : 0, 146 + // }, 147 + // }); 148 + if (!selectedDomain || !publishLink) return; 149 + await addDomainPath({ 150 + domain: selectedDomain, 151 + route: "/" + selectedRoute, 152 + view_permission_token: publishLink, 153 + edit_permission_token: permission_token.id, 154 + }); 155 + 156 + toaster({ 157 + content: ( 158 + <div className="font-bold"> 159 + Published to custom domain!{" "} 160 + <a 161 + className="underline text-accent-2" 162 + href={`https://${selectedDomain}/${selectedRoute}`} 163 + target="_blank" 164 + > 165 + View 166 + </a> 167 + </div> 168 + ), 169 + type: "success", 170 + }); 171 + mutateDomains(); 172 + props.setShareMenuState("default"); 173 + }} 174 + > 175 + Publish! 176 + </ButtonPrimary> 177 + </div> 178 + </div> 179 + ); 180 + }; 181 + 182 + const DomainOption = (props: { 183 + selectedRoute: string; 184 + setSelectedRoute: (s: string) => void; 185 + checked: boolean; 186 + setChecked: (checked: string) => void; 187 + domain: string; 188 + setDomainMenuState: (state: DomainMenuState) => void; 189 + }) => { 190 + let [value, setValue] = useState(""); 191 + let { data } = useSWR(props.domain, async (domain) => { 192 + return await callRPC("get_domain_status", { domain }); 193 + }); 194 + let pending = data?.config?.misconfigured || data?.error; 195 + return ( 196 + <label htmlFor={props.domain}> 197 + <input 198 + type="radio" 199 + name={props.domain} 200 + id={props.domain} 201 + value={props.domain} 202 + checked={props.checked} 203 + className="hidden appearance-none" 204 + onChange={() => { 205 + if (pending) return; 206 + props.setChecked(props.domain); 207 + }} 208 + /> 209 + <div 210 + className={` 211 + px-[6px] py-1 212 + flex 213 + border rounded-md 214 + ${ 215 + pending 216 + ? "border-border-light text-secondary justify-between gap-2 items-center " 217 + : !props.checked 218 + ? "flex-wrap border-border-light" 219 + : "flex-wrap border-accent-1 bg-accent-1 text-accent-2 font-bold" 220 + } `} 221 + > 222 + <div className={`w-max truncate ${pending && "animate-pulse"}`}> 223 + {props.domain} 224 + </div> 225 + {props.checked && ( 226 + <div className="flex gap-0 w-full"> 227 + <span 228 + className="font-normal" 229 + style={value === "" ? { opacity: "0.5" } : {}} 230 + > 231 + / 232 + </span> 233 + 234 + <Input 235 + type="text" 236 + autoFocus 237 + className="appearance-none focus:outline-hidden font-normal text-accent-2 w-full bg-transparent placeholder:text-accent-2 placeholder:opacity-50" 238 + placeholder="add-optional-path" 239 + onChange={(e) => props.setSelectedRoute(e.target.value)} 240 + value={props.selectedRoute} 241 + /> 242 + </div> 243 + )} 244 + {pending && ( 245 + <button 246 + className="text-accent-contrast text-sm" 247 + onMouseDown={() => { 248 + props.setDomainMenuState({ 249 + state: "domain-settings", 250 + domain: props.domain, 251 + }); 252 + }} 253 + > 254 + pending 255 + </button> 256 + )} 257 + </div> 258 + </label> 259 + ); 260 + }; 261 + 262 + export const AddDomain = (props: { 263 + setDomainMenuState: (state: DomainMenuState) => void; 264 + }) => { 265 + let [value, setValue] = useState(""); 266 + let { mutate } = useIdentityData(); 267 + let smoker = useSmoker(); 268 + return ( 269 + <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]"> 270 + <div> 271 + <h3 className="text-secondary">Add a New Domain</h3> 272 + <div className="text-xs italic text-secondary"> 273 + Don't include the protocol or path, just the base domain name for now 274 + </div> 275 + </div> 276 + 277 + <Input 278 + className="input-with-border text-primary" 279 + placeholder="www.example.com" 280 + value={value} 281 + onChange={(e) => setValue(e.target.value)} 282 + /> 283 + 284 + <ButtonPrimary 285 + disabled={!value} 286 + className="place-self-end mt-2" 287 + onMouseDown={async (e) => { 288 + // call the vercel api, set the thing... 289 + let { error } = await addDomain(value); 290 + if (error) { 291 + smoker({ 292 + error: true, 293 + text: 294 + error === "invalid_domain" 295 + ? "Invalid domain! Use just the base domain" 296 + : error === "domain_already_in_use" 297 + ? "That domain is already in use!" 298 + : "An unknown error occured", 299 + position: { 300 + y: e.clientY, 301 + x: e.clientX - 5, 302 + }, 303 + }); 304 + return; 305 + } 306 + mutate(); 307 + props.setDomainMenuState({ state: "domain-settings", domain: value }); 308 + }} 309 + > 310 + Verify Domain 311 + </ButtonPrimary> 312 + </div> 313 + ); 314 + }; 315 + 316 + const DomainSettings = (props: { 317 + domain: string; 318 + setDomainMenuState: (s: DomainMenuState) => void; 319 + }) => { 320 + let isSubdomain = props.domain.split(".").length > 2; 321 + return ( 322 + <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]"> 323 + <h3 className="text-secondary">Verify Domain</h3> 324 + 325 + <div className="text-secondary text-sm flex flex-col gap-3"> 326 + <div className="flex flex-col gap-[6px]"> 327 + <div> 328 + To verify this domain, add the following record to your DNS provider 329 + for <strong>{props.domain}</strong>. 330 + </div> 331 + 332 + {isSubdomain ? ( 333 + <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 334 + <div className="flex flex-col "> 335 + <div className="text-tertiary">Type</div> 336 + <div>CNAME</div> 337 + </div> 338 + <div className="flex flex-col"> 339 + <div className="text-tertiary">Name</div> 340 + <div style={{ wordBreak: "break-word" }}> 341 + {props.domain.split(".").slice(0, -2).join(".")} 342 + </div> 343 + </div> 344 + <div className="flex flex-col"> 345 + <div className="text-tertiary">Value</div> 346 + <div style={{ wordBreak: "break-word" }}> 347 + cname.vercel-dns.com 348 + </div> 349 + </div> 350 + </div> 351 + ) : ( 352 + <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 353 + <div className="flex flex-col "> 354 + <div className="text-tertiary">Type</div> 355 + <div>A</div> 356 + </div> 357 + <div className="flex flex-col"> 358 + <div className="text-tertiary">Name</div> 359 + <div>@</div> 360 + </div> 361 + <div className="flex flex-col"> 362 + <div className="text-tertiary">Value</div> 363 + <div>76.76.21.21</div> 364 + </div> 365 + </div> 366 + )} 367 + </div> 368 + <div> 369 + Once you do this, the status may be pending for up to a few hours. 370 + </div> 371 + <div>Check back later to see if verification was successful.</div> 372 + </div> 373 + 374 + <div className="flex gap-3 justify-between items-center mt-2"> 375 + <button 376 + className="text-accent-contrast font-bold " 377 + onMouseDown={async () => { 378 + await deleteDomain({ domain: props.domain }); 379 + props.setDomainMenuState({ state: "default" }); 380 + }} 381 + > 382 + Delete Domain 383 + </button> 384 + <ButtonPrimary 385 + onMouseDown={() => { 386 + props.setDomainMenuState({ state: "default" }); 387 + }} 388 + > 389 + Back to Domains 390 + </ButtonPrimary> 391 + </div> 392 + </div> 393 + ); 394 + };
+70
app/[leaflet_id]/actions/ShareOptions/getShareLink.ts
···
··· 1 + "use server"; 2 + 3 + import { eq, and } from "drizzle-orm"; 4 + import { drizzle } from "drizzle-orm/node-postgres"; 5 + import { permission_token_rights, permission_tokens } from "drizzle/schema"; 6 + import { pool } from "supabase/pool"; 7 + export async function getShareLink( 8 + token: { id: string; entity_set: string }, 9 + rootEntity: string, 10 + ) { 11 + const client = await pool.connect(); 12 + const db = drizzle(client); 13 + let link = await db.transaction(async (tx) => { 14 + // This will likely error out when if we have multiple permission 15 + // token rights associated with a single token 16 + let [tokenW] = await tx 17 + .select() 18 + .from(permission_tokens) 19 + .leftJoin( 20 + permission_token_rights, 21 + eq(permission_token_rights.token, permission_tokens.id), 22 + ) 23 + .where(eq(permission_tokens.id, token.id)); 24 + if ( 25 + !tokenW.permission_token_rights || 26 + tokenW.permission_token_rights.create_token !== true || 27 + tokenW.permission_tokens.root_entity !== rootEntity || 28 + tokenW.permission_token_rights.entity_set !== token.entity_set 29 + ) { 30 + return null; 31 + } 32 + 33 + let [existingToken] = await tx 34 + .select() 35 + .from(permission_tokens) 36 + .rightJoin( 37 + permission_token_rights, 38 + eq(permission_token_rights.token, permission_tokens.id), 39 + ) 40 + .where( 41 + and( 42 + eq(permission_token_rights.read, true), 43 + eq(permission_token_rights.write, false), 44 + eq(permission_token_rights.create_token, false), 45 + eq(permission_token_rights.change_entity_set, false), 46 + eq(permission_token_rights.entity_set, token.entity_set), 47 + eq(permission_tokens.root_entity, rootEntity), 48 + ), 49 + ); 50 + if (existingToken) { 51 + return existingToken.permission_tokens; 52 + } 53 + let [newToken] = await tx 54 + .insert(permission_tokens) 55 + .values({ root_entity: rootEntity }) 56 + .returning(); 57 + await tx.insert(permission_token_rights).values({ 58 + entity_set: token.entity_set, 59 + token: newToken.id, 60 + read: true, 61 + write: false, 62 + create_token: false, 63 + change_entity_set: false, 64 + }); 65 + return newToken; 66 + }); 67 + 68 + client.release(); 69 + return link; 70 + }
+257
app/[leaflet_id]/actions/ShareOptions/index.tsx
···
··· 1 + import { useReplicache } from "src/replicache"; 2 + import React, { useEffect, useState } from "react"; 3 + import { getShareLink } from "./getShareLink"; 4 + import { useEntitySetContext } from "components/EntitySetProvider"; 5 + import { useSmoker } from "components/Toast"; 6 + import { Menu, MenuItem } from "components/Layout"; 7 + import { ActionButton } from "components/ActionBar/ActionButton"; 8 + import useSWR from "swr"; 9 + import LoginForm from "app/login/LoginForm"; 10 + import { CustomDomainMenu } from "./DomainOptions"; 11 + import { useIdentityData } from "components/IdentityProvider"; 12 + import { 13 + useLeafletDomains, 14 + useLeafletPublicationData, 15 + } from "components/PageSWRDataProvider"; 16 + import { ShareSmall } from "components/Icons/ShareSmall"; 17 + import { PubLeafletDocument } from "lexicons/api"; 18 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 19 + import { AtUri } from "@atproto/syntax"; 20 + import { useIsMobile } from "src/hooks/isMobile"; 21 + 22 + export type ShareMenuStates = "default" | "login" | "domain"; 23 + 24 + export let useReadOnlyShareLink = () => { 25 + let { permission_token, rootEntity } = useReplicache(); 26 + let entity_set = useEntitySetContext(); 27 + let { data: publishLink } = useSWR( 28 + "publishLink-" + permission_token.id, 29 + async () => { 30 + if ( 31 + !permission_token.permission_token_rights.find( 32 + (s) => s.entity_set === entity_set.set && s.create_token, 33 + ) 34 + ) 35 + return; 36 + let shareLink = await getShareLink( 37 + { id: permission_token.id, entity_set: entity_set.set }, 38 + rootEntity, 39 + ); 40 + return shareLink?.id; 41 + }, 42 + ); 43 + return publishLink; 44 + }; 45 + 46 + export function ShareOptions() { 47 + let [menuState, setMenuState] = useState<ShareMenuStates>("default"); 48 + let { data: pub } = useLeafletPublicationData(); 49 + let isMobile = useIsMobile(); 50 + 51 + return ( 52 + <Menu 53 + asChild 54 + side={isMobile ? "top" : "right"} 55 + align={isMobile ? "center" : "start"} 56 + className="max-w-xs" 57 + onOpenChange={() => { 58 + setMenuState("default"); 59 + }} 60 + trigger={ 61 + <ActionButton 62 + icon=<ShareSmall /> 63 + secondary 64 + label={`Share ${pub ? "Draft" : ""}`} 65 + /> 66 + } 67 + > 68 + {menuState === "login" ? ( 69 + <div className="px-3 py-1"> 70 + <LoginForm text="Save your Leaflets and access them on multiple devices!" /> 71 + </div> 72 + ) : menuState === "domain" ? ( 73 + <CustomDomainMenu setShareMenuState={setMenuState} /> 74 + ) : ( 75 + <ShareMenu 76 + setMenuState={setMenuState} 77 + domainConnected={false} 78 + isPub={!!pub} 79 + /> 80 + )} 81 + </Menu> 82 + ); 83 + } 84 + 85 + const ShareMenu = (props: { 86 + setMenuState: (state: ShareMenuStates) => void; 87 + domainConnected: boolean; 88 + isPub?: boolean; 89 + }) => { 90 + let { permission_token } = useReplicache(); 91 + let { data: pub } = useLeafletPublicationData(); 92 + 93 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 94 + 95 + let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; 96 + let postLink = !docURI 97 + ? null 98 + : pub?.publications 99 + ? `${getPublicationURL(pub.publications)}/${docURI.rkey}` 100 + : `p/${docURI.host}/${docURI.rkey}`; 101 + let publishLink = useReadOnlyShareLink(); 102 + let [collabLink, setCollabLink] = useState<null | string>(null); 103 + useEffect(() => { 104 + // strip leading '/' character from pathname 105 + setCollabLink(window.location.pathname.slice(1)); 106 + }, []); 107 + let { data: domains } = useLeafletDomains(); 108 + 109 + return ( 110 + <> 111 + <ShareButton 112 + text={`Share ${postLink ? "Draft" : ""} Edit Link`} 113 + subtext="" 114 + smokerText="Edit link copied!" 115 + id="get-edit-link" 116 + link={collabLink} 117 + /> 118 + <ShareButton 119 + text={`Share ${postLink ? "Draft" : ""} View Link`} 120 + subtext=<> 121 + {domains?.[0] ? ( 122 + <> 123 + This Leaflet is published on{" "} 124 + <span className="italic underline"> 125 + {domains[0].domain} 126 + {domains[0].route} 127 + </span> 128 + </> 129 + ) : ( 130 + "" 131 + )} 132 + </> 133 + smokerText="View link copied!" 134 + id="get-view-link" 135 + fullLink={ 136 + domains?.[0] 137 + ? `https://${domains[0].domain}${domains[0].route}` 138 + : undefined 139 + } 140 + link={publishLink || ""} 141 + /> 142 + {postLink && ( 143 + <> 144 + <hr className="border-border-light" /> 145 + 146 + <ShareButton 147 + text="Share Published Link" 148 + subtext="" 149 + smokerText="Post link copied!" 150 + id="get-post-link" 151 + fullLink={postLink.includes("http") ? postLink : undefined} 152 + link={postLink} 153 + /> 154 + </> 155 + )} 156 + {!props.isPub && ( 157 + <> 158 + <hr className="border-border mt-1" /> 159 + <DomainMenuItem setMenuState={props.setMenuState} /> 160 + </> 161 + )} 162 + </> 163 + ); 164 + }; 165 + 166 + export const ShareButton = (props: { 167 + text: React.ReactNode; 168 + subtext?: React.ReactNode; 169 + smokerText: string; 170 + id: string; 171 + link: null | string; 172 + fullLink?: string; 173 + className?: string; 174 + }) => { 175 + let smoker = useSmoker(); 176 + 177 + return ( 178 + <MenuItem 179 + id={props.id} 180 + onSelect={(e) => { 181 + e.preventDefault(); 182 + let rect = document.getElementById(props.id)?.getBoundingClientRect(); 183 + if (props.link || props.fullLink) { 184 + navigator.clipboard.writeText( 185 + props.fullLink 186 + ? props.fullLink 187 + : `${location.protocol}//${location.host}/${props.link}`, 188 + ); 189 + smoker({ 190 + position: { 191 + x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 192 + y: rect ? rect.top + 26 : 0, 193 + }, 194 + text: props.smokerText, 195 + }); 196 + } 197 + }} 198 + > 199 + <div className={`group/${props.id} ${props.className} leading-snug`}> 200 + {props.text} 201 + 202 + {props.subtext && ( 203 + <div className={`text-sm font-normal text-tertiary`}> 204 + {props.subtext} 205 + </div> 206 + )} 207 + </div> 208 + </MenuItem> 209 + ); 210 + }; 211 + 212 + const DomainMenuItem = (props: { 213 + setMenuState: (state: ShareMenuStates) => void; 214 + }) => { 215 + let { identity } = useIdentityData(); 216 + let { data: domains } = useLeafletDomains(); 217 + 218 + if (identity === null) 219 + return ( 220 + <div className="text-tertiary font-normal text-sm px-3 py-1"> 221 + <button 222 + className="text-accent-contrast hover:font-bold" 223 + onClick={() => { 224 + props.setMenuState("login"); 225 + }} 226 + > 227 + Log In 228 + </button>{" "} 229 + to publish on a custom domain! 230 + </div> 231 + ); 232 + else 233 + return ( 234 + <> 235 + {domains?.[0] ? ( 236 + <button 237 + className="px-3 py-1 text-accent-contrast text-sm hover:font-bold w-fit text-left" 238 + onMouseDown={() => { 239 + props.setMenuState("domain"); 240 + }} 241 + > 242 + Edit custom domain 243 + </button> 244 + ) : ( 245 + <MenuItem 246 + className="font-normal text-tertiary text-sm" 247 + onSelect={(e) => { 248 + e.preventDefault(); 249 + props.setMenuState("domain"); 250 + }} 251 + > 252 + Publish on a custom domain 253 + </MenuItem> 254 + )} 255 + </> 256 + ); 257 + };
+4 -2
app/[leaflet_id]/icon.tsx
··· 24 process.env.SUPABASE_SERVICE_ROLE_KEY as string, 25 { cookies: {} }, 26 ); 27 - export default async function Icon(props: { params: { leaflet_id: string } }) { 28 let res = await supabase 29 .from("permission_tokens") 30 .select("*, permission_token_rights(*)") 31 - .eq("id", props.params.leaflet_id) 32 .single(); 33 let rootEntity = res.data?.root_entity; 34 let outlineColor, fillColor;
··· 24 process.env.SUPABASE_SERVICE_ROLE_KEY as string, 25 { cookies: {} }, 26 ); 27 + export default async function Icon(props: { 28 + params: Promise<{ leaflet_id: string }>; 29 + }) { 30 let res = await supabase 31 .from("permission_tokens") 32 .select("*, permission_token_rights(*)") 33 + .eq("id", (await props.params).leaflet_id) 34 .single(); 35 let rootEntity = res.data?.root_entity; 36 let outlineColor, fillColor;
+3 -2
app/[leaflet_id]/opengraph-image.tsx
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 - params: { leaflet_id: string }; 8 }) { 9 - return getMicroLinkOgImage(`/${props.params.leaflet_id}`); 10 }
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 + params: Promise<{ leaflet_id: string }>; 8 }) { 9 + let params = await props.params; 10 + return getMicroLinkOgImage(`/${params.leaflet_id}`); 11 }
+3 -6
app/[leaflet_id]/page.tsx
··· 4 5 import type { Fact } from "src/replicache"; 6 import type { Attribute } from "src/replicache/attributes"; 7 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 import { Leaflet } from "./Leaflet"; 9 import { scanIndexLocal } from "src/replicache/utils"; 10 import { getRSVPData } from "actions/getRSVPData"; ··· 13 import { supabaseServerClient } from "supabase/serverClient"; 14 import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 15 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 16 17 export const preferredRegion = ["sfo1"]; 18 export const dynamic = "force-dynamic"; ··· 70 ); 71 let rootEntity = res.data?.root_entity; 72 if (!rootEntity || !res.data) return { title: "Leaflet not found" }; 73 - let publication_data = 74 - res.data?.leaflets_in_publications?.[0] || 75 - res.data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 76 - (p) => p.leaflets_in_publications.length, 77 - )?.leaflets_in_publications?.[0]; 78 if (publication_data) { 79 return { 80 title: publication_data.title || "Untitled",
··· 4 5 import type { Fact } from "src/replicache"; 6 import type { Attribute } from "src/replicache/attributes"; 7 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 8 import { Leaflet } from "./Leaflet"; 9 import { scanIndexLocal } from "src/replicache/utils"; 10 import { getRSVPData } from "actions/getRSVPData"; ··· 13 import { supabaseServerClient } from "supabase/serverClient"; 14 import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 15 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 16 + import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 17 18 export const preferredRegion = ["sfo1"]; 19 export const dynamic = "force-dynamic"; ··· 71 ); 72 let rootEntity = res.data?.root_entity; 73 if (!rootEntity || !res.data) return { title: "Leaflet not found" }; 74 + let publication_data = getPublicationMetadataFromLeafletData(res.data); 75 if (publication_data) { 76 return { 77 title: publication_data.title || "Untitled",
+154 -295
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 1 "use client"; 2 - import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 - import { 4 - useState, 5 - useCallback, 6 - useRef, 7 - useLayoutEffect, 8 - useEffect, 9 - } from "react"; 10 - import { createPortal } from "react-dom"; 11 - import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 12 - import * as Popover from "@radix-ui/react-popover"; 13 - import { EditorState, TextSelection, Plugin } from "prosemirror-state"; 14 import { EditorView } from "prosemirror-view"; 15 import { Schema, MarkSpec, Mark } from "prosemirror-model"; 16 import { baseKeymap } from "prosemirror-commands"; ··· 18 import { history, undo, redo } from "prosemirror-history"; 19 import { inputRules, InputRule } from "prosemirror-inputrules"; 20 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21 22 // Schema with only links, mentions, and hashtags marks 23 const bskyPostSchema = new Schema({ ··· 133 return tr; 134 }); 135 } 136 - 137 export function BlueskyPostEditorProsemirror(props: { 138 - editorStateRef: React.MutableRefObject<EditorState | null>; 139 initialContent?: string; 140 onCharCountChange?: (count: number) => void; 141 }) { 142 const mountRef = useRef<HTMLDivElement | null>(null); 143 const viewRef = useRef<EditorView | null>(null); 144 const [editorState, setEditorState] = useState<EditorState | null>(null); 145 - const [mentionState, setMentionState] = useState<{ 146 - active: boolean; 147 - range: { from: number; to: number } | null; 148 - selectedMention: { handle: string; did: string } | null; 149 - }>({ active: false, range: null, selectedMention: null }); 150 151 const handleMentionSelect = useCallback( 152 - ( 153 - mention: { handle: string; did: string }, 154 - range: { from: number; to: number }, 155 - ) => { 156 - if (!viewRef.current) return; 157 const view = viewRef.current; 158 - const { from, to } = range; 159 const tr = view.state.tr; 160 161 - // Delete the query text (keep the @) 162 - tr.delete(from + 1, to); 163 164 - // Insert the mention text after the @ 165 - const mentionText = mention.handle; 166 - tr.insertText(mentionText, from + 1); 167 - 168 - // Apply mention mark to @ and handle 169 - tr.addMark( 170 - from, 171 - from + 1 + mentionText.length, 172 - bskyPostSchema.marks.mention.create({ did: mention.did }), 173 - ); 174 - 175 - // Add a space after the mention 176 - tr.insertText(" ", from + 1 + mentionText.length); 177 178 view.dispatch(tr); 179 view.focus(); 180 }, 181 - [], 182 ); 183 184 - const mentionStateRef = useRef(mentionState); 185 - mentionStateRef.current = mentionState; 186 187 useLayoutEffect(() => { 188 if (!mountRef.current) return; 189 190 const initialState = EditorState.create({ 191 schema: bskyPostSchema, 192 doc: props.initialContent ··· 199 }) 200 : undefined, 201 plugins: [ 202 - inputRules({ rules: [createHashtagInputRule()] }), 203 keymap({ 204 "Mod-z": undo, 205 "Mod-y": redo, 206 "Shift-Mod-z": redo, 207 - Enter: (state, dispatch) => { 208 - // Check if mention autocomplete is active 209 - const currentMentionState = mentionStateRef.current; 210 - if ( 211 - currentMentionState.active && 212 - currentMentionState.selectedMention && 213 - currentMentionState.range 214 - ) { 215 - handleMentionSelect( 216 - currentMentionState.selectedMention, 217 - currentMentionState.range, 218 - ); 219 - return true; 220 - } 221 - // Otherwise let the default Enter behavior happen (new paragraph) 222 - return false; 223 - }, 224 }), 225 keymap(baseKeymap), 226 autolink({ ··· 244 view.updateState(newState); 245 setEditorState(newState); 246 props.editorStateRef.current = newState; 247 - props.onCharCountChange?.(newState.doc.textContent.length); 248 }, 249 }, 250 ); ··· 255 view.destroy(); 256 viewRef.current = null; 257 }; 258 - }, [handleMentionSelect]); 259 260 return ( 261 - <div className="relative w-full h-full"> 262 - {editorState && ( 263 - <MentionAutocomplete 264 - editorState={editorState} 265 - view={viewRef} 266 - onSelect={handleMentionSelect} 267 - onMentionStateChange={(active, range, selectedMention) => { 268 - setMentionState({ active, range, selectedMention }); 269 - }} 270 - /> 271 )} 272 <div 273 ref={mountRef} 274 - className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 275 style={{ 276 wordWrap: "break-word", 277 overflowWrap: "break-word", 278 }} 279 /> 280 </div> 281 ); 282 } 283 284 - function MentionAutocomplete(props: { 285 - editorState: EditorState; 286 - view: React.RefObject<EditorView | null>; 287 - onSelect: ( 288 - mention: { handle: string; did: string }, 289 - range: { from: number; to: number }, 290 - ) => void; 291 - onMentionStateChange: ( 292 - active: boolean, 293 - range: { from: number; to: number } | null, 294 - selectedMention: { handle: string; did: string } | null, 295 - ) => void; 296 - }) { 297 - const [mentionQuery, setMentionQuery] = useState<string | null>(null); 298 - const [mentionRange, setMentionRange] = useState<{ 299 - from: number; 300 - to: number; 301 - } | null>(null); 302 - const [mentionCoords, setMentionCoords] = useState<{ 303 - top: number; 304 - left: number; 305 - } | null>(null); 306 - 307 - const { suggestionIndex, setSuggestionIndex, suggestions } = 308 - useMentionSuggestions(mentionQuery); 309 - 310 - // Check for mention pattern whenever editor state changes 311 - useEffect(() => { 312 - const { $from } = props.editorState.selection; 313 - const textBefore = $from.parent.textBetween( 314 - Math.max(0, $from.parentOffset - 50), 315 - $from.parentOffset, 316 - null, 317 - "\ufffc", 318 - ); 319 - 320 - // Look for @ followed by word characters before cursor 321 - const match = textBefore.match(/@([\w.]*)$/); 322 - 323 - if (match && props.view.current) { 324 - const queryBefore = match[1]; 325 - const from = $from.pos - queryBefore.length - 1; 326 - 327 - // Get text after cursor to find the rest of the handle 328 - const textAfter = $from.parent.textBetween( 329 - $from.parentOffset, 330 - Math.min($from.parent.content.size, $from.parentOffset + 50), 331 - null, 332 - "\ufffc", 333 - ); 334 - 335 - // Match word characters after cursor until space or end 336 - const afterMatch = textAfter.match(/^([\w.]*)/); 337 - const queryAfter = afterMatch ? afterMatch[1] : ""; 338 - 339 - // Combine the full handle 340 - const query = queryBefore + queryAfter; 341 - const to = $from.pos + queryAfter.length; 342 - 343 - setMentionQuery(query); 344 - setMentionRange({ from, to }); 345 - 346 - // Get coordinates for the autocomplete popup 347 - const coords = props.view.current.coordsAtPos(from); 348 - setMentionCoords({ 349 - top: coords.bottom + window.scrollY, 350 - left: coords.left + window.scrollX, 351 - }); 352 - setSuggestionIndex(0); 353 - } else { 354 - setMentionQuery(null); 355 - setMentionRange(null); 356 - setMentionCoords(null); 357 - } 358 - }, [props.editorState, props.view, setSuggestionIndex]); 359 - 360 - // Update parent's mention state 361 - useEffect(() => { 362 - const active = mentionQuery !== null && suggestions.length > 0; 363 - const selectedMention = 364 - active && suggestions[suggestionIndex] 365 - ? suggestions[suggestionIndex] 366 - : null; 367 - props.onMentionStateChange(active, mentionRange, selectedMention); 368 - }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); 369 - 370 - // Handle keyboard navigation for arrow keys only 371 - useEffect(() => { 372 - if (!mentionQuery || !props.view.current) return; 373 - 374 - const handleKeyDown = (e: KeyboardEvent) => { 375 - if (suggestions.length === 0) return; 376 - 377 - if (e.key === "ArrowUp") { 378 - e.preventDefault(); 379 - if (suggestionIndex > 0) { 380 - setSuggestionIndex((i) => i - 1); 381 - } 382 - } else if (e.key === "ArrowDown") { 383 - e.preventDefault(); 384 - if (suggestionIndex < suggestions.length - 1) { 385 - setSuggestionIndex((i) => i + 1); 386 - } 387 - } 388 - }; 389 - 390 - const dom = props.view.current.dom; 391 - dom.addEventListener("keydown", handleKeyDown); 392 - 393 - return () => { 394 - dom.removeEventListener("keydown", handleKeyDown); 395 - }; 396 - }, [ 397 - mentionQuery, 398 - suggestions, 399 - suggestionIndex, 400 - props.view, 401 - setSuggestionIndex, 402 - ]); 403 - 404 - if (!mentionCoords || suggestions.length === 0) return null; 405 - 406 - // The styles in this component should match the Menu styles in components/Layout.tsx 407 - return ( 408 - <Popover.Root open> 409 - {createPortal( 410 - <Popover.Anchor 411 - style={{ 412 - top: mentionCoords.top, 413 - left: mentionCoords.left, 414 - position: "absolute", 415 - }} 416 - />, 417 - document.body, 418 - )} 419 - <Popover.Portal> 420 - <Popover.Content 421 - side="bottom" 422 - align="start" 423 - sideOffset={4} 424 - collisionPadding={20} 425 - onOpenAutoFocus={(e) => e.preventDefault()} 426 - className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`} 427 - > 428 - <ul className="list-none p-0 text-sm"> 429 - {suggestions.map((result, index) => { 430 - return ( 431 - <div 432 - className={` 433 - MenuItem 434 - font-bold z-10 py-1 px-3 435 - text-left text-secondary 436 - flex gap-2 437 - ${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""} 438 - hover:bg-border-light hover:text-secondary 439 - outline-none 440 - `} 441 - key={result.did} 442 - onClick={() => { 443 - if (mentionRange) { 444 - props.onSelect(result, mentionRange); 445 - setMentionQuery(null); 446 - setMentionRange(null); 447 - setMentionCoords(null); 448 - } 449 - }} 450 - onMouseDown={(e) => e.preventDefault()} 451 - > 452 - @{result.handle} 453 - </div> 454 - ); 455 - })} 456 - </ul> 457 - </Popover.Content> 458 - </Popover.Portal> 459 - </Popover.Root> 460 - ); 461 - } 462 - 463 - function useMentionSuggestions(query: string | null) { 464 - const [suggestionIndex, setSuggestionIndex] = useState(0); 465 - const [suggestions, setSuggestions] = useState< 466 - { handle: string; did: string }[] 467 - >([]); 468 - 469 - useDebouncedEffect( 470 - async () => { 471 - if (!query) { 472 - setSuggestions([]); 473 - return; 474 - } 475 - 476 - const agent = new Agent("https://public.api.bsky.app"); 477 - const result = await agent.searchActorsTypeahead({ 478 - q: query, 479 - limit: 8, 480 - }); 481 - setSuggestions( 482 - result.data.actors.map((actor) => ({ 483 - handle: actor.handle, 484 - did: actor.did, 485 - })), 486 - ); 487 - }, 488 - 300, 489 - [query], 490 - ); 491 - 492 - useEffect(() => { 493 - if (suggestionIndex > suggestions.length - 1) { 494 - setSuggestionIndex(Math.max(0, suggestions.length - 1)); 495 - } 496 - }, [suggestionIndex, suggestions.length]); 497 - 498 - return { 499 - suggestions, 500 - suggestionIndex, 501 - setSuggestionIndex, 502 - }; 503 - } 504 - 505 /** 506 * Converts a ProseMirror editor state to Bluesky post facets. 507 * Extracts mentions, links, and hashtags from the editor state and returns them ··· 586 587 return features; 588 }
··· 1 "use client"; 2 + import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 + import { useState, useCallback, useRef, useLayoutEffect } from "react"; 4 + import { EditorState } from "prosemirror-state"; 5 import { EditorView } from "prosemirror-view"; 6 import { Schema, MarkSpec, Mark } from "prosemirror-model"; 7 import { baseKeymap } from "prosemirror-commands"; ··· 9 import { history, undo, redo } from "prosemirror-history"; 10 import { inputRules, InputRule } from "prosemirror-inputrules"; 11 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 12 + import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; 13 + import { schema } from "components/Blocks/TextBlock/schema"; 14 + import { Mention, MentionAutocomplete } from "components/Mention"; 15 16 // Schema with only links, mentions, and hashtags marks 17 const bskyPostSchema = new Schema({ ··· 127 return tr; 128 }); 129 } 130 export function BlueskyPostEditorProsemirror(props: { 131 + editorStateRef: React.RefObject<EditorState | null>; 132 initialContent?: string; 133 onCharCountChange?: (count: number) => void; 134 }) { 135 const mountRef = useRef<HTMLDivElement | null>(null); 136 const viewRef = useRef<EditorView | null>(null); 137 const [editorState, setEditorState] = useState<EditorState | null>(null); 138 + const [mentionOpen, setMentionOpen] = useState(false); 139 + const [mentionCoords, setMentionCoords] = useState<{ 140 + top: number; 141 + left: number; 142 + } | null>(null); 143 + const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 144 + 145 + const openMentionAutocomplete = useCallback(() => { 146 + if (!viewRef.current) return; 147 + const view = viewRef.current; 148 + const pos = view.state.selection.from; 149 + setMentionInsertPos(pos); 150 + const coords = view.coordsAtPos(pos - 1); 151 + 152 + // Get coordinates relative to the positioned parent container 153 + const editorEl = view.dom; 154 + const container = editorEl.closest(".relative") as HTMLElement | null; 155 + 156 + if (container) { 157 + const containerRect = container.getBoundingClientRect(); 158 + setMentionCoords({ 159 + top: coords.bottom - containerRect.top, 160 + left: coords.left - containerRect.left, 161 + }); 162 + } else { 163 + setMentionCoords({ 164 + top: coords.bottom, 165 + left: coords.left, 166 + }); 167 + } 168 + setMentionOpen(true); 169 + }, []); 170 171 const handleMentionSelect = useCallback( 172 + (mention: Mention) => { 173 + if (!viewRef.current || mentionInsertPos === null) return; 174 const view = viewRef.current; 175 + const from = mentionInsertPos - 1; 176 + const to = mentionInsertPos; 177 const tr = view.state.tr; 178 179 + // Delete the @ symbol 180 + tr.delete(from, to); 181 182 + if (mention.type === "did") { 183 + // Insert @handle with mention mark 184 + const mentionText = "@" + mention.handle; 185 + tr.insertText(mentionText, from); 186 + tr.addMark( 187 + from, 188 + from + mentionText.length, 189 + bskyPostSchema.marks.mention.create({ did: mention.did }), 190 + ); 191 + tr.insertText(" ", from + mentionText.length); 192 + } else if (mention.type === "publication") { 193 + // Insert publication name as a link 194 + const linkText = mention.name; 195 + tr.insertText(linkText, from); 196 + tr.addMark( 197 + from, 198 + from + linkText.length, 199 + bskyPostSchema.marks.link.create({ href: mention.url }), 200 + ); 201 + tr.insertText(" ", from + linkText.length); 202 + } else if (mention.type === "post") { 203 + // Insert post title as a link 204 + const linkText = mention.title; 205 + tr.insertText(linkText, from); 206 + tr.addMark( 207 + from, 208 + from + linkText.length, 209 + bskyPostSchema.marks.link.create({ href: mention.url }), 210 + ); 211 + tr.insertText(" ", from + linkText.length); 212 + } 213 214 view.dispatch(tr); 215 view.focus(); 216 }, 217 + [mentionInsertPos], 218 ); 219 220 + const handleMentionOpenChange = useCallback((open: boolean) => { 221 + setMentionOpen(open); 222 + if (!open) { 223 + setMentionCoords(null); 224 + setMentionInsertPos(null); 225 + } 226 + }, []); 227 228 useLayoutEffect(() => { 229 if (!mountRef.current) return; 230 231 + // Input rule to trigger mention autocomplete when @ is typed 232 + const mentionInputRule = new InputRule( 233 + /(?:^|\s)@$/, 234 + (state, match, start, end) => { 235 + setTimeout(() => openMentionAutocomplete(), 0); 236 + return null; 237 + }, 238 + ); 239 + 240 const initialState = EditorState.create({ 241 schema: bskyPostSchema, 242 doc: props.initialContent ··· 249 }) 250 : undefined, 251 plugins: [ 252 + inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }), 253 keymap({ 254 "Mod-z": undo, 255 "Mod-y": redo, 256 "Shift-Mod-z": redo, 257 }), 258 keymap(baseKeymap), 259 autolink({ ··· 277 view.updateState(newState); 278 setEditorState(newState); 279 props.editorStateRef.current = newState; 280 + props.onCharCountChange?.( 281 + newState.doc.textContent.length + newState.doc.children.length - 1, 282 + ); 283 }, 284 }, 285 ); ··· 290 view.destroy(); 291 viewRef.current = null; 292 }; 293 + }, [openMentionAutocomplete]); 294 295 return ( 296 + <div className="relative w-full h-full group"> 297 + <MentionAutocomplete 298 + open={mentionOpen} 299 + onOpenChange={handleMentionOpenChange} 300 + view={viewRef} 301 + onSelect={handleMentionSelect} 302 + coords={mentionCoords} 303 + placeholder="Search people..." 304 + /> 305 + {editorState?.doc.textContent.length === 0 && ( 306 + <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 307 + Write a post to share your writing! 308 + </div> 309 )} 310 <div 311 ref={mountRef} 312 + className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm" 313 style={{ 314 wordWrap: "break-word", 315 overflowWrap: "break-word", 316 }} 317 /> 318 + <IOSBS view={viewRef} /> 319 </div> 320 ); 321 } 322 323 /** 324 * Converts a ProseMirror editor state to Bluesky post facets. 325 * Extracts mentions, links, and hashtags from the editor state and returns them ··· 404 405 return features; 406 } 407 + 408 + export const addMentionToEditor = ( 409 + mention: Mention, 410 + range: { from: number; to: number }, 411 + view: EditorView, 412 + ) => { 413 + console.log("view", view); 414 + if (!view) return; 415 + const { from, to } = range; 416 + const tr = view.state.tr; 417 + 418 + if (mention.type == "did") { 419 + // Delete the @ and any query text 420 + tr.delete(from, to); 421 + // Insert didMention inline node 422 + const mentionText = "@" + mention.handle; 423 + const didMentionNode = schema.nodes.didMention.create({ 424 + did: mention.did, 425 + text: mentionText, 426 + }); 427 + tr.insert(from, didMentionNode); 428 + } 429 + if (mention.type === "publication" || mention.type === "post") { 430 + // Delete the @ and any query text 431 + tr.delete(from, to); 432 + let name = mention.type == "post" ? mention.title : mention.name; 433 + // Insert atMention inline node 434 + const atMentionNode = schema.nodes.atMention.create({ 435 + atURI: mention.uri, 436 + text: name, 437 + }); 438 + tr.insert(from, atMentionNode); 439 + } 440 + console.log("yo", mention); 441 + 442 + // Add a space after the mention 443 + tr.insertText(" ", from + 1); 444 + 445 + view.dispatch(tr); 446 + view.focus(); 447 + };
+202 -91
app/[leaflet_id]/publish/PublishPost.tsx
··· 6 import { Radio } from "components/Checkbox"; 7 import { useParams } from "next/navigation"; 8 import Link from "next/link"; 9 - import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 10 import { PubLeafletPublication } from "lexicons/api"; 11 import { publishPostToBsky } from "./publishBskyPost"; 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 import { AtUri } from "@atproto/syntax"; 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 import { useReplicache } from "src/replicache"; 16 import { 17 BlueskyPostEditorProsemirror, 18 editorStateToFacetedText, 19 } from "./BskyPostEditorProsemirror"; 20 import { EditorState } from "prosemirror-state"; 21 22 type Props = { 23 title: string; ··· 25 root_entity: string; 26 profile: ProfileViewDetailed; 27 description: string; 28 - publication_uri: string; 29 record?: PubLeafletPublication.Record; 30 posts_in_pub?: number; 31 }; 32 33 export function PublishPost(props: Props) { ··· 35 { state: "default" } | { state: "success"; post_url: string } 36 >({ state: "default" }); 37 return ( 38 - <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center"> 39 {publishState.state === "default" ? ( 40 <PublishPostForm setPublishState={setPublishState} {...props} /> 41 ) : ( ··· 55 setPublishState: (s: { state: "success"; post_url: string }) => void; 56 } & Props, 57 ) => { 58 - let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 59 let editorStateRef = useRef<EditorState | null>(null); 60 - let [isLoading, setIsLoading] = useState(false); 61 let [charCount, setCharCount] = useState(0); 62 let params = useParams(); 63 let { rep } = useReplicache(); 64 65 async function submit() { 66 if (isLoading) return; 67 setIsLoading(true); ··· 72 leaflet_id: props.leaflet_id, 73 title: props.title, 74 description: props.description, 75 }); 76 if (!doc) return; 77 78 - let post_url = `https://${props.record?.base_path}/${doc.rkey}`; 79 let [text, facets] = editorStateRef.current 80 ? editorStateToFacetedText(editorStateRef.current) 81 : []; ··· 94 } 95 96 return ( 97 - <div className="flex flex-col gap-4 w-[640px] max-w-full sm:px-4 px-3"> 98 - <h3>Publish Options</h3> 99 <form 100 onSubmit={(e) => { 101 e.preventDefault(); 102 submit(); 103 }} 104 > 105 - <div className="container flex flex-col gap-2 sm:p-3 p-4"> 106 - <Radio 107 - checked={shareOption === "quiet"} 108 - onChange={(e) => { 109 - if (e.target === e.currentTarget) { 110 - setShareOption("quiet"); 111 - } 112 - }} 113 - name="share-options" 114 - id="share-quietly" 115 - value="Share Quietly" 116 - > 117 - <div className="flex flex-col"> 118 - <div className="font-bold">Share Quietly</div> 119 - <div className="text-sm text-tertiary font-normal"> 120 - No one will be notified about this post 121 - </div> 122 - </div> 123 - </Radio> 124 - <Radio 125 - checked={shareOption === "bluesky"} 126 - onChange={(e) => { 127 - if (e.target === e.currentTarget) { 128 - setShareOption("bluesky"); 129 - } 130 - }} 131 - name="share-options" 132 - id="share-bsky" 133 - value="Share on Bluesky" 134 - > 135 - <div className="flex flex-col"> 136 - <div className="font-bold">Share on Bluesky</div> 137 - <div className="text-sm text-tertiary font-normal"> 138 - Pub subscribers will be updated via a custom Bluesky feed 139 - </div> 140 - </div> 141 - </Radio> 142 143 - <div 144 - className={`w-full pl-5 pb-4 ${shareOption !== "bluesky" ? "opacity-50" : ""}`} 145 - > 146 - <div className="opaque-container p-3 rounded-lg!"> 147 - <div className="flex gap-2"> 148 - <img 149 - className="bg-test 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 <div className="flex justify-between"> 182 <Link 183 className="hover:no-underline! font-bold" ··· 199 ); 200 }; 201 202 const PublishPostSuccess = (props: { 203 post_url: string; 204 - publication_uri: string; 205 record: Props["record"]; 206 posts_in_pub: number; 207 }) => { 208 - let uri = new AtUri(props.publication_uri); 209 return ( 210 <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 <PublishIllustration posts_in_pub={props.posts_in_pub} /> 212 <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> 219 <a href={props.post_url}>See published post</a> 220 </div> 221 );
··· 6 import { Radio } from "components/Checkbox"; 7 import { useParams } from "next/navigation"; 8 import Link from "next/link"; 9 + 10 import { PubLeafletPublication } from "lexicons/api"; 11 import { publishPostToBsky } from "./publishBskyPost"; 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 import { AtUri } from "@atproto/syntax"; 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 import { useReplicache } from "src/replicache"; 16 + import { useSubscribe } from "src/replicache/useSubscribe"; 17 import { 18 BlueskyPostEditorProsemirror, 19 editorStateToFacetedText, 20 } from "./BskyPostEditorProsemirror"; 21 import { EditorState } from "prosemirror-state"; 22 + import { TagSelector } from "../../../components/Tags"; 23 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 + import { PubIcon } from "components/ActionBar/Publications"; 25 26 type Props = { 27 title: string; ··· 29 root_entity: string; 30 profile: ProfileViewDetailed; 31 description: string; 32 + publication_uri?: string; 33 record?: PubLeafletPublication.Record; 34 posts_in_pub?: number; 35 + entitiesToDelete?: string[]; 36 + hasDraft: boolean; 37 }; 38 39 export function PublishPost(props: Props) { ··· 41 { state: "default" } | { state: "success"; post_url: string } 42 >({ state: "default" }); 43 return ( 44 + <div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center text-primary"> 45 {publishState.state === "default" ? ( 46 <PublishPostForm setPublishState={setPublishState} {...props} /> 47 ) : ( ··· 61 setPublishState: (s: { state: "success"; post_url: string }) => void; 62 } & Props, 63 ) => { 64 let editorStateRef = useRef<EditorState | null>(null); 65 let [charCount, setCharCount] = useState(0); 66 + let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 67 + let [isLoading, setIsLoading] = useState(false); 68 let params = useParams(); 69 let { rep } = useReplicache(); 70 71 + // For publications with drafts, use Replicache; otherwise use local state 72 + let replicacheTags = useSubscribe(rep, (tx) => 73 + tx.get<string[]>("publication_tags"), 74 + ); 75 + let [localTags, setLocalTags] = useState<string[]>([]); 76 + 77 + // Use Replicache tags only when we have a draft 78 + const hasDraft = props.hasDraft; 79 + const currentTags = hasDraft 80 + ? Array.isArray(replicacheTags) 81 + ? replicacheTags 82 + : [] 83 + : localTags; 84 + 85 + // Update tags via Replicache mutation or local state depending on context 86 + const handleTagsChange = async (newTags: string[]) => { 87 + if (hasDraft) { 88 + await rep?.mutate.updatePublicationDraft({ 89 + tags: newTags, 90 + }); 91 + } else { 92 + setLocalTags(newTags); 93 + } 94 + }; 95 + 96 async function submit() { 97 if (isLoading) return; 98 setIsLoading(true); ··· 103 leaflet_id: props.leaflet_id, 104 title: props.title, 105 description: props.description, 106 + tags: currentTags, 107 + entitiesToDelete: props.entitiesToDelete, 108 }); 109 if (!doc) return; 110 111 + // Generate post URL based on whether it's in a publication or standalone 112 + let post_url = props.record?.base_path 113 + ? `https://${props.record.base_path}/${doc.rkey}` 114 + : `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`; 115 + 116 let [text, facets] = editorStateRef.current 117 ? editorStateToFacetedText(editorStateRef.current) 118 : []; ··· 131 } 132 133 return ( 134 + <div className="flex flex-col gap-4 w-[640px] max-w-full sm:px-4 px-3 text-primary"> 135 <form 136 onSubmit={(e) => { 137 e.preventDefault(); 138 submit(); 139 }} 140 > 141 + <div className="container flex flex-col gap-3 sm:p-3 p-4"> 142 + <PublishingTo 143 + publication_uri={props.publication_uri} 144 + record={props.record} 145 + /> 146 + <hr className="border-border" /> 147 + <ShareOptions 148 + setShareOption={setShareOption} 149 + shareOption={shareOption} 150 + charCount={charCount} 151 + setCharCount={setCharCount} 152 + editorStateRef={editorStateRef} 153 + {...props} 154 + /> 155 + <hr className="border-border " /> 156 + <div className="flex flex-col gap-2"> 157 + <h4>Tags</h4> 158 + <TagSelector 159 + selectedTags={currentTags} 160 + setSelectedTags={handleTagsChange} 161 + /> 162 + </div> 163 + <hr className="border-border mb-2" /> 164 165 <div className="flex justify-between"> 166 <Link 167 className="hover:no-underline! font-bold" ··· 183 ); 184 }; 185 186 + const ShareOptions = (props: { 187 + shareOption: "quiet" | "bluesky"; 188 + setShareOption: (option: typeof props.shareOption) => void; 189 + charCount: number; 190 + setCharCount: (c: number) => void; 191 + editorStateRef: React.MutableRefObject<EditorState | null>; 192 + title: string; 193 + profile: ProfileViewDetailed; 194 + description: string; 195 + record?: PubLeafletPublication.Record; 196 + }) => { 197 + return ( 198 + <div className="flex flex-col gap-2"> 199 + <h4>Notifications</h4> 200 + <Radio 201 + checked={props.shareOption === "quiet"} 202 + onChange={(e) => { 203 + if (e.target === e.currentTarget) { 204 + props.setShareOption("quiet"); 205 + } 206 + }} 207 + name="share-options" 208 + id="share-quietly" 209 + value="Share Quietly" 210 + > 211 + <div className="flex flex-col"> 212 + <div className="font-bold">Share Quietly</div> 213 + <div className="text-sm text-tertiary font-normal"> 214 + No one will be notified about this post 215 + </div> 216 + </div> 217 + </Radio> 218 + <Radio 219 + checked={props.shareOption === "bluesky"} 220 + onChange={(e) => { 221 + if (e.target === e.currentTarget) { 222 + props.setShareOption("bluesky"); 223 + } 224 + }} 225 + name="share-options" 226 + id="share-bsky" 227 + value="Share on Bluesky" 228 + > 229 + <div className="flex flex-col"> 230 + <div className="font-bold">Share on Bluesky</div> 231 + <div className="text-sm text-tertiary font-normal"> 232 + Pub subscribers will be updated via a custom Bluesky feed 233 + </div> 234 + </div> 235 + </Radio> 236 + <div 237 + className={`w-full pl-5 pb-4 ${props.shareOption !== "bluesky" ? "opacity-50" : ""}`} 238 + > 239 + <div className="opaque-container py-2 px-3 text-sm rounded-lg!"> 240 + <div className="flex gap-2"> 241 + <img 242 + className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0" 243 + src={props.profile.avatar} 244 + /> 245 + <div className="flex flex-col w-full"> 246 + <div className="flex gap-2 "> 247 + <p className="font-bold">{props.profile.displayName}</p> 248 + <p className="text-tertiary">@{props.profile.handle}</p> 249 + </div> 250 + <div className="flex flex-col"> 251 + <BlueskyPostEditorProsemirror 252 + editorStateRef={props.editorStateRef} 253 + onCharCountChange={props.setCharCount} 254 + /> 255 + </div> 256 + <div className="opaque-container !border-border overflow-hidden flex flex-col mt-4 w-full"> 257 + <div className="flex flex-col p-2"> 258 + <div className="font-bold">{props.title}</div> 259 + <div className="text-tertiary">{props.description}</div> 260 + <hr className="border-border mt-2 mb-1" /> 261 + <p className="text-xs text-tertiary"> 262 + {props.record?.base_path} 263 + </p> 264 + </div> 265 + </div> 266 + <div className="text-xs text-secondary italic place-self-end pt-2"> 267 + {props.charCount}/300 268 + </div> 269 + </div> 270 + </div> 271 + </div> 272 + </div> 273 + </div> 274 + ); 275 + }; 276 + 277 + const PublishingTo = (props: { 278 + publication_uri?: string; 279 + record?: PubLeafletPublication.Record; 280 + }) => { 281 + if (props.publication_uri && props.record) { 282 + return ( 283 + <div className="flex flex-col gap-1"> 284 + <h3>Publishing to</h3> 285 + <div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]"> 286 + <PubIcon record={props.record} uri={props.publication_uri} /> 287 + <div className="font-bold text-secondary">{props.record.name}</div> 288 + </div> 289 + </div> 290 + ); 291 + } 292 + 293 + return ( 294 + <div className="flex flex-col gap-1"> 295 + <h3>Publishing as</h3> 296 + <div className="flex gap-2 items-center p-2 rounded-md bg-[var(--accent-light)]"> 297 + <LooseLeafSmall className="shrink-0" /> 298 + <div className="font-bold text-secondary">Looseleaf</div> 299 + </div> 300 + </div> 301 + ); 302 + }; 303 + 304 const PublishPostSuccess = (props: { 305 post_url: string; 306 + publication_uri?: string; 307 record: Props["record"]; 308 posts_in_pub: number; 309 }) => { 310 + let uri = props.publication_uri ? new AtUri(props.publication_uri) : null; 311 return ( 312 <div className="container p-4 m-3 sm:m-4 flex flex-col gap-1 justify-center text-center w-fit h-fit mx-auto"> 313 <PublishIllustration posts_in_pub={props.posts_in_pub} /> 314 <h2 className="pt-2">Published!</h2> 315 + {uri && props.record ? ( 316 + <Link 317 + className="hover:no-underline! font-bold place-self-center pt-2" 318 + href={`/lish/${uri.host}/${encodeURIComponent(props.record.name || "")}/dashboard`} 319 + > 320 + <ButtonPrimary>Back to Dashboard</ButtonPrimary> 321 + </Link> 322 + ) : ( 323 + <Link 324 + className="hover:no-underline! font-bold place-self-center pt-2" 325 + href="/" 326 + > 327 + <ButtonPrimary>Back to Home</ButtonPrimary> 328 + </Link> 329 + )} 330 <a href={props.post_url}>See published post</a> 331 </div> 332 );
+69 -9
app/[leaflet_id]/publish/page.tsx
··· 13 type Props = { 14 // this is now a token id not leaflet! Should probs rename 15 params: Promise<{ leaflet_id: string }>; 16 }; 17 export default async function PublishLeafletPage(props: Props) { 18 let leaflet_id = (await props.params).leaflet_id; ··· 27 *, 28 documents_in_publications(count) 29 ), 30 - documents(*))`, 31 ) 32 .eq("id", leaflet_id) 33 .single(); 34 let rootEntity = data?.root_entity; 35 - if (!data || !rootEntity || !data.leaflets_in_publications[0]) 36 return ( 37 <div> 38 missin something ··· 42 43 let identity = await getIdentityData(); 44 if (!identity || !identity.atp_did) return null; 45 - let pub = data.leaflets_in_publications[0]; 46 - let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 47 48 let profile = await agent.getProfile({ actor: identity.atp_did }); 49 return ( 50 <ReplicacheProvider 51 rootEntity={rootEntity} ··· 57 leaflet_id={leaflet_id} 58 root_entity={rootEntity} 59 profile={profile.data} 60 - title={pub.title} 61 - publication_uri={pub.publication} 62 - description={pub.description} 63 - record={pub.publications?.record as PubLeafletPublication.Record} 64 - posts_in_pub={pub.publications?.documents_in_publications[0].count} 65 /> 66 </ReplicacheProvider> 67 );
··· 13 type Props = { 14 // this is now a token id not leaflet! Should probs rename 15 params: Promise<{ leaflet_id: string }>; 16 + searchParams: Promise<{ 17 + publication_uri: string; 18 + title: string; 19 + description: string; 20 + entitiesToDelete: string; 21 + }>; 22 }; 23 export default async function PublishLeafletPage(props: Props) { 24 let leaflet_id = (await props.params).leaflet_id; ··· 33 *, 34 documents_in_publications(count) 35 ), 36 + documents(*)), 37 + leaflets_to_documents( 38 + *, 39 + documents(*) 40 + )`, 41 ) 42 .eq("id", leaflet_id) 43 .single(); 44 let rootEntity = data?.root_entity; 45 + 46 + // Try to find publication from leaflets_in_publications first 47 + let publication = data?.leaflets_in_publications[0]?.publications; 48 + 49 + // If not found, check if publication_uri is in searchParams 50 + if (!publication) { 51 + let pub_uri = (await props.searchParams).publication_uri; 52 + if (pub_uri) { 53 + console.log(decodeURIComponent(pub_uri)); 54 + let { data: pubData, error } = await supabaseServerClient 55 + .from("publications") 56 + .select("*, documents_in_publications(count)") 57 + .eq("uri", decodeURIComponent(pub_uri)) 58 + .single(); 59 + console.log(error); 60 + publication = pubData; 61 + } 62 + } 63 + 64 + // Check basic data requirements 65 + if (!data || !rootEntity) 66 return ( 67 <div> 68 missin something ··· 72 73 let identity = await getIdentityData(); 74 if (!identity || !identity.atp_did) return null; 75 76 + // Get title and description from either source 77 + let title = 78 + data.leaflets_in_publications[0]?.title || 79 + data.leaflets_to_documents[0]?.title || 80 + decodeURIComponent((await props.searchParams).title || ""); 81 + let description = 82 + data.leaflets_in_publications[0]?.description || 83 + data.leaflets_to_documents[0]?.description || 84 + decodeURIComponent((await props.searchParams).description || ""); 85 + 86 + let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 87 let profile = await agent.getProfile({ actor: identity.atp_did }); 88 + 89 + // Parse entitiesToDelete from URL params 90 + let searchParams = await props.searchParams; 91 + let entitiesToDelete: string[] = []; 92 + try { 93 + if (searchParams.entitiesToDelete) { 94 + entitiesToDelete = JSON.parse( 95 + decodeURIComponent(searchParams.entitiesToDelete), 96 + ); 97 + } 98 + } catch (e) { 99 + // If parsing fails, just use empty array 100 + } 101 + 102 + // Check if a draft record exists (either in a publication or standalone) 103 + let hasDraft = 104 + data.leaflets_in_publications.length > 0 || 105 + data.leaflets_to_documents.length > 0; 106 + 107 return ( 108 <ReplicacheProvider 109 rootEntity={rootEntity} ··· 115 leaflet_id={leaflet_id} 116 root_entity={rootEntity} 117 profile={profile.data} 118 + title={title} 119 + description={description} 120 + publication_uri={publication?.uri} 121 + record={publication?.record as PubLeafletPublication.Record | undefined} 122 + posts_in_pub={publication?.documents_in_publications[0]?.count} 123 + entitiesToDelete={entitiesToDelete} 124 + hasDraft={hasDraft} 125 /> 126 </ReplicacheProvider> 127 );
+9 -8
app/[leaflet_id]/publish/publishBskyPost.ts
··· 12 import { createOauthClient } from "src/atproto-oauth"; 13 import { supabaseServerClient } from "supabase/serverClient"; 14 import { Json } from "supabase/database.types"; 15 16 export async function publishPostToBsky(args: { 17 text: string; ··· 31 credentialSession.fetchHandler.bind(credentialSession), 32 ); 33 let newPostUrl = args.url; 34 - let preview_image = await fetch( 35 - `https://pro.microlink.io/?url=${newPostUrl}&screenshot=true&viewport.width=1400&viewport.height=733&meta=false&embed=screenshot.url&force=true`, 36 - { 37 - headers: { 38 - "x-api-key": process.env.MICROLINK_API_KEY!, 39 - }, 40 - }, 41 - ); 42 43 let binary = await preview_image.blob(); 44 let resized_preview_image = await sharp(await binary.arrayBuffer())
··· 12 import { createOauthClient } from "src/atproto-oauth"; 13 import { supabaseServerClient } from "supabase/serverClient"; 14 import { Json } from "supabase/database.types"; 15 + import { 16 + getMicroLinkOgImage, 17 + getWebpageImage, 18 + } from "src/utils/getMicroLinkOgImage"; 19 20 export async function publishPostToBsky(args: { 21 text: string; ··· 35 credentialSession.fetchHandler.bind(credentialSession), 36 ); 37 let newPostUrl = args.url; 38 + let preview_image = await getWebpageImage(newPostUrl, { 39 + width: 1400, 40 + height: 733, 41 + noCache: true, 42 + }); 43 44 let binary = await preview_image.blob(); 45 let resized_preview_image = await sharp(await binary.arrayBuffer())
-30
app/about/page.tsx
··· 1 - import { LegalContent } from "app/legal/content"; 2 - import Link from "next/link"; 3 - 4 - export default function AboutPage() { 5 - return ( 6 - <div className="flex flex-col gap-2"> 7 - <div className="flex flex-col h-[80vh] mx-auto sm:px-4 px-3 sm:py-6 py-4 max-w-prose gap-4 text-lg"> 8 - <p> 9 - Leaflet.pub is a web app for instantly creating and collaborating on 10 - documents.{" "} 11 - <Link href="/" prefetch={false}> 12 - Click here 13 - </Link>{" "} 14 - to create one and get started! 15 - </p> 16 - 17 - <p> 18 - Leaflet is made by Learning Futures Inc. Previously we built{" "} 19 - <a href="https://hyperlink.academy">hyperlink.academy</a> 20 - </p> 21 - <p> 22 - You can find us on{" "} 23 - <a href="https://bsky.app/profile/leaflet.pub">Bluesky</a> or email as 24 - at <a href="mailto:contact@leaflet.pub">contact@leaflet.pub</a> 25 - </p> 26 - </div> 27 - <LegalContent /> 28 - </div> 29 - ); 30 - }
···
+6 -1
app/api/atproto_images/route.ts
··· 16 if (!service) return new NextResponse(null, { status: 404 }); 17 const response = await fetch( 18 `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${params.did}&cid=${params.cid}`, 19 ); 20 21 // Clone the response to modify headers ··· 24 // Set cache-control header to cache indefinitely 25 cachedResponse.headers.set( 26 "Cache-Control", 27 - "public, max-age=31536000, immutable", 28 ); 29 cachedResponse.headers.set( 30 "CDN-Cache-Control",
··· 16 if (!service) return new NextResponse(null, { status: 404 }); 17 const response = await fetch( 18 `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${params.did}&cid=${params.cid}`, 19 + { 20 + headers: { 21 + "Accept-Encoding": "gzip, deflate, br, zstd", 22 + }, 23 + }, 24 ); 25 26 // Clone the response to modify headers ··· 29 // Set cache-control header to cache indefinitely 30 cachedResponse.headers.set( 31 "Cache-Control", 32 + "public, max-age=31536000, immutable, s-maxage=86400, stale-while-revalidate=604800", 33 ); 34 cachedResponse.headers.set( 35 "CDN-Cache-Control",
+75
app/api/bsky/hydrate/route.ts
···
··· 1 + import { Agent, lexToJson } from "@atproto/api"; 2 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 3 + import { NextRequest } from "next/server"; 4 + 5 + export const runtime = "nodejs"; 6 + 7 + export async function GET(req: NextRequest) { 8 + try { 9 + const searchParams = req.nextUrl.searchParams; 10 + const urisParam = searchParams.get("uris"); 11 + 12 + if (!urisParam) { 13 + return Response.json( 14 + { error: "uris parameter is required" }, 15 + { status: 400 }, 16 + ); 17 + } 18 + 19 + // Parse URIs from JSON string 20 + let uris: string[]; 21 + try { 22 + uris = JSON.parse(urisParam); 23 + } catch (e) { 24 + return Response.json( 25 + { error: "uris must be valid JSON array" }, 26 + { status: 400 }, 27 + ); 28 + } 29 + 30 + if (!Array.isArray(uris)) { 31 + return Response.json({ error: "uris must be an array" }, { status: 400 }); 32 + } 33 + 34 + if (uris.length === 0) { 35 + return Response.json([], { 36 + headers: { 37 + "Cache-Control": "public, s-maxage=600, stale-while-revalidate=3600", 38 + }, 39 + }); 40 + } 41 + 42 + // Hydrate Bluesky URIs with post data 43 + let agent = new Agent({ 44 + service: "https://public.api.bsky.app", 45 + }); 46 + 47 + // Process URIs in batches of 25 48 + let allPostRequests = []; 49 + for (let i = 0; i < uris.length; i += 25) { 50 + let batch = uris.slice(i, i + 25); 51 + let batchPosts = agent.getPosts( 52 + { 53 + uris: batch, 54 + }, 55 + { headers: {} }, 56 + ); 57 + allPostRequests.push(batchPosts); 58 + } 59 + let allPosts = (await Promise.all(allPostRequests)).flatMap( 60 + (r) => r.data.posts, 61 + ); 62 + 63 + const posts = lexToJson(allPosts) as PostView[]; 64 + 65 + return Response.json(posts, { 66 + headers: { 67 + // Cache for 1 hour on CDN, allow stale content for 24 hours while revalidating 68 + "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", 69 + }, 70 + }); 71 + } catch (error) { 72 + console.error("Error hydrating Bluesky posts:", error); 73 + return Response.json({ error: "Failed to hydrate posts" }, { status: 500 }); 74 + } 75 + }
+5
app/api/inngest/client.ts
··· 3 import { EventSchemas } from "inngest"; 4 5 export type Events = { 6 "appview/profile-update": { 7 data: { 8 record: any;
··· 3 import { EventSchemas } from "inngest"; 4 5 export type Events = { 6 + "feeds/index-follows": { 7 + data: { 8 + did: string; 9 + }; 10 + }; 11 "appview/profile-update": { 12 data: { 13 record: any;
+158
app/api/inngest/functions/index_follows.ts
···
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { AtpAgent, AtUri } from "@atproto/api"; 3 + import { createIdentity } from "actions/createIdentity"; 4 + import { drizzle } from "drizzle-orm/node-postgres"; 5 + import { inngest } from "../client"; 6 + import { pool } from "supabase/pool"; 7 + 8 + export const index_follows = inngest.createFunction( 9 + { 10 + id: "index_follows", 11 + throttle: { 12 + limit: 1, 13 + period: "5m", 14 + key: "event.data.did", 15 + }, 16 + }, 17 + { event: "feeds/index-follows" }, 18 + async ({ event, step }) => { 19 + let follows: string[] = []; 20 + let cursor: null | string = null; 21 + let hasMore = true; 22 + let pageNumber = 0; 23 + while (hasMore) { 24 + let page: { 25 + cursor?: string; 26 + follows: string[]; 27 + } = await step.run(`get-follows-${pageNumber}`, async () => { 28 + let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 29 + let follows = await agent.app.bsky.graph.getFollows({ 30 + actor: event.data.did, 31 + limit: 100, 32 + cursor: cursor || undefined, 33 + }); 34 + if (!follows.success) 35 + throw new Error( 36 + "error during querying follows for: " + event.data.did, 37 + ); 38 + return { 39 + cursor: follows.data.cursor, 40 + follows: follows.data.follows.map((f) => f.did), 41 + }; 42 + }); 43 + pageNumber++; 44 + follows.push(...page.follows); 45 + cursor = page.cursor || null; 46 + if (!cursor) hasMore = false; 47 + } 48 + let existingFollows: string[] = []; 49 + const batchSize = 100; 50 + let batchNumber = 0; 51 + 52 + // Create all check batches in parallel 53 + const checkBatches: Promise<any>[] = [ 54 + step.run("check-if-identity-exists", async () => { 55 + let { data: exists } = await supabaseServerClient 56 + .from("identities") 57 + .select() 58 + .eq("atp_did", event.data.did) 59 + .single(); 60 + if (!exists) { 61 + const client = await pool.connect(); 62 + let db = drizzle(client); 63 + let identity = await createIdentity(db, { atp_did: event.data.did }); 64 + client.release(); 65 + return identity; 66 + } 67 + }), 68 + ]; 69 + for (let i = 0; i < follows.length; i += batchSize) { 70 + const batch = follows.slice(i, i + batchSize); 71 + checkBatches.push( 72 + step.run(`check-existing-follows-batch-${batchNumber}`, async () => { 73 + const { data: existingIdentities } = await supabaseServerClient 74 + .from("identities") 75 + .select("atp_did") 76 + .in("atp_did", batch); 77 + 78 + return existingIdentities?.map((identity) => identity.atp_did!) || []; 79 + }), 80 + ); 81 + batchNumber++; 82 + } 83 + 84 + // Wait for all check batches to complete 85 + const batchResults = await Promise.all(checkBatches); 86 + existingFollows = batchResults.flat().filter(Boolean); 87 + 88 + // Filter follows to only include those that exist in identities table 89 + const insertBatchSize = 100; 90 + let insertBatchNumber = 0; 91 + 92 + // Create all insert batches in parallel 93 + const insertBatches = []; 94 + for (let i = 0; i < existingFollows.length; i += insertBatchSize) { 95 + const batch = existingFollows.slice(i, i + insertBatchSize); 96 + insertBatches.push( 97 + step.run(`insert-follows-batch-${insertBatchNumber}`, async () => { 98 + const insertData = batch.map((f) => ({ 99 + identity: event.data.did, 100 + follows: f, 101 + })); 102 + 103 + return await supabaseServerClient 104 + .from("bsky_follows") 105 + .upsert(insertData); 106 + }), 107 + ); 108 + insertBatchNumber++; 109 + } 110 + 111 + // Wait for all insert batches to complete 112 + await Promise.all(insertBatches); 113 + 114 + // Delete follows that are no longer in the fetched list 115 + // For large follow lists, we need to batch this operation 116 + await step.run("delete-unfollowed", async () => { 117 + // Get all current follows from the database 118 + const { data: currentFollows } = await supabaseServerClient 119 + .from("bsky_follows") 120 + .select("follows") 121 + .eq("identity", event.data.did); 122 + 123 + if (!currentFollows || currentFollows.length === 0) { 124 + return { deleted: 0 }; 125 + } 126 + 127 + // Find follows that are in the database but not in the newly fetched list 128 + const currentFollowDids = currentFollows.map((f) => f.follows); 129 + const toDelete = currentFollowDids.filter( 130 + (did) => !existingFollows.includes(did) 131 + ); 132 + 133 + if (toDelete.length === 0) { 134 + return { deleted: 0 }; 135 + } 136 + 137 + // Delete in batches to avoid query size limits 138 + const deleteBatchSize = 100; 139 + const deletePromises = []; 140 + for (let i = 0; i < toDelete.length; i += deleteBatchSize) { 141 + const batch = toDelete.slice(i, i + deleteBatchSize); 142 + deletePromises.push( 143 + supabaseServerClient 144 + .from("bsky_follows") 145 + .delete() 146 + .eq("identity", event.data.did) 147 + .in("follows", batch) 148 + ); 149 + } 150 + 151 + await Promise.all(deletePromises); 152 + return { deleted: toDelete.length }; 153 + }); 154 + return { 155 + done: true, 156 + }; 157 + }, 158 + );
+80 -17
app/api/inngest/functions/index_post_mention.ts
··· 3 import { AtpAgent, AtUri } from "@atproto/api"; 4 import { Json } from "supabase/database.types"; 5 import { ids } from "lexicons/api/lexicons"; 6 7 export const index_post_mention = inngest.createFunction( 8 { id: "index_post_mention" }, ··· 11 let url = new URL(event.data.document_link); 12 let path = url.pathname.split("/").filter(Boolean); 13 14 - let { data: pub, error } = await supabaseServerClient 15 - .from("publications") 16 - .select("*") 17 - .eq("record->>base_path", url.host) 18 - .single(); 19 20 - if (!pub) { 21 - return { 22 - message: `No publication found for ${url.host}/${path[0]}`, 23 - error, 24 - }; 25 } 26 27 let bsky_post = await step.run("get-bsky-post-data", async () => { ··· 38 } 39 40 await step.run("index-bsky-post", async () => { 41 - await supabaseServerClient.from("bsky_posts").insert({ 42 uri: bsky_post.uri, 43 cid: bsky_post.cid, 44 post_view: bsky_post as Json, 45 }); 46 - await supabaseServerClient.from("document_mentions_in_bsky").insert({ 47 uri: bsky_post.uri, 48 - document: AtUri.make( 49 - pub.identity_did, 50 - ids.PubLeafletDocument, 51 - path[0], 52 - ).toString(), 53 link: event.data.document_link, 54 }); 55 }); 56 }, 57 );
··· 3 import { AtpAgent, AtUri } from "@atproto/api"; 4 import { Json } from "supabase/database.types"; 5 import { ids } from "lexicons/api/lexicons"; 6 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 7 + import { v7 } from "uuid"; 8 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 9 10 export const index_post_mention = inngest.createFunction( 11 { id: "index_post_mention" }, ··· 14 let url = new URL(event.data.document_link); 15 let path = url.pathname.split("/").filter(Boolean); 16 17 + // Check if this is a standalone document URL (/p/didOrHandle/rkey/...) 18 + const isStandaloneDoc = path[0] === "p" && path.length >= 3; 19 + 20 + let documentUri: string; 21 + let authorDid: string; 22 + 23 + if (isStandaloneDoc) { 24 + // Standalone doc: /p/didOrHandle/rkey/l-quote/... 25 + const didOrHandle = decodeURIComponent(path[1]); 26 + const rkey = path[2]; 27 + 28 + // Resolve handle to DID if necessary 29 + let did = didOrHandle; 30 + if (!didOrHandle.startsWith("did:")) { 31 + const resolved = await step.run("resolve-handle", async () => { 32 + return idResolver.handle.resolve(didOrHandle); 33 + }); 34 + if (!resolved) { 35 + return { message: `Could not resolve handle: ${didOrHandle}` }; 36 + } 37 + did = resolved; 38 + } 39 40 + documentUri = AtUri.make(did, ids.PubLeafletDocument, rkey).toString(); 41 + authorDid = did; 42 + } else { 43 + // Publication post: look up by custom domain 44 + let { data: pub, error } = await supabaseServerClient 45 + .from("publications") 46 + .select("*") 47 + .eq("record->>base_path", url.host) 48 + .single(); 49 + 50 + if (!pub) { 51 + return { 52 + message: `No publication found for ${url.host}/${path[0]}`, 53 + error, 54 + }; 55 + } 56 + 57 + documentUri = AtUri.make( 58 + pub.identity_did, 59 + ids.PubLeafletDocument, 60 + path[0], 61 + ).toString(); 62 + authorDid = pub.identity_did; 63 } 64 65 let bsky_post = await step.run("get-bsky-post-data", async () => { ··· 76 } 77 78 await step.run("index-bsky-post", async () => { 79 + await supabaseServerClient.from("bsky_posts").upsert({ 80 uri: bsky_post.uri, 81 cid: bsky_post.cid, 82 post_view: bsky_post as Json, 83 }); 84 + await supabaseServerClient.from("document_mentions_in_bsky").upsert({ 85 uri: bsky_post.uri, 86 + document: documentUri, 87 link: event.data.document_link, 88 }); 89 + }); 90 + 91 + await step.run("create-notification", async () => { 92 + // Only create notification if the quote is from someone other than the author 93 + if (bsky_post.author.did !== authorDid) { 94 + // Check if a notification already exists for this post and recipient 95 + const { data: existingNotification } = await supabaseServerClient 96 + .from("notifications") 97 + .select("id") 98 + .eq("recipient", authorDid) 99 + .eq("data->>type", "quote") 100 + .eq("data->>bsky_post_uri", bsky_post.uri) 101 + .eq("data->>document_uri", documentUri) 102 + .single(); 103 + 104 + if (!existingNotification) { 105 + const notification: Notification = { 106 + id: v7(), 107 + recipient: authorDid, 108 + data: { 109 + type: "quote", 110 + bsky_post_uri: bsky_post.uri, 111 + document_uri: documentUri, 112 + }, 113 + }; 114 + await supabaseServerClient.from("notifications").insert(notification); 115 + await pingIdentityToUpdateNotification(authorDid); 116 + } 117 + } 118 }); 119 }, 120 );
+7 -2
app/api/inngest/route.tsx
··· 3 import { index_post_mention } from "./functions/index_post_mention"; 4 import { come_online } from "./functions/come_online"; 5 import { batched_update_profiles } from "./functions/batched_update_profiles"; 6 7 - // Create an API that serves zero functions 8 export const { GET, POST, PUT } = serve({ 9 client: inngest, 10 - functions: [index_post_mention, come_online, batched_update_profiles], 11 });
··· 3 import { index_post_mention } from "./functions/index_post_mention"; 4 import { come_online } from "./functions/come_online"; 5 import { batched_update_profiles } from "./functions/batched_update_profiles"; 6 + import { index_follows } from "./functions/index_follows"; 7 8 export const { GET, POST, PUT } = serve({ 9 client: inngest, 10 + functions: [ 11 + index_post_mention, 12 + come_online, 13 + batched_update_profiles, 14 + index_follows, 15 + ], 16 });
+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 import { makeRoute } from "../lib"; 5 import type { Env } from "./route"; 6 import { scanIndexLocal } from "src/replicache/utils"; 7 - import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 8 import * as base64 from "base64-js"; 9 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 10 import { applyUpdate, Doc } from "yjs"; 11 12 export const getFactsFromHomeLeaflets = makeRoute({ ··· 35 let scan = scanIndexLocal(facts[token]); 36 let [root] = scan.eav(token, "root/page"); 37 let rootEntity = root?.data.value || token; 38 - let [title] = getBlocksWithTypeLocal(facts[token], rootEntity).filter( 39 - (b) => b.type === "text" || b.type === "heading", 40 - ); 41 if (!title) titles[token] = "Untitled"; 42 else { 43 let [content] = scan.eav(title.value, "block/text");
··· 4 import { makeRoute } from "../lib"; 5 import type { Env } from "./route"; 6 import { scanIndexLocal } from "src/replicache/utils"; 7 import * as base64 from "base64-js"; 8 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 9 import { applyUpdate, Doc } from "yjs"; 10 11 export const getFactsFromHomeLeaflets = makeRoute({ ··· 34 let scan = scanIndexLocal(facts[token]); 35 let [root] = scan.eav(token, "root/page"); 36 let rootEntity = root?.data.value || token; 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 + 70 if (!title) titles[token] = "Untitled"; 71 else { 72 let [content] = scan.eav(title.value, "block/text");
+4 -2
app/api/rpc/[command]/get_leaflet_data.ts
··· 7 >; 8 9 const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`; 10 export const get_leaflet_data = makeRoute({ 11 route: "get_leaflet_data", 12 input: z.object({ ··· 18 .from("permission_tokens") 19 .select( 20 `*, 21 - permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}))), 22 custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), 23 - ${leaflets_in_publications_query}`, 24 ) 25 .eq("id", token_id) 26 .single();
··· 7 >; 8 9 const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`; 10 + const leaflets_to_documents_query = `leaflets_to_documents(*, documents(*))`; 11 export const get_leaflet_data = makeRoute({ 12 route: "get_leaflet_data", 13 input: z.object({ ··· 19 .from("permission_tokens") 20 .select( 21 `*, 22 + permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}, ${leaflets_to_documents_query}))), 23 custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), 24 + ${leaflets_in_publications_query}, 25 + ${leaflets_to_documents_query}`, 26 ) 27 .eq("id", token_id) 28 .single();
+6
app/api/rpc/[command]/pull.ts
··· 73 let publication_data = data.publications as { 74 description: string; 75 title: string; 76 }[]; 77 let pub_patch = publication_data?.[0] 78 ? [ ··· 85 op: "put", 86 key: "publication_title", 87 value: publication_data[0].title, 88 }, 89 ] 90 : [];
··· 73 let publication_data = data.publications as { 74 description: string; 75 title: string; 76 + tags: string[]; 77 }[]; 78 let pub_patch = publication_data?.[0] 79 ? [ ··· 86 op: "put", 87 key: "publication_title", 88 value: publication_data[0].title, 89 + }, 90 + { 91 + op: "put", 92 + key: "publication_tags", 93 + value: publication_data[0].tags || [], 94 }, 95 ] 96 : [];
+4
app/api/rpc/[command]/route.ts
··· 11 } from "./domain_routes"; 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 import { get_publication_data } from "./get_publication_data"; 14 15 let supabase = createClient<Database>( 16 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 35 get_leaflet_subdomain_status, 36 get_leaflet_data, 37 get_publication_data, 38 ]; 39 export async function POST( 40 req: Request,
··· 11 } from "./domain_routes"; 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 import { get_publication_data } from "./get_publication_data"; 14 + import { search_publication_names } from "./search_publication_names"; 15 + import { search_publication_documents } from "./search_publication_documents"; 16 17 let supabase = createClient<Database>( 18 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 37 get_leaflet_subdomain_status, 38 get_leaflet_data, 39 get_publication_data, 40 + search_publication_names, 41 + search_publication_documents, 42 ]; 43 export async function POST( 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 + });
-74
app/discover/PubListing.tsx
··· 1 - "use client"; 2 - import { AtUri } from "@atproto/syntax"; 3 - import { PublicationSubscription } from "app/reader/getSubscriptions"; 4 - import { PubIcon } from "components/ActionBar/Publications"; 5 - import { Separator } from "components/Layout"; 6 - import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 7 - import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 8 - import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 10 - import { timeAgo } from "src/utils/timeAgo"; 11 - import { Json } from "supabase/database.types"; 12 - 13 - export const PubListing = ( 14 - props: PublicationSubscription & { 15 - resizeHeight?: boolean; 16 - }, 17 - ) => { 18 - let record = props.record as PubLeafletPublication.Record; 19 - let theme = usePubTheme(record); 20 - let backgroundImage = record?.theme?.backgroundImage?.image?.ref 21 - ? blobRefToSrc( 22 - record?.theme?.backgroundImage?.image?.ref, 23 - new AtUri(props.uri).host, 24 - ) 25 - : null; 26 - 27 - let backgroundImageRepeat = record?.theme?.backgroundImage?.repeat; 28 - let backgroundImageSize = record?.theme?.backgroundImage?.width || 500; 29 - if (!record) return null; 30 - return ( 31 - <BaseThemeProvider {...theme} local> 32 - <a 33 - href={`https://${record.base_path}`} 34 - style={{ 35 - backgroundImage: `url(${backgroundImage})`, 36 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 37 - backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 38 - }} 39 - className={`no-underline! flex flex-row gap-2 40 - bg-bg-leaflet 41 - border border-border-light rounded-lg 42 - px-3 py-3 selected-outline 43 - hover:outline-accent-contrast hover:border-accent-contrast`} 44 - > 45 - <div 46 - className={`flex w-full flex-col justify-center text-center max-h-48 pt-4 pb-3 px-3 rounded-lg ${props.resizeHeight ? "" : "sm:h-48 h-full"} ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] " : ""}`} 47 - > 48 - <div className="mx-auto pb-1"> 49 - <PubIcon record={record} uri={props.uri} large /> 50 - </div> 51 - 52 - <h4 className="truncate shrink-0 ">{record.name}</h4> 53 - {record.description && ( 54 - <p className="text-secondary text-sm max-h-full overflow-hidden pb-1"> 55 - {record.description} 56 - </p> 57 - )} 58 - <div className="flex flex-col items-center justify-center text-xs text-tertiary pt-2"> 59 - <div className="flex flex-row gap-2 items-center"> 60 - {props.authorProfile?.handle} 61 - </div> 62 - <p> 63 - Updated{" "} 64 - {timeAgo( 65 - props.documents_in_publications?.[0]?.documents?.indexed_at || 66 - "", 67 - )} 68 - </p> 69 - </div> 70 - </div> 71 - </a> 72 - </BaseThemeProvider> 73 - ); 74 - };
···
-97
app/discover/SortButtons.tsx
··· 1 - "use client"; 2 - import Link from "next/link"; 3 - import { useState } from "react"; 4 - import { theme } from "tailwind.config"; 5 - 6 - export default function SortButtons(props: { order: string }) { 7 - const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( 8 - "recentlyUpdated", 9 - ); 10 - 11 - return ( 12 - <div className="flex gap-2 pt-1"> 13 - <Link href="?order=recentlyUpdated"> 14 - <SortButton selected={props.order === "recentlyUpdated"}> 15 - Recently Updated 16 - </SortButton> 17 - </Link> 18 - 19 - <Link href="?order=popular"> 20 - <SortButton selected={props.order === "popular"}>Popular</SortButton> 21 - </Link> 22 - </div> 23 - ); 24 - } 25 - 26 - const SortButton = (props: { 27 - children: React.ReactNode; 28 - selected: boolean; 29 - }) => { 30 - return ( 31 - <div className="relative"> 32 - <button 33 - style={ 34 - props.selected 35 - ? { backgroundColor: `rgba(var(--accent-1), 0.2)` } 36 - : {} 37 - } 38 - className={`text-sm rounded-md px-[8px] py-0.5 border ${props.selected ? "border-accent-contrast text-accent-1 font-bold" : "text-tertiary border-border-light"}`} 39 - > 40 - {props.children} 41 - </button> 42 - {props.selected && ( 43 - <> 44 - <div className="absolute top-0 -left-2"> 45 - <GlitterBig /> 46 - </div> 47 - <div className="absolute top-4 left-0"> 48 - <GlitterSmall /> 49 - </div> 50 - <div className="absolute -top-2 -right-1"> 51 - <GlitterSmall /> 52 - </div> 53 - </> 54 - )} 55 - </div> 56 - ); 57 - }; 58 - 59 - const GlitterBig = () => { 60 - return ( 61 - <svg 62 - width="16" 63 - height="17" 64 - viewBox="0 0 16 17" 65 - fill="none" 66 - xmlns="http://www.w3.org/2000/svg" 67 - > 68 - <path 69 - d="M8.16553 0.804321C8.5961 0.804329 8.97528 1.03925 9.22803 1.40393C9.47845 1.76546 9.6128 2.25816 9.61279 2.84338C9.61279 2.98187 9.6178 3.11647 9.62646 3.2467C9.65365 3.65499 9.72104 4.02319 9.81006 4.35022C10.0833 5.35388 10.5641 5.96726 10.7349 6.14221C10.7443 6.15184 10.7543 6.16234 10.7642 6.17249C10.9808 6.39533 11.3925 6.8162 12.0142 7.09338C12.206 7.17892 12.4177 7.2502 12.6489 7.29749C12.8402 7.3366 13.0466 7.35993 13.2681 7.35999H13.269C14.2688 7.36032 14.9747 7.96603 14.9771 8.77014C14.9793 9.57755 14.272 10.1833 13.2681 10.1832C13.0278 10.1832 12.8137 10.2034 12.6226 10.2369C12.3793 10.2796 12.1697 10.3455 11.9858 10.4254C11.4714 10.6492 11.1325 10.9918 10.7935 11.3405C10.7739 11.3605 10.7544 11.381 10.7349 11.401C10.3936 11.7507 10.0271 12.1792 9.81006 12.9352C9.72175 13.2428 9.65679 13.6119 9.63135 14.0592C9.62378 14.1924 9.61963 14.3325 9.61963 14.4801C9.61963 15.5836 9.06909 16.4876 8.17822 16.4996C7.74928 16.5053 7.36767 16.2783 7.11182 15.9147C6.85918 15.5556 6.72412 15.065 6.72412 14.4801C6.72412 14.3385 6.71808 14.2015 6.70654 14.069C6.6724 13.6774 6.59177 13.324 6.48779 13.0123C6.16402 12.0419 5.61395 11.4722 5.54443 11.401C5.54371 11.4003 5.54043 11.3977 5.53467 11.3922C5.52778 11.3857 5.51839 11.3767 5.50635 11.3658C5.4823 11.3442 5.44954 11.3158 5.40869 11.2819C5.3268 11.2139 5.21473 11.1255 5.07764 11.0289C4.80173 10.8346 4.43374 10.6113 4.01611 10.443C3.82579 10.3663 3.62728 10.3019 3.42432 10.2565C3.21687 10.21 3.00599 10.1832 2.79541 10.1832C1.79834 10.1832 1.11533 9.56575 1.11865 8.76917C1.12219 7.9773 1.80451 7.36002 2.79541 7.35999C3.01821 7.35999 3.22798 7.33422 3.42432 7.29065C3.62557 7.24597 3.81426 7.18216 3.98877 7.10608C4.6567 6.81484 5.10772 6.35442 5.3042 6.15295C5.30777 6.1493 5.31147 6.14577 5.31494 6.14221C5.51076 5.94157 6.14024 5.28964 6.48584 4.26233C6.59001 3.95264 6.66793 3.60887 6.70068 3.23303C6.71166 3.10697 6.71826 2.977 6.71826 2.84338L6.72412 2.62854C6.75331 2.13723 6.88387 1.72031 7.10303 1.40393C7.35578 1.03923 7.73495 0.804326 8.16553 0.804321Z" 70 - fill={theme.colors["accent-1"]} 71 - stroke={theme.colors["bg-leaflet"]} 72 - strokeLinecap="round" 73 - strokeLinejoin="round" 74 - /> 75 - </svg> 76 - ); 77 - }; 78 - 79 - const GlitterSmall = () => { 80 - return ( 81 - <svg 82 - width="13" 83 - height="14" 84 - viewBox="0 0 13 14" 85 - fill="none" 86 - xmlns="http://www.w3.org/2000/svg" 87 - > 88 - <path 89 - d="M6.37585 1.23596C6.7489 1.23598 7.07064 1.44034 7.28015 1.7428C7.48716 2.04187 7.59266 2.4408 7.59265 2.901C7.59266 3.00294 7.59605 3.10213 7.60242 3.19788C7.62244 3.49844 7.67183 3.76938 7.73718 4.0094C7.93813 4.74731 8.29123 5.1934 8.4071 5.31213L8.57703 5.48206C8.75042 5.64731 9.00188 5.85577 9.33777 6.00549C9.47565 6.06695 9.62723 6.11812 9.79285 6.15198C9.92991 6.18 10.0779 6.1959 10.2372 6.19592C11.0418 6.19604 11.6503 6.69195 11.6522 7.38538C11.654 8.08176 11.0444 8.57683 10.2372 8.57678C10.0618 8.57678 9.90679 8.59077 9.76941 8.61487C9.59484 8.6455 9.4456 8.69297 9.31531 8.74963C8.95055 8.9083 8.70884 9.15057 8.45203 9.41467C8.43719 9.42993 8.42207 9.44621 8.4071 9.46155C8.15582 9.71904 7.89358 10.0262 7.73718 10.5709C7.67315 10.7941 7.62512 11.064 7.60632 11.3942C7.60073 11.4925 7.59754 11.5963 7.59753 11.7057C7.59753 12.5657 7.16303 13.3455 6.38757 13.3561C6.01608 13.3611 5.6911 13.1642 5.47839 12.8619C5.26902 12.5643 5.16394 12.1657 5.16394 11.7057C5.16393 11.6022 5.15871 11.5017 5.15027 11.4049C5.1253 11.1189 5.06701 10.861 4.99109 10.6334C4.75475 9.92518 4.35324 9.51044 4.30554 9.46155C4.30554 9.46155 4.27494 9.43195 4.21179 9.37952C4.15207 9.32993 4.06961 9.26511 3.96863 9.19397C3.76515 9.05064 3.49524 8.88718 3.19031 8.76428C3.05151 8.70835 2.90782 8.66129 2.7616 8.62854C2.61218 8.59509 2.4616 8.57679 2.31238 8.57678C1.50706 8.57678 0.918891 8.07006 0.921753 7.3844C0.924612 6.70329 1.51125 6.19594 2.31238 6.19592C2.47154 6.19591 2.6213 6.17821 2.7616 6.14709C2.90554 6.11514 3.04128 6.06904 3.16687 6.01428C3.64904 5.80395 3.97684 5.47074 4.1239 5.31995C4.12648 5.3173 4.12918 5.31473 4.13171 5.31213C4.27635 5.16393 4.73656 4.68608 4.98914 3.93518C5.06511 3.70931 5.12247 3.45905 5.14636 3.18518C5.15436 3.09338 5.15905 2.99838 5.15906 2.901C5.15906 2.44078 5.26453 2.04187 5.47156 1.7428C5.68108 1.44033 6.00279 1.23595 6.37585 1.23596Z" 90 - fill={theme.colors["accent-1"]} 91 - stroke={theme.colors["bg-leaflet"]} 92 - strokeLinecap="round" 93 - strokeLinejoin="round" 94 - /> 95 - </svg> 96 - ); 97 - };
···
-148
app/discover/SortedPublicationList.tsx
··· 1 - "use client"; 2 - import Link from "next/link"; 3 - import { useState } from "react"; 4 - import { theme } from "tailwind.config"; 5 - import { PublicationsList } from "./page"; 6 - import { PubListing } from "./PubListing"; 7 - 8 - export function SortedPublicationList(props: { 9 - publications: PublicationsList; 10 - order: string; 11 - }) { 12 - let [order, setOrder] = useState(props.order); 13 - return ( 14 - <div className="discoverHeader flex flex-col items-center "> 15 - <SortButtons 16 - order={order} 17 - setOrder={(o) => { 18 - const url = new URL(window.location.href); 19 - url.searchParams.set("order", o); 20 - window.history.pushState({}, "", url); 21 - setOrder(o); 22 - }} 23 - /> 24 - <div className="discoverPubList flex flex-col gap-3 pt-6 w-full"> 25 - {props.publications 26 - ?.filter((pub) => pub.documents_in_publications.length > 0) 27 - ?.sort((a, b) => { 28 - if (order === "popular") { 29 - return ( 30 - b.publication_subscriptions[0].count - 31 - a.publication_subscriptions[0].count 32 - ); 33 - } 34 - const aDate = new Date( 35 - a.documents_in_publications[0]?.indexed_at || 0, 36 - ); 37 - const bDate = new Date( 38 - b.documents_in_publications[0]?.indexed_at || 0, 39 - ); 40 - return bDate.getTime() - aDate.getTime(); 41 - }) 42 - .map((pub) => <PubListing resizeHeight key={pub.uri} {...pub} />)} 43 - </div> 44 - </div> 45 - ); 46 - } 47 - 48 - export default function SortButtons(props: { 49 - order: string; 50 - setOrder: (order: string) => void; 51 - }) { 52 - const [selected, setSelected] = useState<"recentlyUpdated" | "popular">( 53 - "recentlyUpdated", 54 - ); 55 - 56 - return ( 57 - <div className="flex gap-2 pt-1"> 58 - <SortButton 59 - selected={props.order === "recentlyUpdated"} 60 - onClick={() => props.setOrder("recentlyUpdated")} 61 - > 62 - Recently Updated 63 - </SortButton> 64 - 65 - <SortButton 66 - selected={props.order === "popular"} 67 - onClick={() => props.setOrder("popular")} 68 - > 69 - Popular 70 - </SortButton> 71 - </div> 72 - ); 73 - } 74 - 75 - const SortButton = (props: { 76 - children: React.ReactNode; 77 - onClick: () => void; 78 - selected: boolean; 79 - }) => { 80 - return ( 81 - <div className="relative"> 82 - <button 83 - onClick={props.onClick} 84 - style={ 85 - props.selected 86 - ? { backgroundColor: `rgba(var(--accent-1), 0.2)` } 87 - : {} 88 - } 89 - className={`text-sm rounded-md px-[8px] py-0.5 border ${props.selected ? "border-accent-contrast text-accent-1 font-bold" : "text-tertiary border-border-light"}`} 90 - > 91 - {props.children} 92 - </button> 93 - {props.selected && ( 94 - <> 95 - <div className="absolute top-0 -left-2"> 96 - <GlitterBig /> 97 - </div> 98 - <div className="absolute top-4 left-0"> 99 - <GlitterSmall /> 100 - </div> 101 - <div className="absolute -top-2 -right-1"> 102 - <GlitterSmall /> 103 - </div> 104 - </> 105 - )} 106 - </div> 107 - ); 108 - }; 109 - 110 - const GlitterBig = () => { 111 - return ( 112 - <svg 113 - width="16" 114 - height="17" 115 - viewBox="0 0 16 17" 116 - fill="none" 117 - xmlns="http://www.w3.org/2000/svg" 118 - > 119 - <path 120 - d="M8.16553 0.804321C8.5961 0.804329 8.97528 1.03925 9.22803 1.40393C9.47845 1.76546 9.6128 2.25816 9.61279 2.84338C9.61279 2.98187 9.6178 3.11647 9.62646 3.2467C9.65365 3.65499 9.72104 4.02319 9.81006 4.35022C10.0833 5.35388 10.5641 5.96726 10.7349 6.14221C10.7443 6.15184 10.7543 6.16234 10.7642 6.17249C10.9808 6.39533 11.3925 6.8162 12.0142 7.09338C12.206 7.17892 12.4177 7.2502 12.6489 7.29749C12.8402 7.3366 13.0466 7.35993 13.2681 7.35999H13.269C14.2688 7.36032 14.9747 7.96603 14.9771 8.77014C14.9793 9.57755 14.272 10.1833 13.2681 10.1832C13.0278 10.1832 12.8137 10.2034 12.6226 10.2369C12.3793 10.2796 12.1697 10.3455 11.9858 10.4254C11.4714 10.6492 11.1325 10.9918 10.7935 11.3405C10.7739 11.3605 10.7544 11.381 10.7349 11.401C10.3936 11.7507 10.0271 12.1792 9.81006 12.9352C9.72175 13.2428 9.65679 13.6119 9.63135 14.0592C9.62378 14.1924 9.61963 14.3325 9.61963 14.4801C9.61963 15.5836 9.06909 16.4876 8.17822 16.4996C7.74928 16.5053 7.36767 16.2783 7.11182 15.9147C6.85918 15.5556 6.72412 15.065 6.72412 14.4801C6.72412 14.3385 6.71808 14.2015 6.70654 14.069C6.6724 13.6774 6.59177 13.324 6.48779 13.0123C6.16402 12.0419 5.61395 11.4722 5.54443 11.401C5.54371 11.4003 5.54043 11.3977 5.53467 11.3922C5.52778 11.3857 5.51839 11.3767 5.50635 11.3658C5.4823 11.3442 5.44954 11.3158 5.40869 11.2819C5.3268 11.2139 5.21473 11.1255 5.07764 11.0289C4.80173 10.8346 4.43374 10.6113 4.01611 10.443C3.82579 10.3663 3.62728 10.3019 3.42432 10.2565C3.21687 10.21 3.00599 10.1832 2.79541 10.1832C1.79834 10.1832 1.11533 9.56575 1.11865 8.76917C1.12219 7.9773 1.80451 7.36002 2.79541 7.35999C3.01821 7.35999 3.22798 7.33422 3.42432 7.29065C3.62557 7.24597 3.81426 7.18216 3.98877 7.10608C4.6567 6.81484 5.10772 6.35442 5.3042 6.15295C5.30777 6.1493 5.31147 6.14577 5.31494 6.14221C5.51076 5.94157 6.14024 5.28964 6.48584 4.26233C6.59001 3.95264 6.66793 3.60887 6.70068 3.23303C6.71166 3.10697 6.71826 2.977 6.71826 2.84338L6.72412 2.62854C6.75331 2.13723 6.88387 1.72031 7.10303 1.40393C7.35578 1.03923 7.73495 0.804326 8.16553 0.804321Z" 121 - fill={theme.colors["accent-1"]} 122 - stroke={theme.colors["bg-leaflet"]} 123 - strokeLinecap="round" 124 - strokeLinejoin="round" 125 - /> 126 - </svg> 127 - ); 128 - }; 129 - 130 - const GlitterSmall = () => { 131 - return ( 132 - <svg 133 - width="13" 134 - height="14" 135 - viewBox="0 0 13 14" 136 - fill="none" 137 - xmlns="http://www.w3.org/2000/svg" 138 - > 139 - <path 140 - d="M6.37585 1.23596C6.7489 1.23598 7.07064 1.44034 7.28015 1.7428C7.48716 2.04187 7.59266 2.4408 7.59265 2.901C7.59266 3.00294 7.59605 3.10213 7.60242 3.19788C7.62244 3.49844 7.67183 3.76938 7.73718 4.0094C7.93813 4.74731 8.29123 5.1934 8.4071 5.31213L8.57703 5.48206C8.75042 5.64731 9.00188 5.85577 9.33777 6.00549C9.47565 6.06695 9.62723 6.11812 9.79285 6.15198C9.92991 6.18 10.0779 6.1959 10.2372 6.19592C11.0418 6.19604 11.6503 6.69195 11.6522 7.38538C11.654 8.08176 11.0444 8.57683 10.2372 8.57678C10.0618 8.57678 9.90679 8.59077 9.76941 8.61487C9.59484 8.6455 9.4456 8.69297 9.31531 8.74963C8.95055 8.9083 8.70884 9.15057 8.45203 9.41467C8.43719 9.42993 8.42207 9.44621 8.4071 9.46155C8.15582 9.71904 7.89358 10.0262 7.73718 10.5709C7.67315 10.7941 7.62512 11.064 7.60632 11.3942C7.60073 11.4925 7.59754 11.5963 7.59753 11.7057C7.59753 12.5657 7.16303 13.3455 6.38757 13.3561C6.01608 13.3611 5.6911 13.1642 5.47839 12.8619C5.26902 12.5643 5.16394 12.1657 5.16394 11.7057C5.16393 11.6022 5.15871 11.5017 5.15027 11.4049C5.1253 11.1189 5.06701 10.861 4.99109 10.6334C4.75475 9.92518 4.35324 9.51044 4.30554 9.46155C4.30554 9.46155 4.27494 9.43195 4.21179 9.37952C4.15207 9.32993 4.06961 9.26511 3.96863 9.19397C3.76515 9.05064 3.49524 8.88718 3.19031 8.76428C3.05151 8.70835 2.90782 8.66129 2.7616 8.62854C2.61218 8.59509 2.4616 8.57679 2.31238 8.57678C1.50706 8.57678 0.918891 8.07006 0.921753 7.3844C0.924612 6.70329 1.51125 6.19594 2.31238 6.19592C2.47154 6.19591 2.6213 6.17821 2.7616 6.14709C2.90554 6.11514 3.04128 6.06904 3.16687 6.01428C3.64904 5.80395 3.97684 5.47074 4.1239 5.31995C4.12648 5.3173 4.12918 5.31473 4.13171 5.31213C4.27635 5.16393 4.73656 4.68608 4.98914 3.93518C5.06511 3.70931 5.12247 3.45905 5.14636 3.18518C5.15436 3.09338 5.15905 2.99838 5.15906 2.901C5.15906 2.44078 5.26453 2.04187 5.47156 1.7428C5.68108 1.44033 6.00279 1.23595 6.37585 1.23596Z" 141 - fill={theme.colors["accent-1"]} 142 - stroke={theme.colors["bg-leaflet"]} 143 - strokeLinecap="round" 144 - strokeLinejoin="round" 145 - /> 146 - </svg> 147 - ); 148 - };
···
-69
app/discover/page.tsx
··· 1 - import { supabaseServerClient } from "supabase/serverClient"; 2 - import Link from "next/link"; 3 - import { SortedPublicationList } from "./SortedPublicationList"; 4 - import { Metadata } from "next"; 5 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 6 - 7 - export const dynamic = "force-static"; 8 - export const revalidate = 60; 9 - 10 - export type PublicationsList = Awaited<ReturnType<typeof getPublications>>; 11 - async function getPublications() { 12 - let { data: publications, error } = await supabaseServerClient 13 - .from("publications") 14 - .select( 15 - "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", 16 - ) 17 - .or( 18 - "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 19 - ) 20 - .order("indexed_at", { 21 - referencedTable: "documents_in_publications", 22 - ascending: false, 23 - }) 24 - .limit(1, { referencedTable: "documents_in_publications" }); 25 - return publications; 26 - } 27 - 28 - export const metadata: Metadata = { 29 - title: "Leaflet Discover", 30 - description: "Explore publications on Leaflet โœจ Or make your own!", 31 - }; 32 - 33 - export default async function Discover(props: { 34 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 35 - }) { 36 - let order = ((await props.searchParams).order as string) || "recentlyUpdated"; 37 - let publications = await getPublications(); 38 - 39 - return ( 40 - <div className="w-full h-full mx-auto bg-[#FDFCFA]"> 41 - <DashboardLayout 42 - id="discover" 43 - cardBorderHidden={false} 44 - currentPage="discover" 45 - defaultTab="default" 46 - actions={null} 47 - tabs={{ 48 - default: { 49 - controls: null, 50 - content: <DiscoverContent order={order} />, 51 - }, 52 - }} 53 - /> 54 - </div> 55 - ); 56 - } 57 - 58 - const DiscoverContent = async (props: { order: string }) => { 59 - let publications = await getPublications(); 60 - 61 - return ( 62 - <div className="max-w-prose mx-auto w-full"> 63 - <div className="discoverHeader flex flex-col items-center text-center pt-2 px-4"> 64 - <h1>Discover</h1> 65 - </div> 66 - <SortedPublicationList publications={publications} order={props.order} /> 67 - </div> 68 - ); 69 - };
···
+40
app/globals.css
··· 96 --accent-2: 255, 255, 255; 97 --accent-contrast: 0, 0, 225; 98 --accent-1-is-contrast: "true"; 99 100 --highlight-1: 255, 177, 177; 101 --highlight-2: 253, 245, 203; ··· 206 207 /* END GLOBAL STYLING */ 208 } 209 button:hover { 210 cursor: pointer; 211 } ··· 286 @apply py-[1.5px]; 287 } 288 289 .ProseMirror:focus-within .selection-highlight { 290 background-color: transparent; 291 } ··· 334 @apply focus-within:outline-offset-1; 335 336 @apply disabled:border-border-light; 337 @apply disabled:bg-border-light; 338 @apply disabled:text-tertiary; 339 } ··· 365 @apply pl-3; 366 @apply ml-2; 367 } 368 .transparent-container { 369 @apply border; 370 @apply border-border-light; ··· 392 rgb(var(--bg-page)) 85% 393 ); 394 @apply rounded-md; 395 } 396 397 .pwa-padding {
··· 96 --accent-2: 255, 255, 255; 97 --accent-contrast: 0, 0, 225; 98 --accent-1-is-contrast: "true"; 99 + --accent-light: color-mix( 100 + in oklab, 101 + rgb(var(--accent-contrast)), 102 + rgb(var(--bg-page)) 85% 103 + ); 104 105 --highlight-1: 255, 177, 177; 106 --highlight-2: 253, 245, 203; ··· 211 212 /* END GLOBAL STYLING */ 213 } 214 + 215 + img { 216 + font-size: 0; 217 + } 218 + 219 button:hover { 220 cursor: pointer; 221 } ··· 296 @apply py-[1.5px]; 297 } 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 + 305 .ProseMirror:focus-within .selection-highlight { 306 background-color: transparent; 307 } ··· 350 @apply focus-within:outline-offset-1; 351 352 @apply disabled:border-border-light; 353 + @apply disabled:hover:border-border-light; 354 @apply disabled:bg-border-light; 355 @apply disabled:text-tertiary; 356 } ··· 382 @apply pl-3; 383 @apply ml-2; 384 } 385 + 386 .transparent-container { 387 @apply border; 388 @apply border-border-light; ··· 410 rgb(var(--bg-page)) 85% 411 ); 412 @apply rounded-md; 413 + } 414 + 415 + .menuItem { 416 + @apply text-secondary; 417 + @apply hover:text-secondary; 418 + @apply data-highlighted:bg-[var(--accent-light)]; 419 + @apply data-highlighted:outline-none; 420 + @apply hover:bg-[var(--accent-light)]; 421 + text-align: left; 422 + font-weight: 800; 423 + padding: 0.25rem 0.5rem; 424 + border-radius: 0.25rem; 425 + outline: none !important; 426 + cursor: pointer; 427 + background-color: transparent; 428 + display: flex; 429 + gap: 0.5rem; 430 + 431 + :hover { 432 + text-decoration: none !important; 433 + background-color: rgb(var(--accent-light)); 434 + } 435 } 436 437 .pwa-padding {
-27
app/home/Actions/AccountSettings.tsx
··· 1 - "use client"; 2 - 3 - import { ActionButton } from "components/ActionBar/ActionButton"; 4 - import { Menu, MenuItem } from "components/Layout"; 5 - import { mutate } from "swr"; 6 - import { AccountSmall } from "components/Icons/AccountSmall"; 7 - import { LogoutSmall } from "components/Icons/LogoutSmall"; 8 - 9 - // it was going have a popover with a log out button 10 - export const AccountSettings = () => { 11 - return ( 12 - <Menu 13 - asChild 14 - trigger={<ActionButton icon=<AccountSmall /> label="Settings" />} 15 - > 16 - <MenuItem 17 - onSelect={async () => { 18 - await fetch("/api/auth/logout"); 19 - mutate("identity", null); 20 - }} 21 - > 22 - <LogoutSmall /> 23 - Logout 24 - </MenuItem> 25 - </Menu> 26 - ); 27 - };
···
-22
app/home/Actions/Actions.tsx
··· 1 - "use client"; 2 - import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 3 - import { CreateNewLeafletButton } from "./CreateNewButton"; 4 - import { HelpPopover } from "components/HelpPopover"; 5 - import { AccountSettings } from "./AccountSettings"; 6 - import { useIdentityData } from "components/IdentityProvider"; 7 - import { useReplicache } from "src/replicache"; 8 - import { LoginActionButton } from "components/LoginButton"; 9 - 10 - export const Actions = () => { 11 - let { identity } = useIdentityData(); 12 - let { rootEntity } = useReplicache(); 13 - return ( 14 - <> 15 - <CreateNewLeafletButton /> 16 - {identity ? <AccountSettings /> : <LoginActionButton />} 17 - {/*<HelpPopover noShortcuts />*/} 18 - <ThemePopover entityID={rootEntity} home /> 19 - <HelpPopover /> 20 - </> 21 - ); 22 - };
···
-116
app/home/Actions/CreateNewButton.tsx
··· 1 - "use client"; 2 - 3 - import { createNewLeaflet } from "actions/createNewLeaflet"; 4 - import { createNewLeafletFromTemplate } from "actions/createNewLeafletFromTemplate"; 5 - import { ActionButton } from "components/ActionBar/ActionButton"; 6 - import { AddTiny } from "components/Icons/AddTiny"; 7 - import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall"; 8 - import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 9 - import { TemplateSmall } from "components/Icons/TemplateSmall"; 10 - import { Menu, MenuItem } from "components/Layout"; 11 - import { useIsMobile } from "src/hooks/isMobile"; 12 - import { create } from "zustand"; 13 - import { combine, createJSONStorage, persist } from "zustand/middleware"; 14 - 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 - export const CreateNewLeafletButton = (props: {}) => { 42 - let isMobile = useIsMobile(); 43 - let templates = useTemplateState((s) => s.templates); 44 - let openNewLeaflet = (id: string) => { 45 - if (isMobile) { 46 - window.location.href = `/${id}?focusFirstBlock`; 47 - } else { 48 - window.open(`/${id}?focusFirstBlock`, "_blank"); 49 - } 50 - }; 51 - return ( 52 - <Menu 53 - asChild 54 - trigger={ 55 - <ActionButton 56 - id="new-leaflet-button" 57 - primary 58 - icon=<AddTiny className="m-1 shrink-0" /> 59 - label="New" 60 - /> 61 - } 62 - > 63 - <MenuItem 64 - onSelect={async () => { 65 - let id = await createNewLeaflet({ 66 - pageType: "doc", 67 - redirectUser: false, 68 - }); 69 - openNewLeaflet(id); 70 - }} 71 - > 72 - <BlockDocPageSmall />{" "} 73 - <div className="flex flex-col"> 74 - <div>New Doc</div> 75 - <div className="text-tertiary text-sm font-normal"> 76 - A good ol&apos; text document 77 - </div> 78 - </div> 79 - </MenuItem> 80 - <MenuItem 81 - onSelect={async () => { 82 - let id = await createNewLeaflet({ 83 - pageType: "canvas", 84 - redirectUser: false, 85 - }); 86 - openNewLeaflet(id); 87 - }} 88 - > 89 - <BlockCanvasPageSmall /> 90 - <div className="flex flex-col"> 91 - New Canvas 92 - <div className="text-tertiary text-sm font-normal"> 93 - A digital whiteboard 94 - </div> 95 - </div> 96 - </MenuItem> 97 - {templates.length > 0 && ( 98 - <hr className="border-border-light mx-2 mb-0.5" /> 99 - )} 100 - {templates.map((t) => { 101 - return ( 102 - <MenuItem 103 - key={t.id} 104 - onSelect={async () => { 105 - let id = await createNewLeafletFromTemplate(t.id, false); 106 - if (!id.error) openNewLeaflet(id.id); 107 - }} 108 - > 109 - <TemplateSmall /> 110 - New {t.name} 111 - </MenuItem> 112 - ); 113 - })} 114 - </Menu> 115 - ); 116 - };
···
-27
app/home/Actions/HomeHelp.tsx
··· 1 - "use client"; 2 - import { ActionButton } from "components/ActionBar/ActionButton"; 3 - import { HelpSmall } from "components/Icons/HelpSmall"; 4 - import { Popover } from "components/Popover"; 5 - 6 - export const HomeHelp = () => { 7 - return ( 8 - <Popover 9 - className="max-w-sm" 10 - trigger={<ActionButton icon={<HelpSmall />} label="Info" />} 11 - > 12 - <div className="flex flex-col gap-2"> 13 - <p> 14 - Leaflets are saved to home <strong>per-device / browser</strong> using 15 - cookies. 16 - </p> 17 - <p> 18 - <strong>If you clear your cookies, they&apos;ll disappear.</strong> 19 - </p> 20 - <p> 21 - Please <a href="mailto:contact@leaflet.pub">contact us</a> for help 22 - recovering Leaflets! 23 - </p> 24 - </div> 25 - </Popover> 26 - ); 27 - };
···
-26
app/home/HomeEmpty/DiscoverIllo.tsx
··· 1 - import { theme } from "tailwind.config"; 2 - export const DiscoverIllo = () => { 3 - return ( 4 - <svg 5 - className="mx-auto" 6 - width="40" 7 - height="48" 8 - viewBox="0 0 40 48" 9 - fill="none" 10 - xmlns="http://www.w3.org/2000/svg" 11 - > 12 - <path 13 - d="M27.995 7.21895C28.3796 6.88667 30.0087 5.46507 30.4 2.87748C30.7913 0.289891 32.8883 0.607118 32.497 3.19459C32.1056 5.78206 32.9051 7.53471 33.1993 8.00597C33.4935 8.47722 33.9112 9.87785 36.1378 10.2148C38.3645 10.5518 38.0167 12.8516 35.79 12.5149C33.5633 12.1782 33.0608 13.1244 32.3297 13.7561C31.5986 14.3878 30.6667 15.3456 30.2851 17.8686C29.9036 20.3915 27.7969 20.1388 28.1882 17.5515C28.5795 14.9641 27.5024 13.2044 27.3795 13.0075C27.2565 12.8106 25.9473 11.0264 24.2028 10.7626C22.4582 10.4988 22.8231 8.2013 24.5506 8.46255C26.2782 8.72381 27.6104 7.55122 27.995 7.21895Z" 14 - fill={theme.colors["accent-2"]} 15 - /> 16 - <path 17 - d="M14.0436 10.775C14.8742 10.425 15.789 10.2979 16.6757 10.4806C21.184 11.9238 25.7155 13.2951 30.2265 14.7299C33.7072 15.8375 35.1769 19.9862 33.9774 23.7289C32.7772 27.472 29.1634 30.0064 25.682 28.8996C25.0272 28.6913 24.4433 28.376 23.9352 27.9753C24.0578 29.8258 23.5966 31.7973 22.5415 33.5507C20.1175 37.5776 15.2021 39.3975 11.4706 37.17C10.3639 36.5092 9.64595 35.5804 8.92715 34.5472L2.07416 24.6709C1.57874 23.7422 1.40352 22.6478 1.46822 21.605C0.876338 21.6639 0.331032 21.2463 0.234179 20.6453C0.0915272 19.7582 0.253475 18.7713 0.683459 17.8694C1.10716 16.981 1.75459 16.2504 2.51161 15.8132C3.26248 15.3796 4.24046 15.1818 5.15852 15.6351C5.4442 15.7762 5.64396 16.0231 5.73785 16.3048C6.85704 16.0396 7.98533 16.1226 8.87394 16.3813L9.25524 16.5079L9.2715 16.5153L9.8597 16.7611C10.0213 16.6144 10.2168 16.4391 10.4346 16.2548C10.6319 16.0878 10.8582 15.906 11.0967 15.7234C10.6482 15.6994 10.2675 15.3614 10.21 14.9021C10.0757 13.8176 10.3941 12.7515 10.9253 11.951C11.4368 11.1802 12.2818 10.4694 13.3165 10.4497C13.6068 10.4444 13.8663 10.5731 14.0436 10.775ZM19.2798 23.5847C16.693 22.2682 13.0521 23.4364 11.0819 26.7095C9.04105 30.1008 9.84611 34.0029 12.4431 35.5539C15.0407 37.1046 18.8789 35.9739 20.9203 32.5822C22.8879 29.3123 22.2072 25.5671 19.8281 23.9129L19.2798 23.5847ZM12.5066 27.9296C13.8894 25.2252 16.6958 23.8876 18.7744 24.9417C20.8524 25.9961 21.1485 29.4309 20.0335 31.7462C18.9185 34.0614 15.8458 35.7888 13.7672 34.7356C11.6887 33.6815 11.1239 30.6341 12.5066 27.9296ZM18.863 29.3824C18.4624 29.2737 18.0443 29.5112 17.9305 29.9137C17.5451 31.2786 16.6274 32.2476 15.4816 32.5145C15.0747 32.6093 14.8171 33.0162 14.9067 33.4227C14.9968 33.8287 15.4001 34.0811 15.8067 33.9864C17.6111 33.5659 18.8818 32.0851 19.3832 30.3097C19.4968 29.907 19.2635 29.4916 18.863 29.3824ZM8.3153 18.1814C6.98035 17.7934 5.02731 18.0142 3.96143 19.774C3.02036 21.3284 3.35247 23.0216 3.70427 23.7054L8.0744 30.005C8.19007 28.557 8.65007 27.0839 9.45919 25.7395C10.78 23.5456 12.8405 22.0084 15.0456 21.4225L13.8072 20.8264L13.7998 20.8234L12.0263 19.9506C11.9598 19.9179 11.9009 19.8749 11.846 19.8299L9.65871 18.7201L8.57393 18.2668L8.3153 18.1814ZM19.0995 26.5799C18.8494 26.2497 18.3773 26.1876 18.0443 26.4416C17.7113 26.6963 17.6434 27.1707 17.8935 27.5013C17.9343 27.5552 17.977 27.6287 18.0132 27.7089C18.0501 27.7907 18.07 27.8577 18.0768 27.8914C18.1585 28.2993 18.5552 28.5591 18.9635 28.4728C19.3719 28.3861 19.6373 27.986 19.5562 27.5779C19.4936 27.265 19.3269 26.8805 19.0995 26.5799ZM29.6516 16.5241C27.4753 15.8319 24.7243 17.3802 23.7327 20.4717C22.7415 23.5634 24.082 26.4132 26.2584 27.1054C28.4347 27.7968 31.1846 26.2478 32.1759 23.1564C33.1666 20.0654 31.8272 17.217 29.6516 16.5241ZM25.0864 20.9088C25.8644 18.6948 27.8745 17.3801 29.5763 17.9724C31.2782 18.5655 31.8625 21.1674 31.2492 23.0563C30.6358 24.9452 28.4614 26.5857 26.7594 25.9927C25.0576 25.3994 24.3085 23.1233 25.0864 20.9088ZM30.1039 21.3842C29.7952 21.3437 29.5093 21.5623 29.4654 21.8729C29.3114 22.9627 28.7055 23.8052 27.8486 24.1307C27.5561 24.2419 27.4059 24.5693 27.5131 24.8623C27.6207 25.1552 27.9446 25.3027 28.2373 25.192C29.5689 24.6865 30.387 23.4274 30.5857 22.0201C30.6291 21.7096 30.4126 21.4251 30.1039 21.3842ZM16.2087 12.3087C15.6227 12.192 14.6928 12.3931 13.9963 12.9887C13.5732 13.3506 13.2385 13.8583 13.1584 14.5459C13.1767 14.5393 13.1948 14.5317 13.213 14.5253C13.7448 14.3389 14.2572 14.2221 14.6318 14.1515C14.8204 14.1159 14.9777 14.0907 15.09 14.0749C15.3279 14.0415 15.553 14.0285 15.7831 14.1367L20.1739 16.2018C20.4022 16.3094 20.5779 16.5046 20.6616 16.7419C20.7451 16.9794 20.7295 17.2415 20.6188 17.4676C20.0298 18.6703 19.6797 19.9843 19.6168 21.5873L20.1887 21.9303C20.3029 21.99 20.4164 22.0532 20.5286 22.1202C20.8892 22.3354 21.2207 22.5792 21.5247 22.8458C21.4865 21.8762 21.6184 20.8729 21.9311 19.8976C22.5837 17.8636 23.9491 16.1876 25.6185 15.2583L16.2087 12.3087ZM29.9842 19.2073C29.7647 18.9869 29.4064 18.9891 29.1846 19.2117C28.9637 19.4344 28.9614 19.7935 29.1802 20.0139C29.2181 20.052 29.2604 20.1041 29.2969 20.1626C29.334 20.222 29.3556 20.2728 29.3649 20.2995C29.4667 20.5942 29.7886 20.7483 30.0832 20.6439C30.378 20.5386 30.5364 20.2134 30.4349 19.9182C30.3553 19.6875 30.1879 19.4123 29.9842 19.2073ZM18.1891 18.5259C17.7543 18.6688 17.3359 18.813 17.0378 18.938C16.7617 19.0538 16.3329 19.3654 15.8895 19.7357L17.7842 20.6483C17.8606 19.8991 17.9962 19.1952 18.1891 18.5259ZM14.9836 16.0031C14.6688 16.0624 14.2552 16.1582 13.8411 16.3033C13.6711 16.3629 13.5046 16.4296 13.3475 16.5035C12.8795 16.7239 12.263 17.1843 11.71 17.6486L14.054 18.8379C14.1789 18.7233 14.3197 18.5926 14.4737 18.4596C14.9623 18.0377 15.6797 17.4632 16.3048 17.2012C16.554 17.0967 16.8576 16.9855 17.1753 16.8759L15.2333 15.9604C15.1623 15.9716 15.0778 15.9853 14.9836 16.0031Z" 18 - fill={theme.colors["accent-1"]} 19 - /> 20 - <path 21 - d="M10.8586 38.4374C11.0345 38.0999 11.7756 36.6608 11.3198 34.7706C10.8641 32.8804 12.4238 32.5044 12.8795 34.3945C13.3352 36.2847 14.3902 37.2634 14.7294 37.5041C15.0687 37.7448 15.7567 38.5896 17.4129 38.1905C19.0691 37.7913 19.4742 39.4713 17.818 39.8706C16.1619 40.27 16.0766 41.063 15.7422 41.7045C15.4079 42.346 15.0247 43.2688 15.4691 45.1118C15.9135 46.9548 14.3652 47.3779 13.9094 45.4878C13.4537 43.5978 12.2021 42.6929 12.0603 42.5923C11.9186 42.4917 10.4973 41.6358 9.19972 41.9486C7.90219 42.2615 7.50971 40.5782 8.79465 40.2684C10.0796 39.9586 10.6827 38.7749 10.8586 38.4374Z" 22 - fill={theme.colors["accent-2"]} 23 - /> 24 - </svg> 25 - ); 26 - };
···
-108
app/home/HomeEmpty/HomeEmpty.tsx
··· 1 - "use client"; 2 - 3 - import { PubListEmptyIllo } from "components/ActionBar/Publications"; 4 - import { ButtonPrimary } from "components/Buttons"; 5 - import { AddSmall } from "components/Icons/AddSmall"; 6 - import { Link } from "react-aria-components"; 7 - import { DiscoverIllo } from "./DiscoverIllo"; 8 - import { WelcomeToLeafletIllo } from "./WelcomeToLeafletIllo"; 9 - import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 10 - import { PublishSmall } from "components/Icons/PublishSmall"; 11 - import { createNewLeaflet } from "actions/createNewLeaflet"; 12 - import { useIsMobile } from "src/hooks/isMobile"; 13 - 14 - export function HomeEmptyState() { 15 - let isMobile = useIsMobile(); 16 - return ( 17 - <div className="flex flex-col gap-4 font-bold"> 18 - <div className="container p-2 flex gap-4"> 19 - <div className="w-[72px]"> 20 - <WelcomeToLeafletIllo /> 21 - </div> 22 - <div className="flex flex-col grow"> 23 - <h3 className="text-xl font-semibold pt-2">Leaflet</h3> 24 - {/*<h3>A platform for social publishing.</h3>*/} 25 - <div className="font-normal text-tertiary italic"> 26 - Write and share delightful documents! 27 - </div> 28 - <ButtonPrimary 29 - className="!text-lg my-3" 30 - onClick={async () => { 31 - let openNewLeaflet = (id: string) => { 32 - if (isMobile) { 33 - window.location.href = `/${id}?focusFirstBlock`; 34 - } else { 35 - window.open(`/${id}?focusFirstBlock`, "_blank"); 36 - } 37 - }; 38 - 39 - let id = await createNewLeaflet({ 40 - pageType: "doc", 41 - redirectUser: false, 42 - }); 43 - openNewLeaflet(id); 44 - }} 45 - > 46 - <AddSmall /> Write a Doc! 47 - </ButtonPrimary> 48 - </div> 49 - </div> 50 - <div className="flex gap-2 w-full items-center text-tertiary font-normal italic"> 51 - <hr className="border-border w-full" /> 52 - <div>or</div> 53 - <hr className="border-border w-full" /> 54 - </div> 55 - 56 - <PublicationBanner /> 57 - <DiscoverBanner /> 58 - <div className="text-tertiary italic text-sm font-normal -mt-2"> 59 - Right now docs and publications are separate. Soon you'll be able to add 60 - docs to pubs! 61 - </div> 62 - </div> 63 - ); 64 - } 65 - 66 - export const PublicationBanner = (props: { small?: boolean }) => { 67 - return ( 68 - <div 69 - className={`accent-container flex sm:py-2 items-center ${props.small ? "items-start gap-2 p-2 text-sm font-normal" : "items-center p-4 gap-4"}`} 70 - > 71 - {props.small ? ( 72 - <PublishSmall className="shrink-0 text-accent-contrast" /> 73 - ) : ( 74 - <div className="w-[64px] mx-auto"> 75 - <PubListEmptyIllo /> 76 - </div> 77 - )} 78 - <div className={`${props.small ? "pt-[2px]" : ""} grow`}> 79 - <Link href={"/lish/createPub"} className="font-bold"> 80 - Start a Publication 81 - </Link>{" "} 82 - and blog in the Atmosphere 83 - </div> 84 - </div> 85 - ); 86 - }; 87 - 88 - export const DiscoverBanner = (props: { small?: boolean }) => { 89 - return ( 90 - <div 91 - className={`accent-container flex sm:py-2 items-center ${props.small ? "items-start gap-2 p-2 text-sm font-normal" : "items-center p-4 gap-4"}`} 92 - > 93 - {props.small ? ( 94 - <DiscoverSmall className="shrink-0 text-accent-contrast" /> 95 - ) : ( 96 - <div className="w-[64px] mx-auto"> 97 - <DiscoverIllo /> 98 - </div> 99 - )} 100 - <div className={`${props.small ? "pt-[2px]" : ""} grow`}> 101 - <Link href={"/discover"} className="font-bold"> 102 - Explore Publications 103 - </Link>{" "} 104 - on art, tech, games, music & more! 105 - </div> 106 - </div> 107 - ); 108 - };
···
-24
app/home/HomeEmpty/WelcomeToLeafletIllo.tsx
··· 1 - import { theme } from "tailwind.config"; 2 - 3 - export const WelcomeToLeafletIllo = () => { 4 - return ( 5 - <svg 6 - width="73" 7 - height="68" 8 - viewBox="0 0 73 68" 9 - fill="none" 10 - xmlns="http://www.w3.org/2000/svg" 11 - > 12 - <path 13 - d="M21.7639 12.9818C39.5065 8.22165 72.8274 1.9906 72.8274 11.684C72.8274 13.2356 72.5131 14.6375 71.9885 15.9193C71.422 15.1994 70.6084 14.987 69.927 14.6595C69.0312 14.3006 68.1186 14.0196 67.219 13.7347C65.9446 13.3234 64.6378 12.9769 63.4944 12.3705C63.0971 12.1482 62.6977 11.8678 62.5598 11.4935C62.4128 11.1213 62.6531 10.6704 62.9592 10.3334C63.598 9.64116 64.4397 9.13591 65.3 8.65466C64.4055 9.0767 63.5312 9.49833 62.7922 10.1713C62.4481 10.4956 62.061 11.0041 62.219 11.5961C62.3834 12.1584 62.8383 12.4935 63.2483 12.7738C64.436 13.5238 65.7311 13.9496 66.9934 14.4086C67.8817 14.7243 68.7738 15.0378 69.6096 15.4095C70.4149 15.7681 71.3704 16.291 71.4895 16.8314C71.4933 16.884 71.4915 16.9 71.3928 17.0043C71.2883 17.1047 71.1421 17.196 70.9524 17.2894C70.6123 17.455 70.1758 17.5978 69.7727 17.7084C68.9351 17.937 68.049 18.1027 67.1721 18.2513C65.4162 18.5448 63.6217 18.7708 61.8323 19.0199C60.0416 19.2716 58.2462 19.5223 56.429 19.933C55.5221 20.1485 54.6197 20.3715 53.6711 20.8383C53.2374 21.0796 52.6446 21.3532 52.2737 22.1595C52.0934 22.554 52.1241 23.1248 52.2629 23.4594C52.4237 23.8666 52.582 24.0666 52.8069 24.3275C55.2093 26.8202 58.4399 26.9744 61.1399 27.4711C61.3262 27.5015 61.5132 27.5331 61.6995 27.5638C60.4533 29.2073 60.129 30.8258 61.717 32.7035C53.3556 36.0694 40.5573 34.9986 30.1731 37.5492C27.0455 38.2875 23.9357 39.315 21.0657 41.3636C19.6745 42.3955 18.2597 43.7123 17.4534 45.7152C16.5587 47.6874 17.1166 50.6073 18.6731 52.1156C19.6636 53.1771 20.8229 53.8313 21.9641 54.3002C22.9895 54.7165 24.0905 54.774 25.1536 54.5228C25.4784 54.4319 25.7983 54.2981 26.054 54.0844C26.3095 53.8704 26.4797 53.5942 26.5325 53.2894C26.5851 52.9847 26.5177 52.6675 26.3489 52.3802C26.1797 52.0928 25.9225 51.8583 25.6467 51.6635C24.8912 51.0971 24.2127 50.692 23.5432 50.4203C22.7927 50.117 22.1473 49.7103 21.7405 49.2709C21.1437 48.5942 21.0342 48.064 21.3293 47.2084C21.6382 46.365 22.4832 45.4184 23.5022 44.6605C25.5969 43.1038 28.3033 42.084 31.094 41.3705C37.0436 39.9193 43.4459 39.2868 49.7786 38.347C52.9572 37.8792 56.1424 37.383 59.342 36.6058C60.9443 36.2072 62.5256 35.7578 64.136 35.0453C64.1883 35.0196 64.2409 34.9932 64.2942 34.9672C72.897 41.9183 73.7798 46.9821 71.0959 52.3617C68.9368 56.6893 57.6782 70.1038 45.5647 66.642C17.1652 58.5254 -0.313688 61.8818 0.983643 36.35C1.62827 23.6653 13.1137 15.3026 21.7639 12.9818ZM47.5911 44.0482C47.6939 42.4007 46.335 42.3157 46.2317 43.9633C46.1286 45.6104 45.1675 46.5931 44.9397 46.8236C44.7125 47.0535 43.9263 47.8634 42.8059 47.7933C41.6859 47.7235 41.5833 49.1871 42.7141 49.2582C43.8449 49.329 44.7847 50.3786 44.8752 50.4965C44.9656 50.6142 45.7535 51.6618 45.6506 53.309C45.5476 54.9569 46.9105 55.0009 47.011 53.3939C47.1116 51.7875 47.6534 51.1346 48.0852 50.6976C48.5171 50.2607 48.7857 49.6386 50.2297 49.7289C51.6728 49.8185 51.7638 48.3548 50.3206 48.264C48.8768 48.1734 48.5288 47.3157 48.3137 47.0355C48.0986 46.7552 47.488 45.6961 47.5911 44.0482ZM18.887 18.7377C18.8903 16.7042 17.2118 16.7019 17.2083 18.7357C17.2044 20.7693 16.0954 22.0494 15.8333 22.349C15.5704 22.6494 14.6652 23.703 13.2834 23.7006C11.901 23.6983 11.8845 25.5056 13.2805 25.5082C14.675 25.5108 15.9068 26.7289 16.0286 26.8685C16.1478 27.0058 17.1971 28.2352 17.1936 30.2689C17.1902 32.3024 18.8677 32.2546 18.8713 30.2719C18.8748 28.2891 19.4922 27.4449 19.9905 26.8754C20.4888 26.3059 20.7721 25.5208 22.554 25.5238C24.336 25.5269 24.3396 23.7198 22.5579 23.7162C20.7757 23.7129 20.2846 22.6833 19.9993 22.3549C19.7136 22.0258 18.8835 20.7706 18.887 18.7377ZM70.5559 18.5209C68.5834 21.3602 65.7167 23.5582 63.5715 25.5668C62.8433 25.4447 62.1381 25.3759 61.4397 25.2875C58.698 24.9636 55.7947 24.7314 54.1506 23.0892C53.1448 22.9109 55.2165 21.8481 56.8167 21.475C58.5129 21.0124 60.2807 20.6842 62.0471 20.3597C63.8164 20.0365 65.6066 19.7363 67.384 19.3646C68.2733 19.1761 69.1747 18.9708 70.0657 18.6898C70.2249 18.6387 70.3894 18.5819 70.5559 18.5209Z" 14 - fill="color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)" 15 - /> 16 - <path 17 - fill-rule="evenodd" 18 - clip-rule="evenodd" 19 - d="M33.1936 19.9644C33.8224 20.2936 34.5595 20.6793 35.7586 19.9049C36.5426 19.3985 37.1772 17.1878 37.8852 14.7211C38.9202 11.1155 40.1122 6.96302 42.1573 6.78723C44.6243 6.57519 44.7981 7.92712 44.9319 8.96695C45.0257 9.69631 45.0997 10.2721 45.9315 10.047C46.7788 9.8176 47.3647 8.61374 48.0447 7.2167C48.984 5.28694 50.1027 2.98859 52.3373 2.38086C53.9128 1.95238 54.054 2.64187 54.1973 3.34108C54.2888 3.788 54.3812 4.2389 54.8494 4.40436C55.3107 4.56736 56.2896 4.23303 57.4149 3.84869C59.219 3.23253 61.3994 2.48783 62.4269 3.45769C63.3302 4.31032 62.738 5.52027 62.1704 6.67999C61.6896 7.66228 61.2264 8.60853 61.7046 9.27112C62.0948 9.81171 62.6783 10.0278 63.2502 10.2396C64.1857 10.5861 65.0904 10.9211 65.0681 12.6458C65.0445 14.4695 62.5034 15.5801 60.1214 16.6211C58.126 17.4931 56.2423 18.3164 56.0441 19.4692C55.9197 20.1921 56.464 20.3823 57.0738 20.5954C57.8026 20.85 58.6249 21.1373 58.5116 22.4058C58.2468 25.3711 55.1373 26.1276 52.219 26.8377C49.7141 27.4471 47.3501 28.0223 47.0466 29.9306C46.8714 31.0323 47.5477 31.0548 48.2898 31.0794C49.1981 31.1095 50.205 31.1429 49.869 33.1634C49.2261 37.03 43.6557 38.4183 38.5564 39.6893C36.6866 40.1553 34.8802 40.6056 33.4032 41.1564C33.2347 41.2193 33.1049 41.3564 33.0503 41.5278C32.2094 44.1647 31.8313 46.932 31.428 49.8838C31.3394 50.5323 31.2496 51.1896 31.1534 51.8565C31.0262 52.738 29.2486 55.1385 28.3628 55.2311C27.9656 55.2726 27.8339 52.2775 27.9611 51.3959C28.4347 48.1126 29.1515 45.0249 30.0528 42.1314C31.418 36.9751 34.9339 30.201 39.0683 24.7487C42.7265 19.8134 46.4117 15.8889 50.9826 12.4792C51.5547 12.0524 51.1837 11.3521 50.5642 11.7068C46.3602 14.1137 42.9934 17.6783 39.4855 21.2985C35.6701 25.2361 32.8282 29.9969 31.1838 33.3557C30.8939 33.9479 29.8993 33.7609 29.8756 33.102C29.6905 27.9469 29.636 23.1879 30.7621 21.1685C31.8313 19.2514 32.4334 19.5665 33.1936 19.9644Z" 20 - fill={theme.colors["accent-1"]} 21 - /> 22 - </svg> 23 - ); 24 - };
···
-333
app/home/HomeLayout.tsx
··· 1 - "use client"; 2 - 3 - import { getHomeDocs, HomeDoc } from "./storage"; 4 - import useSWR from "swr"; 5 - import { 6 - Fact, 7 - PermissionToken, 8 - ReplicacheProvider, 9 - useEntity, 10 - } from "src/replicache"; 11 - import { LeafletListItem } from "./LeafletList/LeafletListItem"; 12 - import { useIdentityData } from "components/IdentityProvider"; 13 - import type { Attribute } from "src/replicache/attributes"; 14 - import { callRPC } from "app/api/rpc/client"; 15 - import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 16 - import { HomeSmall } from "components/Icons/HomeSmall"; 17 - import { 18 - HomeDashboardControls, 19 - DashboardLayout, 20 - DashboardState, 21 - useDashboardState, 22 - } from "components/PageLayouts/DashboardLayout"; 23 - import { Actions } from "./Actions/Actions"; 24 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 25 - import { Json } from "supabase/database.types"; 26 - import { useTemplateState } from "./Actions/CreateNewButton"; 27 - import { CreateNewLeafletButton } from "./Actions/CreateNewButton"; 28 - import { ActionButton } from "components/ActionBar/ActionButton"; 29 - import { AddTiny } from "components/Icons/AddTiny"; 30 - import { 31 - get_leaflet_data, 32 - GetLeafletDataReturnType, 33 - } from "app/api/rpc/[command]/get_leaflet_data"; 34 - import { useEffect, useRef, useState } from "react"; 35 - import { Input } from "components/Input"; 36 - import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 37 - import { 38 - ButtonPrimary, 39 - ButtonSecondary, 40 - ButtonTertiary, 41 - } from "components/Buttons"; 42 - import { AddSmall } from "components/Icons/AddSmall"; 43 - import { PublishIllustration } from "app/[leaflet_id]/publish/PublishIllustration/PublishIllustration"; 44 - import { PubListEmptyIllo } from "components/ActionBar/Publications"; 45 - import { theme } from "tailwind.config"; 46 - import Link from "next/link"; 47 - import { DiscoverIllo } from "./HomeEmpty/DiscoverIllo"; 48 - import { WelcomeToLeafletIllo } from "./HomeEmpty/WelcomeToLeafletIllo"; 49 - import { 50 - DiscoverBanner, 51 - HomeEmptyState, 52 - PublicationBanner, 53 - } from "./HomeEmpty/HomeEmpty"; 54 - 55 - type Leaflet = { 56 - added_at: string; 57 - token: PermissionToken & { 58 - leaflets_in_publications?: Exclude< 59 - GetLeafletDataReturnType["result"]["data"], 60 - null 61 - >["leaflets_in_publications"]; 62 - }; 63 - }; 64 - 65 - export const HomeLayout = (props: { 66 - entityID: string; 67 - titles: { [root_entity: string]: string }; 68 - initialFacts: { 69 - [root_entity: string]: Fact<Attribute>[]; 70 - }; 71 - }) => { 72 - let hasBackgroundImage = !!useEntity( 73 - props.entityID, 74 - "theme/background-image", 75 - ); 76 - let cardBorderHidden = !!useCardBorderHidden(props.entityID); 77 - 78 - let [searchValue, setSearchValue] = useState(""); 79 - let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); 80 - 81 - useDebouncedEffect( 82 - () => { 83 - setDebouncedSearchValue(searchValue); 84 - }, 85 - 200, 86 - [searchValue], 87 - ); 88 - 89 - let { identity } = useIdentityData(); 90 - 91 - let hasPubs = !identity || identity.publications.length === 0 ? false : true; 92 - let hasTemplates = 93 - useTemplateState((s) => s.templates).length === 0 ? false : true; 94 - 95 - return ( 96 - <DashboardLayout 97 - id="home" 98 - cardBorderHidden={cardBorderHidden} 99 - currentPage="home" 100 - defaultTab="home" 101 - actions={<Actions />} 102 - tabs={{ 103 - home: { 104 - controls: ( 105 - <HomeDashboardControls 106 - defaultDisplay={"grid"} 107 - searchValue={searchValue} 108 - setSearchValueAction={setSearchValue} 109 - hasBackgroundImage={hasBackgroundImage} 110 - hasPubs={hasPubs} 111 - hasTemplates={hasTemplates} 112 - /> 113 - ), 114 - content: ( 115 - <HomeLeafletList 116 - titles={props.titles} 117 - initialFacts={props.initialFacts} 118 - cardBorderHidden={cardBorderHidden} 119 - searchValue={debouncedSearchValue} 120 - /> 121 - ), 122 - }, 123 - }} 124 - /> 125 - ); 126 - }; 127 - 128 - export function HomeLeafletList(props: { 129 - titles: { [root_entity: string]: string }; 130 - initialFacts: { 131 - [root_entity: string]: Fact<Attribute>[]; 132 - }; 133 - searchValue: string; 134 - cardBorderHidden: boolean; 135 - }) { 136 - let { identity } = useIdentityData(); 137 - let { data: initialFacts } = useSWR( 138 - "home-leaflet-data", 139 - async () => { 140 - if (identity) { 141 - let { result } = await callRPC("getFactsFromHomeLeaflets", { 142 - tokens: identity.permission_token_on_homepage.map( 143 - (ptrh) => ptrh.permission_tokens.root_entity, 144 - ), 145 - }); 146 - let titles = { 147 - ...result.titles, 148 - ...identity.permission_token_on_homepage.reduce( 149 - (acc, tok) => { 150 - let title = 151 - tok.permission_tokens.leaflets_in_publications[0]?.title; 152 - if (title) acc[tok.permission_tokens.root_entity] = title; 153 - return acc; 154 - }, 155 - {} as { [k: string]: string }, 156 - ), 157 - }; 158 - return { ...result, titles }; 159 - } 160 - }, 161 - { fallbackData: { facts: props.initialFacts, titles: props.titles } }, 162 - ); 163 - 164 - let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { 165 - fallbackData: [], 166 - }); 167 - let leaflets: Leaflet[] = identity 168 - ? identity.permission_token_on_homepage.map((ptoh) => ({ 169 - added_at: ptoh.created_at, 170 - token: ptoh.permission_tokens as PermissionToken, 171 - })) 172 - : localLeaflets 173 - .sort((a, b) => (a.added_at > b.added_at ? -1 : 1)) 174 - .filter((d) => !d.hidden) 175 - .map((ll) => ll); 176 - 177 - return leaflets.length === 0 ? ( 178 - <HomeEmptyState /> 179 - ) : ( 180 - <> 181 - <LeafletList 182 - defaultDisplay="grid" 183 - searchValue={props.searchValue} 184 - leaflets={leaflets} 185 - titles={initialFacts?.titles || {}} 186 - cardBorderHidden={props.cardBorderHidden} 187 - initialFacts={initialFacts?.facts || {}} 188 - showPreview 189 - /> 190 - <div className="spacer h-4 w-full bg-transparent shrink-0 " /> 191 - 192 - {leaflets.filter((l) => !!l.token.leaflets_in_publications).length === 193 - 0 && <PublicationBanner small />} 194 - <DiscoverBanner small /> 195 - </> 196 - ); 197 - } 198 - 199 - export function LeafletList(props: { 200 - leaflets: Leaflet[]; 201 - titles: { [root_entity: string]: string }; 202 - defaultDisplay: Exclude<DashboardState["display"], undefined>; 203 - initialFacts: { 204 - [root_entity: string]: Fact<Attribute>[]; 205 - }; 206 - searchValue: string; 207 - cardBorderHidden: boolean; 208 - showPreview?: boolean; 209 - }) { 210 - let { identity } = useIdentityData(); 211 - let { display } = useDashboardState(); 212 - 213 - display = display || props.defaultDisplay; 214 - 215 - let searchedLeaflets = useSearchedLeaflets( 216 - props.leaflets, 217 - props.titles, 218 - props.searchValue, 219 - ); 220 - 221 - return ( 222 - <div 223 - className={` 224 - leafletList 225 - w-full 226 - ${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"} `} 227 - > 228 - {props.leaflets.map(({ token: leaflet, added_at }, index) => ( 229 - <ReplicacheProvider 230 - disablePull 231 - initialFactsOnly={!!identity} 232 - key={leaflet.id} 233 - rootEntity={leaflet.root_entity} 234 - token={leaflet} 235 - name={leaflet.root_entity} 236 - initialFacts={props.initialFacts?.[leaflet.root_entity] || []} 237 - > 238 - <StaticLeafletDataContext 239 - value={{ 240 - ...leaflet, 241 - leaflets_in_publications: leaflet.leaflets_in_publications || [], 242 - blocked_by_admin: null, 243 - custom_domain_routes: [], 244 - }} 245 - > 246 - <LeafletListItem 247 - title={props?.titles?.[leaflet.root_entity] || "Untitled"} 248 - token={leaflet} 249 - draft={!!leaflet.leaflets_in_publications?.length} 250 - published={!!leaflet.leaflets_in_publications?.find((l) => l.doc)} 251 - publishedAt={ 252 - leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents 253 - ?.indexed_at 254 - } 255 - leaflet_id={leaflet.root_entity} 256 - loggedIn={!!identity} 257 - display={display} 258 - added_at={added_at} 259 - cardBorderHidden={props.cardBorderHidden} 260 - index={index} 261 - showPreview={props.showPreview} 262 - isHidden={ 263 - !searchedLeaflets.some( 264 - (sl) => sl.token.root_entity === leaflet.root_entity, 265 - ) 266 - } 267 - /> 268 - </StaticLeafletDataContext> 269 - </ReplicacheProvider> 270 - ))} 271 - </div> 272 - ); 273 - } 274 - 275 - function useSearchedLeaflets( 276 - leaflets: Leaflet[], 277 - titles: { [root_entity: string]: string }, 278 - searchValue: string, 279 - ) { 280 - let { sort, filter } = useDashboardState(); 281 - 282 - let sortedLeaflets = leaflets.sort((a, b) => { 283 - if (sort === "alphabetical") { 284 - if (titles[a.token.root_entity] === titles[b.token.root_entity]) { 285 - return a.added_at > b.added_at ? -1 : 1; 286 - } else { 287 - return titles[a.token.root_entity].toLocaleLowerCase() > 288 - titles[b.token.root_entity].toLocaleLowerCase() 289 - ? 1 290 - : -1; 291 - } 292 - } else { 293 - return a.added_at === b.added_at 294 - ? a.token.root_entity > b.token.root_entity 295 - ? -1 296 - : 1 297 - : a.added_at > b.added_at 298 - ? -1 299 - : 1; 300 - } 301 - }); 302 - 303 - let allTemplates = useTemplateState((s) => s.templates); 304 - let filteredLeaflets = sortedLeaflets.filter(({ token: leaflet }) => { 305 - let published = !!leaflet.leaflets_in_publications?.find((l) => l.doc); 306 - let drafts = !!leaflet.leaflets_in_publications?.length && !published; 307 - let docs = !leaflet.leaflets_in_publications?.length; 308 - let templates = !!allTemplates.find((t) => t.id === leaflet.id); 309 - // If no filters are active, show all 310 - if ( 311 - !filter.drafts && 312 - !filter.published && 313 - !filter.docs && 314 - !filter.templates 315 - ) 316 - return true; 317 - 318 - return ( 319 - (filter.drafts && drafts) || 320 - (filter.published && published) || 321 - (filter.docs && docs) || 322 - (filter.templates && templates) 323 - ); 324 - }); 325 - if (searchValue === "") return filteredLeaflets; 326 - let searchedLeaflets = filteredLeaflets.filter(({ token: leaflet }) => { 327 - return titles[leaflet.root_entity] 328 - ?.toLowerCase() 329 - .includes(searchValue.toLowerCase()); 330 - }); 331 - 332 - return searchedLeaflets; 333 - }
···
-13
app/home/IdentitySetter.tsx
··· 1 - "use client"; 2 - 3 - import { useEffect } from "react"; 4 - 5 - export function IdentitySetter(props: { 6 - cb: () => Promise<void>; 7 - call: boolean; 8 - }) { 9 - useEffect(() => { 10 - if (props.call) props.cb(); 11 - }, [props]); 12 - return null; 13 - }
···
-68
app/home/LeafletList/LeafletContent.tsx
··· 1 - "use client"; 2 - import { BlockPreview } from "components/Blocks/PageLinkBlock"; 3 - import { useEffect, useRef, useState } from "react"; 4 - import { useBlocks } from "src/hooks/queries/useBlocks"; 5 - import { useEntity } from "src/replicache"; 6 - import { CanvasContent } from "components/Canvas"; 7 - import styles from "./LeafletPreview.module.css"; 8 - import { PublicationMetadataPreview } from "components/Pages/PublicationMetadata"; 9 - 10 - export const LeafletContent = (props: { 11 - entityID: string; 12 - isOnScreen: boolean; 13 - }) => { 14 - let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; 15 - let blocks = useBlocks(props.entityID); 16 - let previewRef = useRef<HTMLDivElement | null>(null); 17 - 18 - if (type === "canvas") 19 - return ( 20 - <div 21 - className={`pageLinkBlockPreview shrink-0 h-full overflow-clip relative bg-bg-page shadow-sm rounded-md`} 22 - > 23 - <div 24 - className={`absolute top-0 left-0 origin-top-left pointer-events-none ${styles.scaleLeafletCanvasPreview}`} 25 - style={{ 26 - width: `1272px`, 27 - height: "calc(1272px * 2)", 28 - }} 29 - > 30 - {props.isOnScreen && ( 31 - <CanvasContent entityID={props.entityID} preview /> 32 - )} 33 - </div> 34 - </div> 35 - ); 36 - 37 - return ( 38 - <div 39 - ref={previewRef} 40 - className={`pageLinkBlockPreview h-full overflow-clip flex flex-col gap-0.5 no-underline relative`} 41 - > 42 - <div 43 - className={`absolute top-0 left-0 w-full h-full origin-top-left pointer-events-none ${styles.scaleLeafletDocPreview}`} 44 - style={{ 45 - width: `var(--page-width-units)`, 46 - }} 47 - > 48 - <PublicationMetadataPreview /> 49 - 50 - {props.isOnScreen && 51 - blocks.slice(0, 10).map((b, index, arr) => { 52 - return ( 53 - <BlockPreview 54 - pageType="doc" 55 - entityID={b.value} 56 - previousBlock={arr[index - 1] || null} 57 - nextBlock={arr[index + 1] || null} 58 - nextPosition={""} 59 - previewRef={previewRef} 60 - {...b} 61 - key={b.factID} 62 - /> 63 - ); 64 - })} 65 - </div> 66 - </div> 67 - ); 68 - };
···
-88
app/home/LeafletList/LeafletInfo.tsx
··· 1 - "use client"; 2 - import { PermissionToken } from "src/replicache"; 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 - import { timeAgo } from "src/utils/timeAgo"; 9 - 10 - export const LeafletInfo = (props: { 11 - title?: string; 12 - draft?: boolean; 13 - published?: boolean; 14 - token: PermissionToken; 15 - leaflet_id: string; 16 - loggedIn: boolean; 17 - isTemplate: boolean; 18 - className?: string; 19 - display: "grid" | "list"; 20 - added_at: string; 21 - publishedAt?: string; 22 - }) => { 23 - let [prefetch, setPrefetch] = useState(false); 24 - let prettyCreatedAt = props.added_at ? timeAgo(props.added_at) : ""; 25 - 26 - let prettyPublishedAt = props.publishedAt ? timeAgo(props.publishedAt) : ""; 27 - 28 - return ( 29 - <div 30 - className={`leafletInfo w-full min-w-0 flex flex-col ${props.className}`} 31 - > 32 - <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> 44 - <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 - /> 57 - </div> 58 - </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 ? ( 67 - <div 68 - className={`text-xs ${props.published ? "font-bold text-tertiary" : "text-tertiary"}`} 69 - > 70 - {props.published 71 - ? `Published ${prettyPublishedAt}` 72 - : `Draft ${prettyCreatedAt}`} 73 - </div> 74 - ) : ( 75 - <div className="text-xs text-tertiary">{prettyCreatedAt}</div> 76 - )} 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} 86 - </div> 87 - ); 88 - };
···
-102
app/home/LeafletList/LeafletListItem.tsx
··· 1 - "use client"; 2 - import { PermissionToken } from "src/replicache"; 3 - import { useTemplateState } from "../Actions/CreateNewButton"; 4 - import { LeafletListPreview, LeafletGridPreview } from "./LeafletPreview"; 5 - import { LeafletInfo } from "./LeafletInfo"; 6 - import { useState, useRef, useEffect } from "react"; 7 - 8 - export const LeafletListItem = (props: { 9 - token: PermissionToken; 10 - leaflet_id: string; 11 - loggedIn: boolean; 12 - display: "list" | "grid"; 13 - cardBorderHidden: boolean; 14 - added_at: string; 15 - title: string; 16 - draft?: boolean; 17 - published?: boolean; 18 - publishedAt?: string; 19 - index: number; 20 - isHidden: boolean; 21 - showPreview?: boolean; 22 - }) => { 23 - let isTemplate = useTemplateState( 24 - (s) => !!s.templates.find((t) => t.id === props.token.id), 25 - ); 26 - 27 - let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); 28 - let previewRef = useRef<HTMLDivElement | null>(null); 29 - 30 - useEffect(() => { 31 - if (!previewRef.current) return; 32 - let observer = new IntersectionObserver( 33 - (entries) => { 34 - entries.forEach((entry) => { 35 - if (entry.isIntersecting) { 36 - setIsOnScreen(true); 37 - } else { 38 - setIsOnScreen(false); 39 - } 40 - }); 41 - }, 42 - { threshold: 0.1, root: null }, 43 - ); 44 - observer.observe(previewRef.current); 45 - return () => observer.disconnect(); 46 - }, [previewRef]); 47 - 48 - if (props.display === "list") 49 - return ( 50 - <> 51 - <div 52 - ref={previewRef} 53 - className={`gap-3 w-full ${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border"}`} 54 - style={{ 55 - backgroundColor: props.cardBorderHidden 56 - ? "transparent" 57 - : "rgba(var(--bg-page), var(--bg-page-alpha))", 58 - 59 - display: props.isHidden ? "none" : "flex", 60 - }} 61 - > 62 - {props.showPreview && ( 63 - <LeafletListPreview isVisible={isOnScreen} {...props} /> 64 - )} 65 - <LeafletInfo isTemplate={isTemplate} {...props} /> 66 - </div> 67 - {props.cardBorderHidden && ( 68 - <hr 69 - className="last:hidden border-border-light" 70 - style={{ 71 - display: props.isHidden ? "none" : "block", 72 - }} 73 - /> 74 - )} 75 - </> 76 - ); 77 - return ( 78 - <div 79 - ref={previewRef} 80 - className={`leafletGridListItem relative 81 - flex flex-col gap-1 p-1 h-52 82 - block-border border-border! hover:outline-border 83 - `} 84 - style={{ 85 - backgroundColor: props.cardBorderHidden 86 - ? "transparent" 87 - : "rgba(var(--bg-page), var(--bg-page-alpha))", 88 - 89 - display: props.isHidden ? "none" : "flex", 90 - }} 91 - > 92 - <div className="grow"> 93 - <LeafletGridPreview {...props} isVisible={isOnScreen} /> 94 - </div> 95 - <LeafletInfo 96 - isTemplate={isTemplate} 97 - className="px-1 pb-0.5 shrink-0" 98 - {...props} 99 - /> 100 - </div> 101 - ); 102 - };
···
-220
app/home/LeafletList/LeafletOptions.tsx
··· 1 - "use client"; 2 - 3 - import { Menu, MenuItem } from "components/Layout"; 4 - import { useReplicache, type PermissionToken } from "src/replicache"; 5 - import { hideDoc } from "../storage"; 6 - 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"; 12 - 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"; 18 - 19 - export const LeafletOptions = (props: { 20 - leaflet: PermissionToken; 21 - isTemplate: boolean; 22 - loggedIn: boolean; 23 - added_at: string; 24 - }) => { 25 - let { mutate: mutateIdentity } = useIdentityData(); 26 - let [state, setState] = useState<"normal" | "template">("normal"); 27 - let [open, setOpen] = useState(false); 28 - let smoker = useSmoker(); 29 - let toaster = useToaster(); 30 - return ( 31 - <> 32 - <Menu 33 - open={open} 34 - align="end" 35 - onOpenChange={(o) => { 36 - setOpen(o); 37 - setState("normal"); 38 - }} 39 - trigger={ 40 - <div 41 - className="text-secondary shrink-0" 42 - onClick={(e) => { 43 - e.preventDefault; 44 - e.stopPropagation; 45 - }} 46 - > 47 - <MoreOptionsVerticalTiny /> 48 - </div> 49 - } 50 - > 51 - {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 - /> 128 - ) : null} 129 - </Menu> 130 - </> 131 - ); 132 - }; 133 - 134 - const UndoRemoveFromHomeButton = (props: { 135 - leaflet: PermissionToken; 136 - added_at: string | undefined; 137 - }) => { 138 - let toaster = useToaster(); 139 - let { mutate } = useIdentityData(); 140 - 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(); 164 - 165 - toaster({ 166 - content: <div className="font-bold">Recovered Doc!</div>, 167 - type: "success", 168 - }); 169 - }} 170 - className="underline" 171 - > 172 - Undo? 173 - </button> 174 - ); 175 - }; 176 - 177 - const AddTemplateForm = (props: { 178 - leaflet: PermissionToken; 179 - close: () => void; 180 - }) => { 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> 194 - 195 - <ButtonPrimary 196 - onClick={() => { 197 - useTemplateState.getState().addTemplate({ 198 - name, 199 - id: props.leaflet.id, 200 - }); 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 - }} 214 - className="place-self-end" 215 - > 216 - Add Template 217 - </ButtonPrimary> 218 - </div> 219 - ); 220 - };
···
-16
app/home/LeafletList/LeafletPreview.module.css
··· 1 - .scaleLeafletDocPreview { 2 - transform: scale(calc(160 / var(--page-width-unitless))); 3 - } 4 - 5 - .scaleLeafletCanvasPreview { 6 - transform: scale(calc(160 / 1272)); 7 - } 8 - 9 - @media (min-width: 640px) { 10 - .scaleLeafletDocPreview { 11 - transform: scale(calc(192 / var(--page-width-unitless))); 12 - } 13 - .scaleLeafletCanvasPreview { 14 - transform: scale(calc(192 / 1272)); 15 - } 16 - }
···
-190
app/home/LeafletList/LeafletPreview.tsx
··· 1 - "use client"; 2 - import { 3 - ThemeBackgroundProvider, 4 - ThemeProvider, 5 - } from "components/ThemeManager/ThemeProvider"; 6 - import { 7 - PermissionToken, 8 - useEntity, 9 - useReferenceToEntity, 10 - } from "src/replicache"; 11 - import { useTemplateState } from "../Actions/CreateNewButton"; 12 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 13 - import { LeafletContent } from "./LeafletContent"; 14 - import { Tooltip } from "components/Tooltip"; 15 - import { useState } from "react"; 16 - import Link from "next/link"; 17 - import { SpeedyLink } from "components/SpeedyLink"; 18 - 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; 32 - 33 - let cardBorderHidden = useCardBorderHidden(root); 34 - let rootBackgroundImage = useEntity(root, "theme/card-background-image"); 35 - let rootBackgroundRepeat = useEntity( 36 - root, 37 - "theme/card-background-image-repeat", 38 - ); 39 - let rootBackgroundOpacity = useEntity( 40 - root, 41 - "theme/card-background-image-opacity", 42 - ); 43 - 44 - return ( 45 - <Tooltip 46 - open={true} 47 - delayDuration={0} 48 - side="right" 49 - trigger={ 50 - <div className="w-12 h-full py-1"> 51 - <div className="rounded-md h-full overflow-hidden"> 52 - <ThemeProvider local entityID={root} className=""> 53 - <ThemeBackgroundProvider entityID={root}> 54 - <div className="w-full h-full rounded-md p-1 border border-border"> 55 - <div 56 - className={`w-full h-full rounded-[2px]`} 57 - style={ 58 - cardBorderHidden 59 - ? { 60 - borderWidth: "2px", 61 - borderColor: "rgb(var(--primary))", 62 - } 63 - : { 64 - backgroundColor: 65 - "rgba(var(--bg-page), var(--bg-page-alpha))", 66 - } 67 - } 68 - /> 69 - </div> 70 - </ThemeBackgroundProvider> 71 - </ThemeProvider> 72 - </div> 73 - </div> 74 - } 75 - className="p-1!" 76 - > 77 - <ThemeProvider local entityID={root} className="rounded-sm"> 78 - <ThemeBackgroundProvider entityID={root}> 79 - <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 - > 105 - <LeafletContent entityID={page} isOnScreen={props.isVisible} /> 106 - </div> 107 - </div> 108 - </ThemeBackgroundProvider> 109 - </ThemeProvider> 110 - </Tooltip> 111 - ); 112 - }; 113 - 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; 127 - 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 - return ( 139 - <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"> 142 - <ThemeBackgroundProvider entityID={root}> 143 - <div 144 - 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" 146 - > 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 - > 172 - <LeafletContent entityID={page} isOnScreen={props.isVisible} /> 173 - </div> 174 - </div> 175 - </ThemeBackgroundProvider> 176 - </div> 177 - <LeafletPreviewLink id={props.token.id} /> 178 - </div> 179 - </ThemeProvider> 180 - ); 181 - }; 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 - };
···
-25
app/home/LoggedOutWarning.tsx
··· 1 - "use client"; 2 - import { useIdentityData } from "components/IdentityProvider"; 3 - import { LoginButton } from "components/LoginButton"; 4 - 5 - export const LoggedOutWarning = (props: {}) => { 6 - let { identity } = useIdentityData(); 7 - if (identity) return null; 8 - return ( 9 - <div 10 - className={` 11 - homeWarning z-10 shrink-0 12 - bg-bg-page rounded-md 13 - absolute bottom-16 left-2 right-2 14 - sm:static sm:mr-1 sm:ml-6 sm:mt-6 border border-border-light`} 15 - > 16 - <div className="px-2 py-1 text-sm text-tertiary flex sm:flex-row flex-col sm:gap-4 gap-1 items-center sm:justify-between"> 17 - <p className="font-bold"> 18 - Log in to collect all your Leaflets and access them on multiple 19 - devices 20 - </p> 21 - <LoginButton /> 22 - </div> 23 - </div> 24 - ); 25 - };
···
-105
app/home/icon.tsx
··· 1 - import { ImageResponse } from "next/og"; 2 - import type { Fact } from "src/replicache"; 3 - import type { Attribute } from "src/replicache/attributes"; 4 - import { Database } from "../../supabase/database.types"; 5 - import { createServerClient } from "@supabase/ssr"; 6 - import { parseHSBToRGB } from "src/utils/parseHSB"; 7 - import { cookies } from "next/headers"; 8 - 9 - // Route segment config 10 - export const revalidate = 0; 11 - export const preferredRegion = ["sfo1"]; 12 - export const dynamic = "force-dynamic"; 13 - export const fetchCache = "force-no-store"; 14 - 15 - // Image metadata 16 - export const size = { 17 - width: 32, 18 - height: 32, 19 - }; 20 - export const contentType = "image/png"; 21 - 22 - // Image generation 23 - let supabase = createServerClient<Database>( 24 - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 25 - process.env.SUPABASE_SERVICE_ROLE_KEY as string, 26 - { cookies: {} }, 27 - ); 28 - export default async function Icon() { 29 - let cookieStore = await cookies(); 30 - let identity = cookieStore.get("identity"); 31 - let rootEntity: string | null = null; 32 - if (identity) { 33 - let res = await supabase 34 - .from("identities") 35 - .select( 36 - `*, 37 - permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), 38 - permission_token_on_homepage( 39 - *, permission_tokens(*, permission_token_rights(*)) 40 - ) 41 - `, 42 - ) 43 - .eq("id", identity?.value) 44 - .single(); 45 - rootEntity = res.data?.permission_tokens?.root_entity || null; 46 - } 47 - let outlineColor, fillColor; 48 - if (rootEntity) { 49 - let { data } = await supabase.rpc("get_facts", { 50 - root: rootEntity, 51 - }); 52 - let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 53 - let themePageBG = initialFacts.find( 54 - (f) => f.attribute === "theme/card-background", 55 - ) as Fact<"theme/card-background"> | undefined; 56 - 57 - let themePrimary = initialFacts.find( 58 - (f) => f.attribute === "theme/primary", 59 - ) as Fact<"theme/primary"> | undefined; 60 - 61 - outlineColor = parseHSBToRGB(`hsba(${themePageBG?.data.value})`); 62 - 63 - fillColor = parseHSBToRGB(`hsba(${themePrimary?.data.value})`); 64 - } 65 - 66 - return new ImageResponse( 67 - ( 68 - // ImageResponse JSX element 69 - <div style={{ display: "flex" }}> 70 - <svg 71 - width="32" 72 - height="32" 73 - viewBox="0 0 32 32" 74 - fill="none" 75 - xmlns="http://www.w3.org/2000/svg" 76 - > 77 - {/* outline */} 78 - <path 79 - fillRule="evenodd" 80 - clipRule="evenodd" 81 - 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" 82 - fill={outlineColor ? outlineColor : "#FFFFFF"} 83 - /> 84 - 85 - {/* fill */} 86 - <path 87 - fillRule="evenodd" 88 - clipRule="evenodd" 89 - 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" 90 - fill={fillColor ? fillColor : "#272727"} 91 - /> 92 - </svg> 93 - </div> 94 - ), 95 - // ImageResponse options 96 - { 97 - // For convenience, we can re-use the exported icons size metadata 98 - // config to also set the ImageResponse's width and height. 99 - ...size, 100 - headers: { 101 - "Cache-Control": "no-cache", 102 - }, 103 - }, 104 - ); 105 - }
···
-124
app/home/page.tsx
··· 1 - import { cookies } from "next/headers"; 2 - import { Fact, ReplicacheProvider, useEntity } from "src/replicache"; 3 - import type { Attribute } from "src/replicache/attributes"; 4 - import { 5 - ThemeBackgroundProvider, 6 - ThemeProvider, 7 - } from "components/ThemeManager/ThemeProvider"; 8 - import { EntitySetProvider } from "components/EntitySetProvider"; 9 - import { createIdentity } from "actions/createIdentity"; 10 - import { drizzle } from "drizzle-orm/node-postgres"; 11 - import { IdentitySetter } from "./IdentitySetter"; 12 - 13 - import { getIdentityData } from "actions/getIdentityData"; 14 - import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 15 - import { supabaseServerClient } from "supabase/serverClient"; 16 - import { pool } from "supabase/pool"; 17 - 18 - import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 19 - import { HomeLayout } from "./HomeLayout"; 20 - 21 - export default async function Home() { 22 - let cookieStore = await cookies(); 23 - let auth_res = await getIdentityData(); 24 - let identity: string | undefined; 25 - if (auth_res) identity = auth_res.id; 26 - else identity = cookieStore.get("identity")?.value; 27 - let needstosetcookie = false; 28 - if (!identity) { 29 - const client = await pool.connect(); 30 - const db = drizzle(client); 31 - let newIdentity = await createIdentity(db); 32 - client.release(); 33 - identity = newIdentity.id; 34 - needstosetcookie = true; 35 - } 36 - 37 - async function setCookie() { 38 - "use server"; 39 - 40 - (await cookies()).set("identity", identity as string, { 41 - sameSite: "strict", 42 - }); 43 - } 44 - 45 - let permission_token = auth_res?.home_leaflet; 46 - if (!permission_token) { 47 - let res = await supabaseServerClient 48 - .from("identities") 49 - .select( 50 - `*, 51 - permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)) 52 - `, 53 - ) 54 - .eq("id", identity) 55 - .single(); 56 - permission_token = res.data?.permission_tokens; 57 - } 58 - 59 - if (!permission_token) 60 - return ( 61 - <NotFoundLayout> 62 - <p className="font-bold">Sorry, we can't find this home!</p> 63 - <p> 64 - This may be a glitch on our end. If the issue persists please{" "} 65 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 66 - </p> 67 - </NotFoundLayout> 68 - ); 69 - let [homeLeafletFacts, allLeafletFacts] = await Promise.all([ 70 - supabaseServerClient.rpc("get_facts", { 71 - root: permission_token.root_entity, 72 - }), 73 - auth_res 74 - ? getFactsFromHomeLeaflets.handler( 75 - { 76 - tokens: auth_res.permission_token_on_homepage.map( 77 - (r) => r.permission_tokens.root_entity, 78 - ), 79 - }, 80 - { supabase: supabaseServerClient }, 81 - ) 82 - : undefined, 83 - ]); 84 - let initialFacts = 85 - (homeLeafletFacts.data as unknown as Fact<Attribute>[]) || []; 86 - 87 - let root_entity = permission_token.root_entity; 88 - let home_docs_initialFacts = allLeafletFacts?.result || {}; 89 - 90 - return ( 91 - <ReplicacheProvider 92 - rootEntity={root_entity} 93 - token={permission_token} 94 - name={root_entity} 95 - initialFacts={initialFacts} 96 - > 97 - <IdentitySetter cb={setCookie} call={needstosetcookie} /> 98 - <EntitySetProvider 99 - set={permission_token.permission_token_rights[0].entity_set} 100 - > 101 - <ThemeProvider entityID={root_entity}> 102 - <ThemeBackgroundProvider entityID={root_entity}> 103 - <HomeLayout 104 - titles={{ 105 - ...home_docs_initialFacts.titles, 106 - ...auth_res?.permission_token_on_homepage.reduce( 107 - (acc, tok) => { 108 - let title = 109 - tok.permission_tokens.leaflets_in_publications[0]?.title; 110 - if (title) acc[tok.permission_tokens.root_entity] = title; 111 - return acc; 112 - }, 113 - {} as { [k: string]: string }, 114 - ), 115 - }} 116 - entityID={root_entity} 117 - initialFacts={home_docs_initialFacts.facts || {}} 118 - /> 119 - </ThemeBackgroundProvider> 120 - </ThemeProvider> 121 - </EntitySetProvider> 122 - </ReplicacheProvider> 123 - ); 124 - }
···
-68
app/home/storage.ts
··· 1 - import type { PermissionToken } from "src/replicache"; 2 - import { mutate } from "swr"; 3 - 4 - export type HomeDoc = { 5 - token: PermissionToken; 6 - added_at: string; 7 - hidden?: boolean; 8 - }; 9 - type HomeDocsStorage = { 10 - version: number; 11 - docs: Array<HomeDoc>; 12 - }; 13 - let defaultValue: HomeDocsStorage = { 14 - version: 1, 15 - docs: [], 16 - }; 17 - const key = "homepageDocs-v1"; 18 - let tokenCache = new Map<string, PermissionToken>(); 19 - export function getHomeDocs() { 20 - let homepageDocs: HomeDocsStorage = JSON.parse( 21 - window.localStorage.getItem(key) || JSON.stringify(defaultValue), 22 - ); 23 - return homepageDocs.docs.map((d) => { 24 - let cachedToken = tokenCache.get(d.token.id); 25 - if (!cachedToken) { 26 - cachedToken = d.token; 27 - tokenCache.set(d.token.id, d.token); 28 - } 29 - return { ...d, token: cachedToken }; 30 - }); 31 - } 32 - 33 - export function addDocToHome(doc: PermissionToken) { 34 - let homepageDocs = getHomeDocs(); 35 - if (homepageDocs.find((d) => d.token.id === doc.id)) return; 36 - homepageDocs.push({ token: doc, added_at: new Date().toISOString() }); 37 - let newValue: HomeDocsStorage = { 38 - version: 1, 39 - docs: homepageDocs, 40 - }; 41 - window.localStorage.setItem(key, JSON.stringify(newValue)); 42 - } 43 - 44 - export function removeDocFromHome(doc: PermissionToken) { 45 - let homepageDocs = getHomeDocs(); 46 - let newDocs = homepageDocs.filter((d) => d.token.id !== doc.id); 47 - let newValue: HomeDocsStorage = { 48 - version: 1, 49 - docs: newDocs, 50 - }; 51 - window.localStorage.setItem(key, JSON.stringify(newValue)); 52 - } 53 - 54 - export function hideDoc(doc: PermissionToken) { 55 - let homepageDocs = getHomeDocs(); 56 - let newDocs = homepageDocs.filter((d) => d.token.id !== doc.id); 57 - newDocs.push({ 58 - token: doc, 59 - added_at: new Date().toISOString(), 60 - hidden: true, 61 - }); 62 - let newValue: HomeDocsStorage = { 63 - version: 1, 64 - docs: newDocs, 65 - }; 66 - window.localStorage.setItem(key, JSON.stringify(newValue)); 67 - mutate("leaflets"); 68 - }
···
+7 -4
app/layout.tsx
··· 7 import { PopUpProvider } from "components/Toast"; 8 import { IdentityProviderServer } from "components/IdentityProviderServer"; 9 import { headers } from "next/headers"; 10 - import { IPLocationProvider } from "components/Providers/IPLocationProvider"; 11 import { RouteUIStateManager } from "components/RouteUIStateManger"; 12 13 export const metadata = { ··· 55 children: React.ReactNode; 56 } 57 ) { 58 - let ipLocation = (await headers()).get("X-Vercel-IP-Country"); 59 return ( 60 <html suppressHydrationWarning lang="en" className={`${quattro.variable}`}> 61 <body> ··· 77 <InitialPageLoad> 78 <PopUpProvider> 79 <IdentityProviderServer> 80 - <IPLocationProvider country={ipLocation}> 81 <ViewportSizeLayout>{children}</ViewportSizeLayout> 82 <RouteUIStateManager /> 83 - </IPLocationProvider> 84 </IdentityProviderServer> 85 </PopUpProvider> 86 </InitialPageLoad>
··· 7 import { PopUpProvider } from "components/Toast"; 8 import { IdentityProviderServer } from "components/IdentityProviderServer"; 9 import { headers } from "next/headers"; 10 + import { RequestHeadersProvider } from "components/Providers/RequestHeadersProvider"; 11 import { RouteUIStateManager } from "components/RouteUIStateManger"; 12 13 export const metadata = { ··· 55 children: React.ReactNode; 56 } 57 ) { 58 + let headersList = await headers(); 59 + let ipLocation = headersList.get("X-Vercel-IP-Country"); 60 + let acceptLanguage = headersList.get("accept-language"); 61 + let ipTimezone = headersList.get("X-Vercel-IP-Timezone"); 62 return ( 63 <html suppressHydrationWarning lang="en" className={`${quattro.variable}`}> 64 <body> ··· 80 <InitialPageLoad> 81 <PopUpProvider> 82 <IdentityProviderServer> 83 + <RequestHeadersProvider country={ipLocation} language={acceptLanguage} timezone={ipTimezone}> 84 <ViewportSizeLayout>{children}</ViewportSizeLayout> 85 <RouteUIStateManager /> 86 + </RequestHeadersProvider> 87 </IdentityProviderServer> 88 </PopUpProvider> 89 </InitialPageLoad>
+50 -197
app/lish/Subscribe.tsx
··· 24 import LoginForm from "app/login/LoginForm"; 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 27 - type State = 28 - | { state: "email" } 29 - | { state: "code"; token: string } 30 - | { state: "success" }; 31 - export const SubscribeButton = (props: { 32 - compact?: boolean; 33 - publication: string; 34 - }) => { 35 - let { identity, mutate } = useIdentityData(); 36 - let [emailInputValue, setEmailInputValue] = useState(""); 37 - let [codeInputValue, setCodeInputValue] = useState(""); 38 - let [state, setState] = useState<State>({ state: "email" }); 39 - 40 - if (state.state === "email") { 41 - return ( 42 - <div className="flex gap-2"> 43 - <div className="flex relative w-full max-w-sm"> 44 - <Input 45 - type="email" 46 - className="input-with-border pr-[104px]! py-1! grow w-full" 47 - placeholder={ 48 - props.compact ? "subscribe with email..." : "email here..." 49 - } 50 - disabled={!!identity?.email} 51 - value={identity?.email ? identity.email : emailInputValue} 52 - onChange={(e) => { 53 - setEmailInputValue(e.currentTarget.value); 54 - }} 55 - /> 56 - <ButtonPrimary 57 - compact 58 - className="absolute right-1 top-1 outline-0!" 59 - onClick={async () => { 60 - if (identity?.email) { 61 - await subscribeToPublicationWithEmail(props.publication); 62 - //optimistically could add! 63 - await mutate(); 64 - return; 65 - } 66 - let tokenID = await requestAuthEmailToken(emailInputValue); 67 - setState({ state: "code", token: tokenID }); 68 - }} 69 - > 70 - {props.compact ? ( 71 - <ArrowRightTiny className="w-4 h-6" /> 72 - ) : ( 73 - "Subscribe" 74 - )} 75 - </ButtonPrimary> 76 - </div> 77 - {/* <ShareButton /> */} 78 - </div> 79 - ); 80 - } 81 - if (state.state === "code") { 82 - return ( 83 - <div 84 - className="w-full flex flex-col justify-center place-items-center p-4 rounded-md" 85 - style={{ 86 - background: 87 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 88 - }} 89 - > 90 - <div className="flex flex-col leading-snug text-secondary"> 91 - <div>Please enter the code we sent to </div> 92 - <div className="italic font-bold">{emailInputValue}</div> 93 - </div> 94 - 95 - <ConfirmCodeInput 96 - publication={props.publication} 97 - token={state.token} 98 - codeInputValue={codeInputValue} 99 - setCodeInputValue={setCodeInputValue} 100 - setState={setState} 101 - /> 102 - 103 - <button 104 - className="text-accent-contrast text-sm mt-1" 105 - onClick={() => { 106 - setState({ state: "email" }); 107 - }} 108 - > 109 - Re-enter Email 110 - </button> 111 - </div> 112 - ); 113 - } 114 - 115 - if (state.state === "success") { 116 - return ( 117 - <div 118 - 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"}`} 119 - style={{ 120 - background: 121 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 122 - }} 123 - > 124 - <div className="flex gap-2 leading-snug font-bold italic"> 125 - <div>You're subscribed!</div> 126 - {/* <ShareButton /> */} 127 - </div> 128 - </div> 129 - ); 130 - } 131 - }; 132 - 133 - export const ShareButton = () => { 134 - return ( 135 - <button className="text-accent-contrast"> 136 - <ShareSmall /> 137 - </button> 138 - ); 139 - }; 140 - 141 - const ConfirmCodeInput = (props: { 142 - codeInputValue: string; 143 - token: string; 144 - setCodeInputValue: (value: string) => void; 145 - setState: (state: State) => void; 146 - publication: string; 147 - }) => { 148 - let { mutate } = useIdentityData(); 149 - return ( 150 - <div className="relative w-fit mt-2"> 151 - <Input 152 - type="text" 153 - pattern="[0-9]" 154 - className="input-with-border pr-[88px]! py-1! max-w-[156px]" 155 - placeholder="000000" 156 - value={props.codeInputValue} 157 - onChange={(e) => { 158 - props.setCodeInputValue(e.currentTarget.value); 159 - }} 160 - /> 161 - <ButtonPrimary 162 - compact 163 - className="absolute right-1 top-1 outline-0!" 164 - onClick={async () => { 165 - console.log( 166 - await confirmEmailAuthToken(props.token, props.codeInputValue), 167 - ); 168 - 169 - await subscribeToPublicationWithEmail(props.publication); 170 - //optimistically could add! 171 - await mutate(); 172 - props.setState({ state: "success" }); 173 - return; 174 - }} 175 - > 176 - Confirm 177 - </ButtonPrimary> 178 - </div> 179 - ); 180 - }; 181 - 182 export const SubscribeWithBluesky = (props: { 183 - isPost?: boolean; 184 pubName: string; 185 pub_uri: string; 186 base_url: string; ··· 207 } 208 return ( 209 <div className="flex flex-col gap-2 text-center justify-center"> 210 - {props.isPost && ( 211 - <div className="text-sm text-tertiary font-bold"> 212 - Get updates from {props.pubName}! 213 - </div> 214 - )} 215 <div className="flex flex-row gap-2 place-self-center"> 216 <BlueskySubscribeButton 217 pub_uri={props.pub_uri} 218 setSuccessModalOpen={setSuccessModalOpen} 219 /> 220 - <a href={`${props.base_url}/rss`} className="flex" target="_blank"> 221 - <span className="sr-only">Subscribe to RSS</span> 222 <RSSSmall className="self-center" aria-hidden /> 223 </a> 224 </div> ··· 226 ); 227 }; 228 229 - const ManageSubscription = (props: { 230 - isPost?: boolean; 231 - pubName: string; 232 pub_uri: string; 233 subscribers: { identity: string }[]; 234 }) => { 235 let toaster = useToaster(); 236 let [hasFeed] = useState(false); ··· 242 }); 243 }, null); 244 return ( 245 - <div 246 - className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 247 > 248 - <div className="font-bold text-tertiary text-sm"> 249 - You&apos;re Subscribed{props.isPost ? ` to ${props.pubName}` : "!"} 250 </div> 251 - <Popover 252 - trigger={<div className="text-accent-contrast text-sm">Manage</div>} 253 - > 254 - <div className="max-w-sm flex flex-col gap-3 justify-center text-center"> 255 - {!hasFeed && ( 256 - <> 257 - <div className="flex flex-col gap-2 font-bold text-secondary w-full"> 258 - Updates via Bluesky custom feed! 259 - <a 260 - href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 261 - target="_blank" 262 - className=" place-self-center" 263 - > 264 - <ButtonPrimary>View Feed</ButtonPrimary> 265 - </a> 266 - </div> 267 - <hr className="border-border-light" /> 268 - </> 269 - )} 270 - <form action={unsubscribe}> 271 - <button className="font-bold text-accent-contrast w-max place-self-center"> 272 - {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 273 - </button> 274 - </form> 275 - </div>{" "} 276 - </Popover> 277 - </div> 278 ); 279 }; 280 ··· 407 </Dialog.Root> 408 ); 409 };
··· 24 import LoginForm from "app/login/LoginForm"; 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 27 export const SubscribeWithBluesky = (props: { 28 pubName: string; 29 pub_uri: string; 30 base_url: string; ··· 51 } 52 return ( 53 <div className="flex flex-col gap-2 text-center justify-center"> 54 <div className="flex flex-row gap-2 place-self-center"> 55 <BlueskySubscribeButton 56 pub_uri={props.pub_uri} 57 setSuccessModalOpen={setSuccessModalOpen} 58 /> 59 + <a 60 + href={`${props.base_url}/rss`} 61 + className="flex" 62 + target="_blank" 63 + aria-label="Subscribe to RSS" 64 + > 65 <RSSSmall className="self-center" aria-hidden /> 66 </a> 67 </div> ··· 69 ); 70 }; 71 72 + export const ManageSubscription = (props: { 73 pub_uri: string; 74 subscribers: { identity: string }[]; 75 + base_url: string; 76 }) => { 77 let toaster = useToaster(); 78 let [hasFeed] = useState(false); ··· 84 }); 85 }, null); 86 return ( 87 + <Popover 88 + trigger={ 89 + <div className="text-accent-contrast text-sm">Manage Subscription</div> 90 + } 91 > 92 + <div className="max-w-sm flex flex-col gap-1"> 93 + <h4>Update Options</h4> 94 + 95 + {!hasFeed && ( 96 + <a 97 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 98 + target="_blank" 99 + className=" place-self-center" 100 + > 101 + <ButtonPrimary fullWidth compact className="!px-4"> 102 + View Bluesky Custom Feed 103 + </ButtonPrimary> 104 + </a> 105 + )} 106 + 107 + <a 108 + href={`${props.base_url}/rss`} 109 + className="flex" 110 + target="_blank" 111 + aria-label="Subscribe to RSS" 112 + > 113 + <ButtonPrimary fullWidth compact> 114 + Get RSS 115 + </ButtonPrimary> 116 + </a> 117 + 118 + <hr className="border-border-light my-1" /> 119 + 120 + <form action={unsubscribe}> 121 + <button className="font-bold text-accent-contrast w-max place-self-center"> 122 + {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 123 + </button> 124 + </form> 125 </div> 126 + </Popover> 127 ); 128 }; 129 ··· 256 </Dialog.Root> 257 ); 258 }; 259 + 260 + export const SubscribeOnPost = () => { 261 + return <div></div>; 262 + };
+10
app/lish/[did]/[publication]/LocalizedDate.tsx
···
··· 1 + "use client"; 2 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 3 + 4 + export function LocalizedDate(props: { 5 + dateString: string; 6 + options?: Intl.DateTimeFormatOptions; 7 + }) { 8 + const formattedDate = useLocalizedDate(props.dateString, props.options); 9 + return <>{formattedDate}</>; 10 + }
+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 import { UnicodeString } from "@atproto/api"; 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 4 type Facet = PubLeafletRichtextFacet.Main; 5 export function BaseTextBlock(props: { ··· 22 let isStrikethrough = segment.facet?.find( 23 PubLeafletRichtextFacet.isStrikethrough, 24 ); 25 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 26 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 27 let isHighlighted = segment.facet?.find( ··· 36 ${isStrikethrough ? "line-through decoration-tertiary" : ""} 37 ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 38 39 if (isCode) { 40 children.push( 41 <code key={counter} className={className} id={id?.id}> 42 - {segment.text} 43 </code>, 44 ); 45 } else if (link) { 46 children.push( 47 <a ··· 50 className={`text-accent-contrast hover:underline ${className}`} 51 target="_blank" 52 > 53 - {segment.text} 54 </a>, 55 ); 56 } else { 57 children.push( 58 <span key={counter} className={className} id={id?.id}> 59 - {segment.text} 60 </span>, 61 ); 62 }
··· 1 import { UnicodeString } from "@atproto/api"; 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 4 + import { AtMentionLink } from "components/AtMentionLink"; 5 6 type Facet = PubLeafletRichtextFacet.Main; 7 export function BaseTextBlock(props: { ··· 24 let isStrikethrough = segment.facet?.find( 25 PubLeafletRichtextFacet.isStrikethrough, 26 ); 27 + let isDidMention = segment.facet?.find( 28 + PubLeafletRichtextFacet.isDidMention, 29 + ); 30 + let isAtMention = segment.facet?.find( 31 + PubLeafletRichtextFacet.isAtMention, 32 + ); 33 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 34 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 35 let isHighlighted = segment.facet?.find( ··· 44 ${isStrikethrough ? "line-through decoration-tertiary" : ""} 45 ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 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 + 53 if (isCode) { 54 children.push( 55 <code key={counter} className={className} id={id?.id}> 56 + {renderedText} 57 </code>, 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 + ); 81 } else if (link) { 82 children.push( 83 <a ··· 86 className={`text-accent-contrast hover:underline ${className}`} 87 target="_blank" 88 > 89 + {renderedText} 90 </a>, 91 ); 92 } else { 93 children.push( 94 <span key={counter} className={className} id={id?.id}> 95 + {renderedText} 96 </span>, 97 ); 98 }
+246
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
···
··· 1 + "use client"; 2 + import { 3 + PubLeafletPagesCanvas, 4 + PubLeafletPagesLinearDocument, 5 + PubLeafletPublication, 6 + } from "lexicons/api"; 7 + import { PostPageData } from "./getPostPageData"; 8 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 9 + import { AppBskyFeedDefs } from "@atproto/api"; 10 + import { PageWrapper } from "components/Pages/Page"; 11 + import { Block } from "./PostContent"; 12 + import { CanvasBackgroundPattern } from "components/Canvas"; 13 + import { 14 + getCommentCount, 15 + getQuoteCount, 16 + Interactions, 17 + } from "./Interactions/Interactions"; 18 + import { Separator } from "components/Layout"; 19 + import { Popover } from "components/Popover"; 20 + import { InfoSmall } from "components/Icons/InfoSmall"; 21 + import { PostHeader } from "./PostHeader/PostHeader"; 22 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 + import { PollData } from "./fetchPollData"; 24 + import { SharedPageProps } from "./PostPages"; 25 + import { useIsMobile } from "src/hooks/isMobile"; 26 + 27 + export function CanvasPage({ 28 + blocks, 29 + pages, 30 + ...props 31 + }: Omit<SharedPageProps, "allPages"> & { 32 + blocks: PubLeafletPagesCanvas.Block[]; 33 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 34 + }) { 35 + const { 36 + document, 37 + did, 38 + profile, 39 + preferences, 40 + pubRecord, 41 + theme, 42 + prerenderedCodeBlocks, 43 + bskyPostData, 44 + pollData, 45 + document_uri, 46 + pageId, 47 + pageOptions, 48 + fullPageScroll, 49 + hasPageBackground, 50 + } = props; 51 + if (!document) return null; 52 + 53 + let isSubpage = !!pageId; 54 + let drawer = useDrawerOpen(document_uri); 55 + 56 + return ( 57 + <PageWrapper 58 + pageType="canvas" 59 + fullPageScroll={fullPageScroll} 60 + cardBorderHidden={!hasPageBackground} 61 + id={pageId ? `post-page-${pageId}` : "post-page"} 62 + drawerOpen={ 63 + !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 64 + } 65 + pageOptions={pageOptions} 66 + > 67 + <CanvasMetadata 68 + pageId={pageId} 69 + isSubpage={isSubpage} 70 + data={document} 71 + profile={profile} 72 + preferences={preferences} 73 + commentsCount={getCommentCount(document, pageId)} 74 + quotesCount={getQuoteCount(document, pageId)} 75 + /> 76 + <CanvasContent 77 + blocks={blocks} 78 + did={did} 79 + prerenderedCodeBlocks={prerenderedCodeBlocks} 80 + bskyPostData={bskyPostData} 81 + pollData={pollData} 82 + pageId={pageId} 83 + pages={pages} 84 + /> 85 + </PageWrapper> 86 + ); 87 + } 88 + 89 + function CanvasContent({ 90 + blocks, 91 + did, 92 + prerenderedCodeBlocks, 93 + bskyPostData, 94 + pageId, 95 + pollData, 96 + pages, 97 + }: { 98 + blocks: PubLeafletPagesCanvas.Block[]; 99 + did: string; 100 + prerenderedCodeBlocks?: Map<string, string>; 101 + pollData: PollData[]; 102 + bskyPostData: AppBskyFeedDefs.PostView[]; 103 + pageId?: string; 104 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 105 + }) { 106 + let height = blocks.length > 0 ? Math.max(...blocks.map((b) => b.y), 0) : 0; 107 + 108 + return ( 109 + <div className="canvasWrapper h-full w-fit overflow-y-scroll postContent"> 110 + <div 111 + style={{ 112 + minHeight: height + 512, 113 + contain: "size layout paint", 114 + }} 115 + className="relative h-full w-[1272px]" 116 + > 117 + <CanvasBackground /> 118 + 119 + {blocks 120 + .sort((a, b) => { 121 + if (a.y === b.y) { 122 + return a.x - b.x; 123 + } 124 + return a.y - b.y; 125 + }) 126 + .map((canvasBlock, index) => { 127 + return ( 128 + <CanvasBlock 129 + key={index} 130 + canvasBlock={canvasBlock} 131 + did={did} 132 + pollData={pollData} 133 + prerenderedCodeBlocks={prerenderedCodeBlocks} 134 + bskyPostData={bskyPostData} 135 + pageId={pageId} 136 + pages={pages} 137 + index={index} 138 + /> 139 + ); 140 + })} 141 + </div> 142 + </div> 143 + ); 144 + } 145 + 146 + function CanvasBlock({ 147 + canvasBlock, 148 + did, 149 + prerenderedCodeBlocks, 150 + bskyPostData, 151 + pollData, 152 + pageId, 153 + pages, 154 + index, 155 + }: { 156 + canvasBlock: PubLeafletPagesCanvas.Block; 157 + did: string; 158 + prerenderedCodeBlocks?: Map<string, string>; 159 + bskyPostData: AppBskyFeedDefs.PostView[]; 160 + pollData: PollData[]; 161 + pageId?: string; 162 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 163 + index: number; 164 + }) { 165 + let { x, y, width, rotation } = canvasBlock; 166 + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 167 + 168 + // Wrap the block in a LinearDocument.Block structure for compatibility 169 + let linearBlock: PubLeafletPagesLinearDocument.Block = { 170 + $type: "pub.leaflet.pages.linearDocument#block", 171 + block: canvasBlock.block, 172 + }; 173 + 174 + return ( 175 + <div 176 + className="absolute rounded-lg flex items-stretch origin-center p-3" 177 + style={{ 178 + top: 0, 179 + left: 0, 180 + width, 181 + transform, 182 + }} 183 + > 184 + <div className="contents"> 185 + <Block 186 + pollData={pollData} 187 + pageId={pageId} 188 + pages={pages} 189 + bskyPostData={bskyPostData} 190 + block={linearBlock} 191 + did={did} 192 + index={[index]} 193 + preview={false} 194 + prerenderedCodeBlocks={prerenderedCodeBlocks} 195 + /> 196 + </div> 197 + </div> 198 + ); 199 + } 200 + 201 + const CanvasMetadata = (props: { 202 + pageId: string | undefined; 203 + isSubpage: boolean | undefined; 204 + data: PostPageData; 205 + profile: ProfileViewDetailed; 206 + preferences: { showComments?: boolean }; 207 + quotesCount: number | undefined; 208 + commentsCount: number | undefined; 209 + }) => { 210 + let isMobile = useIsMobile(); 211 + return ( 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"> 213 + <Interactions 214 + quotesCount={props.quotesCount || 0} 215 + commentsCount={props.commentsCount || 0} 216 + showComments={props.preferences.showComments} 217 + pageId={props.pageId} 218 + /> 219 + {!props.isSubpage && ( 220 + <> 221 + <Separator classname="h-5" /> 222 + <Popover 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"}`} 226 + trigger={<InfoSmall />} 227 + > 228 + <PostHeader 229 + data={props.data} 230 + profile={props.profile} 231 + preferences={props.preferences} 232 + /> 233 + </Popover> 234 + </> 235 + )} 236 + </div> 237 + ); 238 + }; 239 + 240 + const CanvasBackground = () => { 241 + return ( 242 + <div className="w-full h-full pointer-events-none"> 243 + <CanvasBackgroundPattern pattern="grid" /> 244 + </div> 245 + ); 246 + };
+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 + }
+227 -12
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 8 import { EditorState, TextSelection } from "prosemirror-state"; 9 import { EditorView } from "prosemirror-view"; 10 import { history, redo, undo } from "prosemirror-history"; 11 import { 12 MutableRefObject, 13 RefObject, 14 useEffect, 15 useLayoutEffect, 16 useRef, ··· 36 import { CloseTiny } from "components/Icons/CloseTiny"; 37 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 38 import { betterIsUrl } from "src/utils/isURL"; 39 40 export function CommentBox(props: { 41 doc_uri: string; 42 replyTo?: string; 43 onSubmit?: () => void; 44 autoFocus?: boolean; 45 }) { 46 let mountRef = useRef<HTMLPreElement | null>(null); 47 let { 48 commentBox: { quote }, 49 } = useInteractionState(props.doc_uri); 50 let [loading, setLoading] = useState(false); 51 52 - const handleSubmit = async () => { 53 if (loading || !view.current) return; 54 55 setLoading(true); 56 let currentState = view.current.state; 57 let [plaintext, facets] = docToFacetedText(currentState.doc); 58 let comment = await publishComment({ 59 document: props.doc_uri, 60 comment: { 61 plaintext, ··· 111 "Mod-y": redo, 112 "Shift-Mod-z": redo, 113 "Ctrl-Enter": () => { 114 - handleSubmit(); 115 return true; 116 }, 117 "Meta-Enter": () => { 118 - handleSubmit(); 119 return true; 120 }, 121 }), ··· 125 shouldAutoLink: () => true, 126 defaultProtocol: "https", 127 }), 128 history(), 129 ], 130 }), 131 ); 132 - let view = useRef<null | EditorView>(null); 133 useLayoutEffect(() => { 134 if (!mountRef.current) return; 135 view.current = new EditorView( ··· 184 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 185 if (!direct) return; 186 if (node.nodeSize - 2 <= _pos) return; 187 let mark = 188 - node 189 - .nodeAt(_pos - 1) 190 - ?.marks.find((f) => f.type === multiBlockSchema.marks.link) || 191 - node 192 - .nodeAt(Math.max(_pos - 2, 0)) 193 - ?.marks.find((f) => f.type === multiBlockSchema.marks.link); 194 if (mark) { 195 window.open(mark.attrs.href, "_blank"); 196 } 197 }, 198 dispatchTransaction(tr) { ··· 214 }, []); 215 216 return ( 217 - <div className=" flex flex-col"> 218 {quote && ( 219 <div className="relative mt-2 mb-2"> 220 <QuoteContent position={quote} did="" index={-1} /> ··· 233 <div className="w-full relative group"> 234 <pre 235 ref={mountRef} 236 className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`} 237 /> 238 <IOSBS view={view} /> 239 </div> 240 <div className="flex justify-between pt-1"> 241 <div className="flex gap-1"> ··· 258 view={view} 259 /> 260 </div> 261 - <ButtonPrimary compact onClick={handleSubmit}> 262 {loading ? <DotLoader /> : <ShareSmall />} 263 </ButtonPrimary> 264 </div> ··· 325 facets.push(facet); 326 } 327 } 328 329 fullText += text; 330 byteOffset += unicodeString.length;
··· 8 import { EditorState, TextSelection } from "prosemirror-state"; 9 import { EditorView } from "prosemirror-view"; 10 import { history, redo, undo } from "prosemirror-history"; 11 + import { InputRule, inputRules } from "prosemirror-inputrules"; 12 import { 13 MutableRefObject, 14 RefObject, 15 + useCallback, 16 useEffect, 17 useLayoutEffect, 18 useRef, ··· 38 import { CloseTiny } from "components/Icons/CloseTiny"; 39 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 40 import { betterIsUrl } from "src/utils/isURL"; 41 + import { Mention, MentionAutocomplete } from "components/Mention"; 42 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 43 + 44 + const addMentionToEditor = ( 45 + mention: Mention, 46 + range: { from: number; to: number }, 47 + view: EditorView, 48 + ) => { 49 + if (!view) return; 50 + const { from, to } = range; 51 + const tr = view.state.tr; 52 + 53 + if (mention.type === "did") { 54 + // Delete the @ and any query text 55 + tr.delete(from, to); 56 + // Insert didMention inline node 57 + const mentionText = "@" + mention.handle; 58 + const didMentionNode = multiBlockSchema.nodes.didMention.create({ 59 + did: mention.did, 60 + text: mentionText, 61 + }); 62 + tr.insert(from, didMentionNode); 63 + // Add a space after the mention 64 + tr.insertText(" ", from + 1); 65 + } 66 + if (mention.type === "publication" || mention.type === "post") { 67 + // Delete the @ and any query text 68 + tr.delete(from, to); 69 + let name = mention.type === "post" ? mention.title : mention.name; 70 + // Insert atMention inline node 71 + const atMentionNode = multiBlockSchema.nodes.atMention.create({ 72 + atURI: mention.uri, 73 + text: name, 74 + }); 75 + tr.insert(from, atMentionNode); 76 + // Add a space after the mention 77 + tr.insertText(" ", from + 1); 78 + } 79 + 80 + view.dispatch(tr); 81 + view.focus(); 82 + }; 83 84 export function CommentBox(props: { 85 doc_uri: string; 86 + pageId?: string; 87 replyTo?: string; 88 onSubmit?: () => void; 89 autoFocus?: boolean; 90 + className?: string; 91 }) { 92 let mountRef = useRef<HTMLPreElement | null>(null); 93 let { 94 commentBox: { quote }, 95 } = useInteractionState(props.doc_uri); 96 let [loading, setLoading] = useState(false); 97 + let view = useRef<null | EditorView>(null); 98 99 + // Mention autocomplete state 100 + const [mentionOpen, setMentionOpen] = useState(false); 101 + const [mentionCoords, setMentionCoords] = useState<{ 102 + top: number; 103 + left: number; 104 + } | null>(null); 105 + // Use a ref for insert position to avoid stale closure issues 106 + const mentionInsertPosRef = useRef<number | null>(null); 107 + 108 + // Use a ref for the callback so input rules can access it 109 + const openMentionAutocompleteRef = useRef<() => void>(() => {}); 110 + openMentionAutocompleteRef.current = () => { 111 + if (!view.current) return; 112 + 113 + const pos = view.current.state.selection.from; 114 + mentionInsertPosRef.current = pos; 115 + 116 + // Get coordinates for the popup relative to the positioned parent 117 + const coords = view.current.coordsAtPos(pos - 1); 118 + 119 + // Find the relative positioned parent container 120 + const editorEl = view.current.dom; 121 + const container = editorEl.closest(".relative") as HTMLElement | null; 122 + 123 + if (container) { 124 + const containerRect = container.getBoundingClientRect(); 125 + setMentionCoords({ 126 + top: coords.bottom - containerRect.top, 127 + left: coords.left - containerRect.left, 128 + }); 129 + } else { 130 + setMentionCoords({ 131 + top: coords.bottom, 132 + left: coords.left, 133 + }); 134 + } 135 + setMentionOpen(true); 136 + }; 137 + 138 + const handleMentionSelect = useCallback((mention: Mention) => { 139 + if (!view.current || mentionInsertPosRef.current === null) return; 140 + 141 + const from = mentionInsertPosRef.current - 1; 142 + const to = mentionInsertPosRef.current; 143 + 144 + addMentionToEditor(mention, { from, to }, view.current); 145 + view.current.focus(); 146 + }, []); 147 + 148 + const handleMentionOpenChange = useCallback((open: boolean) => { 149 + setMentionOpen(open); 150 + if (!open) { 151 + setMentionCoords(null); 152 + mentionInsertPosRef.current = null; 153 + } 154 + }, []); 155 + 156 + // Use a ref for handleSubmit so keyboard shortcuts can access it 157 + const handleSubmitRef = useRef<() => Promise<void>>(async () => {}); 158 + handleSubmitRef.current = async () => { 159 if (loading || !view.current) return; 160 161 setLoading(true); 162 let currentState = view.current.state; 163 let [plaintext, facets] = docToFacetedText(currentState.doc); 164 let comment = await publishComment({ 165 + pageId: props.pageId, 166 document: props.doc_uri, 167 comment: { 168 plaintext, ··· 218 "Mod-y": redo, 219 "Shift-Mod-z": redo, 220 "Ctrl-Enter": () => { 221 + handleSubmitRef.current(); 222 return true; 223 }, 224 "Meta-Enter": () => { 225 + handleSubmitRef.current(); 226 return true; 227 }, 228 }), ··· 232 shouldAutoLink: () => true, 233 defaultProtocol: "https", 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 + }), 245 history(), 246 ], 247 }), 248 ); 249 useLayoutEffect(() => { 250 if (!mountRef.current) return; 251 view.current = new EditorView( ··· 300 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 301 if (!direct) return; 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 308 let mark = 309 + nodeAt1?.marks.find( 310 + (f) => f.type === multiBlockSchema.marks.link, 311 + ) || 312 + nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link); 313 if (mark) { 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; 352 } 353 }, 354 dispatchTransaction(tr) { ··· 370 }, []); 371 372 return ( 373 + <div className={`flex flex-col grow ${props.className}`}> 374 {quote && ( 375 <div className="relative mt-2 mb-2"> 376 <QuoteContent position={quote} did="" index={-1} /> ··· 389 <div className="w-full relative group"> 390 <pre 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 + }} 404 className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`} 405 /> 406 <IOSBS view={view} /> 407 + <MentionAutocomplete 408 + open={mentionOpen} 409 + onOpenChange={handleMentionOpenChange} 410 + view={view} 411 + onSelect={handleMentionSelect} 412 + coords={mentionCoords} 413 + /> 414 </div> 415 <div className="flex justify-between pt-1"> 416 <div className="flex gap-1"> ··· 433 view={view} 434 /> 435 </div> 436 + <ButtonPrimary compact onClick={() => handleSubmitRef.current()}> 437 {loading ? <DotLoader /> : <ShareSmall />} 438 </ButtonPrimary> 439 </div> ··· 500 facets.push(facet); 501 } 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 + }); 543 544 fullText += text; 545 byteOffset += unicodeString.length;
+124 -2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 5 import { PubLeafletRichtextFacet } from "lexicons/api"; 6 import { createOauthClient } from "src/atproto-oauth"; 7 import { TID } from "@atproto/common"; 8 - import { AtUri, Un$Typed } from "@atproto/api"; 9 import { supabaseServerClient } from "supabase/serverClient"; 10 import { Json } from "supabase/database.types"; 11 12 export async function publishComment(args: { 13 document: string; 14 comment: { 15 plaintext: string; 16 facets: PubLeafletRichtextFacet.Main[]; ··· 28 ); 29 let record: Un$Typed<PubLeafletComment.Record> = { 30 subject: args.document, 31 createdAt: new Date().toISOString(), 32 plaintext: args.comment.plaintext, 33 facets: args.comment.facets, ··· 63 } as unknown as Json, 64 }) 65 .select(); 66 67 return { 68 record: data?.[0].record as Json, 69 - profile: profile.value, 70 uri: uri.toString(), 71 }; 72 }
··· 5 import { PubLeafletRichtextFacet } from "lexicons/api"; 6 import { createOauthClient } from "src/atproto-oauth"; 7 import { TID } from "@atproto/common"; 8 + import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; 9 import { supabaseServerClient } from "supabase/serverClient"; 10 import { Json } from "supabase/database.types"; 11 + import { 12 + Notification, 13 + NotificationData, 14 + pingIdentityToUpdateNotification, 15 + } from "src/notifications"; 16 + import { v7 } from "uuid"; 17 18 export async function publishComment(args: { 19 document: string; 20 + pageId?: string; 21 comment: { 22 plaintext: string; 23 facets: PubLeafletRichtextFacet.Main[]; ··· 35 ); 36 let record: Un$Typed<PubLeafletComment.Record> = { 37 subject: args.document, 38 + onPage: args.pageId, 39 createdAt: new Date().toISOString(), 40 plaintext: args.comment.plaintext, 41 facets: args.comment.facets, ··· 71 } as unknown as Json, 72 }) 73 .select(); 74 + let notifications: Notification[] = []; 75 + let recipient = args.comment.replyTo 76 + ? new AtUri(args.comment.replyTo).host 77 + : new AtUri(args.document).host; 78 + if (recipient !== credentialSession.did) { 79 + notifications.push({ 80 + id: v7(), 81 + recipient, 82 + data: { 83 + type: "comment", 84 + comment_uri: uri.toString(), 85 + parent_uri: args.comment.replyTo, 86 + }, 87 + }); 88 + } 89 + 90 + // Create mention notifications from comment facets 91 + const mentionNotifications = createCommentMentionNotifications( 92 + args.comment.facets, 93 + uri.toString(), 94 + credentialSession.did!, 95 + ); 96 + notifications.push(...mentionNotifications); 97 + 98 + // Insert all notifications and ping recipients 99 + if (notifications.length > 0) { 100 + // SOMEDAY: move this out the action with inngest or workflows 101 + await supabaseServerClient.from("notifications").insert(notifications); 102 + 103 + // Ping all unique recipients 104 + const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))]; 105 + await Promise.all( 106 + uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)), 107 + ); 108 + } 109 110 return { 111 record: data?.[0].record as Json, 112 + profile: lexToJson(profile.value), 113 uri: uri.toString(), 114 }; 115 } 116 + 117 + /** 118 + * Creates mention notifications from comment facets 119 + * Handles didMention (people) and atMention (publications/documents) 120 + */ 121 + function createCommentMentionNotifications( 122 + facets: PubLeafletRichtextFacet.Main[], 123 + commentUri: string, 124 + commenterDid: string, 125 + ): Notification[] { 126 + const notifications: Notification[] = []; 127 + const notifiedRecipients = new Set<string>(); // Avoid duplicate notifications 128 + 129 + for (const facet of facets) { 130 + for (const feature of facet.features) { 131 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 132 + // DID mention - notify the mentioned person directly 133 + const recipientDid = feature.did; 134 + 135 + // Don't notify yourself 136 + if (recipientDid === commenterDid) continue; 137 + // Avoid duplicate notifications to the same person 138 + if (notifiedRecipients.has(recipientDid)) continue; 139 + notifiedRecipients.add(recipientDid); 140 + 141 + notifications.push({ 142 + id: v7(), 143 + recipient: recipientDid, 144 + data: { 145 + type: "comment_mention", 146 + comment_uri: commentUri, 147 + mention_type: "did", 148 + }, 149 + }); 150 + } else if (PubLeafletRichtextFacet.isAtMention(feature)) { 151 + // AT-URI mention - notify the owner of the publication/document 152 + try { 153 + const mentionedUri = new AtUri(feature.atURI); 154 + const recipientDid = mentionedUri.host; 155 + 156 + // Don't notify yourself 157 + if (recipientDid === commenterDid) continue; 158 + // Avoid duplicate notifications to the same person for the same mentioned item 159 + const dedupeKey = `${recipientDid}:${feature.atURI}`; 160 + if (notifiedRecipients.has(dedupeKey)) continue; 161 + notifiedRecipients.add(dedupeKey); 162 + 163 + if (mentionedUri.collection === "pub.leaflet.publication") { 164 + notifications.push({ 165 + id: v7(), 166 + recipient: recipientDid, 167 + data: { 168 + type: "comment_mention", 169 + comment_uri: commentUri, 170 + mention_type: "publication", 171 + mentioned_uri: feature.atURI, 172 + }, 173 + }); 174 + } else if (mentionedUri.collection === "pub.leaflet.document") { 175 + notifications.push({ 176 + id: v7(), 177 + recipient: recipientDid, 178 + data: { 179 + type: "comment_mention", 180 + comment_uri: commentUri, 181 + mention_type: "document", 182 + mentioned_uri: feature.atURI, 183 + }, 184 + }); 185 + } 186 + } catch (error) { 187 + console.error("Failed to parse AT-URI for mention:", feature.atURI, error); 188 + } 189 + } 190 + } 191 + } 192 + 193 + return notifications; 194 + }
+92 -57
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 17 import { usePathname } from "next/navigation"; 18 import { QuoteContent } from "../Quotes"; 19 import { timeAgo } from "src/utils/timeAgo"; 20 21 export type Comment = { 22 record: Json; 23 uri: string; 24 bsky_profiles: { record: Json } | null; 25 }; 26 - export function Comments(props: { document_uri: string; comments: Comment[] }) { 27 let { identity } = useIdentityData(); 28 let { localComments } = useInteractionState(props.document_uri); 29 let comments = useMemo(() => { 30 - return [...localComments, ...props.comments]; 31 }, [props.comments, localComments]); 32 let pathname = usePathname(); 33 let redirectRoute = useMemo(() => { 34 let url = new URL(pathname, window.location.origin); 35 url.searchParams.set("refreshAuth", ""); 36 url.searchParams.set("interactionDrawer", "comments"); ··· 52 </button> 53 </div> 54 {identity?.atp_did ? ( 55 - <CommentBox doc_uri={props.document_uri} /> 56 ) : ( 57 <div className="w-full accent-container text-tertiary text-center italic p-3 flex flex-col gap-2"> 58 Connect a Bluesky account to comment ··· 79 ?.record as AppBskyActorProfile.Record; 80 return ( 81 <Comment 82 profile={profile} 83 document={props.document_uri} 84 comment={comment} ··· 99 comments: Comment[]; 100 profile?: AppBskyActorProfile.Record; 101 record: PubLeafletComment.Record; 102 }) => { 103 return ( 104 <div className="comment"> ··· 130 /> 131 </pre> 132 <Replies 133 comment_uri={props.comment.uri} 134 comments={props.comments} 135 document={props.document} ··· 142 comment_uri: string; 143 comments: Comment[]; 144 document: string; 145 }) => { 146 let { identity } = useIdentityData(); 147 148 let [replyBoxOpen, setReplyBoxOpen] = useState(false); 149 let [repliesOpen, setRepliesOpen] = useState(true); 150 let replies = props.comments 151 .filter( 152 (comment) => ··· 161 new Date(aRecord.createdAt).getTime() 162 ); 163 }); 164 return ( 165 <> 166 <div className="flex gap-2 items-center"> ··· 188 </> 189 )} 190 </div> 191 - <div className="flex flex-col gap-2"> 192 - {replyBoxOpen && ( 193 - <CommentBox 194 - doc_uri={props.document} 195 - replyTo={props.comment_uri} 196 - autoFocus={true} 197 - onSubmit={() => { 198 - setReplyBoxOpen(false); 199 - }} 200 - /> 201 - )} 202 - {repliesOpen && replies.length > 0 && ( 203 - <div className="repliesWrapper flex"> 204 - <button 205 - className="repliesCollapse pr-[14px] ml-[7px] pt-0.5" 206 - onClick={() => { 207 - setReplyBoxOpen(false); 208 - setRepliesOpen(false); 209 - }} 210 - > 211 - <div className="bg-border-light w-[2px] h-full" /> 212 - </button> 213 - <div className="repliesContent flex flex-col gap-3 pt-2 w-full"> 214 - {replies.map((reply) => { 215 - return ( 216 - <Comment 217 - document={props.document} 218 - key={reply.uri} 219 - comment={reply} 220 - profile={ 221 - reply.bsky_profiles?.record as AppBskyActorProfile.Record 222 - } 223 - record={reply.record as PubLeafletComment.Record} 224 - comments={props.comments} 225 - /> 226 - ); 227 - })} 228 </div> 229 - </div> 230 - )} 231 - </div> 232 </> 233 ); 234 }; 235 236 const DatePopover = (props: { date: string }) => { 237 - let [t, full] = useMemo(() => { 238 - return [ 239 - timeAgo(props.date), 240 - new Date(props.date).toLocaleTimeString(undefined, { 241 - year: "numeric", 242 - month: "2-digit", 243 - day: "2-digit", 244 - hour: "2-digit", 245 - minute: "2-digit", 246 - }), 247 - ]; 248 - }, [props.date]); 249 return ( 250 <Popover 251 trigger={ 252 - <div className="italic text-sm text-tertiary hover:underline">{t}</div> 253 } 254 > 255 - <div className="text-sm text-secondary">{full}</div> 256 </Popover> 257 ); 258 };
··· 17 import { usePathname } from "next/navigation"; 18 import { QuoteContent } from "../Quotes"; 19 import { timeAgo } from "src/utils/timeAgo"; 20 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 22 export type Comment = { 23 record: Json; 24 uri: string; 25 bsky_profiles: { record: Json } | null; 26 }; 27 + export function Comments(props: { 28 + document_uri: string; 29 + comments: Comment[]; 30 + pageId?: string; 31 + }) { 32 let { identity } = useIdentityData(); 33 let { localComments } = useInteractionState(props.document_uri); 34 let comments = useMemo(() => { 35 + return [ 36 + ...localComments.filter( 37 + (c) => (c.record as any)?.onPage === props.pageId, 38 + ), 39 + ...props.comments, 40 + ]; 41 }, [props.comments, localComments]); 42 let pathname = usePathname(); 43 let redirectRoute = useMemo(() => { 44 + if (typeof window === "undefined") return; 45 let url = new URL(pathname, window.location.origin); 46 url.searchParams.set("refreshAuth", ""); 47 url.searchParams.set("interactionDrawer", "comments"); ··· 63 </button> 64 </div> 65 {identity?.atp_did ? ( 66 + <CommentBox doc_uri={props.document_uri} pageId={props.pageId} /> 67 ) : ( 68 <div className="w-full accent-container text-tertiary text-center italic p-3 flex flex-col gap-2"> 69 Connect a Bluesky account to comment ··· 90 ?.record as AppBskyActorProfile.Record; 91 return ( 92 <Comment 93 + pageId={props.pageId} 94 profile={profile} 95 document={props.document_uri} 96 comment={comment} ··· 111 comments: Comment[]; 112 profile?: AppBskyActorProfile.Record; 113 record: PubLeafletComment.Record; 114 + pageId?: string; 115 }) => { 116 return ( 117 <div className="comment"> ··· 143 /> 144 </pre> 145 <Replies 146 + pageId={props.pageId} 147 comment_uri={props.comment.uri} 148 comments={props.comments} 149 document={props.document} ··· 156 comment_uri: string; 157 comments: Comment[]; 158 document: string; 159 + pageId?: string; 160 }) => { 161 let { identity } = useIdentityData(); 162 163 let [replyBoxOpen, setReplyBoxOpen] = useState(false); 164 let [repliesOpen, setRepliesOpen] = useState(true); 165 + 166 let replies = props.comments 167 .filter( 168 (comment) => ··· 177 new Date(aRecord.createdAt).getTime() 178 ); 179 }); 180 + 181 + let repliesOrReplyBoxOpen = 182 + replyBoxOpen || (repliesOpen && replies.length > 0); 183 return ( 184 <> 185 <div className="flex gap-2 items-center"> ··· 207 </> 208 )} 209 </div> 210 + {repliesOrReplyBoxOpen && ( 211 + <div className="flex flex-col pt-1"> 212 + {replyBoxOpen && ( 213 + <div className="repliesWrapper flex w-full"> 214 + <button 215 + className="repliesCollapse pr-[14px] ml-[7px]" 216 + onClick={() => { 217 + setReplyBoxOpen(false); 218 + setRepliesOpen(false); 219 + }} 220 + > 221 + <div className="bg-border-light w-[2px] h-full" /> 222 + </button> 223 + <CommentBox 224 + className="pt-3" 225 + pageId={props.pageId} 226 + doc_uri={props.document} 227 + replyTo={props.comment_uri} 228 + autoFocus={true} 229 + onSubmit={() => { 230 + setReplyBoxOpen(false); 231 + }} 232 + /> 233 + </div> 234 + )} 235 + {repliesOpen && replies.length > 0 && ( 236 + <div className="repliesWrapper flex"> 237 + <button 238 + className="repliesCollapse pr-[14px] ml-[7px]" 239 + onClick={() => { 240 + setReplyBoxOpen(false); 241 + setRepliesOpen(false); 242 + }} 243 + > 244 + <div className="bg-border-light w-[2px] h-full" /> 245 + </button> 246 + <div className="repliesContent flex flex-col gap-3 pt-2 w-full"> 247 + {replies.map((reply) => { 248 + return ( 249 + <Comment 250 + pageId={props.pageId} 251 + document={props.document} 252 + key={reply.uri} 253 + comment={reply} 254 + profile={ 255 + reply.bsky_profiles 256 + ?.record as AppBskyActorProfile.Record 257 + } 258 + record={reply.record as PubLeafletComment.Record} 259 + comments={props.comments} 260 + /> 261 + ); 262 + })} 263 + </div> 264 </div> 265 + )} 266 + </div> 267 + )} 268 </> 269 ); 270 }; 271 272 const DatePopover = (props: { date: string }) => { 273 + const timeAgoText = useMemo(() => timeAgo(props.date), [props.date]); 274 + const fullDate = useLocalizedDate(props.date, { 275 + year: "numeric", 276 + month: "2-digit", 277 + day: "2-digit", 278 + hour: "2-digit", 279 + minute: "2-digit", 280 + }); 281 + 282 return ( 283 <Popover 284 trigger={ 285 + <div className="italic text-sm text-tertiary hover:underline"> 286 + {timeAgoText} 287 + </div> 288 } 289 > 290 + <div className="text-sm text-secondary">{fullDate}</div> 291 </Popover> 292 ); 293 };
+48 -23
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 "use client"; 2 import { Media } from "components/Media"; 3 import { Quotes } from "./Quotes"; 4 - import { useInteractionState } from "./Interactions"; 5 import { Json } from "supabase/database.types"; 6 import { Comment, Comments } from "./Comments"; 7 import { useSearchParams } from "next/navigation"; 8 9 export const InteractionDrawer = (props: { 10 document_uri: string; 11 - quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 12 comments: Comment[]; 13 did: string; 14 }) => { 15 - let params = useSearchParams(); 16 - let interactionDrawerSearchParam = params.get("interactionDrawer"); 17 - let { drawerOpen: open, drawer } = useInteractionState(); 18 - if (open === false || (open === undefined && !interactionDrawerSearchParam)) 19 - return null; 20 - let currentDrawer = drawer || interactionDrawerSearchParam; 21 return ( 22 <> 23 - <div className="sm:pr-4 pr-[6px] snap-center"> 24 - <div className="shrink-0 w-[calc(var(--page-width-units)-12px)] sm:w-(--page-width-units) h-full flex z-10"> 25 - <div 26 - id="interaction-drawer" 27 - className="opaque-container rounded-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll " 28 - > 29 - {currentDrawer === "quotes" ? ( 30 - <Quotes {...props} /> 31 - ) : ( 32 - <Comments 33 - document_uri={props.document_uri} 34 - comments={props.comments} 35 - /> 36 - )} 37 - </div> 38 </div> 39 </div> 40 </> 41 ); 42 };
··· 1 "use client"; 2 import { Media } from "components/Media"; 3 import { Quotes } from "./Quotes"; 4 + import { InteractionState, useInteractionState } from "./Interactions"; 5 import { Json } from "supabase/database.types"; 6 import { Comment, Comments } from "./Comments"; 7 import { useSearchParams } from "next/navigation"; 8 + import { SandwichSpacer } from "components/LeafletLayout"; 9 + import { decodeQuotePosition } from "../quotePosition"; 10 11 export const InteractionDrawer = (props: { 12 document_uri: string; 13 + quotesAndMentions: { uri: string; link?: string }[]; 14 comments: Comment[]; 15 did: string; 16 + pageId?: string; 17 }) => { 18 + let drawer = useDrawerOpen(props.document_uri); 19 + if (!drawer) return null; 20 + 21 + // Filter comments and quotes based on pageId 22 + const filteredComments = props.comments.filter( 23 + (c) => (c.record as any)?.onPage === props.pageId, 24 + ); 25 + 26 + const filteredQuotesAndMentions = props.quotesAndMentions.filter((q) => { 27 + if (!q.link) return !props.pageId; // Direct mentions without quote context go to main page 28 + const url = new URL(q.link); 29 + const quoteParam = url.pathname.split("/l-quote/")[1]; 30 + if (!quoteParam) return !props.pageId; 31 + const quotePosition = decodeQuotePosition(quoteParam); 32 + return quotePosition?.pageId === props.pageId; 33 + }); 34 + 35 return ( 36 <> 37 + <SandwichSpacer noWidth /> 38 + <div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]"> 39 + <div 40 + id="interaction-drawer" 41 + className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] " 42 + > 43 + {drawer.drawer === "quotes" ? ( 44 + <Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} /> 45 + ) : ( 46 + <Comments 47 + document_uri={props.document_uri} 48 + comments={filteredComments} 49 + pageId={props.pageId} 50 + /> 51 + )} 52 </div> 53 </div> 54 </> 55 ); 56 }; 57 + 58 + export const useDrawerOpen = (uri: string) => { 59 + let params = useSearchParams(); 60 + let interactionDrawerSearchParam = params.get("interactionDrawer"); 61 + let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 62 + if (open === false || (open === undefined && !interactionDrawerSearchParam)) 63 + return null; 64 + drawer = 65 + drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 66 + return { drawer, pageId }; 67 + };
+268 -51
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 5 import type { Json } from "supabase/database.types"; 6 import { create } from "zustand"; 7 import type { Comment } from "./Comments"; 8 - import { QuotePosition } from "../quotePosition"; 9 import { useContext } from "react"; 10 import { PostPageContext } from "../PostPageContext"; 11 12 - type InteractionState = { 13 drawerOpen: undefined | boolean; 14 drawer: undefined | "comments" | "quotes"; 15 localComments: Comment[]; 16 commentBox: { quote: QuotePosition | null }; ··· 27 [document_uri: string]: InteractionState; 28 }>(() => ({})); 29 30 - export function useInteractionState(document_uri?: string) { 31 return useInteractionStateStore((state) => { 32 - if (!document_uri || !state[document_uri]) { 33 return defaultInteractionState; 34 } 35 return state[document_uri]; ··· 83 export function openInteractionDrawer( 84 drawer: "comments" | "quotes", 85 document_uri: string, 86 ) { 87 flushSync(() => { 88 - setInteractionState(document_uri, { drawerOpen: true, drawer }); 89 }); 90 - let el = document.getElementById("interaction-drawer"); 91 - let isOffscreen = false; 92 - if (el) { 93 - const rect = el.getBoundingClientRect(); 94 - const windowWidth = 95 - window.innerWidth || document.documentElement.clientWidth; 96 - isOffscreen = rect.right > windowWidth - 64; 97 - } 98 - 99 - if (el && isOffscreen) 100 - el.scrollIntoView({ 101 - behavior: "smooth", 102 - block: "center", 103 - inline: "center", 104 - }); 105 } 106 107 export const Interactions = (props: { 108 quotesCount: number; 109 commentsCount: number; 110 - compact?: boolean; 111 className?: string; 112 showComments?: boolean; 113 }) => { 114 const data = useContext(PostPageContext); 115 const document_uri = data?.uri; 116 if (!document_uri) 117 throw new Error("document_uri not available in PostPageContext"); 118 119 - let { drawerOpen, drawer } = useInteractionState(document_uri); 120 121 return ( 122 - <div 123 - className={`flex gap-2 text-tertiary ${props.compact ? "text-sm" : ""} ${props.className}`} 124 - > 125 - <button 126 - className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 127 - onClick={() => { 128 - if (!drawerOpen || drawer !== "quotes") 129 - openInteractionDrawer("quotes", document_uri); 130 - else setInteractionState(document_uri, { drawerOpen: false }); 131 - }} 132 - > 133 - <span className="sr-only">Post quotes</span> 134 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 135 - {!props.compact && ( 136 - <span 137 - aria-hidden 138 - >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 139 - )} 140 - </button> 141 {props.showComments === false ? null : ( 142 <button 143 - className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 144 onClick={() => { 145 - if (!drawerOpen || drawer !== "comments") 146 - openInteractionDrawer("comments", document_uri); 147 else setInteractionState(document_uri, { drawerOpen: false }); 148 }} 149 > 150 - <span className="sr-only">Post comments</span> 151 - <CommentTiny aria-hidden /> {props.commentsCount}{" "} 152 - {!props.compact && ( 153 - <span 154 - aria-hidden 155 - >{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span> 156 - )} 157 </button> 158 )} 159 </div> 160 ); 161 };
··· 5 import type { Json } from "supabase/database.types"; 6 import { create } from "zustand"; 7 import type { Comment } from "./Comments"; 8 + import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 9 import { useContext } from "react"; 10 import { PostPageContext } from "../PostPageContext"; 11 + import { scrollIntoView } from "src/utils/scrollIntoView"; 12 + import { TagTiny } from "components/Icons/TagTiny"; 13 + import { Tag } from "components/Tags"; 14 + import { Popover } from "components/Popover"; 15 + import { PostPageData } from "../getPostPageData"; 16 + import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 17 + import { prefetchQuotesData } from "./Quotes"; 18 + import { useIdentityData } from "components/IdentityProvider"; 19 + import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; 20 + import { EditTiny } from "components/Icons/EditTiny"; 21 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 22 23 + export type InteractionState = { 24 drawerOpen: undefined | boolean; 25 + pageId?: string; 26 drawer: undefined | "comments" | "quotes"; 27 localComments: Comment[]; 28 commentBox: { quote: QuotePosition | null }; ··· 39 [document_uri: string]: InteractionState; 40 }>(() => ({})); 41 42 + export function useInteractionState(document_uri: string) { 43 return useInteractionStateStore((state) => { 44 + if (!state[document_uri]) { 45 return defaultInteractionState; 46 } 47 return state[document_uri]; ··· 95 export function openInteractionDrawer( 96 drawer: "comments" | "quotes", 97 document_uri: string, 98 + pageId?: string, 99 ) { 100 flushSync(() => { 101 + setInteractionState(document_uri, { drawerOpen: true, drawer, pageId }); 102 }); 103 + scrollIntoView("interaction-drawer"); 104 } 105 106 export const Interactions = (props: { 107 quotesCount: number; 108 commentsCount: number; 109 className?: string; 110 showComments?: boolean; 111 + pageId?: string; 112 }) => { 113 const data = useContext(PostPageContext); 114 const document_uri = data?.uri; 115 + let { identity } = useIdentityData(); 116 if (!document_uri) 117 throw new Error("document_uri not available in PostPageContext"); 118 119 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 120 + 121 + const handleQuotePrefetch = () => { 122 + if (data?.quotesAndMentions) { 123 + prefetchQuotesData(data.quotesAndMentions); 124 + } 125 + }; 126 + 127 + const tags = (data?.data as any)?.tags as string[] | undefined; 128 + const tagCount = tags?.length || 0; 129 130 return ( 131 + <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 132 + {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 133 + 134 + {props.quotesCount > 0 && ( 135 + <button 136 + className="flex w-fit gap-2 items-center" 137 + onClick={() => { 138 + if (!drawerOpen || drawer !== "quotes") 139 + openInteractionDrawer("quotes", document_uri, props.pageId); 140 + else setInteractionState(document_uri, { drawerOpen: false }); 141 + }} 142 + onMouseEnter={handleQuotePrefetch} 143 + onTouchStart={handleQuotePrefetch} 144 + aria-label="Post quotes" 145 + > 146 + <QuoteTiny aria-hidden /> {props.quotesCount} 147 + </button> 148 + )} 149 {props.showComments === false ? null : ( 150 <button 151 + className="flex gap-2 items-center w-fit" 152 onClick={() => { 153 + if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 154 + openInteractionDrawer("comments", document_uri, props.pageId); 155 else setInteractionState(document_uri, { drawerOpen: false }); 156 }} 157 + aria-label="Post comments" 158 > 159 + <CommentTiny aria-hidden /> {props.commentsCount} 160 </button> 161 )} 162 </div> 163 ); 164 }; 165 + 166 + export const ExpandedInteractions = (props: { 167 + quotesCount: number; 168 + commentsCount: number; 169 + className?: string; 170 + showComments?: boolean; 171 + pageId?: string; 172 + }) => { 173 + const data = useContext(PostPageContext); 174 + let { identity } = useIdentityData(); 175 + 176 + const document_uri = data?.uri; 177 + if (!document_uri) 178 + throw new Error("document_uri not available in PostPageContext"); 179 + 180 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 181 + 182 + const handleQuotePrefetch = () => { 183 + if (data?.quotesAndMentions) { 184 + prefetchQuotesData(data.quotesAndMentions); 185 + } 186 + }; 187 + let publication = data?.documents_in_publications[0]?.publications; 188 + 189 + const tags = (data?.data as any)?.tags as string[] | undefined; 190 + const tagCount = tags?.length || 0; 191 + 192 + let subscribed = 193 + identity?.atp_did && 194 + publication?.publication_subscriptions && 195 + publication?.publication_subscriptions.find( 196 + (s) => s.identity === identity.atp_did, 197 + ); 198 + 199 + let isAuthor = 200 + identity && 201 + identity.atp_did === 202 + data.documents_in_publications[0]?.publications?.identity_did && 203 + data.leaflets_in_publications[0]; 204 + 205 + return ( 206 + <div 207 + className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`} 208 + > 209 + {!subscribed && !isAuthor && publication && publication.record && ( 210 + <div className="text-center flex flex-col accent-container rounded-md mb-3"> 211 + <div className="flex flex-col py-4"> 212 + <div className="leading-snug flex flex-col pb-2 text-sm"> 213 + <div className="font-bold">Subscribe to {publication.name}</div>{" "} 214 + to get updates in Reader, RSS, or via Bluesky Feed 215 + </div> 216 + <SubscribeWithBluesky 217 + pubName={publication.name} 218 + pub_uri={publication.uri} 219 + base_url={ 220 + (publication.record as PubLeafletPublication.Record) 221 + .base_path || "" 222 + } 223 + subscribers={publication?.publication_subscriptions} 224 + /> 225 + </div> 226 + </div> 227 + )} 228 + {tagCount > 0 && ( 229 + <> 230 + <hr className="border-border-light mb-3" /> 231 + 232 + <TagList tags={tags} className="mb-3" /> 233 + </> 234 + )} 235 + <hr className="border-border-light mb-3 " /> 236 + <div className="flex gap-2 justify-between"> 237 + <div className="flex gap-2"> 238 + {props.quotesCount > 0 && ( 239 + <button 240 + className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 241 + onClick={() => { 242 + if (!drawerOpen || drawer !== "quotes") 243 + openInteractionDrawer("quotes", document_uri, props.pageId); 244 + else setInteractionState(document_uri, { drawerOpen: false }); 245 + }} 246 + onMouseEnter={handleQuotePrefetch} 247 + onTouchStart={handleQuotePrefetch} 248 + aria-label="Post quotes" 249 + > 250 + <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 251 + <span 252 + aria-hidden 253 + >{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span> 254 + </button> 255 + )} 256 + {props.showComments === false ? null : ( 257 + <button 258 + className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 259 + onClick={() => { 260 + if ( 261 + !drawerOpen || 262 + drawer !== "comments" || 263 + pageId !== props.pageId 264 + ) 265 + openInteractionDrawer("comments", document_uri, props.pageId); 266 + else setInteractionState(document_uri, { drawerOpen: false }); 267 + }} 268 + aria-label="Post comments" 269 + > 270 + <CommentTiny aria-hidden />{" "} 271 + {props.commentsCount > 0 ? ( 272 + <span aria-hidden> 273 + {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 274 + </span> 275 + ) : ( 276 + "Comment" 277 + )} 278 + </button> 279 + )} 280 + </div> 281 + <EditButton document={data} /> 282 + {subscribed && publication && ( 283 + <ManageSubscription 284 + base_url={getPublicationURL(publication)} 285 + pub_uri={publication.uri} 286 + subscribers={publication.publication_subscriptions} 287 + /> 288 + )} 289 + </div> 290 + </div> 291 + ); 292 + }; 293 + 294 + const TagPopover = (props: { 295 + tagCount: number; 296 + tags: string[] | undefined; 297 + }) => { 298 + return ( 299 + <Popover 300 + className="p-2! max-w-xs" 301 + trigger={ 302 + <div className="tags flex gap-1 items-center "> 303 + <TagTiny /> {props.tagCount} 304 + </div> 305 + } 306 + > 307 + <TagList tags={props.tags} className="text-secondary!" /> 308 + </Popover> 309 + ); 310 + }; 311 + 312 + const TagList = (props: { className?: string; tags: string[] | undefined }) => { 313 + if (!props.tags) return; 314 + return ( 315 + <div className="flex gap-1 flex-wrap"> 316 + {props.tags.map((tag, index) => ( 317 + <Tag name={tag} key={index} className={props.className} /> 318 + ))} 319 + </div> 320 + ); 321 + }; 322 + export function getQuoteCount(document: PostPageData, pageId?: string) { 323 + if (!document) return; 324 + return getQuoteCountFromArray(document.quotesAndMentions, pageId); 325 + } 326 + 327 + export function getQuoteCountFromArray( 328 + quotesAndMentions: { uri: string; link?: string }[], 329 + pageId?: string, 330 + ) { 331 + if (pageId) { 332 + return quotesAndMentions.filter((q) => { 333 + if (!q.link) return false; 334 + return q.link.includes(pageId); 335 + }).length; 336 + } else { 337 + return quotesAndMentions.filter((q) => { 338 + if (!q.link) return true; // Direct mentions go to main page 339 + const url = new URL(q.link); 340 + const quoteParam = url.pathname.split("/l-quote/")[1]; 341 + if (!quoteParam) return true; 342 + const quotePosition = decodeQuotePosition(quoteParam); 343 + return !quotePosition?.pageId; 344 + }).length; 345 + } 346 + } 347 + 348 + export function getCommentCount(document: PostPageData, pageId?: string) { 349 + if (!document) return; 350 + if (pageId) 351 + return document.comments_on_documents.filter( 352 + (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 353 + ).length; 354 + else 355 + return document.comments_on_documents.filter( 356 + (c) => !(c.record as PubLeafletComment.Record)?.onPage, 357 + ).length; 358 + } 359 + 360 + const EditButton = (props: { document: PostPageData }) => { 361 + let { identity } = useIdentityData(); 362 + if (!props.document) return; 363 + if ( 364 + identity && 365 + identity.atp_did === 366 + props.document.documents_in_publications[0]?.publications?.identity_did && 367 + props.document.leaflets_in_publications[0] 368 + ) 369 + return ( 370 + <a 371 + href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`} 372 + className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1" 373 + > 374 + <EditTiny /> Edit Post 375 + </a> 376 + ); 377 + return; 378 + };
+111 -10
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 5 import { setInteractionState } from "./Interactions"; 6 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 7 import { AtUri } from "@atproto/api"; 8 - import { Json } from "supabase/database.types"; 9 import { PostPageContext } from "../PostPageContext"; 10 import { 11 PubLeafletBlocksText, ··· 19 import { useActiveHighlightState } from "../useHighlight"; 20 import { PostContent } from "../PostContent"; 21 import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 22 23 export const Quotes = (props: { 24 - quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 25 did: string; 26 }) => { 27 let data = useContext(PostPageContext); 28 const document_uri = data?.uri; 29 - if (!document_uri) throw new Error('document_uri not available in PostPageContext'); 30 31 return ( 32 <div className="flex flex-col gap-2"> ··· 34 Quotes 35 <button 36 className="text-tertiary" 37 - onClick={() => setInteractionState(document_uri, { drawerOpen: false })} 38 > 39 <CloseTiny /> 40 </button> 41 </div> 42 - {props.quotes.length === 0 ? ( 43 <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 44 <div className="font-bold">no quotes yet!</div> 45 <div>highlight any part of this post to quote it</div> 46 </div> 47 ) : ( 48 <div className="quotes flex flex-col gap-8"> 49 - {props.quotes.map((q, index) => { 50 - let pv = q.bsky_posts?.post_view as unknown as PostView; 51 const url = new URL(q.link); 52 const quoteParam = url.pathname.split("/l-quote/")[1]; 53 if (!quoteParam) return null; 54 const quotePosition = decodeQuotePosition(quoteParam); 55 if (!quotePosition) return null; 56 return ( 57 - <div key={index} className="flex flex-col "> 58 <QuoteContent 59 index={index} 60 did={props.did} ··· 72 </div> 73 ); 74 })} 75 </div> 76 )} 77 </div> ··· 87 const data = useContext(PostPageContext); 88 89 let record = data?.data as PubLeafletDocument.Record; 90 - let page = record.pages[0] as PubLeafletPagesLinearDocument.Main; 91 // Extract blocks within the quote range 92 const content = extractQuotedBlocks(page.blocks || [], props.position, []); 93 return ( ··· 103 <div 104 className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" 105 onClick={(e) => { 106 let scrollMargin = isMobile 107 ? 16 108 : e.currentTarget.getBoundingClientRect().top; ··· 125 > 126 <div className="italic border border-border-light rounded-md px-2 pt-1"> 127 <PostContent 128 bskyPostData={[]} 129 blocks={content} 130 did={props.did} ··· 137 ); 138 }; 139 140 - const BskyPost = (props: { 141 rkey: string; 142 content: string; 143 user: string;
··· 5 import { setInteractionState } from "./Interactions"; 6 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 7 import { AtUri } from "@atproto/api"; 8 import { PostPageContext } from "../PostPageContext"; 9 import { 10 PubLeafletBlocksText, ··· 18 import { useActiveHighlightState } from "../useHighlight"; 19 import { PostContent } from "../PostContent"; 20 import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 21 + import { flushSync } from "react-dom"; 22 + import { openPage } from "../PostPages"; 23 + import useSWR, { mutate } from "swr"; 24 + import { DotLoader } from "components/utils/DotLoader"; 25 + 26 + // Helper to get SWR key for quotes 27 + export function getQuotesSWRKey(uris: string[]) { 28 + if (uris.length === 0) return null; 29 + const params = new URLSearchParams({ 30 + uris: JSON.stringify(uris), 31 + }); 32 + return `/api/bsky/hydrate?${params.toString()}`; 33 + } 34 + 35 + // Fetch posts from API route 36 + async function fetchBskyPosts(uris: string[]): Promise<PostView[]> { 37 + const params = new URLSearchParams({ 38 + uris: JSON.stringify(uris), 39 + }); 40 + 41 + const response = await fetch(`/api/bsky/hydrate?${params.toString()}`); 42 + 43 + if (!response.ok) { 44 + throw new Error("Failed to fetch Bluesky posts"); 45 + } 46 + 47 + return response.json(); 48 + } 49 + 50 + // Prefetch quotes data 51 + export function prefetchQuotesData( 52 + quotesAndMentions: { uri: string; link?: string }[], 53 + ) { 54 + const uris = quotesAndMentions.map((q) => q.uri); 55 + const key = getQuotesSWRKey(uris); 56 + if (key) { 57 + // Start fetching without blocking 58 + mutate(key, fetchBskyPosts(uris), { revalidate: false }); 59 + } 60 + } 61 62 export const Quotes = (props: { 63 + quotesAndMentions: { uri: string; link?: string }[]; 64 did: string; 65 }) => { 66 let data = useContext(PostPageContext); 67 const document_uri = data?.uri; 68 + if (!document_uri) 69 + throw new Error("document_uri not available in PostPageContext"); 70 + 71 + // Fetch Bluesky post data for all URIs 72 + const uris = props.quotesAndMentions.map((q) => q.uri); 73 + const key = getQuotesSWRKey(uris); 74 + const { data: bskyPosts, isLoading } = useSWR(key, () => 75 + fetchBskyPosts(uris), 76 + ); 77 + 78 + // Separate quotes with links (quoted content) from direct mentions 79 + const quotesWithLinks = props.quotesAndMentions.filter((q) => q.link); 80 + const directMentions = props.quotesAndMentions.filter((q) => !q.link); 81 + 82 + // Create a map of URIs to post views for easy lookup 83 + const postViewMap = new Map<string, PostView>(); 84 + bskyPosts?.forEach((pv) => { 85 + postViewMap.set(pv.uri, pv); 86 + }); 87 88 return ( 89 <div className="flex flex-col gap-2"> ··· 91 Quotes 92 <button 93 className="text-tertiary" 94 + onClick={() => 95 + setInteractionState(document_uri, { drawerOpen: false }) 96 + } 97 > 98 <CloseTiny /> 99 </button> 100 </div> 101 + {props.quotesAndMentions.length === 0 ? ( 102 <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 103 <div className="font-bold">no quotes yet!</div> 104 <div>highlight any part of this post to quote it</div> 105 </div> 106 + ) : isLoading ? ( 107 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm mt-8"> 108 + <span>loading</span> 109 + <DotLoader /> 110 + </div> 111 ) : ( 112 <div className="quotes flex flex-col gap-8"> 113 + {/* Quotes with links (quoted content) */} 114 + {quotesWithLinks.map((q, index) => { 115 + const pv = postViewMap.get(q.uri); 116 + if (!pv || !q.link) return null; 117 const url = new URL(q.link); 118 const quoteParam = url.pathname.split("/l-quote/")[1]; 119 if (!quoteParam) return null; 120 const quotePosition = decodeQuotePosition(quoteParam); 121 if (!quotePosition) return null; 122 return ( 123 + <div key={`quote-${index}`} className="flex flex-col "> 124 <QuoteContent 125 index={index} 126 did={props.did} ··· 138 </div> 139 ); 140 })} 141 + 142 + {/* Direct post mentions (without quoted content) */} 143 + {directMentions.length > 0 && ( 144 + <div className="flex flex-col gap-4"> 145 + <div className="text-secondary font-bold">Post Mentions</div> 146 + <div className="flex flex-col gap-8"> 147 + {directMentions.map((q, index) => { 148 + const pv = postViewMap.get(q.uri); 149 + if (!pv) return null; 150 + return ( 151 + <BskyPost 152 + key={`mention-${index}`} 153 + rkey={new AtUri(pv.uri).rkey} 154 + content={pv.record.text as string} 155 + user={pv.author.displayName || pv.author.handle} 156 + profile={pv.author} 157 + handle={pv.author.handle} 158 + /> 159 + ); 160 + })} 161 + </div> 162 + </div> 163 + )} 164 </div> 165 )} 166 </div> ··· 176 const data = useContext(PostPageContext); 177 178 let record = data?.data as PubLeafletDocument.Record; 179 + let page: PubLeafletPagesLinearDocument.Main | undefined = ( 180 + props.position.pageId 181 + ? record.pages.find( 182 + (p) => 183 + (p as PubLeafletPagesLinearDocument.Main).id === 184 + props.position.pageId, 185 + ) 186 + : record.pages[0] 187 + ) as PubLeafletPagesLinearDocument.Main; 188 // Extract blocks within the quote range 189 const content = extractQuotedBlocks(page.blocks || [], props.position, []); 190 return ( ··· 200 <div 201 className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" 202 onClick={(e) => { 203 + if (props.position.pageId) 204 + flushSync(() => openPage(undefined, props.position.pageId!)); 205 let scrollMargin = isMobile 206 ? 16 207 : e.currentTarget.getBoundingClientRect().top; ··· 224 > 225 <div className="italic border border-border-light rounded-md px-2 pt-1"> 226 <PostContent 227 + pollData={[]} 228 + pages={[]} 229 bskyPostData={[]} 230 blocks={content} 231 did={props.did} ··· 238 ); 239 }; 240 241 + export const BskyPost = (props: { 242 rkey: string; 243 content: string; 244 user: string;
+98
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
···
··· 1 + "use client"; 2 + import { 3 + PubLeafletComment, 4 + PubLeafletDocument, 5 + PubLeafletPagesLinearDocument, 6 + PubLeafletPublication, 7 + } from "lexicons/api"; 8 + import { PostPageData } from "./getPostPageData"; 9 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 + import { EditTiny } from "components/Icons/EditTiny"; 13 + import { 14 + ExpandedInteractions, 15 + getCommentCount, 16 + getQuoteCount, 17 + Interactions, 18 + } from "./Interactions/Interactions"; 19 + import { PostContent } from "./PostContent"; 20 + import { PostHeader } from "./PostHeader/PostHeader"; 21 + import { useIdentityData } from "components/IdentityProvider"; 22 + import { AppBskyFeedDefs } from "@atproto/api"; 23 + import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 24 + import { PageWrapper } from "components/Pages/Page"; 25 + import { decodeQuotePosition } from "./quotePosition"; 26 + import { PollData } from "./fetchPollData"; 27 + import { SharedPageProps } from "./PostPages"; 28 + 29 + export function LinearDocumentPage({ 30 + blocks, 31 + ...props 32 + }: Omit<SharedPageProps, "allPages"> & { 33 + blocks: PubLeafletPagesLinearDocument.Block[]; 34 + }) { 35 + const { 36 + document, 37 + did, 38 + profile, 39 + preferences, 40 + pubRecord, 41 + theme, 42 + prerenderedCodeBlocks, 43 + bskyPostData, 44 + pollData, 45 + document_uri, 46 + pageId, 47 + pageOptions, 48 + fullPageScroll, 49 + hasPageBackground, 50 + } = props; 51 + let drawer = useDrawerOpen(document_uri); 52 + 53 + if (!document) return null; 54 + 55 + let record = document.data as PubLeafletDocument.Record; 56 + 57 + const isSubpage = !!pageId; 58 + 59 + return ( 60 + <> 61 + <PageWrapper 62 + pageType="doc" 63 + fullPageScroll={fullPageScroll} 64 + cardBorderHidden={!hasPageBackground} 65 + id={pageId ? `post-page-${pageId}` : "post-page"} 66 + drawerOpen={ 67 + !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 68 + } 69 + pageOptions={pageOptions} 70 + > 71 + {!isSubpage && profile && ( 72 + <PostHeader 73 + data={document} 74 + profile={profile} 75 + preferences={preferences} 76 + /> 77 + )} 78 + <PostContent 79 + pollData={pollData} 80 + pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 81 + pageId={pageId} 82 + bskyPostData={bskyPostData} 83 + blocks={blocks} 84 + did={did} 85 + prerenderedCodeBlocks={prerenderedCodeBlocks} 86 + /> 87 + 88 + <ExpandedInteractions 89 + pageId={pageId} 90 + showComments={preferences.showComments} 91 + commentsCount={getCommentCount(document, pageId) || 0} 92 + quotesCount={getQuoteCount(document, pageId) || 0} 93 + /> 94 + {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 95 + </PageWrapper> 96 + </> 97 + ); 98 + }
-23
app/lish/[did]/[publication]/[rkey]/PageLayout.tsx
··· 1 - "use client"; 2 - 3 - import { useInteractionState } from "./Interactions/Interactions"; 4 - 5 - export function PageLayout(props: { children: React.ReactNode }) { 6 - let { drawerOpen } = useInteractionState(); 7 - return ( 8 - <div 9 - onScroll={(e) => {}} 10 - className="post w-full relative overflow-x-scroll snap-x snap-mandatory no-scrollbar grow items-stretch flex h-full pwa-padding mx-auto " 11 - id="page-carousel" 12 - > 13 - {/* if you adjust this padding, remember to adjust the negative margins on page 14 - in [rkey]/page/PostPage when card borders are hidden */} 15 - <div 16 - id="pages" 17 - className="postWrapper flex h-full gap-0 sm:gap-3 py-2 sm:py-6 w-full" 18 - > 19 - {props.children} 20 - </div> 21 - </div> 22 - ); 23 - }
···
+141 -27
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 1 import { 2 PubLeafletBlocksMath, 3 PubLeafletBlocksCode, ··· 8 PubLeafletBlocksWebsite, 9 PubLeafletDocument, 10 PubLeafletPagesLinearDocument, 11 PubLeafletBlocksHorizontalRule, 12 PubLeafletBlocksBlockquote, 13 PubLeafletBlocksBskyPost, 14 PubLeafletBlocksIframe, 15 } from "lexicons/api"; 16 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 17 import { TextBlock } from "./TextBlock"; 18 import { Popover } from "components/Popover"; 19 import { theme } from "tailwind.config"; 20 import { ImageAltSmall } from "components/Icons/ImageAlt"; 21 - import { codeToHtml } from "shiki"; 22 - import Katex from "katex"; 23 import { StaticMathBlock } from "./StaticMathBlock"; 24 import { PubCodeBlock } from "./PubCodeBlock"; 25 import { AppBskyFeedDefs } from "@atproto/api"; 26 import { PubBlueskyPostBlock } from "./PublishBskyPostBlock"; 27 28 export function PostContent({ 29 blocks, ··· 32 className, 33 prerenderedCodeBlocks, 34 bskyPostData, 35 }: { 36 blocks: PubLeafletPagesLinearDocument.Block[]; 37 did: string; 38 preview?: boolean; 39 className?: string; 40 prerenderedCodeBlocks?: Map<string, string>; 41 bskyPostData: AppBskyFeedDefs.PostView[]; 42 }) { 43 return ( 44 <div 45 - id="post-content" 46 - className={`postContent flex flex-col pb-1 sm:pb-2 pt-1 sm:pt-2 ${className}`} 47 > 48 {blocks.map((b, index) => { 49 return ( 50 <Block 51 bskyPostData={bskyPostData} 52 block={b} 53 did={did} ··· 56 index={[index]} 57 preview={preview} 58 prerenderedCodeBlocks={prerenderedCodeBlocks} 59 /> 60 ); 61 })} ··· 63 ); 64 } 65 66 - let Block = ({ 67 block, 68 did, 69 isList, ··· 72 previousBlock, 73 prerenderedCodeBlocks, 74 bskyPostData, 75 }: { 76 preview?: boolean; 77 index: number[]; 78 block: PubLeafletPagesLinearDocument.Block; 79 did: string; 80 isList?: boolean; 81 previousBlock?: PubLeafletPagesLinearDocument.Block; 82 prerenderedCodeBlocks?: Map<string, string>; 83 bskyPostData: AppBskyFeedDefs.PostView[]; 84 }) => { 85 let b = block; 86 let blockProps = { ··· 89 scrollMarginBottom: "4rem", 90 wordBreak: "break-word" as React.CSSProperties["wordBreak"], 91 }, 92 - id: preview ? undefined : index.join("."), 93 "data-index": index.join("."), 94 }; 95 let alignment = 96 b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight" ··· 100 : b.alignment === 101 "lex:pub.leaflet.pages.linearDocument#textAlignJustify" 102 ? "text-justify justify-start" 103 - : ""; 104 - if (!alignment && PubLeafletBlocksImage.isMain(b.block)) 105 alignment = "text-center justify-center"; 106 107 - // non text blocks, they need this padding, pt-3 sm:pt-4, which is applied in each case 108 let className = ` 109 postBlockWrapper 110 min-h-7 111 - pt-1 pb-2 112 - ${isList && "isListItem pb-0! "} 113 ${alignment} 114 `; 115 116 switch (true) { 117 case PubLeafletBlocksBskyPost.isMain(b.block): { 118 let uri = b.block.postRef.uri; 119 let post = bskyPostData.find((p) => p.uri === uri); 120 if (!post) return <div>no prefetched post rip</div>; 121 - return <PubBlueskyPostBlock post={post} />; 122 } 123 case PubLeafletBlocksIframe.isMain(b.block): { 124 return ( ··· 135 case PubLeafletBlocksHorizontalRule.isMain(b.block): { 136 return <hr className="my-2 w-full border-border-light" />; 137 } 138 case PubLeafletBlocksUnorderedList.isMain(b.block): { 139 return ( 140 <ul className="-ml-px sm:ml-[9px] pb-2"> 141 {b.block.children.map((child, i) => ( 142 <ListItem 143 bskyPostData={bskyPostData} 144 index={[...index, i]} 145 item={child} 146 did={did} 147 key={i} 148 className={className} 149 /> 150 ))} 151 </ul> ··· 165 href={b.block.src} 166 target="_blank" 167 className={` 168 - my-2 169 externalLinkBlock flex relative group/linkBlock 170 h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline 171 hover:border-accent-contrast shadow-sm ··· 212 } 213 case PubLeafletBlocksImage.isMain(b.block): { 214 return ( 215 - <div className={`relative flex ${alignment}`} {...blockProps}> 216 <img 217 alt={b.block.alt} 218 height={b.block.aspectRatio?.height} 219 width={b.block.aspectRatio?.width} 220 - className={`pt-3! sm:pt-4! rounded-md ${className}`} 221 src={blobRefToSrc(b.block.image.ref, did)} 222 /> 223 {b.block.alt && ( ··· 238 } 239 case PubLeafletBlocksBlockquote.isMain(b.block): { 240 return ( 241 - // 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. 242 <blockquote 243 - className={` blockquote py-0! mb-2! last:mb-3! sm:last:mb-4! first:mt-2! sm:first:pt-3 ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2!" : "mt-1!"}`} 244 {...blockProps} 245 > 246 <TextBlock ··· 248 plaintext={b.block.plaintext} 249 index={index} 250 preview={preview} 251 /> 252 </blockquote> 253 ); 254 } 255 case PubLeafletBlocksText.isMain(b.block): 256 return ( 257 - <p className={` ${className}`} {...blockProps}> 258 <TextBlock 259 facets={b.block.facets} 260 plaintext={b.block.plaintext} 261 index={index} 262 preview={preview} 263 /> 264 </p> 265 ); 266 case PubLeafletBlocksHeader.isMain(b.block): { 267 if (b.block.level === 1) 268 return ( 269 - <h2 className={`${className}`} {...blockProps}> 270 - <TextBlock {...b.block} index={index} preview={preview} /> 271 </h2> 272 ); 273 if (b.block.level === 2) 274 return ( 275 - <h3 className={`${className}`} {...blockProps}> 276 - <TextBlock {...b.block} index={index} preview={preview} /> 277 </h3> 278 ); 279 if (b.block.level === 3) 280 return ( 281 - <h4 className={`${className}`} {...blockProps}> 282 - <TextBlock {...b.block} index={index} preview={preview} /> 283 </h4> 284 ); 285 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 286 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 287 return ( 288 - <h6 className={`${className}`} {...blockProps}> 289 - <TextBlock {...b.block} index={index} preview={preview} /> 290 </h6> 291 ); 292 } ··· 297 298 function ListItem(props: { 299 index: number[]; 300 item: PubLeafletBlocksUnorderedList.ListItem; 301 did: string; 302 className?: string; 303 bskyPostData: AppBskyFeedDefs.PostView[]; 304 }) { 305 let children = props.item.children?.length ? ( 306 <ul className="-ml-[7px] sm:ml-[7px]"> 307 {props.item.children.map((child, index) => ( 308 <ListItem 309 bskyPostData={props.bskyPostData} 310 index={[...props.index, index]} 311 item={child} 312 did={props.did} 313 key={index} 314 className={props.className} 315 /> 316 ))} 317 </ul> 318 ) : null; 319 - 320 return ( 321 <li className={`pb-0! flex flex-row gap-2`}> 322 <div ··· 324 /> 325 <div className="flex flex-col w-full"> 326 <Block 327 bskyPostData={props.bskyPostData} 328 block={{ block: props.item.content }} 329 did={props.did} 330 isList 331 index={props.index} 332 /> 333 {children}{" "} 334 </div>
··· 1 + "use client"; 2 import { 3 PubLeafletBlocksMath, 4 PubLeafletBlocksCode, ··· 9 PubLeafletBlocksWebsite, 10 PubLeafletDocument, 11 PubLeafletPagesLinearDocument, 12 + PubLeafletPagesCanvas, 13 PubLeafletBlocksHorizontalRule, 14 PubLeafletBlocksBlockquote, 15 PubLeafletBlocksBskyPost, 16 PubLeafletBlocksIframe, 17 + PubLeafletBlocksPage, 18 + PubLeafletBlocksPoll, 19 + PubLeafletBlocksButton, 20 } from "lexicons/api"; 21 + 22 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 23 import { TextBlock } from "./TextBlock"; 24 import { Popover } from "components/Popover"; 25 import { theme } from "tailwind.config"; 26 import { ImageAltSmall } from "components/Icons/ImageAlt"; 27 import { StaticMathBlock } from "./StaticMathBlock"; 28 import { PubCodeBlock } from "./PubCodeBlock"; 29 import { AppBskyFeedDefs } from "@atproto/api"; 30 import { PubBlueskyPostBlock } from "./PublishBskyPostBlock"; 31 + import { openPage } from "./PostPages"; 32 + import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; 33 + import { PublishedPageLinkBlock } from "./PublishedPageBlock"; 34 + import { PublishedPollBlock } from "./PublishedPollBlock"; 35 + import { PollData } from "./fetchPollData"; 36 + import { ButtonPrimary } from "components/Buttons"; 37 38 export function PostContent({ 39 blocks, ··· 42 className, 43 prerenderedCodeBlocks, 44 bskyPostData, 45 + pageId, 46 + pages, 47 + pollData, 48 }: { 49 blocks: PubLeafletPagesLinearDocument.Block[]; 50 + pageId?: string; 51 did: string; 52 preview?: boolean; 53 className?: string; 54 prerenderedCodeBlocks?: Map<string, string>; 55 bskyPostData: AppBskyFeedDefs.PostView[]; 56 + pollData: PollData[]; 57 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 58 }) { 59 return ( 60 <div 61 + //The postContent class is important for QuoteHandler 62 + className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`} 63 > 64 {blocks.map((b, index) => { 65 return ( 66 <Block 67 + pageId={pageId} 68 + pages={pages} 69 bskyPostData={bskyPostData} 70 block={b} 71 did={did} ··· 74 index={[index]} 75 preview={preview} 76 prerenderedCodeBlocks={prerenderedCodeBlocks} 77 + pollData={pollData} 78 /> 79 ); 80 })} ··· 82 ); 83 } 84 85 + export let Block = ({ 86 block, 87 did, 88 isList, ··· 91 previousBlock, 92 prerenderedCodeBlocks, 93 bskyPostData, 94 + pageId, 95 + pages, 96 + pollData, 97 }: { 98 + pageId?: string; 99 preview?: boolean; 100 index: number[]; 101 block: PubLeafletPagesLinearDocument.Block; 102 did: string; 103 isList?: boolean; 104 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 105 previousBlock?: PubLeafletPagesLinearDocument.Block; 106 prerenderedCodeBlocks?: Map<string, string>; 107 bskyPostData: AppBskyFeedDefs.PostView[]; 108 + pollData: PollData[]; 109 }) => { 110 let b = block; 111 let blockProps = { ··· 114 scrollMarginBottom: "4rem", 115 wordBreak: "break-word" as React.CSSProperties["wordBreak"], 116 }, 117 + id: preview 118 + ? undefined 119 + : pageId 120 + ? `${pageId}~${index.join(".")}` 121 + : index.join("."), 122 "data-index": index.join("."), 123 + "data-page-id": pageId, 124 }; 125 let alignment = 126 b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight" ··· 130 : b.alignment === 131 "lex:pub.leaflet.pages.linearDocument#textAlignJustify" 132 ? "text-justify justify-start" 133 + : b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignLeft" 134 + ? "text-left justify-start" 135 + : undefined; 136 + if ( 137 + !alignment && 138 + (PubLeafletBlocksImage.isMain(b.block) || 139 + PubLeafletBlocksButton.isMain(b.block)) 140 + ) 141 alignment = "text-center justify-center"; 142 143 let className = ` 144 postBlockWrapper 145 min-h-7 146 + mt-1 mb-2 147 + ${isList && "isListItem mb-0! "} 148 ${alignment} 149 `; 150 151 switch (true) { 152 + case PubLeafletBlocksPage.isMain(b.block): { 153 + let id = b.block.id; 154 + let page = pages.find((p) => p.id === id); 155 + if (!page) return; 156 + 157 + const isCanvas = PubLeafletPagesCanvas.isMain(page); 158 + 159 + return ( 160 + <PublishedPageLinkBlock 161 + blocks={page.blocks} 162 + pageId={id} 163 + parentPageId={pageId} 164 + did={did} 165 + bskyPostData={bskyPostData} 166 + isCanvas={isCanvas} 167 + pages={pages} 168 + className={className} 169 + /> 170 + ); 171 + } 172 case PubLeafletBlocksBskyPost.isMain(b.block): { 173 let uri = b.block.postRef.uri; 174 let post = bskyPostData.find((p) => p.uri === uri); 175 if (!post) return <div>no prefetched post rip</div>; 176 + return <PubBlueskyPostBlock post={post} className={className} />; 177 } 178 case PubLeafletBlocksIframe.isMain(b.block): { 179 return ( ··· 190 case PubLeafletBlocksHorizontalRule.isMain(b.block): { 191 return <hr className="my-2 w-full border-border-light" />; 192 } 193 + case PubLeafletBlocksPoll.isMain(b.block): { 194 + let { cid, uri } = b.block.pollRef; 195 + const pollVoteData = pollData.find((p) => p.uri === uri && p.cid === cid); 196 + if (!pollVoteData) return null; 197 + return ( 198 + <PublishedPollBlock 199 + block={b.block} 200 + className={className} 201 + pollData={pollVoteData} 202 + /> 203 + ); 204 + } 205 + case PubLeafletBlocksButton.isMain(b.block): { 206 + return ( 207 + <div className={`flex ${alignment} ${className}`} {...blockProps}> 208 + <a href={b.block.url} target="_blank" rel="noopener noreferrer"> 209 + <ButtonPrimary role="link" type="submit"> 210 + {b.block.text} 211 + </ButtonPrimary> 212 + </a> 213 + </div> 214 + ); 215 + } 216 case PubLeafletBlocksUnorderedList.isMain(b.block): { 217 return ( 218 <ul className="-ml-px sm:ml-[9px] pb-2"> 219 {b.block.children.map((child, i) => ( 220 <ListItem 221 + pollData={pollData} 222 + pages={pages} 223 bskyPostData={bskyPostData} 224 index={[...index, i]} 225 item={child} 226 did={did} 227 key={i} 228 className={className} 229 + pageId={pageId} 230 /> 231 ))} 232 </ul> ··· 246 href={b.block.src} 247 target="_blank" 248 className={` 249 + ${className} 250 externalLinkBlock flex relative group/linkBlock 251 h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline 252 hover:border-accent-contrast shadow-sm ··· 293 } 294 case PubLeafletBlocksImage.isMain(b.block): { 295 return ( 296 + <div 297 + className={`imageBlock relative flex ${alignment}`} 298 + {...blockProps} 299 + > 300 <img 301 alt={b.block.alt} 302 height={b.block.aspectRatio?.height} 303 width={b.block.aspectRatio?.width} 304 + className={`rounded-lg border border-transparent ${className}`} 305 src={blobRefToSrc(b.block.image.ref, did)} 306 /> 307 {b.block.alt && ( ··· 322 } 323 case PubLeafletBlocksBlockquote.isMain(b.block): { 324 return ( 325 + // all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding. 326 <blockquote 327 + className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`} 328 {...blockProps} 329 > 330 <TextBlock ··· 332 plaintext={b.block.plaintext} 333 index={index} 334 preview={preview} 335 + pageId={pageId} 336 /> 337 </blockquote> 338 ); 339 } 340 case PubLeafletBlocksText.isMain(b.block): 341 return ( 342 + <p className={`textBlock ${className}`} {...blockProps}> 343 <TextBlock 344 facets={b.block.facets} 345 plaintext={b.block.plaintext} 346 index={index} 347 preview={preview} 348 + pageId={pageId} 349 /> 350 </p> 351 ); 352 case PubLeafletBlocksHeader.isMain(b.block): { 353 if (b.block.level === 1) 354 return ( 355 + <h2 className={`h1Block ${className}`} {...blockProps}> 356 + <TextBlock 357 + {...b.block} 358 + index={index} 359 + preview={preview} 360 + pageId={pageId} 361 + /> 362 </h2> 363 ); 364 if (b.block.level === 2) 365 return ( 366 + <h3 className={`h2Block ${className}`} {...blockProps}> 367 + <TextBlock 368 + {...b.block} 369 + index={index} 370 + preview={preview} 371 + pageId={pageId} 372 + /> 373 </h3> 374 ); 375 if (b.block.level === 3) 376 return ( 377 + <h4 className={`h3Block ${className}`} {...blockProps}> 378 + <TextBlock 379 + {...b.block} 380 + index={index} 381 + preview={preview} 382 + pageId={pageId} 383 + /> 384 </h4> 385 ); 386 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 387 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 388 return ( 389 + <h6 className={`h6Block ${className}`} {...blockProps}> 390 + <TextBlock 391 + {...b.block} 392 + index={index} 393 + preview={preview} 394 + pageId={pageId} 395 + /> 396 </h6> 397 ); 398 } ··· 403 404 function ListItem(props: { 405 index: number[]; 406 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 407 item: PubLeafletBlocksUnorderedList.ListItem; 408 did: string; 409 className?: string; 410 bskyPostData: AppBskyFeedDefs.PostView[]; 411 + pollData: PollData[]; 412 + pageId?: string; 413 }) { 414 let children = props.item.children?.length ? ( 415 <ul className="-ml-[7px] sm:ml-[7px]"> 416 {props.item.children.map((child, index) => ( 417 <ListItem 418 + pages={props.pages} 419 + pollData={props.pollData} 420 bskyPostData={props.bskyPostData} 421 index={[...props.index, index]} 422 item={child} 423 did={props.did} 424 key={index} 425 className={props.className} 426 + pageId={props.pageId} 427 /> 428 ))} 429 </ul> 430 ) : null; 431 return ( 432 <li className={`pb-0! flex flex-row gap-2`}> 433 <div ··· 435 /> 436 <div className="flex flex-col w-full"> 437 <Block 438 + pollData={props.pollData} 439 + pages={props.pages} 440 bskyPostData={props.bskyPostData} 441 block={{ block: props.item.content }} 442 did={props.did} 443 isList 444 index={props.index} 445 + pageId={props.pageId} 446 /> 447 {children}{" "} 448 </div>
-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 - // };
···
+91 -65
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 1 "use client"; 2 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 3 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 - import { Interactions } from "../Interactions/Interactions"; 5 import { PostPageData } from "../getPostPageData"; 6 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 7 import { useIdentityData } from "components/IdentityProvider"; 8 import { EditTiny } from "components/Icons/EditTiny"; 9 import { SpeedyLink } from "components/SpeedyLink"; 10 11 export function PostHeader(props: { 12 data: PostPageData; 13 - name: string; 14 profile: ProfileViewDetailed; 15 preferences: { showComments?: boolean }; 16 }) { ··· 19 20 let record = document?.data as PubLeafletDocument.Record; 21 let profile = props.profile; 22 - let pub = props.data?.documents_in_publications[0].publications; 23 - let pubRecord = pub?.record as PubLeafletPublication.Record; 24 25 - if (!document?.data || !document.documents_in_publications[0].publications) 26 - return; 27 return ( 28 - <> 29 - {/* <CollapsedPostHeader 30 - pubIcon={ 31 - pubRecord?.icon && pub 32 - ? blobRefToSrc(pubRecord.icon.ref, new AtUri(pub.uri).host) 33 - : undefined 34 - } 35 - title={record.title} 36 - quotes={document.document_mentions_in_bsky} 37 - /> */} 38 - <div className="max-w-prose w-full mx-auto" id="post-header"> 39 - <div className="pubHeader flex flex-col pb-5"> 40 - <div className="flex justify-between w-full"> 41 <SpeedyLink 42 className="font-bold hover:no-underline text-accent-contrast" 43 - href={ 44 - document && 45 - getPublicationURL( 46 - document.documents_in_publications[0].publications, 47 - ) 48 - } 49 > 50 - {props.name} 51 </SpeedyLink> 52 - {identity && 53 - identity.atp_did === 54 - document.documents_in_publications[0]?.publications 55 - .identity_did && 56 - document.leaflets_in_publications[0] && ( 57 - <a 58 - className=" rounded-full flex place-items-center" 59 - href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 60 - > 61 - <EditTiny className="shrink-0" /> 62 - </a> 63 - )} 64 - </div> 65 - <h2 className="">{record.title}</h2> 66 - {record.description ? ( 67 - <p className="italic text-secondary">{record.description}</p> 68 - ) : null} 69 - 70 - <div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap"> 71 {profile ? ( 72 <> 73 <a 74 className="text-tertiary" 75 href={`https://bsky.app/profile/${profile.handle}`} 76 > 77 - by {profile.displayName || profile.handle} 78 </a> 79 </> 80 ) : null} 81 {record.publishedAt ? ( 82 <> 83 - | 84 - <p suppressHydrationWarning> 85 - {new Date(record.publishedAt).toLocaleDateString(undefined, { 86 - year: "numeric", 87 - month: "long", 88 - day: "2-digit", 89 - })} 90 - </p> 91 </> 92 ) : null} 93 - |{" "} 94 - <Interactions 95 - showComments={props.preferences.showComments} 96 - compact 97 - quotesCount={document.document_mentions_in_bsky.length} 98 - commentsCount={document.comments_on_documents.length} 99 - /> 100 </div> 101 - </div> 102 - </div> 103 - </> 104 ); 105 }
··· 1 "use client"; 2 + import { 3 + PubLeafletComment, 4 + PubLeafletDocument, 5 + PubLeafletPublication, 6 + } from "lexicons/api"; 7 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 8 + import { 9 + Interactions, 10 + getQuoteCount, 11 + getCommentCount, 12 + } from "../Interactions/Interactions"; 13 import { PostPageData } from "../getPostPageData"; 14 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 15 import { useIdentityData } from "components/IdentityProvider"; 16 import { EditTiny } from "components/Icons/EditTiny"; 17 import { SpeedyLink } from "components/SpeedyLink"; 18 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 + import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page"; 20 + import { Separator } from "components/Layout"; 21 22 export function PostHeader(props: { 23 data: PostPageData; 24 profile: ProfileViewDetailed; 25 preferences: { showComments?: boolean }; 26 }) { ··· 29 30 let record = document?.data as PubLeafletDocument.Record; 31 let profile = props.profile; 32 + let pub = props.data?.documents_in_publications[0]?.publications; 33 + 34 + const formattedDate = useLocalizedDate( 35 + record.publishedAt || new Date().toISOString(), 36 + { 37 + year: "numeric", 38 + month: "long", 39 + day: "2-digit", 40 + }, 41 + ); 42 43 + if (!document?.data) return; 44 return ( 45 + <PostHeaderLayout 46 + pubLink={ 47 + <> 48 + {pub && ( 49 <SpeedyLink 50 className="font-bold hover:no-underline text-accent-contrast" 51 + href={document && getPublicationURL(pub)} 52 > 53 + {pub?.name} 54 </SpeedyLink> 55 + )} 56 + {identity && 57 + pub && 58 + identity.atp_did === pub.identity_did && 59 + document.leaflets_in_publications[0] && ( 60 + <a 61 + className=" rounded-full flex place-items-center" 62 + href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 63 + > 64 + <EditTiny className="shrink-0" /> 65 + </a> 66 + )} 67 + </> 68 + } 69 + postTitle={record.title} 70 + postDescription={record.description} 71 + postInfo={ 72 + <> 73 + <div className="flex flex-row gap-2 items-center"> 74 {profile ? ( 75 <> 76 <a 77 className="text-tertiary" 78 href={`https://bsky.app/profile/${profile.handle}`} 79 > 80 + {profile.displayName || profile.handle} 81 </a> 82 </> 83 ) : null} 84 {record.publishedAt ? ( 85 <> 86 + <Separator classname="h-4!" /> 87 + <p>{formattedDate}</p> 88 </> 89 ) : null} 90 </div> 91 + <Interactions 92 + showComments={props.preferences.showComments} 93 + quotesCount={getQuoteCount(document) || 0} 94 + commentsCount={getCommentCount(document) || 0} 95 + /> 96 + </> 97 + } 98 + /> 99 ); 100 } 101 + 102 + export const PostHeaderLayout = (props: { 103 + pubLink: React.ReactNode; 104 + postTitle: React.ReactNode | undefined; 105 + postDescription: React.ReactNode | undefined; 106 + postInfo: React.ReactNode; 107 + }) => { 108 + return ( 109 + <div 110 + className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" 111 + id="post-header" 112 + > 113 + <div className="pubInfo flex text-accent-contrast font-bold justify-between w-full"> 114 + {props.pubLink} 115 + </div> 116 + <h2 117 + className={`postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent ${!props.postTitle && "text-tertiary italic"}`} 118 + > 119 + {props.postTitle ? props.postTitle : "Untitled"} 120 + </h2> 121 + {props.postDescription ? ( 122 + <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} 128 + </div> 129 + </div> 130 + ); 131 + };
-114
app/lish/[did]/[publication]/[rkey]/PostPage.tsx
··· 1 - "use client"; 2 - import { 3 - PubLeafletPagesLinearDocument, 4 - PubLeafletPublication, 5 - } from "lexicons/api"; 6 - import { PostPageData } from "./getPostPageData"; 7 - import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 8 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 9 - import { SubscribeWithBluesky } from "app/lish/Subscribe"; 10 - import { EditTiny } from "components/Icons/EditTiny"; 11 - import { Interactions, useInteractionState } from "./Interactions/Interactions"; 12 - import { PostContent } from "./PostContent"; 13 - import { PostHeader } from "./PostHeader/PostHeader"; 14 - import { useIdentityData } from "components/IdentityProvider"; 15 - import { AppBskyFeedDefs } from "@atproto/api"; 16 - 17 - export function PostPage({ 18 - document, 19 - blocks, 20 - name, 21 - did, 22 - profile, 23 - preferences, 24 - pubRecord, 25 - prerenderedCodeBlocks, 26 - bskyPostData, 27 - }: { 28 - document: PostPageData; 29 - blocks: PubLeafletPagesLinearDocument.Block[]; 30 - name: string; 31 - profile: ProfileViewDetailed; 32 - pubRecord: PubLeafletPublication.Record; 33 - did: string; 34 - prerenderedCodeBlocks?: Map<string, string>; 35 - bskyPostData: AppBskyFeedDefs.PostView[]; 36 - preferences: { showComments?: boolean }; 37 - }) { 38 - let { identity } = useIdentityData(); 39 - const document_uri = document?.uri; 40 - let { drawerOpen } = useInteractionState(document_uri); 41 - if (!document || !document.documents_in_publications[0].publications) 42 - return null; 43 - 44 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 45 - return ( 46 - <> 47 - {(drawerOpen || hasPageBackground) && ( 48 - <div 49 - className="spacer sm:block hidden" 50 - style={{ 51 - width: `calc(50vw - 12px - ((var(--page-width-units)/2))`, 52 - }} 53 - /> 54 - )} 55 - <div 56 - id="post-page" 57 - className={`postPageWrapper relative overflow-y-auto sm:mx-0 mx-[6px] w-full 58 - ${drawerOpen || hasPageBackground ? "max-w-(--page-width-units) shrink-0 snap-center " : "w-full"} 59 - ${ 60 - hasPageBackground 61 - ? "h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] rounded-lg border border-border " 62 - : "sm:h-[calc(100%+48px)] h-[calc(100%+24px)] sm:-my-6 -my-3 " 63 - }`} 64 - > 65 - <div 66 - className={`postPageContent sm:max-w-prose mx-auto h-fit w-full px-3 sm:px-4 ${hasPageBackground ? " pt-2 pb-3 sm:pb-6" : "py-6 sm:py-9"}`} 67 - > 68 - <PostHeader 69 - data={document} 70 - profile={profile} 71 - name={name} 72 - preferences={preferences} 73 - /> 74 - <PostContent 75 - bskyPostData={bskyPostData} 76 - blocks={blocks} 77 - did={did} 78 - prerenderedCodeBlocks={prerenderedCodeBlocks} 79 - /> 80 - <Interactions 81 - showComments={preferences.showComments} 82 - quotesCount={document.document_mentions_in_bsky.length} 83 - commentsCount={document.comments_on_documents.length} 84 - /> 85 - <hr className="border-border-light mb-4 mt-4" /> 86 - {identity && 87 - identity.atp_did === 88 - document.documents_in_publications[0]?.publications?.identity_did && 89 - document.leaflets_in_publications[0] ? ( 90 - <a 91 - href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 92 - 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" 93 - > 94 - <EditTiny /> Edit Post 95 - </a> 96 - ) : ( 97 - <SubscribeWithBluesky 98 - isPost 99 - base_url={getPublicationURL( 100 - document.documents_in_publications[0].publications, 101 - )} 102 - pub_uri={document.documents_in_publications[0].publications.uri} 103 - subscribers={ 104 - document.documents_in_publications[0].publications 105 - .publication_subscriptions 106 - } 107 - pubName={name} 108 - /> 109 - )} 110 - </div> 111 - </div> 112 - </> 113 - ); 114 - }
···
+301
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
··· 1 + "use client"; 2 + import { 3 + PubLeafletDocument, 4 + PubLeafletPagesLinearDocument, 5 + PubLeafletPagesCanvas, 6 + PubLeafletPublication, 7 + } from "lexicons/api"; 8 + import { PostPageData } from "./getPostPageData"; 9 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 + import { AppBskyFeedDefs } from "@atproto/api"; 11 + import { create } from "zustand/react"; 12 + import { 13 + InteractionDrawer, 14 + useDrawerOpen, 15 + } from "./Interactions/InteractionDrawer"; 16 + import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 17 + import { PageOptionButton } from "components/Pages/PageOptions"; 18 + import { CloseTiny } from "components/Icons/CloseTiny"; 19 + import { Fragment, useEffect } from "react"; 20 + import { flushSync } from "react-dom"; 21 + import { scrollIntoView } from "src/utils/scrollIntoView"; 22 + import { useParams } from "next/navigation"; 23 + import { decodeQuotePosition } from "./quotePosition"; 24 + import { PollData } from "./fetchPollData"; 25 + import { LinearDocumentPage } from "./LinearDocumentPage"; 26 + import { CanvasPage } from "./CanvasPage"; 27 + 28 + const usePostPageUIState = create(() => ({ 29 + pages: [] as string[], 30 + initialized: false, 31 + })); 32 + 33 + export const useOpenPages = () => { 34 + const { quote } = useParams(); 35 + const state = usePostPageUIState((s) => s); 36 + 37 + if (!state.initialized && quote) { 38 + const decodedQuote = decodeQuotePosition(quote as string); 39 + if (decodedQuote?.pageId) { 40 + return [decodedQuote.pageId]; 41 + } 42 + } 43 + 44 + return state.pages; 45 + }; 46 + 47 + export const useInitializeOpenPages = () => { 48 + const { quote } = useParams(); 49 + 50 + useEffect(() => { 51 + const state = usePostPageUIState.getState(); 52 + if (!state.initialized) { 53 + if (quote) { 54 + const decodedQuote = decodeQuotePosition(quote as string); 55 + if (decodedQuote?.pageId) { 56 + usePostPageUIState.setState({ 57 + pages: [decodedQuote.pageId], 58 + initialized: true, 59 + }); 60 + return; 61 + } 62 + } 63 + // Mark as initialized even if no pageId found 64 + usePostPageUIState.setState({ initialized: true }); 65 + } 66 + }, [quote]); 67 + }; 68 + 69 + export const openPage = ( 70 + parent: string | undefined, 71 + page: string, 72 + options?: { scrollIntoView?: boolean }, 73 + ) => { 74 + flushSync(() => { 75 + usePostPageUIState.setState((state) => { 76 + let parentPosition = state.pages.findIndex((s) => s == parent); 77 + return { 78 + pages: 79 + parentPosition === -1 80 + ? [page] 81 + : [...state.pages.slice(0, parentPosition + 1), page], 82 + initialized: true, 83 + }; 84 + }); 85 + }); 86 + 87 + if (options?.scrollIntoView !== false) { 88 + scrollIntoView(`post-page-${page}`); 89 + } 90 + }; 91 + 92 + export const closePage = (page: string) => 93 + usePostPageUIState.setState((state) => { 94 + let parentPosition = state.pages.findIndex((s) => s == page); 95 + return { 96 + pages: state.pages.slice(0, parentPosition), 97 + initialized: true, 98 + }; 99 + }); 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 + 147 + export function PostPages({ 148 + document, 149 + did, 150 + profile, 151 + preferences, 152 + pubRecord, 153 + prerenderedCodeBlocks, 154 + bskyPostData, 155 + document_uri, 156 + pollData, 157 + }: { 158 + document_uri: string; 159 + document: PostPageData; 160 + profile: ProfileViewDetailed; 161 + pubRecord?: PubLeafletPublication.Record; 162 + did: string; 163 + prerenderedCodeBlocks?: Map<string, string>; 164 + bskyPostData: AppBskyFeedDefs.PostView[]; 165 + preferences: { showComments?: boolean }; 166 + pollData: PollData[]; 167 + }) { 168 + let drawer = useDrawerOpen(document_uri); 169 + useInitializeOpenPages(); 170 + let openPageIds = useOpenPages(); 171 + if (!document) return null; 172 + 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; 179 + let quotesAndMentions = document.quotesAndMentions; 180 + 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 + 212 + return ( 213 + <> 214 + {!sharedProps.fullPageScroll && <BookendSpacer />} 215 + 216 + <PageRenderer page={firstPage} {...sharedProps} /> 217 + 218 + {drawer && !drawer.pageId && ( 219 + <InteractionDrawer 220 + document_uri={document.uri} 221 + comments={ 222 + pubRecord?.preferences?.showComments === false 223 + ? [] 224 + : document.comments_on_documents 225 + } 226 + quotesAndMentions={quotesAndMentions} 227 + did={did} 228 + /> 229 + )} 230 + 231 + {openPageIds.map((pageId) => { 232 + let page = record.pages.find( 233 + (p) => 234 + ( 235 + p as 236 + | PubLeafletPagesLinearDocument.Main 237 + | PubLeafletPagesCanvas.Main 238 + ).id === pageId, 239 + ) as 240 + | PubLeafletPagesLinearDocument.Main 241 + | PubLeafletPagesCanvas.Main 242 + | undefined; 243 + 244 + if (!page) return null; 245 + 246 + return ( 247 + <Fragment key={pageId}> 248 + <SandwichSpacer /> 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 + /> 261 + {drawer && drawer.pageId === page.id && ( 262 + <InteractionDrawer 263 + pageId={page.id} 264 + document_uri={document.uri} 265 + comments={ 266 + pubRecord?.preferences?.showComments === false 267 + ? [] 268 + : document.comments_on_documents 269 + } 270 + quotesAndMentions={quotesAndMentions} 271 + did={did} 272 + /> 273 + )} 274 + </Fragment> 275 + ); 276 + })} 277 + 278 + {!sharedProps.fullPageScroll && <BookendSpacer />} 279 + </> 280 + ); 281 + } 282 + 283 + const PageOptions = (props: { 284 + onClick: () => void; 285 + hasPageBackground: boolean; 286 + }) => { 287 + return ( 288 + <div 289 + className={`pageOptions w-fit z-10 290 + absolute sm:-right-[20px] right-3 sm:top-3 top-0 291 + flex sm:flex-col flex-row-reverse gap-1 items-start`} 292 + > 293 + <PageOptionButton 294 + cardBorderHidden={!props.hasPageBackground} 295 + onClick={props.onClick} 296 + > 297 + <CloseTiny /> 298 + </PageOptionButton> 299 + </div> 300 + ); 301 + };
+5 -5
app/lish/[did]/[publication]/[rkey]/PubCodeBlock.tsx
··· 2 3 import { PubLeafletBlocksCode } from "lexicons/api"; 4 import { useLayoutEffect, useState } from "react"; 5 - import { codeToHtml } from "shiki"; 6 7 export function PubCodeBlock({ 8 block, ··· 14 const [html, setHTML] = useState<string | null>(prerenderedCode || null); 15 16 useLayoutEffect(() => { 17 - codeToHtml(block.plaintext, { 18 - lang: block.language || "plaintext", 19 - theme: block.syntaxHighlightingTheme || "github-light", 20 - }).then(setHTML); 21 }, [block]); 22 return ( 23 <div
··· 2 3 import { PubLeafletBlocksCode } from "lexicons/api"; 4 import { useLayoutEffect, useState } from "react"; 5 + import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 6 7 export function PubCodeBlock({ 8 block, ··· 14 const [html, setHTML] = useState<string | null>(prerenderedCode || null); 15 16 useLayoutEffect(() => { 17 + const lang = bundledLanguagesInfo.find((l) => l.id === block.language)?.id || "plaintext"; 18 + const theme = bundledThemesInfo.find((t) => t.id === block.syntaxHighlightingTheme)?.id || "github-light"; 19 + 20 + codeToHtml(block.plaintext, { lang, theme }).then(setHTML); 21 }, [block]); 22 return ( 23 <div
+21 -13
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 7 import { focusBlock } from "src/utils/focusBlock"; 8 import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; 9 import { Separator } from "components/Layout"; 10 - import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 11 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 12 import { CommentTiny } from "components/Icons/CommentTiny"; 13 import { 14 BlueskyEmbed, 15 PostNotAvailable, 16 } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 17 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 18 19 - export const PubBlueskyPostBlock = ({ post }: { post: PostView }) => { 20 switch (true) { 21 case AppBskyFeedDefs.isBlockedPost(post) || 22 AppBskyFeedDefs.isBlockedAuthor(post) || ··· 46 return ( 47 <div 48 className={` 49 block-border 50 mb-2 51 flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page ··· 54 {post.author && record && ( 55 <> 56 <div className="bskyAuthor w-full flex items-center gap-2"> 57 - <img 58 - src={post.author?.avatar} 59 - alt={`${post.author?.displayName}'s avatar`} 60 - className="shink-0 w-8 h-8 rounded-full border border-border-light" 61 - /> 62 <div className="grow flex flex-col gap-0.5 leading-tight"> 63 <div className=" font-bold text-secondary"> 64 {post.author?.displayName} ··· 115 }; 116 117 const ClientDate = (props: { date?: string }) => { 118 - let pageLoaded = useInitialPageLoad(); 119 - if (!pageLoaded) return null; 120 - 121 - let datetimeFormatted = new Date(props.date ? props.date : "").toLocaleString( 122 - "en-US", 123 { 124 month: "short", 125 day: "numeric", ··· 130 }, 131 ); 132 133 - return <div className="text-xs text-tertiary">{datetimeFormatted}</div>; 134 };
··· 7 import { focusBlock } from "src/utils/focusBlock"; 8 import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; 9 import { Separator } from "components/Layout"; 10 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 11 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 12 import { CommentTiny } from "components/Icons/CommentTiny"; 13 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 14 import { 15 BlueskyEmbed, 16 PostNotAvailable, 17 } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 18 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 19 20 + export const PubBlueskyPostBlock = (props: { 21 + post: PostView; 22 + className: string; 23 + }) => { 24 + let post = props.post; 25 switch (true) { 26 case AppBskyFeedDefs.isBlockedPost(post) || 27 AppBskyFeedDefs.isBlockedAuthor(post) || ··· 51 return ( 52 <div 53 className={` 54 + ${props.className} 55 block-border 56 mb-2 57 flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page ··· 60 {post.author && record && ( 61 <> 62 <div className="bskyAuthor w-full flex items-center gap-2"> 63 + {post.author.avatar && ( 64 + <img 65 + src={post.author?.avatar} 66 + alt={`${post.author?.displayName}'s avatar`} 67 + className="shink-0 w-8 h-8 rounded-full border border-border-light" 68 + /> 69 + )} 70 <div className="grow flex flex-col gap-0.5 leading-tight"> 71 <div className=" font-bold text-secondary"> 72 {post.author?.displayName} ··· 123 }; 124 125 const ClientDate = (props: { date?: string }) => { 126 + let pageLoaded = useHasPageLoaded(); 127 + const formattedDate = useLocalizedDate( 128 + props.date || new Date().toISOString(), 129 { 130 month: "short", 131 day: "numeric", ··· 136 }, 137 ); 138 139 + if (!pageLoaded) return null; 140 + 141 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 142 };
+331
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
···
··· 1 + "use client"; 2 + 3 + import { useEntity, useReplicache } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { CSSProperties, useContext, useRef } from "react"; 6 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 + import { PostContent, Block } from "./PostContent"; 8 + import { 9 + PubLeafletBlocksHeader, 10 + PubLeafletBlocksText, 11 + PubLeafletComment, 12 + PubLeafletPagesLinearDocument, 13 + PubLeafletPagesCanvas, 14 + PubLeafletPublication, 15 + } from "lexicons/api"; 16 + import { AppBskyFeedDefs } from "@atproto/api"; 17 + import { TextBlock } from "./TextBlock"; 18 + import { PostPageContext } from "./PostPageContext"; 19 + import { openPage, useOpenPages } from "./PostPages"; 20 + import { 21 + openInteractionDrawer, 22 + setInteractionState, 23 + useInteractionState, 24 + } from "./Interactions/Interactions"; 25 + import { CommentTiny } from "components/Icons/CommentTiny"; 26 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 + import { CanvasBackgroundPattern } from "components/Canvas"; 28 + 29 + export function PublishedPageLinkBlock(props: { 30 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; 31 + parentPageId: string | undefined; 32 + pageId: string; 33 + did: string; 34 + preview?: boolean; 35 + className?: string; 36 + prerenderedCodeBlocks?: Map<string, string>; 37 + bskyPostData: AppBskyFeedDefs.PostView[]; 38 + isCanvas?: boolean; 39 + pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 40 + }) { 41 + //switch to use actually state 42 + let openPages = useOpenPages(); 43 + let isOpen = openPages.includes(props.pageId); 44 + return ( 45 + <div 46 + className={`w-full cursor-pointer 47 + pageLinkBlockWrapper relative group/pageLinkBlock 48 + bg-bg-page shadow-sm 49 + flex overflow-clip 50 + block-border 51 + ${isOpen && "!border-tertiary"} 52 + ${props.className} 53 + `} 54 + onClick={(e) => { 55 + if (e.isDefaultPrevented()) return; 56 + if (e.shiftKey) return; 57 + e.preventDefault(); 58 + e.stopPropagation(); 59 + 60 + openPage(props.parentPageId, props.pageId); 61 + }} 62 + > 63 + {props.isCanvas ? ( 64 + <CanvasLinkBlock 65 + blocks={props.blocks as PubLeafletPagesCanvas.Block[]} 66 + did={props.did} 67 + pageId={props.pageId} 68 + bskyPostData={props.bskyPostData} 69 + pages={props.pages || []} 70 + /> 71 + ) : ( 72 + <DocLinkBlock 73 + {...props} 74 + blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]} 75 + /> 76 + )} 77 + </div> 78 + ); 79 + } 80 + export function DocLinkBlock(props: { 81 + blocks: PubLeafletPagesLinearDocument.Block[]; 82 + pageId: string; 83 + parentPageId?: string; 84 + did: string; 85 + preview?: boolean; 86 + className?: string; 87 + prerenderedCodeBlocks?: Map<string, string>; 88 + bskyPostData: AppBskyFeedDefs.PostView[]; 89 + }) { 90 + let [title, description] = props.blocks 91 + .map((b) => b.block) 92 + .filter( 93 + (b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b), 94 + ); 95 + 96 + return ( 97 + <div 98 + style={{ "--list-marker-width": "20px" } as CSSProperties} 99 + className={` 100 + w-full h-[104px] 101 + `} 102 + > 103 + <> 104 + <div className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full"> 105 + <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip flex flex-col "> 106 + <div className="grow"> 107 + {title && ( 108 + <div 109 + className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 110 + > 111 + <TextBlock 112 + facets={title.facets} 113 + plaintext={title.plaintext} 114 + index={[]} 115 + preview 116 + /> 117 + </div> 118 + )} 119 + {description && ( 120 + <div 121 + className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 122 + > 123 + <TextBlock 124 + facets={description.facets} 125 + plaintext={description.plaintext} 126 + index={[]} 127 + preview 128 + /> 129 + </div> 130 + )} 131 + </div> 132 + 133 + <Interactions 134 + pageId={props.pageId} 135 + parentPageId={props.parentPageId} 136 + /> 137 + </div> 138 + {!props.preview && ( 139 + <PagePreview blocks={props.blocks} did={props.did} /> 140 + )} 141 + </div> 142 + </> 143 + </div> 144 + ); 145 + } 146 + 147 + export function PagePreview(props: { 148 + did: string; 149 + blocks: PubLeafletPagesLinearDocument.Block[]; 150 + }) { 151 + let previewRef = useRef<HTMLDivElement | null>(null); 152 + let { rootEntity } = useReplicache(); 153 + let data = useContext(PostPageContext); 154 + let theme = data?.theme; 155 + let pageWidth = `var(--page-width-unitless)`; 156 + let cardBorderHidden = !theme?.showPageBackground; 157 + return ( 158 + <div 159 + ref={previewRef} 160 + className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`} 161 + > 162 + <div 163 + className="absolute top-0 left-0 origin-top-left pointer-events-none " 164 + style={{ 165 + width: `calc(1px * ${pageWidth})`, 166 + height: `calc(100vh - 64px)`, 167 + transform: `scale(calc((120 / ${pageWidth} )))`, 168 + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 169 + }} 170 + > 171 + {!cardBorderHidden && ( 172 + <div 173 + className={`pageLinkBlockBackground 174 + absolute top-0 left-0 right-0 bottom-0 175 + pointer-events-none 176 + `} 177 + /> 178 + )} 179 + <PostContent 180 + pollData={[]} 181 + pages={[]} 182 + did={props.did} 183 + blocks={props.blocks} 184 + preview 185 + bskyPostData={[]} 186 + /> 187 + </div> 188 + </div> 189 + ); 190 + } 191 + 192 + const Interactions = (props: { pageId: string; parentPageId?: string }) => { 193 + const data = useContext(PostPageContext); 194 + const document_uri = data?.uri; 195 + if (!document_uri) 196 + throw new Error("document_uri not available in PostPageContext"); 197 + let comments = data.comments_on_documents.filter( 198 + (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 199 + ).length; 200 + let quotes = data.document_mentions_in_bsky.filter((q) => 201 + q.link.includes(props.pageId), 202 + ).length; 203 + 204 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 205 + 206 + return ( 207 + <div 208 + className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`} 209 + > 210 + {quotes > 0 && ( 211 + <button 212 + className={`flex gap-1 items-center`} 213 + onClick={(e) => { 214 + e.preventDefault(); 215 + e.stopPropagation(); 216 + openPage(props.parentPageId, props.pageId, { 217 + scrollIntoView: false, 218 + }); 219 + if (!drawerOpen || drawer !== "quotes") 220 + openInteractionDrawer("quotes", document_uri, props.pageId); 221 + else setInteractionState(document_uri, { drawerOpen: false }); 222 + }} 223 + > 224 + <span className="sr-only">Page quotes</span> 225 + <QuoteTiny aria-hidden /> {quotes}{" "} 226 + </button> 227 + )} 228 + {comments > 0 && ( 229 + <button 230 + className={`flex gap-1 items-center`} 231 + onClick={(e) => { 232 + e.preventDefault(); 233 + e.stopPropagation(); 234 + openPage(props.parentPageId, props.pageId, { 235 + scrollIntoView: false, 236 + }); 237 + if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 238 + openInteractionDrawer("comments", document_uri, props.pageId); 239 + else setInteractionState(document_uri, { drawerOpen: false }); 240 + }} 241 + > 242 + <span className="sr-only">Page comments</span> 243 + <CommentTiny aria-hidden /> {comments}{" "} 244 + </button> 245 + )} 246 + </div> 247 + ); 248 + }; 249 + 250 + const CanvasLinkBlock = (props: { 251 + blocks: PubLeafletPagesCanvas.Block[]; 252 + did: string; 253 + pageId: string; 254 + bskyPostData: AppBskyFeedDefs.PostView[]; 255 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 256 + }) => { 257 + let pageWidth = `var(--page-width-unitless)`; 258 + let height = 259 + props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0; 260 + 261 + return ( 262 + <div 263 + style={{ contain: "size layout paint" }} 264 + className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`} 265 + > 266 + <div 267 + className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`} 268 + style={{ 269 + width: `calc(1px * ${pageWidth})`, 270 + height: "calc(1150px * 2)", 271 + transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`, 272 + }} 273 + > 274 + <div 275 + style={{ 276 + minHeight: height + 512, 277 + contain: "size layout paint", 278 + }} 279 + className="relative h-full w-[1272px]" 280 + > 281 + <div className="w-full h-full pointer-events-none"> 282 + <CanvasBackgroundPattern pattern="grid" /> 283 + </div> 284 + {props.blocks 285 + .sort((a, b) => { 286 + if (a.y === b.y) { 287 + return a.x - b.x; 288 + } 289 + return a.y - b.y; 290 + }) 291 + .map((canvasBlock, index) => { 292 + let { x, y, width, rotation } = canvasBlock; 293 + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 294 + 295 + // Wrap the block in a LinearDocument.Block structure for compatibility 296 + let linearBlock: PubLeafletPagesLinearDocument.Block = { 297 + $type: "pub.leaflet.pages.linearDocument#block", 298 + block: canvasBlock.block, 299 + }; 300 + 301 + return ( 302 + <div 303 + key={index} 304 + className="absolute rounded-lg flex items-stretch origin-center p-3" 305 + style={{ 306 + top: 0, 307 + left: 0, 308 + width, 309 + transform, 310 + }} 311 + > 312 + <div className="contents"> 313 + <Block 314 + pollData={[]} 315 + pageId={props.pageId} 316 + pages={props.pages} 317 + bskyPostData={props.bskyPostData} 318 + block={linearBlock} 319 + did={props.did} 320 + index={[index]} 321 + preview={true} 322 + /> 323 + </div> 324 + </div> 325 + ); 326 + })} 327 + </div> 328 + </div> 329 + </div> 330 + ); 331 + };
+346
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
···
··· 1 + "use client"; 2 + 3 + import { 4 + PubLeafletBlocksPoll, 5 + PubLeafletPollDefinition, 6 + PubLeafletPollVote, 7 + } from "lexicons/api"; 8 + import { useState, useEffect } from "react"; 9 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 10 + import { useIdentityData } from "components/IdentityProvider"; 11 + import { AtpAgent } from "@atproto/api"; 12 + import { voteOnPublishedPoll } from "./voteOnPublishedPoll"; 13 + import { PollData } from "./fetchPollData"; 14 + import { Popover } from "components/Popover"; 15 + import LoginForm from "app/login/LoginForm"; 16 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 17 + import { getVoterIdentities, VoterIdentity } from "./getVoterIdentities"; 18 + import { Json } from "supabase/database.types"; 19 + import { InfoSmall } from "components/Icons/InfoSmall"; 20 + 21 + // Helper function to extract the first option from a vote record 22 + const getVoteOption = (voteRecord: any): string | null => { 23 + try { 24 + const record = voteRecord as PubLeafletPollVote.Record; 25 + return record.option && record.option.length > 0 ? record.option[0] : null; 26 + } catch { 27 + return null; 28 + } 29 + }; 30 + 31 + export const PublishedPollBlock = (props: { 32 + block: PubLeafletBlocksPoll.Main; 33 + pollData: PollData; 34 + className?: string; 35 + }) => { 36 + const { identity } = useIdentityData(); 37 + const [selectedOption, setSelectedOption] = useState<string | null>(null); 38 + const [isVoting, setIsVoting] = useState(false); 39 + const [showResults, setShowResults] = useState(false); 40 + const [optimisticVote, setOptimisticVote] = useState<{ 41 + option: string; 42 + voter_did: string; 43 + } | null>(null); 44 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 45 + let [isClient, setIsClient] = useState(false); 46 + useEffect(() => { 47 + setIsClient(true); 48 + }, []); 49 + 50 + const handleVote = async () => { 51 + if (!selectedOption || !identity?.atp_did) return; 52 + 53 + setIsVoting(true); 54 + 55 + // Optimistically add the vote 56 + setOptimisticVote({ 57 + option: selectedOption, 58 + voter_did: identity.atp_did, 59 + }); 60 + setShowResults(true); 61 + 62 + try { 63 + const result = await voteOnPublishedPoll( 64 + props.block.pollRef.uri, 65 + props.block.pollRef.cid, 66 + selectedOption, 67 + ); 68 + 69 + if (!result.success) { 70 + console.error("Failed to vote:", result.error); 71 + // Revert optimistic update on failure 72 + setOptimisticVote(null); 73 + setShowResults(false); 74 + } 75 + } catch (error) { 76 + console.error("Failed to vote:", error); 77 + // Revert optimistic update on failure 78 + setOptimisticVote(null); 79 + setShowResults(false); 80 + } finally { 81 + setIsVoting(false); 82 + } 83 + }; 84 + 85 + const hasVoted = 86 + !!identity?.atp_did && 87 + (!!props.pollData?.atp_poll_votes.find( 88 + (v) => v.voter_did === identity?.atp_did, 89 + ) || 90 + !!optimisticVote); 91 + let isCreator = 92 + identity?.atp_did && props.pollData.uri.includes(identity?.atp_did); 93 + const displayResults = showResults || hasVoted; 94 + 95 + return ( 96 + <div 97 + className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`} 98 + style={{ 99 + backgroundColor: 100 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 101 + }} 102 + > 103 + {displayResults ? ( 104 + <> 105 + <PollResults 106 + pollData={props.pollData} 107 + hasVoted={hasVoted} 108 + setShowResults={setShowResults} 109 + optimisticVote={optimisticVote} 110 + /> 111 + {!hasVoted && ( 112 + <div className="flex justify-start"> 113 + <button 114 + className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 115 + onClick={() => setShowResults(false)} 116 + > 117 + Back to Voting 118 + </button> 119 + </div> 120 + )} 121 + </> 122 + ) : ( 123 + <> 124 + {pollRecord.options.map((option, index) => ( 125 + <PollOptionButton 126 + key={index} 127 + option={option} 128 + optionIndex={index.toString()} 129 + selected={selectedOption === index.toString()} 130 + onSelect={() => setSelectedOption(index.toString())} 131 + disabled={!identity?.atp_did} 132 + /> 133 + ))} 134 + <div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-2 items-center pt-2"> 135 + <div className="text-sm text-tertiary">All votes are public</div> 136 + <div className="flex sm:gap-3 sm:flex-row flex-col-reverse sm:justify-end justify-center gap-1 items-center"> 137 + <button 138 + className="w-fit font-bold text-accent-contrast" 139 + onClick={() => setShowResults(!showResults)} 140 + > 141 + See Results 142 + </button> 143 + {identity?.atp_did ? ( 144 + <ButtonPrimary 145 + className="place-self-end" 146 + onClick={handleVote} 147 + disabled={!selectedOption || isVoting} 148 + > 149 + {isVoting ? "Voting..." : "Vote!"} 150 + </ButtonPrimary> 151 + ) : ( 152 + <Popover 153 + asChild 154 + trigger={ 155 + <ButtonPrimary className="place-self-center"> 156 + <BlueskyTiny /> Login to vote 157 + </ButtonPrimary> 158 + } 159 + > 160 + {isClient && ( 161 + <LoginForm 162 + text="Log in to vote on this poll!" 163 + noEmail 164 + redirectRoute={window?.location.href + "?refreshAuth"} 165 + /> 166 + )} 167 + </Popover> 168 + )} 169 + </div> 170 + </div> 171 + </> 172 + )} 173 + </div> 174 + ); 175 + }; 176 + 177 + const PollOptionButton = (props: { 178 + option: PubLeafletPollDefinition.Option; 179 + optionIndex: string; 180 + selected: boolean; 181 + onSelect: () => void; 182 + disabled?: boolean; 183 + }) => { 184 + const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary; 185 + 186 + return ( 187 + <div className="flex gap-2 items-center"> 188 + <ButtonComponent 189 + className="pollOption grow max-w-full flex" 190 + onClick={props.onSelect} 191 + disabled={props.disabled} 192 + > 193 + {props.option.text} 194 + </ButtonComponent> 195 + </div> 196 + ); 197 + }; 198 + 199 + const PollResults = (props: { 200 + pollData: PollData; 201 + hasVoted: boolean; 202 + setShowResults: (show: boolean) => void; 203 + optimisticVote: { option: string; voter_did: string } | null; 204 + }) => { 205 + // Merge optimistic vote with actual votes 206 + const allVotes = props.optimisticVote 207 + ? [ 208 + ...props.pollData.atp_poll_votes, 209 + { 210 + voter_did: props.optimisticVote.voter_did, 211 + record: { 212 + $type: "pub.leaflet.poll.vote", 213 + option: [props.optimisticVote.option], 214 + }, 215 + }, 216 + ] 217 + : props.pollData.atp_poll_votes; 218 + 219 + const totalVotes = allVotes.length || 0; 220 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 221 + let optionsWithCount = pollRecord.options.map((o, index) => ({ 222 + ...o, 223 + votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()), 224 + })); 225 + 226 + const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length)); 227 + return ( 228 + <> 229 + {pollRecord.options.map((option, index) => { 230 + const voteRecords = allVotes.filter( 231 + (v) => getVoteOption(v.record) === index.toString(), 232 + ); 233 + const isWinner = totalVotes > 0 && voteRecords.length === highestVotes; 234 + 235 + return ( 236 + <PollResult 237 + key={index} 238 + option={option} 239 + votes={voteRecords.length} 240 + voteRecords={voteRecords} 241 + totalVotes={totalVotes} 242 + winner={isWinner} 243 + /> 244 + ); 245 + })} 246 + </> 247 + ); 248 + }; 249 + 250 + const VoterListPopover = (props: { 251 + votes: number; 252 + voteRecords: { voter_did: string; record: Json }[]; 253 + }) => { 254 + const [voterIdentities, setVoterIdentities] = useState<VoterIdentity[]>([]); 255 + const [isLoading, setIsLoading] = useState(false); 256 + const [hasFetched, setHasFetched] = useState(false); 257 + 258 + const handleOpenChange = async () => { 259 + if (!hasFetched && props.voteRecords.length > 0) { 260 + setIsLoading(true); 261 + setHasFetched(true); 262 + try { 263 + const dids = props.voteRecords.map((v) => v.voter_did); 264 + const identities = await getVoterIdentities(dids); 265 + setVoterIdentities(identities); 266 + } catch (error) { 267 + console.error("Failed to fetch voter identities:", error); 268 + } finally { 269 + setIsLoading(false); 270 + } 271 + } 272 + }; 273 + 274 + return ( 275 + <Popover 276 + trigger={ 277 + <button 278 + className="hover:underline cursor-pointer" 279 + disabled={props.votes === 0} 280 + > 281 + {props.votes} 282 + </button> 283 + } 284 + onOpenChange={handleOpenChange} 285 + className="w-64 max-h-80" 286 + > 287 + {isLoading ? ( 288 + <div className="flex justify-center py-4"> 289 + <div className="text-sm text-secondary">Loading...</div> 290 + </div> 291 + ) : ( 292 + <div className="flex flex-col gap-1 text-sm py-0.5"> 293 + {voterIdentities.map((voter) => ( 294 + <a 295 + key={voter.did} 296 + href={`https://bsky.app/profile/${voter.handle || voter.did}`} 297 + target="_blank" 298 + rel="noopener noreferrer" 299 + className="" 300 + > 301 + @{voter.handle || voter.did} 302 + </a> 303 + ))} 304 + </div> 305 + )} 306 + </Popover> 307 + ); 308 + }; 309 + 310 + const PollResult = (props: { 311 + option: PubLeafletPollDefinition.Option; 312 + votes: number; 313 + voteRecords: { voter_did: string; record: Json }[]; 314 + totalVotes: number; 315 + winner: boolean; 316 + }) => { 317 + return ( 318 + <div 319 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 320 + > 321 + <div 322 + style={{ 323 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`, 324 + paintOrder: "stroke fill", 325 + }} 326 + className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10" 327 + > 328 + <div className="grow max-w-full truncate">{props.option.text}</div> 329 + <VoterListPopover votes={props.votes} voteRecords={props.voteRecords} /> 330 + </div> 331 + <div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0"> 332 + <div 333 + className="bg-accent-contrast rounded-[2px] m-0.5" 334 + style={{ 335 + maskImage: "var(--hatchSVG)", 336 + maskRepeat: "repeat repeat", 337 + ...(props.votes === 0 338 + ? { width: "4px" } 339 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 340 + }} 341 + /> 342 + <div /> 343 + </div> 344 + </div> 345 + ); 346 + };
+42 -14
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 14 import { setInteractionState } from "./Interactions/Interactions"; 15 import { PostPageContext } from "./PostPageContext"; 16 import { PubLeafletPublication } from "lexicons/api"; 17 18 export function QuoteHandler() { 19 let [position, setPosition] = useState<{ ··· 24 useEffect(() => { 25 const handleSelectionChange = (e: Event) => { 26 const selection = document.getSelection(); 27 - const postContent = document.getElementById("post-content"); 28 const isWithinPostContent = 29 - postContent && selection?.rangeCount && selection.rangeCount > 0 30 - ? postContent.contains( 31 - selection.getRangeAt(0).commonAncestorContainer, 32 - ) 33 : false; 34 35 if (!selection || !isWithinPostContent || !selection?.toString()) ··· 74 selectionTop += quoteRect.height + 8; 75 } 76 77 let startIndex = findDataIndex(range.startContainer); 78 let endIndex = findDataIndex(range.endContainer); 79 if (!startIndex || !endIndex) return; ··· 88 endIndex?.element, 89 ); 90 let position: QuotePosition = { 91 start: { 92 block: startIndex?.index.split(".").map((i) => parseInt(i)), 93 offset: startOffset, ··· 114 return ( 115 <div 116 id="quote-trigger" 117 - className={`accent-container border border-border-light text-accent-contrast px-1 flex gap-1 text-sm justify-center text-center items-center`} 118 style={{ 119 position: "absolute", 120 top: position.top, ··· 145 // Clear existing query parameters 146 currentUrl.search = ""; 147 148 - currentUrl.hash = `#${pos?.start.block.join(".")}_${pos?.start.offset}`; 149 return [currentUrl.toString(), pos]; 150 }, [props.position]); 151 let pubRecord = data.documents_in_publications[0]?.publications?.record as ··· 195 className="flex gap-1 items-center hover:font-bold px-1" 196 onClick={() => { 197 if (!position) return; 198 - setInteractionState(document_uri, { 199 - drawer: "comments", 200 - drawerOpen: true, 201 - commentBox: { quote: position }, 202 - }); 203 }} 204 > 205 <CommentTiny /> Comment ··· 210 ); 211 }; 212 213 - function findDataIndex(node: Node): { index: string; element: Element } | null { 214 if (node.nodeType === Node.ELEMENT_NODE) { 215 const element = node as Element; 216 if (element.hasAttribute("data-index")) { 217 const index = element.getAttribute("data-index"); 218 if (index) { 219 - return { index, element }; 220 } 221 } 222 }
··· 14 import { setInteractionState } from "./Interactions/Interactions"; 15 import { PostPageContext } from "./PostPageContext"; 16 import { PubLeafletPublication } from "lexicons/api"; 17 + import { flushSync } from "react-dom"; 18 + import { scrollIntoView } from "src/utils/scrollIntoView"; 19 20 export function QuoteHandler() { 21 let [position, setPosition] = useState<{ ··· 26 useEffect(() => { 27 const handleSelectionChange = (e: Event) => { 28 const selection = document.getSelection(); 29 + 30 + // Check if selection is within any element with postContent class 31 const isWithinPostContent = 32 + selection?.rangeCount && selection.rangeCount > 0 33 + ? (() => { 34 + const range = selection.getRangeAt(0); 35 + const ancestor = range.commonAncestorContainer; 36 + const element = 37 + ancestor.nodeType === Node.ELEMENT_NODE 38 + ? (ancestor as Element) 39 + : ancestor.parentElement; 40 + return element?.closest(".postContent") !== null; 41 + })() 42 : false; 43 44 if (!selection || !isWithinPostContent || !selection?.toString()) ··· 83 selectionTop += quoteRect.height + 8; 84 } 85 86 + // Ensure tooltip stays within viewport bounds (330px wide + 8px padding) 87 + const TOOLTIP_WIDTH = 338; 88 + const viewportWidth = window.innerWidth; 89 + const maxLeft = viewportWidth - TOOLTIP_WIDTH; 90 + 91 + // Clamp selectionLeft to stay within bounds 92 + selectionLeft = Math.max(8, Math.min(selectionLeft, maxLeft)); 93 + 94 let startIndex = findDataIndex(range.startContainer); 95 let endIndex = findDataIndex(range.endContainer); 96 if (!startIndex || !endIndex) return; ··· 105 endIndex?.element, 106 ); 107 let position: QuotePosition = { 108 + ...(startIndex.pageId && { pageId: startIndex.pageId }), 109 start: { 110 block: startIndex?.index.split(".").map((i) => parseInt(i)), 111 offset: startOffset, ··· 132 return ( 133 <div 134 id="quote-trigger" 135 + className={`z-20 accent-container border border-border-light text-accent-contrast px-1 flex gap-1 text-sm justify-center text-center items-center`} 136 style={{ 137 position: "absolute", 138 top: position.top, ··· 163 // Clear existing query parameters 164 currentUrl.search = ""; 165 166 + const fragmentId = pos?.pageId 167 + ? `${pos.pageId}~${pos.start.block.join(".")}_${pos.start.offset}` 168 + : `${pos?.start.block.join(".")}_${pos?.start.offset}`; 169 + currentUrl.hash = `#${fragmentId}`; 170 return [currentUrl.toString(), pos]; 171 }, [props.position]); 172 let pubRecord = data.documents_in_publications[0]?.publications?.record as ··· 216 className="flex gap-1 items-center hover:font-bold px-1" 217 onClick={() => { 218 if (!position) return; 219 + flushSync(() => 220 + setInteractionState(document_uri, { 221 + drawer: "comments", 222 + drawerOpen: true, 223 + pageId: position.pageId, 224 + commentBox: { quote: position }, 225 + }), 226 + ); 227 + scrollIntoView("interaction-drawer"); 228 }} 229 > 230 <CommentTiny /> Comment ··· 235 ); 236 }; 237 238 + function findDataIndex( 239 + node: Node, 240 + ): { index: string; element: Element; pageId?: string } | null { 241 if (node.nodeType === Node.ELEMENT_NODE) { 242 const element = node as Element; 243 if (element.hasAttribute("data-index")) { 244 const index = element.getAttribute("data-index"); 245 if (index) { 246 + const pageId = element.getAttribute("data-page-id") || undefined; 247 + return { index, element, pageId }; 248 } 249 } 250 }
+9 -5
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 import { BaseTextBlock } from "./BaseTextBlock"; 16 import { StaticMathBlock } from "./StaticMathBlock"; 17 - import { codeToHtml } from "shiki"; 18 19 export function StaticPostContent({ 20 blocks, ··· 62 return <StaticMathBlock block={b.block} />; 63 } 64 case PubLeafletBlocksCode.isMain(b.block): { 65 - let html = await codeToHtml(b.block.plaintext, { 66 - lang: b.block.language || "plaintext", 67 - theme: b.block.syntaxHighlightingTheme || "github-light", 68 - }); 69 return ( 70 <div 71 className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline"
··· 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 import { BaseTextBlock } from "./BaseTextBlock"; 16 import { StaticMathBlock } from "./StaticMathBlock"; 17 + import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 18 19 export function StaticPostContent({ 20 blocks, ··· 62 return <StaticMathBlock block={b.block} />; 63 } 64 case PubLeafletBlocksCode.isMain(b.block): { 65 + let { language, syntaxHighlightingTheme } = b.block; 66 + const lang = 67 + bundledLanguagesInfo.find((l) => l.id === language)?.id || "plaintext"; 68 + const theme = 69 + bundledThemesInfo.find((t) => t.id === syntaxHighlightingTheme)?.id || 70 + "github-light"; 71 + 72 + let html = await codeToHtml(b.block.plaintext, { lang, theme }); 73 return ( 74 <div 75 className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline"
+7 -3
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
··· 11 facets?: Facet[]; 12 index: number[]; 13 preview?: boolean; 14 }) { 15 let children = []; 16 - let highlights = useHighlight(props.index); 17 let facets = useMemo(() => { 18 if (props.preview) return props.facets; 19 let facets = [...(props.facets || [])]; 20 for (let highlight of highlights) { 21 facets = addFacet( 22 facets, 23 { ··· 35 { $type: "pub.leaflet.richtext.facet#highlight" }, 36 { 37 $type: "pub.leaflet.richtext.facet#id", 38 - id: `${props.index.join(".")}_${highlight.startOffset || 0}`, 39 }, 40 ], 41 }, ··· 43 ); 44 } 45 return facets; 46 - }, [props.plaintext, props.facets, highlights, props.preview]); 47 return <BaseTextBlock {...props} facets={facets} />; 48 } 49
··· 11 facets?: Facet[]; 12 index: number[]; 13 preview?: boolean; 14 + pageId?: string; 15 }) { 16 let children = []; 17 + let highlights = useHighlight(props.index, props.pageId); 18 let facets = useMemo(() => { 19 if (props.preview) return props.facets; 20 let facets = [...(props.facets || [])]; 21 for (let highlight of highlights) { 22 + const fragmentId = props.pageId 23 + ? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}` 24 + : `${props.index.join(".")}_${highlight.startOffset || 0}`; 25 facets = addFacet( 26 facets, 27 { ··· 39 { $type: "pub.leaflet.richtext.facet#highlight" }, 40 { 41 $type: "pub.leaflet.richtext.facet#id", 42 + id: fragmentId, 43 }, 44 ], 45 }, ··· 47 ); 48 } 49 return facets; 50 + }, [props.plaintext, props.facets, highlights, props.preview, props.pageId]); 51 return <BaseTextBlock {...props} facets={facets} />; 52 } 53
+12 -7
app/lish/[did]/[publication]/[rkey]/extractCodeBlocks.ts
··· 1 import { 2 PubLeafletDocument, 3 PubLeafletPagesLinearDocument, 4 PubLeafletBlocksCode, 5 } from "lexicons/api"; 6 - import { codeToHtml } from "shiki"; 7 8 export async function extractCodeBlocks( 9 - blocks: PubLeafletPagesLinearDocument.Block[], 10 ): Promise<Map<string, string>> { 11 const codeBlocks = new Map<string, string>(); 12 13 - // Process all pages in the document 14 for (let i = 0; i < blocks.length; i++) { 15 const block = blocks[i]; 16 const currentIndex = [i]; 17 const indexKey = currentIndex.join("."); 18 19 if (PubLeafletBlocksCode.isMain(block.block)) { 20 - const html = await codeToHtml(block.block.plaintext, { 21 - lang: block.block.language || "plaintext", 22 - theme: block.block.syntaxHighlightingTheme || "github-light", 23 - }); 24 codeBlocks.set(indexKey, html); 25 } 26 }
··· 1 import { 2 PubLeafletDocument, 3 PubLeafletPagesLinearDocument, 4 + PubLeafletPagesCanvas, 5 PubLeafletBlocksCode, 6 } from "lexicons/api"; 7 + import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 8 9 export async function extractCodeBlocks( 10 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[], 11 ): Promise<Map<string, string>> { 12 const codeBlocks = new Map<string, string>(); 13 14 + // Process all blocks (works for both linear and canvas) 15 for (let i = 0; i < blocks.length; i++) { 16 const block = blocks[i]; 17 const currentIndex = [i]; 18 const indexKey = currentIndex.join("."); 19 20 if (PubLeafletBlocksCode.isMain(block.block)) { 21 + let { language, syntaxHighlightingTheme } = block.block; 22 + const lang = 23 + bundledLanguagesInfo.find((l) => l.id === language)?.id || "plaintext"; 24 + let theme = 25 + bundledThemesInfo.find((t) => t.id === syntaxHighlightingTheme)?.id || 26 + "github-light"; 27 + 28 + const html = await codeToHtml(block.block.plaintext, { lang, theme }); 29 codeBlocks.set(indexKey, html); 30 } 31 }
+25
app/lish/[did]/[publication]/[rkey]/fetchPollData.ts
···
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { Json } from "supabase/database.types"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + 7 + export type PollData = { 8 + uri: string; 9 + cid: string; 10 + record: Json; 11 + atp_poll_votes: { record: Json; voter_did: string }[]; 12 + }; 13 + 14 + export async function fetchPollData(pollUris: string[]): Promise<PollData[]> { 15 + // Get current user's identity to check if they've voted 16 + const identity = await getIdentityData(); 17 + const userDid = identity?.atp_did; 18 + 19 + const { data } = await supabaseServerClient 20 + .from("atp_poll_records") 21 + .select(`*, atp_poll_votes(*)`) 22 + .in("uri", pollUris); 23 + 24 + return data || []; 25 + }
+85 -3
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 3 - export type PostPageData = Awaited<ReturnType<typeof getPostPageData>>; 4 export async function getPostPageData(uri: string) { 5 let { data: document } = await supabaseServerClient 6 .from("documents") ··· 10 uri, 11 comments_on_documents(*, bsky_profiles(*)), 12 documents_in_publications(publications(*, publication_subscriptions(*))), 13 - document_mentions_in_bsky(*, bsky_posts(*)), 14 leaflets_in_publications(*) 15 `, 16 ) 17 .eq("uri", uri) 18 .single(); 19 - return document; 20 }
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 5 export async function getPostPageData(uri: string) { 6 let { data: document } = await supabaseServerClient 7 .from("documents") ··· 11 uri, 12 comments_on_documents(*, bsky_profiles(*)), 13 documents_in_publications(publications(*, publication_subscriptions(*))), 14 + document_mentions_in_bsky(*), 15 leaflets_in_publications(*) 16 `, 17 ) 18 .eq("uri", uri) 19 .single(); 20 + 21 + if (!document) return null; 22 + 23 + // Fetch constellation backlinks for mentions 24 + const pubRecord = document.documents_in_publications[0]?.publications 25 + ?.record as PubLeafletPublication.Record; 26 + let aturi = new AtUri(uri); 27 + const postUrl = pubRecord 28 + ? `https://${pubRecord?.base_path}/${aturi.rkey}` 29 + : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; 30 + const constellationBacklinks = await getConstellationBacklinks(postUrl); 31 + 32 + // Deduplicate constellation backlinks (same post could appear in both links and embeds) 33 + const uniqueBacklinks = Array.from( 34 + new Map(constellationBacklinks.map((b) => [b.uri, b])).values(), 35 + ); 36 + 37 + // Combine database mentions (already deduplicated by DB constraint) and constellation backlinks 38 + const quotesAndMentions: { uri: string; link?: string }[] = [ 39 + // Database mentions (quotes with link to quoted content) 40 + ...document.document_mentions_in_bsky.map((m) => ({ 41 + uri: m.uri, 42 + link: m.link, 43 + })), 44 + // Constellation backlinks (direct post mentions without quote context) 45 + ...uniqueBacklinks, 46 + ]; 47 + 48 + let theme = 49 + ( 50 + document?.documents_in_publications[0]?.publications 51 + ?.record as PubLeafletPublication.Record 52 + )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 53 + 54 + return { 55 + ...document, 56 + quotesAndMentions, 57 + theme, 58 + }; 59 } 60 + 61 + export type PostPageData = Awaited<ReturnType<typeof getPostPageData>>; 62 + 63 + const headers = { 64 + "Content-type": "application/json", 65 + "user-agent": "leaflet.pub", 66 + }; 67 + 68 + // Fetch constellation backlinks without hydrating with Bluesky post data 69 + export async function getConstellationBacklinks( 70 + url: string, 71 + ): Promise<{ uri: string }[]> { 72 + try { 73 + let baseURL = `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(url)}`; 74 + let externalEmbeds = new URL( 75 + `${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:embed.external.uri")}`, 76 + ); 77 + let linkFacets = new URL( 78 + `${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:facets[].features[app.bsky.richtext.facet#link].uri")}`, 79 + ); 80 + 81 + let [links, embeds] = (await Promise.all([ 82 + fetch(linkFacets, { headers, next: { revalidate: 3600 } }).then((req) => 83 + req.json(), 84 + ), 85 + fetch(externalEmbeds, { headers, next: { revalidate: 3600 } }).then( 86 + (req) => req.json(), 87 + ), 88 + ])) as ConstellationResponse[]; 89 + 90 + let uris = [...links.records, ...embeds.records].map((i) => 91 + AtUri.make(i.did, i.collection, i.rkey).toString(), 92 + ); 93 + 94 + return uris.map((uri) => ({ uri })); 95 + } catch (e) { 96 + return []; 97 + } 98 + } 99 + 100 + type ConstellationResponse = { 101 + records: { did: string; collection: string; rkey: string }[]; 102 + };
+35
app/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts
···
··· 1 + "use server"; 2 + 3 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 4 + 5 + export type VoterIdentity = { 6 + did: string; 7 + handle: string | null; 8 + }; 9 + 10 + export async function getVoterIdentities( 11 + dids: string[], 12 + ): Promise<VoterIdentity[]> { 13 + const identities = await Promise.all( 14 + dids.map(async (did) => { 15 + try { 16 + const resolved = await idResolver.did.resolve(did); 17 + const handle = resolved?.alsoKnownAs?.[0] 18 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 19 + : null; 20 + return { 21 + did, 22 + handle, 23 + }; 24 + } catch (error) { 25 + console.error(`Failed to resolve DID ${did}:`, error); 26 + return { 27 + did, 28 + handle: null, 29 + }; 30 + } 31 + }), 32 + ); 33 + 34 + return identities; 35 + }
+4 -3
app/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts
··· 5 export const revalidate = 60; 6 7 export default async function OpenGraphImage(props: { 8 - params: { publication: string; did: string; rkey: string; quote: string }; 9 }) { 10 - let quotePosition = decodeQuotePosition(props.params.quote); 11 return getMicroLinkOgImage( 12 - `/lish/${decodeURIComponent(props.params.did)}/${decodeURIComponent(props.params.publication)}/${props.params.rkey}/l-quote/${props.params.quote}#${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`, 13 { 14 width: 620, 15 height: 324,
··· 5 export const revalidate = 60; 6 7 export default async function OpenGraphImage(props: { 8 + params: Promise<{ publication: string; did: string; rkey: string; quote: string }>; 9 }) { 10 + let params = await props.params; 11 + let quotePosition = decodeQuotePosition(params.quote); 12 return getMicroLinkOgImage( 13 + `/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/l-quote/${params.quote}#${quotePosition?.pageId ? `${quotePosition.pageId}~` : ""}${quotePosition?.start.block.join(".")}_${quotePosition?.start.offset}`, 14 { 15 width: 620, 16 height: 324,
+3 -2
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 - params: { publication: string; did: string; rkey: string }; 8 }) { 9 return getMicroLinkOgImage( 10 - `/lish/${decodeURIComponent(props.params.did)}/${decodeURIComponent(props.params.publication)}/${props.params.rkey}/`, 11 ); 12 }
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 + params: Promise<{ publication: string; did: string; rkey: string }>; 8 }) { 9 + let params = await props.params; 10 return getMicroLinkOgImage( 11 + `/lish/${decodeURIComponent(params.did)}/${decodeURIComponent(params.publication)}/${params.rkey}/`, 12 ); 13 }
+28 -144
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 import { AtUri } from "@atproto/syntax"; 3 import { ids } from "lexicons/api/lexicons"; 4 - import { 5 - PubLeafletBlocksBskyPost, 6 - PubLeafletDocument, 7 - PubLeafletPagesLinearDocument, 8 - PubLeafletPublication, 9 - } from "lexicons/api"; 10 import { Metadata } from "next"; 11 - import { AtpAgent } from "@atproto/api"; 12 - import { QuoteHandler } from "./QuoteHandler"; 13 - import { InteractionDrawer } from "./Interactions/InteractionDrawer"; 14 - import { 15 - PublicationBackgroundProvider, 16 - PublicationThemeProvider, 17 - } from "components/ThemeManager/PublicationThemeProvider"; 18 - import { getPostPageData } from "./getPostPageData"; 19 - import { PostPageContextProvider } from "./PostPageContext"; 20 - import { PostPage } from "./PostPage"; 21 - import { PageLayout } from "./PageLayout"; 22 - import { extractCodeBlocks } from "./extractCodeBlocks"; 23 - import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 24 25 export async function generateMetadata(props: { 26 params: Promise<{ publication: string; did: string; rkey: string }>; 27 }): Promise<Metadata> { 28 let params = await props.params; 29 let did = decodeURIComponent(params.did); 30 - let publication = decodeURIComponent(params.publication); 31 if (!did) return { title: "Publication 404" }; 32 33 let [{ data: document }] = await Promise.all([ 34 supabaseServerClient 35 .from("documents") 36 - .select("*") 37 .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) 38 .single(), 39 ]); ··· 42 let docRecord = document.data as PubLeafletDocument.Record; 43 44 return { 45 - title: docRecord.title + " - " + publication, 46 description: docRecord?.description || "", 47 }; 48 } 49 export default async function Post(props: { 50 params: Promise<{ publication: string; did: string; rkey: string }>; 51 }) { 52 - let did = decodeURIComponent((await props.params).did); 53 if (!did) 54 return ( 55 - <NotFoundLayout> 56 - <p className="font-bold">Sorry, can&apos;t resolve handle.</p> 57 <p> 58 This may be a glitch on our end. If the issue persists please{" "} 59 <a href="mailto:contact@leaflet.pub">send us a note</a>. 60 </p> 61 - </NotFoundLayout> 62 - ); 63 - let agent = new AtpAgent({ 64 - service: "https://public.api.bsky.app", 65 - fetch: (...args) => 66 - fetch(args[0], { 67 - ...args[1], 68 - cache: "no-store", 69 - next: { revalidate: 3600 }, 70 - }), 71 - }); 72 - let [document, profile] = await Promise.all([ 73 - getPostPageData( 74 - AtUri.make( 75 - did, 76 - ids.PubLeafletDocument, 77 - (await props.params).rkey, 78 - ).toString(), 79 - ), 80 - agent.getProfile({ actor: did }), 81 - ]); 82 - if (!document?.data || !document.documents_in_publications[0].publications) 83 - return ( 84 - <NotFoundLayout> 85 - <p className="font-bold">Sorry, we can't find this post!</p> 86 - <p> 87 - This may be a glitch on our end. If the issue persists please{" "} 88 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 89 - </p> 90 - </NotFoundLayout> 91 - ); 92 - let record = document.data as PubLeafletDocument.Record; 93 - let bskyPosts = record.pages.flatMap((p) => { 94 - let page = p as PubLeafletPagesLinearDocument.Main; 95 - return page.blocks?.filter( 96 - (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, 97 ); 98 - }); 99 - let bskyPostData = 100 - bskyPosts.length > 0 101 - ? await agent.getPosts( 102 - { 103 - uris: bskyPosts 104 - .map((p) => { 105 - let block = p?.block as PubLeafletBlocksBskyPost.Main; 106 - return block.postRef.uri; 107 - }) 108 - .slice(0, 24), 109 - }, 110 - { headers: {} }, 111 - ) 112 - : { data: { posts: [] } }; 113 - let firstPage = record.pages[0]; 114 - let blocks: PubLeafletPagesLinearDocument.Block[] = []; 115 - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 116 - blocks = firstPage.blocks || []; 117 - } 118 119 - let pubRecord = document.documents_in_publications[0]?.publications 120 - .record as PubLeafletPublication.Record; 121 - 122 - let hasPageBackground = !!pubRecord.theme?.showPageBackground; 123 - let prerenderedCodeBlocks = await extractCodeBlocks(blocks); 124 - 125 - return ( 126 - <PostPageContextProvider value={document}> 127 - <PublicationThemeProvider 128 - record={pubRecord} 129 - pub_creator={ 130 - document.documents_in_publications[0].publications.identity_did 131 - } 132 - > 133 - <PublicationBackgroundProvider 134 - record={pubRecord} 135 - pub_creator={ 136 - document.documents_in_publications[0].publications.identity_did 137 - } 138 - > 139 - {/* 140 - TODO: SCROLL PAGE TO FIT DRAWER 141 - If the drawer fits without scrolling, dont scroll 142 - If both drawer and page fit if you scrolled it, scroll it all into the center 143 - If the drawer and pafe doesn't all fit, scroll to drawer 144 - 145 - TODO: SROLL BAR 146 - If there is no drawer && there is no page bg, scroll the entire page 147 - If there is either a drawer open OR a page background, scroll just the post content 148 - 149 - TODO: HIGHLIGHTING BORKED 150 - on chrome, if you scroll backward, things stop working 151 - seems like if you use an older browser, sel direction is not a thing yet 152 - */} 153 - <PageLayout> 154 - <PostPage 155 - preferences={pubRecord.preferences || {}} 156 - pubRecord={pubRecord} 157 - profile={JSON.parse(JSON.stringify(profile.data))} 158 - document={document} 159 - bskyPostData={bskyPostData.data.posts} 160 - did={did} 161 - blocks={blocks} 162 - name={decodeURIComponent((await props.params).publication)} 163 - prerenderedCodeBlocks={prerenderedCodeBlocks} 164 - /> 165 - <InteractionDrawer 166 - document_uri={document.uri} 167 - comments={ 168 - pubRecord.preferences?.showComments === false 169 - ? [] 170 - : document.comments_on_documents 171 - } 172 - quotes={document.document_mentions_in_bsky} 173 - did={did} 174 - /> 175 - </PageLayout> 176 - 177 - <QuoteHandler /> 178 - </PublicationBackgroundProvider> 179 - </PublicationThemeProvider> 180 - </PostPageContextProvider> 181 - ); 182 }
··· 1 import { supabaseServerClient } from "supabase/serverClient"; 2 import { AtUri } from "@atproto/syntax"; 3 import { ids } from "lexicons/api/lexicons"; 4 + import { PubLeafletDocument } from "lexicons/api"; 5 import { Metadata } from "next"; 6 + import { DocumentPageRenderer } from "./DocumentPageRenderer"; 7 8 export async function generateMetadata(props: { 9 params: Promise<{ publication: string; did: string; rkey: string }>; 10 }): Promise<Metadata> { 11 let params = await props.params; 12 let did = decodeURIComponent(params.did); 13 if (!did) return { title: "Publication 404" }; 14 15 let [{ data: document }] = await Promise.all([ 16 supabaseServerClient 17 .from("documents") 18 + .select("*, documents_in_publications(publications(*))") 19 .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) 20 .single(), 21 ]); ··· 24 let docRecord = document.data as PubLeafletDocument.Record; 25 26 return { 27 + icons: { 28 + icon: { 29 + url: 30 + process.env.NODE_ENV === "development" 31 + ? `/lish/${did}/${params.publication}/icon` 32 + : "/icon", 33 + sizes: "32x32", 34 + type: "image/png", 35 + }, 36 + other: { 37 + rel: "alternate", 38 + url: document.uri, 39 + }, 40 + }, 41 + title: 42 + docRecord.title + 43 + " - " + 44 + document.documents_in_publications[0]?.publications?.name, 45 description: docRecord?.description || "", 46 }; 47 } 48 export default async function Post(props: { 49 params: Promise<{ publication: string; did: string; rkey: string }>; 50 }) { 51 + let params = await props.params; 52 + let did = decodeURIComponent(params.did); 53 + 54 if (!did) 55 return ( 56 + <div className="p-4 text-lg text-center flex flex-col gap-4"> 57 + <p>Sorry, can&apos;t resolve handle.</p> 58 <p> 59 This may be a glitch on our end. If the issue persists please{" "} 60 <a href="mailto:contact@leaflet.pub">send us a note</a>. 61 </p> 62 + </div> 63 ); 64 65 + return <DocumentPageRenderer did={did} rkey={params.rkey} />; 66 }
+20 -5
app/lish/[did]/[publication]/[rkey]/quotePosition.ts
··· 1 export interface QuotePosition { 2 start: { 3 block: number[]; 4 offset: number; ··· 14 /** 15 * Encodes quote position into a URL-friendly string 16 * Format: startBlock_startOffset-endBlock_endOffset 17 * Block paths are joined with dots: 1.2.0_45-1.2.3_67 18 - * Simple blocks: 0:12-2:45 19 */ 20 export function encodeQuotePosition(position: QuotePosition): string { 21 - const { start, end } = position; 22 - return `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`; 23 } 24 25 /** ··· 28 */ 29 export function decodeQuotePosition(encoded: string): QuotePosition | null { 30 try { 31 - // Match format: blockPath:number-blockPath:number 32 // Block paths can be: 5, 1.2, 0.1.3, etc. 33 - const match = encoded.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/); 34 35 if (!match) { 36 return null; ··· 39 const [, startBlockPath, startOffset, endBlockPath, endOffset] = match; 40 41 const position: QuotePosition = { 42 start: { 43 block: startBlockPath.split(".").map((i) => parseInt(i)), 44 offset: parseInt(startOffset, 10),
··· 1 export interface QuotePosition { 2 + pageId?: string; 3 start: { 4 block: number[]; 5 offset: number; ··· 15 /** 16 * Encodes quote position into a URL-friendly string 17 * Format: startBlock_startOffset-endBlock_endOffset 18 + * Format with page: pageId~startBlock_startOffset-endBlock_endOffset 19 * Block paths are joined with dots: 1.2.0_45-1.2.3_67 20 + * Simple blocks: 0_12-2_45 21 + * With page: page1~0_12-2_45 22 */ 23 export function encodeQuotePosition(position: QuotePosition): string { 24 + const { pageId, start, end } = position; 25 + const positionStr = `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`; 26 + return pageId ? `${pageId}~${positionStr}` : positionStr; 27 } 28 29 /** ··· 32 */ 33 export function decodeQuotePosition(encoded: string): QuotePosition | null { 34 try { 35 + // Check for pageId prefix (format: pageId~blockPath_number-blockPath_number) 36 + let pageId: string | undefined; 37 + let positionStr = encoded; 38 + 39 + const tildeIndex = encoded.indexOf("~"); 40 + if (tildeIndex !== -1) { 41 + pageId = encoded.substring(0, tildeIndex); 42 + positionStr = encoded.substring(tildeIndex + 1); 43 + } 44 + 45 + // Match format: blockPath_number-blockPath_number 46 // Block paths can be: 5, 1.2, 0.1.3, etc. 47 + const match = positionStr.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/); 48 49 if (!match) { 50 return null; ··· 53 const [, startBlockPath, startOffset, endBlockPath, endOffset] = match; 54 55 const position: QuotePosition = { 56 + ...(pageId && { pageId }), 57 start: { 58 block: startBlockPath.split(".").map((i) => parseInt(i)), 59 offset: parseInt(startOffset, 10),
+9 -1
app/lish/[did]/[publication]/[rkey]/useHighlight.tsx
··· 11 activeHighlight: null as null | QuotePosition, 12 })); 13 14 - export const useHighlight = (pos: number[]) => { 15 let doc = useContext(PostPageContext); 16 let { quote } = useParams(); 17 let activeHighlight = useActiveHighlightState( ··· 23 return highlights 24 .map((quotePosition) => { 25 if (!quotePosition) return null; 26 let maxLength = Math.max( 27 quotePosition.start.block.length, 28 quotePosition.end.block.length,
··· 11 activeHighlight: null as null | QuotePosition, 12 })); 13 14 + export const useHighlight = (pos: number[], pageId?: string) => { 15 let doc = useContext(PostPageContext); 16 let { quote } = useParams(); 17 let activeHighlight = useActiveHighlightState( ··· 23 return highlights 24 .map((quotePosition) => { 25 if (!quotePosition) return null; 26 + // Filter by pageId if provided 27 + if (pageId && quotePosition.pageId !== pageId) { 28 + return null; 29 + } 30 + // If highlight has pageId but block doesn't, skip 31 + if (quotePosition.pageId && !pageId) { 32 + return null; 33 + } 34 let maxLength = Math.max( 35 quotePosition.start.block.length, 36 quotePosition.end.block.length,
+64
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
···
··· 1 + "use server"; 2 + 3 + import { createOauthClient } from "src/atproto-oauth"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + import { AtpBaseClient, AtUri } from "@atproto/api"; 6 + import { PubLeafletPollVote } from "lexicons/api"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { Json } from "supabase/database.types"; 9 + import { TID } from "@atproto/common"; 10 + 11 + export async function voteOnPublishedPoll( 12 + pollUri: string, 13 + pollCid: string, 14 + selectedOption: string, 15 + ): Promise<{ success: boolean; error?: string }> { 16 + try { 17 + const identity = await getIdentityData(); 18 + 19 + if (!identity?.atp_did) { 20 + return { success: false, error: "Not authenticated" }; 21 + } 22 + 23 + const oauthClient = await createOauthClient(); 24 + const session = await oauthClient.restore(identity.atp_did); 25 + let agent = new AtpBaseClient(session.fetchHandler.bind(session)); 26 + 27 + const voteRecord: PubLeafletPollVote.Record = { 28 + $type: "pub.leaflet.poll.vote", 29 + poll: { 30 + uri: pollUri, 31 + cid: pollCid, 32 + }, 33 + option: [selectedOption], 34 + }; 35 + 36 + const rkey = TID.nextStr(); 37 + const voteUri = AtUri.make(identity.atp_did, "pub.leaflet.poll.vote", rkey); 38 + 39 + // Write to database optimistically before creating the record 40 + await supabaseServerClient.from("atp_poll_votes").upsert({ 41 + uri: voteUri.toString(), 42 + voter_did: identity.atp_did, 43 + poll_uri: pollUri, 44 + poll_cid: pollCid, 45 + record: voteRecord as unknown as Json, 46 + }); 47 + 48 + // Create the record on ATP 49 + await agent.com.atproto.repo.createRecord({ 50 + repo: identity.atp_did, 51 + collection: "pub.leaflet.poll.vote", 52 + rkey, 53 + record: voteRecord, 54 + }); 55 + 56 + return { success: true }; 57 + } catch (error) { 58 + console.error("Failed to vote:", error); 59 + return { 60 + success: false, 61 + error: error instanceof Error ? error.message : "Failed to vote", 62 + }; 63 + } 64 + }
+1
app/lish/[did]/[publication]/atom/route.ts
··· 19 return new Response(feed.atom1(), { 20 headers: { 21 "Content-Type": "application/atom+xml", 22 "CDN-Cache-Control": "s-maxage=300, stale-while-revalidate=3600", 23 }, 24 });
··· 19 return new Response(feed.atom1(), { 20 headers: { 21 "Content-Type": "application/atom+xml", 22 + "Cache-Control": "s-maxage=300, stale-while-revalidate=3600", 23 "CDN-Cache-Control": "s-maxage=300, stale-while-revalidate=3600", 24 }, 25 });
+1 -48
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 1 "use client"; 2 3 - import { Media } from "components/Media"; 4 import { NewDraftActionButton } from "./NewDraftButton"; 5 import { ActionButton } from "components/ActionBar/ActionButton"; 6 - import { useRouter } from "next/navigation"; 7 - import { Popover } from "components/Popover"; 8 - import { SettingsSmall } from "components/Icons/SettingsSmall"; 9 import { ShareSmall } from "components/Icons/ShareSmall"; 10 import { Menu } from "components/Layout"; 11 import { MenuItem } from "components/Layout"; 12 - import { HomeSmall } from "components/Icons/HomeSmall"; 13 - import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; 14 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 15 import { usePublicationData } from "./PublicationSWRProvider"; 16 import { useSmoker } from "components/Toast"; 17 - import { PaintSmall } from "components/Icons/PaintSmall"; 18 - import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; 19 import { useIsMobile } from "src/hooks/isMobile"; 20 import { SpeedyLink } from "components/SpeedyLink"; 21 ··· 24 <> 25 <NewDraftActionButton publication={props.publication} /> 26 <PublicationShareButton /> 27 - <PublicationThemeButton /> 28 <PublicationSettingsButton publication={props.publication} /> 29 </> 30 ); ··· 85 </Menu> 86 ); 87 } 88 - 89 - function PublicationSettingsButton(props: { publication: string }) { 90 - let isMobile = useIsMobile(); 91 - return ( 92 - <Popover 93 - asChild 94 - side={isMobile ? "top" : "right"} 95 - align={isMobile ? "center" : "start"} 96 - className="max-w-xs" 97 - trigger={ 98 - <ActionButton 99 - id="pub-settings-button" 100 - icon=<SettingsSmall /> 101 - label="Settings" 102 - /> 103 - } 104 - > 105 - <EditPubForm /> 106 - </Popover> 107 - ); 108 - } 109 - 110 - function PublicationThemeButton() { 111 - let isMobile = useIsMobile(); 112 - 113 - return ( 114 - <Popover 115 - asChild 116 - className="max-w-xs pb-0 bg-white!" 117 - side={isMobile ? "top" : "right"} 118 - align={isMobile ? "center" : "start"} 119 - trigger={ 120 - <ActionButton id="pub-theme-button" icon=<PaintSmall /> label="Theme" /> 121 - } 122 - > 123 - <PubThemeSetter /> 124 - </Popover> 125 - ); 126 - }
··· 1 "use client"; 2 3 import { NewDraftActionButton } from "./NewDraftButton"; 4 + import { PublicationSettingsButton } from "./PublicationSettings"; 5 import { ActionButton } from "components/ActionBar/ActionButton"; 6 import { ShareSmall } from "components/Icons/ShareSmall"; 7 import { Menu } from "components/Layout"; 8 import { MenuItem } from "components/Layout"; 9 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 import { usePublicationData } from "./PublicationSWRProvider"; 11 import { useSmoker } from "components/Toast"; 12 import { useIsMobile } from "src/hooks/isMobile"; 13 import { SpeedyLink } from "components/SpeedyLink"; 14 ··· 17 <> 18 <NewDraftActionButton publication={props.publication} /> 19 <PublicationShareButton /> 20 <PublicationSettingsButton publication={props.publication} /> 21 </> 22 ); ··· 77 </Menu> 78 ); 79 }
+7 -4
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 3 import { NewDraftSecondaryButton } from "./NewDraftButton"; 4 import React from "react"; 5 import { usePublicationData } from "./PublicationSWRProvider"; 6 - import { LeafletList } from "app/home/HomeLayout"; 7 8 export function DraftList(props: { 9 searchValue: string; ··· 26 cardBorderHidden={!props.showPageBackground} 27 leaflets={leaflets_in_publications 28 .filter((l) => !l.documents) 29 .map((l) => { 30 return { 31 token: { 32 ...l.permission_tokens!, 33 leaflets_in_publications: [ ··· 39 }, 40 ], 41 }, 42 - added_at: "", 43 }; 44 })} 45 initialFacts={pub_data.leaflet_data.facts || {}} 46 titles={{ 47 ...leaflets_in_publications.reduce( 48 (acc, leaflet) => { 49 - if (leaflet.title && leaflet.permission_tokens) 50 - acc[leaflet.permission_tokens.root_entity] = leaflet.title; 51 return acc; 52 }, 53 {} as { [l: string]: string },
··· 3 import { NewDraftSecondaryButton } from "./NewDraftButton"; 4 import React from "react"; 5 import { usePublicationData } from "./PublicationSWRProvider"; 6 + import { LeafletList } from "app/(home-pages)/home/HomeLayout"; 7 8 export function DraftList(props: { 9 searchValue: string; ··· 26 cardBorderHidden={!props.showPageBackground} 27 leaflets={leaflets_in_publications 28 .filter((l) => !l.documents) 29 + .filter((l) => !l.archived) 30 .map((l) => { 31 return { 32 + archived: l.archived, 33 + added_at: "", 34 token: { 35 ...l.permission_tokens!, 36 leaflets_in_publications: [ ··· 42 }, 43 ], 44 }, 45 }; 46 })} 47 initialFacts={pub_data.leaflet_data.facts || {}} 48 titles={{ 49 ...leaflets_in_publications.reduce( 50 (acc, leaflet) => { 51 + if (leaflet.permission_tokens) 52 + acc[leaflet.permission_tokens.root_entity] = 53 + leaflet.title || "Untitled"; 54 return acc; 55 }, 56 {} as { [l: string]: string },
+22 -2
app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
··· 2 3 import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 4 import { callRPC } from "app/api/rpc/client"; 5 - import { createContext, useContext } from "react"; 6 - import useSWR, { SWRConfig } from "swr"; 7 8 const PublicationContext = createContext({ name: "", did: "" }); 9 export function PublicationSWRDataProvider(props: { ··· 13 children: React.ReactNode; 14 }) { 15 let key = `publication-data-${props.publication_did}-${props.publication_rkey}`; 16 return ( 17 <PublicationContext 18 value={{ name: props.publication_rkey, did: props.publication_did }} ··· 41 ); 42 return { data, mutate }; 43 }
··· 2 3 import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 4 import { callRPC } from "app/api/rpc/client"; 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"]; 10 11 const PublicationContext = createContext({ name: "", did: "" }); 12 export function PublicationSWRDataProvider(props: { ··· 16 children: React.ReactNode; 17 }) { 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]); 23 return ( 24 <PublicationContext 25 value={{ name: props.publication_rkey, did: props.publication_did }} ··· 48 ); 49 return { data, mutate }; 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 + }
+132
app/lish/[did]/[publication]/dashboard/PublicationSettings.tsx
···
··· 1 + "use client"; 2 + 3 + import { ActionButton } from "components/ActionBar/ActionButton"; 4 + import { Popover } from "components/Popover"; 5 + import { SettingsSmall } from "components/Icons/SettingsSmall"; 6 + import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; 7 + import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; 8 + import { useIsMobile } from "src/hooks/isMobile"; 9 + import { useState } from "react"; 10 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 11 + import { theme } from "tailwind.config"; 12 + import { ButtonPrimary } from "components/Buttons"; 13 + import { DotLoader } from "components/utils/DotLoader"; 14 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 15 + 16 + export function PublicationSettingsButton(props: { publication: string }) { 17 + let isMobile = useIsMobile(); 18 + let [state, setState] = useState<"menu" | "general" | "theme">("menu"); 19 + let [loading, setLoading] = useState(false); 20 + 21 + return ( 22 + <Popover 23 + asChild 24 + onOpenChange={() => setState("menu")} 25 + side={isMobile ? "top" : "right"} 26 + align={isMobile ? "center" : "start"} 27 + className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} 28 + arrowFill={theme.colors["border-light"]} 29 + trigger={ 30 + <ActionButton 31 + id="pub-settings-button" 32 + icon=<SettingsSmall /> 33 + label="Settings" 34 + /> 35 + } 36 + > 37 + {state === "general" ? ( 38 + <EditPubForm 39 + backToMenuAction={() => setState("menu")} 40 + loading={loading} 41 + setLoadingAction={setLoading} 42 + /> 43 + ) : state === "theme" ? ( 44 + <PubThemeSetter 45 + backToMenu={() => setState("menu")} 46 + loading={loading} 47 + setLoading={setLoading} 48 + /> 49 + ) : ( 50 + <PubSettingsMenu 51 + state={state} 52 + setState={setState} 53 + loading={loading} 54 + setLoading={setLoading} 55 + /> 56 + )} 57 + </Popover> 58 + ); 59 + } 60 + 61 + const PubSettingsMenu = (props: { 62 + state: "menu" | "general" | "theme"; 63 + setState: (s: typeof props.state) => void; 64 + loading: boolean; 65 + setLoading: (l: boolean) => void; 66 + }) => { 67 + let menuItemClassName = 68 + "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 69 + 70 + return ( 71 + <div className="flex flex-col gap-0.5"> 72 + <PubSettingsHeader 73 + loading={props.loading} 74 + setLoadingAction={props.setLoading} 75 + state={"menu"} 76 + /> 77 + <button 78 + className={menuItemClassName} 79 + type="button" 80 + onClick={() => { 81 + props.setState("general"); 82 + }} 83 + > 84 + Publication Settings 85 + <ArrowRightTiny /> 86 + </button> 87 + <button 88 + className={menuItemClassName} 89 + type="button" 90 + onClick={() => props.setState("theme")} 91 + > 92 + Publication Theme 93 + <ArrowRightTiny /> 94 + </button> 95 + </div> 96 + ); 97 + }; 98 + 99 + export const PubSettingsHeader = (props: { 100 + state: "menu" | "general" | "theme"; 101 + backToMenuAction?: () => void; 102 + loading: boolean; 103 + setLoadingAction: (l: boolean) => void; 104 + }) => { 105 + return ( 106 + <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 107 + {props.state === "menu" 108 + ? "Settings" 109 + : props.state === "general" 110 + ? "General" 111 + : props.state === "theme" 112 + ? "Publication Theme" 113 + : ""} 114 + {props.state !== "menu" && ( 115 + <div className="flex gap-2"> 116 + <button 117 + type="button" 118 + onClick={() => { 119 + props.backToMenuAction && props.backToMenuAction(); 120 + }} 121 + > 122 + <GoBackSmall className="text-accent-contrast" /> 123 + </button> 124 + 125 + <ButtonPrimary compact type="submit"> 126 + {props.loading ? <DotLoader /> : "Update"} 127 + </ButtonPrimary> 128 + </div> 129 + )} 130 + </div> 131 + ); 132 + };
+15 -7
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 8 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 import { Checkbox } from "components/Checkbox"; 10 import { useEffect, useState } from "react"; 11 12 type subscriber = { email: string | undefined; did: string | undefined }; 13 ··· 198 @{props.handle} 199 </a> 200 )} 201 - <div className="px-1 py-0 h-max rounded-md text-sm italic text-tertiary"> 202 - {new Date(props.createdAt).toLocaleString(undefined, { 203 - year: "2-digit", 204 - month: "2-digit", 205 - day: "2-digit", 206 - })} 207 - </div> 208 </div> 209 </> 210 // </Checkbox> ··· 235 </Menu> 236 ); 237 };
··· 8 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 import { Checkbox } from "components/Checkbox"; 10 import { useEffect, useState } from "react"; 11 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 12 13 type subscriber = { email: string | undefined; did: string | undefined }; 14 ··· 199 @{props.handle} 200 </a> 201 )} 202 + <SubscriberDate createdAt={props.createdAt} /> 203 </div> 204 </> 205 // </Checkbox> ··· 230 </Menu> 231 ); 232 }; 233 + 234 + function SubscriberDate(props: { createdAt: string }) { 235 + const formattedDate = useLocalizedDate(props.createdAt, { 236 + year: "2-digit", 237 + month: "2-digit", 238 + day: "2-digit", 239 + }); 240 + return ( 241 + <div className="px-1 py-0 h-max rounded-md text-sm italic text-tertiary"> 242 + {formattedDate} 243 + </div> 244 + ); 245 + }
+138 -138
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument } from "lexicons/api"; 4 import { EditTiny } from "components/Icons/EditTiny"; 5 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 14 import { DeleteSmall } from "components/Icons/DeleteSmall"; 15 import { ShareSmall } from "components/Icons/ShareSmall"; 16 - import { ShareButton } from "components/ShareOptions"; 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 21 export function PublishedPostsList(props: { 22 searchValue: string; ··· 25 let { data } = usePublicationData(); 26 let params = useParams(); 27 let { publication } = data!; 28 if (!publication) return null; 29 if (publication.documents_in_publications.length === 0) 30 return ( ··· 52 (l) => doc.documents && l.doc === doc.documents.uri, 53 ); 54 let uri = new AtUri(doc.documents.uri); 55 - let record = doc.documents.data as PubLeafletDocument.Record; 56 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 57 let comments = doc.documents.comments_on_documents[0]?.count || 0; 58 59 return ( 60 <Fragment key={doc.documents?.uri}> ··· 74 href={`${getPublicationURL(publication)}/${uri.rkey}`} 75 > 76 <h3 className="text-primary grow leading-snug"> 77 - {record.title} 78 </h3> 79 </a> 80 <div className="flex justify-start align-top flex-row gap-1"> 81 - {leaflet && ( 82 - <SpeedyLink 83 - className="pt-[6px]" 84 - href={`/${leaflet.leaflet}`} 85 - > 86 - <EditTiny /> 87 - </SpeedyLink> 88 )} 89 - <Options document_uri={doc.documents.uri} /> 90 </div> 91 </div> 92 93 - {record.description ? ( 94 <p className="italic text-secondary"> 95 - {record.description} 96 </p> 97 ) : null} 98 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3"> 99 - {record.publishedAt ? ( 100 - <p className="text-sm text-tertiary"> 101 - Published{" "} 102 - {new Date(record.publishedAt).toLocaleDateString( 103 - undefined, 104 - { 105 - year: "numeric", 106 - month: "long", 107 - day: "2-digit", 108 - }, 109 - )} 110 - </p> 111 ) : null} 112 - {(comments > 0 || quotes > 0) && record.publishedAt 113 - ? " | " 114 - : ""} 115 - {quotes > 0 && ( 116 - <SpeedyLink 117 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 118 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 119 - > 120 - <QuoteTiny /> {quotes} 121 - </SpeedyLink> 122 - )} 123 - {comments > 0 && quotes > 0 ? " " : ""} 124 - {comments > 0 && ( 125 - <SpeedyLink 126 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 127 - className="flex flex-row gap-1 text-sm text-tertiary items-center" 128 - > 129 - <CommentTiny /> {comments} 130 - </SpeedyLink> 131 - )} 132 </div> 133 </div> 134 </div> ··· 142 ); 143 } 144 145 - let Options = (props: { document_uri: string }) => { 146 - return ( 147 - <Menu 148 - align="end" 149 - alignOffset={20} 150 - asChild 151 - trigger={ 152 - <button className="text-secondary rounded-md selected-outline border-transparent! hover:border-border! h-min"> 153 - <MoreOptionsVerticalTiny /> 154 - </button> 155 - } 156 - > 157 - <> 158 - <OptionsMenu document_uri={props.document_uri} /> 159 - </> 160 - </Menu> 161 - ); 162 - }; 163 164 - function OptionsMenu(props: { document_uri: string }) { 165 - let { mutate, data } = usePublicationData(); 166 - let [state, setState] = useState<"normal" | "confirm">("normal"); 167 168 - let postLink = data?.publication 169 - ? `${getPublicationURL(data?.publication)}/${new AtUri(props.document_uri).rkey}` 170 - : null; 171 172 - if (state === "normal") { 173 - return ( 174 - <> 175 - <ShareButton 176 - className="justify-end" 177 - text={ 178 - <div className="flex gap-2"> 179 - Share Post Link 180 - <ShareSmall /> 181 - </div> 182 - } 183 - subtext="" 184 - smokerText="Post link copied!" 185 - id="get-post-link" 186 - fullLink={postLink?.includes("https") ? postLink : undefined} 187 - link={postLink} 188 - /> 189 190 - <hr className="border-border-light" /> 191 - <MenuItem 192 - className="justify-end" 193 - onSelect={async (e) => { 194 - e.preventDefault(); 195 - setState("confirm"); 196 - return; 197 - }} 198 - > 199 - Delete Post 200 - <DeleteSmall /> 201 - </MenuItem> 202 - </> 203 - ); 204 - } 205 - if (state === "confirm") { 206 - return ( 207 - <div className="flex flex-col items-center font-bold text-secondary px-2 py-1"> 208 - Are you sure? 209 - <div className="text-sm text-tertiary font-normal"> 210 - This action cannot be undone! 211 - </div> 212 - <ButtonPrimary 213 - className="mt-2" 214 - onClick={async () => { 215 - await mutate((data) => { 216 - if (!data) return data; 217 - return { 218 - ...data, 219 - publication: { 220 - ...data.publication!, 221 - leaflets_in_publications: 222 - data.publication?.leaflets_in_publications.filter( 223 - (l) => l.doc !== props.document_uri, 224 - ) || [], 225 - documents_in_publications: 226 - data.publication?.documents_in_publications.filter( 227 - (d) => d.documents?.uri !== props.document_uri, 228 - ) || [], 229 - }, 230 - }; 231 - }, false); 232 - await deletePost(props.document_uri); 233 - }} 234 - > 235 - Delete 236 - </ButtonPrimary> 237 - </div> 238 - ); 239 - } 240 }
··· 1 "use client"; 2 import { AtUri } from "@atproto/syntax"; 3 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 import { EditTiny } from "components/Icons/EditTiny"; 5 6 import { usePublicationData } from "./PublicationSWRProvider"; ··· 13 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 14 import { DeleteSmall } from "components/Icons/DeleteSmall"; 15 import { ShareSmall } from "components/Icons/ShareSmall"; 16 + import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; 17 import { SpeedyLink } from "components/SpeedyLink"; 18 import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 import { CommentTiny } from "components/Icons/CommentTiny"; 20 + import { InteractionPreview } from "components/InteractionsPreview"; 21 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 22 + import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; 23 + import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; 24 25 export function PublishedPostsList(props: { 26 searchValue: string; ··· 29 let { data } = usePublicationData(); 30 let params = useParams(); 31 let { publication } = data!; 32 + let pubRecord = publication?.record as PubLeafletPublication.Record; 33 + 34 if (!publication) return null; 35 if (publication.documents_in_publications.length === 0) 36 return ( ··· 58 (l) => doc.documents && l.doc === doc.documents.uri, 59 ); 60 let uri = new AtUri(doc.documents.uri); 61 + let postRecord = doc.documents.data as PubLeafletDocument.Record; 62 let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 63 let comments = doc.documents.comments_on_documents[0]?.count || 0; 64 + let tags = (postRecord?.tags as string[] | undefined) || []; 65 + 66 + let postLink = data?.publication 67 + ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` 68 + : ""; 69 70 return ( 71 <Fragment key={doc.documents?.uri}> ··· 85 href={`${getPublicationURL(publication)}/${uri.rkey}`} 86 > 87 <h3 className="text-primary grow leading-snug"> 88 + {postRecord.title} 89 </h3> 90 </a> 91 <div className="flex justify-start align-top flex-row gap-1"> 92 + {leaflet && leaflet.permission_tokens && ( 93 + <> 94 + <SpeedyLink 95 + className="pt-[6px]" 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 + </> 125 )} 126 </div> 127 </div> 128 129 + {postRecord.description ? ( 130 <p className="italic text-secondary"> 131 + {postRecord.description} 132 </p> 133 ) : null} 134 + <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 135 + {postRecord.publishedAt ? ( 136 + <PublishedDate dateString={postRecord.publishedAt} /> 137 ) : null} 138 + <InteractionPreview 139 + quotesCount={quotes} 140 + commentsCount={comments} 141 + tags={tags} 142 + showComments={pubRecord?.preferences?.showComments} 143 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 + /> 145 </div> 146 </div> 147 </div> ··· 155 ); 156 } 157 158 + // function OptionsMenu(props: { document_uri: string }) { 159 + // let { mutate, data } = usePublicationData(); 160 + // let [state, setState] = useState<"normal" | "confirm">("normal"); 161 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 + // /> 179 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 + //} 231 232 + function PublishedDate(props: { dateString: string }) { 233 + const formattedDate = useLocalizedDate(props.dateString, { 234 + year: "numeric", 235 + month: "long", 236 + day: "2-digit", 237 + }); 238 239 + return <p className="text-sm text-tertiary">Published {formattedDate}</p>; 240 }
+23
app/lish/[did]/[publication]/dashboard/deletePost.ts
··· 30 .delete() 31 .eq("doc", document_uri), 32 ]); 33 return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 34 }
··· 30 .delete() 31 .eq("doc", document_uri), 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 + ]); 56 return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 57 }
+1 -1
app/lish/[did]/[publication]/dashboard/page.tsx
··· 69 publication_rkey={uri.rkey} 70 publication_data={publication_data} 71 > 72 - <PublicationThemeProviderDashboard record={record}> 73 <PublicationDashboard publication={publication} record={record} /> 74 </PublicationThemeProviderDashboard> 75 </PublicationSWRDataProvider>
··· 69 publication_rkey={uri.rkey} 70 publication_data={publication_data} 71 > 72 + <PublicationThemeProviderDashboard> 73 <PublicationDashboard publication={publication} record={record} /> 74 </PublicationThemeProviderDashboard> 75 </PublicationSWRDataProvider>
+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 + }
-68
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": "public, max-age=3600", 63 - }, 64 - }); 65 - } catch (e) { 66 - return redirect("/icon.png"); 67 - } 68 - }
···
+14
app/lish/[did]/[publication]/layout.tsx
··· 46 return { 47 title: pubRecord?.name || "Untitled Publication", 48 description: pubRecord?.description || "", 49 alternates: pubRecord?.base_path 50 ? { 51 types: {
··· 46 return { 47 title: pubRecord?.name || "Untitled Publication", 48 description: pubRecord?.description || "", 49 + icons: { 50 + icon: { 51 + url: 52 + process.env.NODE_ENV === "development" 53 + ? `/lish/${did}/${publication_name}/icon` 54 + : "/icon", 55 + sizes: "32x32", 56 + type: "image/png", 57 + }, 58 + other: { 59 + rel: "alternate", 60 + url: publication.uri, 61 + }, 62 + }, 63 alternates: pubRecord?.base_path 64 ? { 65 types: {
+3 -2
app/lish/[did]/[publication]/opengraph-image.ts
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 - params: { publication: string; did: string }; 8 }) { 9 return getMicroLinkOgImage( 10 - `/lish/${encodeURIComponent(props.params.did)}/${encodeURIComponent(props.params.publication)}/`, 11 ); 12 }
··· 4 export const revalidate = 60; 5 6 export default async function OpenGraphImage(props: { 7 + params: Promise<{ publication: string; did: string }>; 8 }) { 9 + let params = await props.params; 10 return getMicroLinkOgImage( 11 + `/lish/${encodeURIComponent(params.did)}/${encodeURIComponent(params.publication)}/`, 12 ); 13 }
+106 -115
app/lish/[did]/[publication]/page.tsx
··· 14 import { SpeedyLink } from "components/SpeedyLink"; 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 18 export default async function Publication(props: { 19 params: Promise<{ publication: string; did: string }>; ··· 58 try { 59 return ( 60 <PublicationThemeProvider 61 - record={record} 62 pub_creator={publication.identity_did} 63 > 64 <PublicationBackgroundProvider 65 - record={record} 66 pub_creator={publication.identity_did} 67 > 68 - <div 69 - 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"}`} 70 > 71 - <div 72 - 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"}`} 73 - > 74 - <div className="pubHeader flex flex-col pb-8 w-full text-center justify-center "> 75 - {record?.icon && ( 76 - <div 77 - className="shrink-0 w-10 h-10 rounded-full mx-auto" 78 - style={{ 79 - backgroundImage: `url(/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 80 - backgroundRepeat: "no-repeat", 81 - backgroundPosition: "center", 82 - backgroundSize: "cover", 83 - }} 84 - /> 85 - )} 86 - <h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 "> 87 - {publication.name} 88 - </h2> 89 - <p className="sm:text-lg text-secondary"> 90 - {record?.description}{" "} 91 </p> 92 - {profile && ( 93 - <p className="italic text-tertiary sm:text-base text-sm"> 94 - <strong className="">by {profile.displayName}</strong>{" "} 95 - <a 96 - className="text-tertiary" 97 - href={`https://bsky.app/profile/${profile.handle}`} 98 - > 99 - @{profile.handle} 100 - </a> 101 - </p> 102 - )} 103 - <div className="sm:pt-4 pt-4"> 104 - <SubscribeWithBluesky 105 - base_url={getPublicationURL(publication)} 106 - pubName={publication.name} 107 - pub_uri={publication.uri} 108 - subscribers={publication.publication_subscriptions} 109 - /> 110 - </div> 111 </div> 112 - <div className="publicationPostList w-full flex flex-col gap-4"> 113 - {publication.documents_in_publications 114 - .filter((d) => !!d?.documents) 115 - .sort((a, b) => { 116 - let aRecord = a.documents 117 - ?.data! as PubLeafletDocument.Record; 118 - let bRecord = b.documents 119 - ?.data! as PubLeafletDocument.Record; 120 - const aDate = aRecord.publishedAt 121 - ? new Date(aRecord.publishedAt) 122 - : new Date(0); 123 - const bDate = bRecord.publishedAt 124 - ? new Date(bRecord.publishedAt) 125 - : new Date(0); 126 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 127 - }) 128 - .map((doc) => { 129 - if (!doc.documents) return null; 130 - let uri = new AtUri(doc.documents.uri); 131 - let doc_record = doc.documents 132 - .data as PubLeafletDocument.Record; 133 - let quotes = 134 - doc.documents.document_mentions_in_bsky[0].count || 0; 135 - let comments = 136 - record?.preferences?.showComments === false 137 - ? 0 138 - : doc.documents.comments_on_documents[0].count || 0; 139 140 - return ( 141 - <React.Fragment key={doc.documents?.uri}> 142 - <div className="flex w-full grow flex-col "> 143 - <SpeedyLink 144 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 145 - className="publishedPost hover:no-underline! flex flex-col" 146 - > 147 - <h3 className="text-primary">{doc_record.title}</h3> 148 - <p className="italic text-secondary"> 149 - {doc_record.description} 150 - </p> 151 - </SpeedyLink> 152 153 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2"> 154 - <p className="text-sm text-tertiary "> 155 - {doc_record.publishedAt && 156 - new Date( 157 - doc_record.publishedAt, 158 - ).toLocaleDateString(undefined, { 159 year: "numeric", 160 month: "long", 161 day: "2-digit", 162 - })}{" "} 163 - </p> 164 - {comments > 0 || quotes > 0 ? "| " : ""} 165 - {quotes > 0 && ( 166 - <SpeedyLink 167 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`} 168 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 169 - > 170 - <QuoteTiny /> {quotes} 171 - </SpeedyLink> 172 - )} 173 - {comments > 0 && 174 - record?.preferences?.showComments !== false && ( 175 - <SpeedyLink 176 - href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`} 177 - className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap" 178 - > 179 - <CommentTiny /> {comments} 180 - </SpeedyLink> 181 - )} 182 - </div> 183 </div> 184 - <hr className="last:hidden border-border-light" /> 185 - </React.Fragment> 186 - ); 187 - })} 188 - </div> 189 </div> 190 - </div> 191 </PublicationBackgroundProvider> 192 </PublicationThemeProvider> 193 );
··· 14 import { SpeedyLink } from "components/SpeedyLink"; 15 import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { InteractionPreview } from "components/InteractionsPreview"; 18 + import { LocalizedDate } from "./LocalizedDate"; 19 + import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 21 export default async function Publication(props: { 22 params: Promise<{ publication: string; did: string }>; ··· 61 try { 62 return ( 63 <PublicationThemeProvider 64 + theme={record?.theme} 65 pub_creator={publication.identity_did} 66 > 67 <PublicationBackgroundProvider 68 + theme={record?.theme} 69 pub_creator={publication.identity_did} 70 > 71 + <PublicationHomeLayout 72 + uri={publication.uri} 73 + showPageBackground={!!showPageBackground} 74 > 75 + <div className="pubHeader flex flex-col pb-8 w-full text-center justify-center "> 76 + {record?.icon && ( 77 + <div 78 + className="shrink-0 w-10 h-10 rounded-full mx-auto" 79 + style={{ 80 + backgroundImage: `url(/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 81 + backgroundRepeat: "no-repeat", 82 + backgroundPosition: "center", 83 + backgroundSize: "cover", 84 + }} 85 + /> 86 + )} 87 + <h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 "> 88 + {publication.name} 89 + </h2> 90 + <p className="sm:text-lg text-secondary"> 91 + {record?.description}{" "} 92 + </p> 93 + {profile && ( 94 + <p className="italic text-tertiary sm:text-base text-sm"> 95 + <strong className="">by {profile.displayName}</strong>{" "} 96 + <a 97 + className="text-tertiary" 98 + href={`https://bsky.app/profile/${profile.handle}`} 99 + > 100 + @{profile.handle} 101 + </a> 102 </p> 103 + )} 104 + <div className="sm:pt-4 pt-4"> 105 + <SubscribeWithBluesky 106 + base_url={getPublicationURL(publication)} 107 + pubName={publication.name} 108 + pub_uri={publication.uri} 109 + subscribers={publication.publication_subscriptions} 110 + /> 111 </div> 112 + </div> 113 + <div className="publicationPostList w-full flex flex-col gap-4"> 114 + {publication.documents_in_publications 115 + .filter((d) => !!d?.documents) 116 + .sort((a, b) => { 117 + let aRecord = a.documents?.data! as PubLeafletDocument.Record; 118 + let bRecord = b.documents?.data! as PubLeafletDocument.Record; 119 + const aDate = aRecord.publishedAt 120 + ? new Date(aRecord.publishedAt) 121 + : new Date(0); 122 + const bDate = bRecord.publishedAt 123 + ? new Date(bRecord.publishedAt) 124 + : new Date(0); 125 + return bDate.getTime() - aDate.getTime(); // Sort by most recent first 126 + }) 127 + .map((doc) => { 128 + if (!doc.documents) return null; 129 + let uri = new AtUri(doc.documents.uri); 130 + let doc_record = doc.documents 131 + .data as PubLeafletDocument.Record; 132 + let quotes = 133 + doc.documents.document_mentions_in_bsky[0].count || 0; 134 + let comments = 135 + record?.preferences?.showComments === false 136 + ? 0 137 + : doc.documents.comments_on_documents[0].count || 0; 138 + let tags = (doc_record?.tags as string[] | undefined) || []; 139 140 + return ( 141 + <React.Fragment key={doc.documents?.uri}> 142 + <div className="flex w-full grow flex-col "> 143 + <SpeedyLink 144 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 145 + className="publishedPost hover:no-underline! flex flex-col" 146 + > 147 + <h3 className="text-primary">{doc_record.title}</h3> 148 + <p className="italic text-secondary"> 149 + {doc_record.description} 150 + </p> 151 + </SpeedyLink> 152 153 + <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2"> 154 + <p className="text-sm text-tertiary "> 155 + {doc_record.publishedAt && ( 156 + <LocalizedDate 157 + dateString={doc_record.publishedAt} 158 + options={{ 159 year: "numeric", 160 month: "long", 161 day: "2-digit", 162 + }} 163 + /> 164 + )}{" "} 165 + </p> 166 + {comments > 0 || quotes > 0 ? "| " : ""} 167 + <InteractionPreview 168 + quotesCount={quotes} 169 + commentsCount={comments} 170 + tags={tags} 171 + postUrl="" 172 + showComments={record?.preferences?.showComments} 173 + /> 174 </div> 175 + </div> 176 + <hr className="last:hidden border-border-light" /> 177 + </React.Fragment> 178 + ); 179 + })} 180 </div> 181 + </PublicationHomeLayout> 182 </PublicationBackgroundProvider> 183 </PublicationThemeProvider> 184 );
+1
app/lish/[did]/[publication]/rss/route.ts
··· 19 return new Response(feed.rss2(), { 20 headers: { 21 "Content-Type": "application/rss+xml", 22 "CDN-Cache-Control": "s-maxage=300, stale-while-revalidate=3600", 23 }, 24 });
··· 19 return new Response(feed.rss2(), { 20 headers: { 21 "Content-Type": "application/rss+xml", 22 + "Cache-Control": "s-maxage=300, stale-while-revalidate=3600", 23 "CDN-Cache-Control": "s-maxage=300, stale-while-revalidate=3600", 24 }, 25 });
+6 -7
app/lish/createPub/CreatePubForm.tsx
··· 127 onChange={(e) => setShowInDiscover(e.target.checked)} 128 > 129 <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 130 - <p className="font-bold italic"> 131 - Show In{" "} 132 <a href="/discover" target="_blank"> 133 Discover 134 - </a> 135 - </p> 136 - <p className="text-sm text-tertiary font-normal"> 137 - This publication will appear on our public Discover page 138 </p> 139 </div> 140 </Checkbox> 141 <hr className="border-border-light" /> 142 143 - <div className="flex w-full justify-center"> 144 <ButtonPrimary 145 type="submit" 146 disabled={
··· 127 onChange={(e) => setShowInDiscover(e.target.checked)} 128 > 129 <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 130 + <p className="font-bold italic">Show In Discover</p> 131 + <p className="text-sm text-tertiary font-normal"> 132 + Your posts will appear on our{" "} 133 <a href="/discover" target="_blank"> 134 Discover 135 + </a>{" "} 136 + page. You can change this at any time! 137 </p> 138 </div> 139 </Checkbox> 140 <hr className="border-border-light" /> 141 142 + <div className="flex w-full justify-end"> 143 <ButtonPrimary 144 type="submit" 145 disabled={
+110 -99
app/lish/createPub/UpdatePubForm.tsx
··· 20 import Link from "next/link"; 21 import { Checkbox } from "components/Checkbox"; 22 import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 23 24 - export const EditPubForm = () => { 25 let { data } = usePublicationData(); 26 let { publication: pubData } = data || {}; 27 let record = pubData?.record as PubLeafletPublication.Record; ··· 57 58 return ( 59 <form 60 - className="flex flex-col gap-3 w-[1000px] max-w-full py-1" 61 onSubmit={async (e) => { 62 if (!pubData) return; 63 e.preventDefault(); 64 - setFormState("loading"); 65 let data = await updatePublication({ 66 uri: pubData.uri, 67 name: nameValue, ··· 73 }, 74 }); 75 toast({ type: "success", content: "Updated!" }); 76 - setFormState("normal"); 77 mutate("publication-data"); 78 }} 79 > 80 - <div className="flex items-center justify-between gap-2 "> 81 - <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 82 - Logo <span className="font-normal">(optional)</span> 83 - </p> 84 - <div 85 - className={`w-8 h-8 rounded-full flex items-center justify-center cursor-pointer ${iconPreview ? "border border-border-light hover:outline-border" : "border border-dotted border-accent-contrast hover:outline-accent-contrast"} selected-outline`} 86 - onClick={() => fileInputRef.current?.click()} 87 - > 88 - {iconPreview ? ( 89 - <img 90 - src={iconPreview} 91 - alt="Logo preview" 92 - className="w-full h-full rounded-full object-cover" 93 - /> 94 - ) : ( 95 - <AddTiny className="text-accent-1" /> 96 - )} 97 </div> 98 - <input 99 - type="file" 100 - accept="image/*" 101 - className="hidden" 102 - ref={fileInputRef} 103 - onChange={(e) => { 104 - const file = e.target.files?.[0]; 105 - if (file) { 106 - setIconFile(file); 107 - const reader = new FileReader(); 108 - reader.onload = (e) => { 109 - setIconPreview(e.target?.result as string); 110 - }; 111 - reader.readAsDataURL(file); 112 - } 113 - }} 114 - /> 115 - </div> 116 117 - <label> 118 - <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 119 - Publication Name 120 - </p> 121 - <Input 122 - className="input-with-border w-full text-primary" 123 - type="text" 124 - id="pubName" 125 - value={nameValue} 126 - onChange={(e) => { 127 - setNameValue(e.currentTarget.value); 128 - }} 129 - /> 130 - </label> 131 - <label> 132 - <p className="text-tertiary italic text-sm font-bold pl-0.5 pb-0.5"> 133 - Description <span className="font-normal">(optional)</span> 134 - </p> 135 - <Input 136 - textarea 137 - className="input-with-border w-full text-primary" 138 - rows={3} 139 - id="pubDescription" 140 - value={descriptionValue} 141 - onChange={(e) => { 142 - setDescriptionValue(e.currentTarget.value); 143 - }} 144 - /> 145 - </label> 146 - 147 - <CustomDomainForm /> 148 - <hr className="border-border-light" /> 149 - 150 - <Checkbox 151 - checked={showInDiscover} 152 - onChange={(e) => setShowInDiscover(e.target.checked)} 153 - > 154 - <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 155 - <p className="font-bold"> 156 - Show In{" "} 157 - <a href="/discover" target="_blank"> 158 - Discover 159 - </a> 160 </p> 161 - <p className="text-xs text-tertiary font-normal"> 162 - This publication will appear on our public Discover page 163 </p> 164 - </div> 165 - </Checkbox> 166 167 - <Checkbox 168 - checked={showComments} 169 - onChange={(e) => setShowComments(e.target.checked)} 170 - > 171 - <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 172 - <p className="font-bold">Show comments on posts</p> 173 - </div> 174 - </Checkbox> 175 - <hr className="border-border-light" /> 176 177 - <ButtonPrimary className="place-self-end" type="submit"> 178 - {formState === "loading" ? <DotLoader /> : "Update!"} 179 - </ButtonPrimary> 180 </form> 181 ); 182 }; ··· 456 <div style={{ wordBreak: "break-word" }}> 457 { 458 config?.recommendedIPv4.sort((a, b) => a.rank - b.rank)[0] 459 - .value 460 } 461 </div> 462 </td>
··· 20 import Link from "next/link"; 21 import { Checkbox } from "components/Checkbox"; 22 import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 23 + import { PubSettingsHeader } from "../[did]/[publication]/dashboard/PublicationSettings"; 24 25 + export const EditPubForm = (props: { 26 + backToMenuAction: () => void; 27 + loading: boolean; 28 + setLoadingAction: (l: boolean) => void; 29 + }) => { 30 let { data } = usePublicationData(); 31 let { publication: pubData } = data || {}; 32 let record = pubData?.record as PubLeafletPublication.Record; ··· 62 63 return ( 64 <form 65 onSubmit={async (e) => { 66 if (!pubData) return; 67 e.preventDefault(); 68 + props.setLoadingAction(true); 69 let data = await updatePublication({ 70 uri: pubData.uri, 71 name: nameValue, ··· 77 }, 78 }); 79 toast({ type: "success", content: "Updated!" }); 80 + props.setLoadingAction(false); 81 mutate("publication-data"); 82 }} 83 > 84 + <PubSettingsHeader 85 + loading={props.loading} 86 + setLoadingAction={props.setLoadingAction} 87 + backToMenuAction={props.backToMenuAction} 88 + state={"theme"} 89 + /> 90 + <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2"> 91 + <div className="flex items-center justify-between gap-2 "> 92 + <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 93 + Logo <span className="font-normal">(optional)</span> 94 + </p> 95 + <div 96 + className={`w-8 h-8 rounded-full flex items-center justify-center cursor-pointer ${iconPreview ? "border border-border-light hover:outline-border" : "border border-dotted border-accent-contrast hover:outline-accent-contrast"} selected-outline`} 97 + onClick={() => fileInputRef.current?.click()} 98 + > 99 + {iconPreview ? ( 100 + <img 101 + src={iconPreview} 102 + alt="Logo preview" 103 + className="w-full h-full rounded-full object-cover" 104 + /> 105 + ) : ( 106 + <AddTiny className="text-accent-1" /> 107 + )} 108 + </div> 109 + <input 110 + type="file" 111 + accept="image/*" 112 + className="hidden" 113 + ref={fileInputRef} 114 + onChange={(e) => { 115 + const file = e.target.files?.[0]; 116 + if (file) { 117 + setIconFile(file); 118 + const reader = new FileReader(); 119 + reader.onload = (e) => { 120 + setIconPreview(e.target?.result as string); 121 + }; 122 + reader.readAsDataURL(file); 123 + } 124 + }} 125 + /> 126 </div> 127 128 + <label> 129 + <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 130 + Publication Name 131 </p> 132 + <Input 133 + className="input-with-border w-full text-primary" 134 + type="text" 135 + id="pubName" 136 + value={nameValue} 137 + onChange={(e) => { 138 + setNameValue(e.currentTarget.value); 139 + }} 140 + /> 141 + </label> 142 + <label> 143 + <p className="text-tertiary italic text-sm font-bold pl-0.5 pb-0.5"> 144 + Description <span className="font-normal">(optional)</span> 145 </p> 146 + <Input 147 + textarea 148 + className="input-with-border w-full text-primary" 149 + rows={3} 150 + id="pubDescription" 151 + value={descriptionValue} 152 + onChange={(e) => { 153 + setDescriptionValue(e.currentTarget.value); 154 + }} 155 + /> 156 + </label> 157 + 158 + <CustomDomainForm /> 159 + <hr className="border-border-light" /> 160 161 + <Checkbox 162 + checked={showInDiscover} 163 + onChange={(e) => setShowInDiscover(e.target.checked)} 164 + > 165 + <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 166 + <p className="font-bold"> 167 + Show In{" "} 168 + <a href="/discover" target="_blank"> 169 + Discover 170 + </a> 171 + </p> 172 + <p className="text-xs text-tertiary font-normal"> 173 + Your posts will appear on our{" "} 174 + <a href="/discover" target="_blank"> 175 + Discover 176 + </a>{" "} 177 + page. You can change this at any time! 178 + </p> 179 + </div> 180 + </Checkbox> 181 182 + <Checkbox 183 + checked={showComments} 184 + onChange={(e) => setShowComments(e.target.checked)} 185 + > 186 + <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 187 + <p className="font-bold">Show comments on posts</p> 188 + </div> 189 + </Checkbox> 190 + </div> 191 </form> 192 ); 193 }; ··· 467 <div style={{ wordBreak: "break-word" }}> 468 { 469 config?.recommendedIPv4.sort((a, b) => a.rank - b.rank)[0] 470 + .value[0] 471 } 472 </div> 473 </td>
+1
app/lish/createPub/createPublication.ts
··· 101 await supabaseServerClient 102 .from("custom_domains") 103 .insert({ domain, confirmed: true, identity: null }); 104 await supabaseServerClient 105 .from("publication_domains") 106 .insert({ domain, publication: result.uri, identity: identity.atp_did });
··· 101 await supabaseServerClient 102 .from("custom_domains") 103 .insert({ domain, confirmed: true, identity: null }); 104 + 105 await supabaseServerClient 106 .from("publication_domains") 107 .insert({ domain, publication: result.uri, identity: identity.atp_did });
+3 -11
app/lish/createPub/getPublicationURL.ts
··· 3 import { isProductionDomain } from "src/utils/isProductionDeployment"; 4 import { Json } from "supabase/database.types"; 5 6 - export function getPublicationURL(pub: { 7 - uri: string; 8 - name: string; 9 - record: Json; 10 - }) { 11 let record = pub.record as PubLeafletPublication.Record; 12 if (isProductionDomain() && record?.base_path) 13 return `https://${record.base_path}`; 14 else return getBasePublicationURL(pub); 15 } 16 17 - export function getBasePublicationURL(pub: { 18 - uri: string; 19 - name: string; 20 - record: Json; 21 - }) { 22 let record = pub.record as PubLeafletPublication.Record; 23 let aturi = new AtUri(pub.uri); 24 - return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name || pub.name)}`; 25 }
··· 3 import { isProductionDomain } from "src/utils/isProductionDeployment"; 4 import { Json } from "supabase/database.types"; 5 6 + export function getPublicationURL(pub: { uri: string; record: Json }) { 7 let record = pub.record as PubLeafletPublication.Record; 8 if (isProductionDomain() && record?.base_path) 9 return `https://${record.base_path}`; 10 else return getBasePublicationURL(pub); 11 } 12 13 + export function getBasePublicationURL(pub: { uri: string; record: Json }) { 14 let record = pub.record as PubLeafletPublication.Record; 15 let aturi = new AtUri(pub.uri); 16 + return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`; 17 }
+1 -1
app/lish/createPub/page.tsx
··· 26 <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto"> 27 <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 28 <h2 className="text-center">Create Your Publication!</h2> 29 - <div className="container w-full p-3"> 30 <CreatePubForm /> 31 </div> 32 </div>
··· 26 <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto"> 27 <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 28 <h2 className="text-center">Create Your Publication!</h2> 29 + <div className="opaque-container w-full sm:py-4 p-3"> 30 <CreatePubForm /> 31 </div> 32 </div>
-7
app/lish/createPub/updatePublication.ts
··· 10 import { supabaseServerClient } from "supabase/serverClient"; 11 import { Json } from "supabase/database.types"; 12 import { AtUri } from "@atproto/syntax"; 13 - import { redirect } from "next/navigation"; 14 import { $Typed } from "@atproto/api"; 15 - import { ids } from "lexicons/api/lexicons"; 16 17 export async function updatePublication({ 18 uri, ··· 87 .eq("uri", uri) 88 .select() 89 .single(); 90 - if (name !== existingPub.name) 91 - return redirect( 92 - `/lish/${aturi.host}/${encodeURIComponent(name)}/dashboard`, 93 - ); 94 - 95 return { success: true, publication }; 96 } 97
··· 10 import { supabaseServerClient } from "supabase/serverClient"; 11 import { Json } from "supabase/database.types"; 12 import { AtUri } from "@atproto/syntax"; 13 import { $Typed } from "@atproto/api"; 14 15 export async function updatePublication({ 16 uri, ··· 85 .eq("uri", uri) 86 .select() 87 .single(); 88 return { success: true, publication }; 89 } 90
+21
app/lish/subscribeToPublication.ts
··· 12 import { encodeActionToSearchParam } from "app/api/oauth/[route]/afterSignInActions"; 13 import { Json } from "supabase/database.types"; 14 import { IdResolver } from "@atproto/identity"; 15 16 let leafletFeedURI = 17 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; ··· 46 publication, 47 identity: credentialSession.did!, 48 }); 49 let bsky = new BskyAgent(credentialSession); 50 let [prefs, profile, resolveDid] = await Promise.all([ 51 bsky.app.bsky.actor.getPreferences(),
··· 12 import { encodeActionToSearchParam } from "app/api/oauth/[route]/afterSignInActions"; 13 import { Json } from "supabase/database.types"; 14 import { IdResolver } from "@atproto/identity"; 15 + import { 16 + Notification, 17 + pingIdentityToUpdateNotification, 18 + } from "src/notifications"; 19 + import { v7 } from "uuid"; 20 21 let leafletFeedURI = 22 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; ··· 51 publication, 52 identity: credentialSession.did!, 53 }); 54 + 55 + // Create notification for the publication owner 56 + let publicationOwner = new AtUri(publication).host; 57 + if (publicationOwner !== credentialSession.did) { 58 + let notification: Notification = { 59 + id: v7(), 60 + recipient: publicationOwner, 61 + data: { 62 + type: "subscribe", 63 + subscription_uri: record.uri, 64 + }, 65 + }; 66 + await supabaseServerClient.from("notifications").insert(notification); 67 + await pingIdentityToUpdateNotification(publicationOwner); 68 + } 69 + 70 let bsky = new BskyAgent(credentialSession); 71 let [prefs, profile, resolveDid] = await Promise.all([ 72 bsky.app.bsky.actor.getPreferences(),
+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 + }
+15 -12
app/login/LoginForm.tsx
··· 5 } from "actions/emailAuth"; 6 import { loginWithEmailToken } from "actions/login"; 7 import { ActionAfterSignIn } from "app/api/oauth/[route]/afterSignInActions"; 8 - import { getHomeDocs } from "app/home/storage"; 9 import { ButtonPrimary } from "components/Buttons"; 10 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 11 import { BlueskySmall } from "components/Icons/BlueskySmall"; ··· 13 import { useSmoker, useToaster } from "components/Toast"; 14 import React, { useState } from "react"; 15 import { mutate } from "swr"; 16 17 export default function LoginForm(props: { 18 noEmail?: boolean; ··· 167 export function BlueskyLogin(props: { 168 redirectRoute?: string; 169 action?: ActionAfterSignIn; 170 }) { 171 const [signingWithHandle, setSigningWithHandle] = useState(false); 172 const [handle, setHandle] = useState(""); ··· 186 /> 187 )} 188 {signingWithHandle ? ( 189 - <div className="w-full flex flex-col gap-2"> 190 <Input 191 type="text" 192 name="handle" ··· 197 onChange={(e) => setHandle(e.target.value)} 198 required 199 /> 200 - <ButtonPrimary type="submit" fullWidth className="py-2"> 201 - <BlueskySmall /> 202 - Sign In 203 - </ButtonPrimary> 204 </div> 205 ) : ( 206 - <div className="flex flex-col"> 207 - <ButtonPrimary fullWidth className="py-2"> 208 - <BlueskySmall /> 209 - Log In/Sign Up with Bluesky 210 </ButtonPrimary> 211 <button 212 type="button" 213 - className="text-sm text-accent-contrast place-self-center mt-[6px]" 214 onClick={() => setSigningWithHandle(true)} 215 > 216 - or use an ATProto handle 217 </button> 218 </div> 219 )}
··· 5 } from "actions/emailAuth"; 6 import { loginWithEmailToken } from "actions/login"; 7 import { ActionAfterSignIn } from "app/api/oauth/[route]/afterSignInActions"; 8 + import { getHomeDocs } from "app/(home-pages)/home/storage"; 9 import { ButtonPrimary } from "components/Buttons"; 10 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 11 import { BlueskySmall } from "components/Icons/BlueskySmall"; ··· 13 import { useSmoker, useToaster } from "components/Toast"; 14 import React, { useState } from "react"; 15 import { mutate } from "swr"; 16 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 17 18 export default function LoginForm(props: { 19 noEmail?: boolean; ··· 168 export function BlueskyLogin(props: { 169 redirectRoute?: string; 170 action?: ActionAfterSignIn; 171 + compact?: boolean; 172 }) { 173 const [signingWithHandle, setSigningWithHandle] = useState(false); 174 const [handle, setHandle] = useState(""); ··· 188 /> 189 )} 190 {signingWithHandle ? ( 191 + <div className="w-full flex gap-1"> 192 <Input 193 type="text" 194 name="handle" ··· 199 onChange={(e) => setHandle(e.target.value)} 200 required 201 /> 202 + <ButtonPrimary type="submit">Sign In</ButtonPrimary> 203 </div> 204 ) : ( 205 + <div className="flex flex-col justify-center"> 206 + <ButtonPrimary 207 + fullWidth={!props.compact} 208 + compact={props.compact} 209 + className={`${props.compact ? "mx-auto text-sm" : "py-2"}`} 210 + > 211 + {props.compact ? <BlueskyTiny /> : <BlueskySmall />} 212 + {props.compact ? "Link" : "Log In/Sign Up with"} Bluesky 213 </ButtonPrimary> 214 <button 215 type="button" 216 + className={`${props.compact ? "text-xs mt-0.5" : "text-sm mt-[6px]"} text-accent-contrast place-self-center`} 217 onClick={() => setSigningWithHandle(true)} 218 > 219 + use an ATProto handle 220 </button> 221 </div> 222 )}
+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 + }
-279
app/reader/ReaderContent.tsx
··· 1 - "use client"; 2 - import { AtUri } from "@atproto/api"; 3 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 - import { PubIcon } from "components/ActionBar/Publications"; 5 - import { ButtonPrimary } from "components/Buttons"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 - import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 8 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 9 - import { Separator } from "components/Layout"; 10 - import { SpeedyLink } from "components/SpeedyLink"; 11 - import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 12 - import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 - import { useSmoker } from "components/Toast"; 14 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 15 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 16 - import { Json } from "supabase/database.types"; 17 - import type { Cursor, Post } from "./getReaderFeed"; 18 - import useSWRInfinite from "swr/infinite"; 19 - import { getReaderFeed } from "./getReaderFeed"; 20 - import { useEffect, useRef } from "react"; 21 - import { useRouter } from "next/navigation"; 22 - 23 - export const ReaderContent = (props: { 24 - root_entity: string; 25 - posts: Post[]; 26 - nextCursor: Cursor | null; 27 - }) => { 28 - const getKey = ( 29 - pageIndex: number, 30 - previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 31 - ) => { 32 - // Reached the end 33 - if (previousPageData && !previousPageData.nextCursor) return null; 34 - 35 - // First page, we don't have previousPageData 36 - if (pageIndex === 0) return ["reader-feed", null] as const; 37 - 38 - // Add the cursor to the key 39 - return ["reader-feed", previousPageData?.nextCursor] as const; 40 - }; 41 - 42 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 43 - getKey, 44 - ([_, cursor]) => getReaderFeed(cursor), 45 - { 46 - fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 47 - revalidateFirstPage: false, 48 - }, 49 - ); 50 - 51 - const loadMoreRef = useRef<HTMLDivElement>(null); 52 - 53 - // Set up intersection observer to load more when trigger element is visible 54 - useEffect(() => { 55 - const observer = new IntersectionObserver( 56 - (entries) => { 57 - if (entries[0].isIntersecting && !isValidating) { 58 - const hasMore = data && data[data.length - 1]?.nextCursor; 59 - if (hasMore) { 60 - setSize(size + 1); 61 - } 62 - } 63 - }, 64 - { threshold: 0.1 }, 65 - ); 66 - 67 - if (loadMoreRef.current) { 68 - observer.observe(loadMoreRef.current); 69 - } 70 - 71 - return () => observer.disconnect(); 72 - }, [data, size, setSize, isValidating]); 73 - 74 - const allPosts = data ? data.flatMap((page) => page.posts) : []; 75 - 76 - if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />; 77 - 78 - return ( 79 - <div className="flex flex-col gap-3 relative"> 80 - {allPosts.map((p) => ( 81 - <Post {...p} key={p.documents.uri} /> 82 - ))} 83 - {/* Trigger element for loading more posts */} 84 - <div 85 - ref={loadMoreRef} 86 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 87 - aria-hidden="true" 88 - /> 89 - {isValidating && ( 90 - <div className="text-center text-tertiary py-4"> 91 - Loading more posts... 92 - </div> 93 - )} 94 - </div> 95 - ); 96 - }; 97 - 98 - const Post = (props: Post) => { 99 - let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 100 - 101 - let postRecord = props.documents.data as PubLeafletDocument.Record; 102 - let postUri = new AtUri(props.documents.uri); 103 - 104 - let theme = usePubTheme(pubRecord); 105 - let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 106 - ? blobRefToSrc( 107 - pubRecord?.theme?.backgroundImage?.image?.ref, 108 - new AtUri(props.publication.uri).host, 109 - ) 110 - : null; 111 - 112 - let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 113 - let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 114 - 115 - let showPageBackground = pubRecord.theme?.showPageBackground; 116 - 117 - let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 118 - let comments = 119 - pubRecord.preferences?.showComments === false 120 - ? 0 121 - : props.documents.comments_on_documents?.[0]?.count || 0; 122 - 123 - return ( 124 - <BaseThemeProvider {...theme} local> 125 - <div 126 - style={{ 127 - backgroundImage: `url(${backgroundImage})`, 128 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 129 - backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 130 - }} 131 - className={`no-underline! flex flex-row gap-2 w-full relative 132 - bg-bg-leaflet 133 - border border-border-light rounded-lg 134 - sm:p-2 p-2 selected-outline 135 - hover:outline-accent-contrast hover:border-accent-contrast 136 - `} 137 - > 138 - <a 139 - className="h-full w-full absolute top-0 left-0" 140 - href={`${props.publication.href}/${postUri.rkey}`} 141 - /> 142 - <div 143 - className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 144 - style={{ 145 - backgroundColor: showPageBackground 146 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 147 - : "transparent", 148 - }} 149 - > 150 - <h3 className="text-primary truncate">{postRecord.title}</h3> 151 - 152 - <p className="text-secondary">{postRecord.description}</p> 153 - <div className="flex justify-between items-end"> 154 - <div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-center justify-start pt-1 md:pt-3"> 155 - <PubInfo 156 - href={props.publication.href} 157 - pubRecord={pubRecord} 158 - uri={props.publication.uri} 159 - /> 160 - <Separator classname="h-4 !min-h-0 md:block hidden" /> 161 - <PostInfo 162 - author={props.author || ""} 163 - publishedAt={postRecord.publishedAt} 164 - /> 165 - </div> 166 - 167 - <PostInterations 168 - postUrl={`${props.publication.href}/${postUri.rkey}`} 169 - quotesCount={quotes} 170 - commentsCount={comments} 171 - showComments={pubRecord.preferences?.showComments} 172 - /> 173 - </div> 174 - </div> 175 - </div> 176 - </BaseThemeProvider> 177 - ); 178 - }; 179 - 180 - const PubInfo = (props: { 181 - href: string; 182 - pubRecord: PubLeafletPublication.Record; 183 - uri: string; 184 - }) => { 185 - return ( 186 - <a 187 - href={props.href} 188 - className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0" 189 - > 190 - <PubIcon small record={props.pubRecord} uri={props.uri} /> 191 - {props.pubRecord.name} 192 - </a> 193 - ); 194 - }; 195 - 196 - const PostInfo = (props: { 197 - author: string; 198 - publishedAt: string | undefined; 199 - }) => { 200 - return ( 201 - <div className="flex gap-2 grow items-center shrink-0"> 202 - {props.author} 203 - {props.publishedAt && ( 204 - <> 205 - <Separator classname="h-4 !min-h-0" /> 206 - {new Date(props.publishedAt).toLocaleDateString("en-US", { 207 - year: "numeric", 208 - month: "short", 209 - day: "numeric", 210 - })}{" "} 211 - </> 212 - )} 213 - </div> 214 - ); 215 - }; 216 - 217 - const PostInterations = (props: { 218 - quotesCount: number; 219 - commentsCount: number; 220 - postUrl: string; 221 - showComments: boolean | undefined; 222 - }) => { 223 - let smoker = useSmoker(); 224 - let interactionsAvailable = 225 - props.quotesCount > 0 || 226 - (props.showComments !== false && props.commentsCount > 0); 227 - 228 - return ( 229 - <div className={`flex gap-2 text-tertiary text-sm items-center`}> 230 - {props.quotesCount === 0 ? null : ( 231 - <div className={`flex gap-1 items-center `}> 232 - <span className="sr-only">Post quotes</span> 233 - <QuoteTiny aria-hidden /> {props.quotesCount} 234 - </div> 235 - )} 236 - {props.showComments === false || props.commentsCount === 0 ? null : ( 237 - <div className={`flex gap-1 items-center`}> 238 - <span className="sr-only">Post comments</span> 239 - <CommentTiny aria-hidden /> {props.commentsCount} 240 - </div> 241 - )} 242 - {interactionsAvailable && <Separator classname="h-4 !min-h-0" />} 243 - <button 244 - id={`copy-post-link-${props.postUrl}`} 245 - className="flex gap-1 items-center hover:font-bold relative" 246 - onClick={(e) => { 247 - e.stopPropagation(); 248 - e.preventDefault(); 249 - let mouseX = e.clientX; 250 - let mouseY = e.clientY; 251 - 252 - if (!props.postUrl) return; 253 - navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 254 - 255 - smoker({ 256 - text: <strong>Copied Link!</strong>, 257 - position: { 258 - y: mouseY, 259 - x: mouseX, 260 - }, 261 - }); 262 - }} 263 - > 264 - Share 265 - </button> 266 - </div> 267 - ); 268 - }; 269 - const ReaderEmpty = () => { 270 - return ( 271 - <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center font-bold text-tertiary"> 272 - Nothing to read yetโ€ฆ <br /> 273 - Subscribe to publications and find their posts here! 274 - <ButtonPrimary className="mx-auto place-self-center"> 275 - <DiscoverSmall /> Discover Publications 276 - </ButtonPrimary> 277 - </div> 278 - ); 279 - };
···
-102
app/reader/SubscriptionsContent.tsx
··· 1 - "use client"; 2 - import { PubListing } from "app/discover/PubListing"; 3 - import { ButtonPrimary } from "components/Buttons"; 4 - import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 5 - import { Json } from "supabase/database.types"; 6 - import { PublicationSubscription, getSubscriptions } from "./getSubscriptions"; 7 - import useSWRInfinite from "swr/infinite"; 8 - import { useEffect, useRef } from "react"; 9 - import { Cursor } from "./getReaderFeed"; 10 - 11 - export const SubscriptionsContent = (props: { 12 - publications: PublicationSubscription[]; 13 - nextCursor: Cursor | null; 14 - }) => { 15 - const getKey = ( 16 - pageIndex: number, 17 - previousPageData: { 18 - subscriptions: PublicationSubscription[]; 19 - nextCursor: Cursor | null; 20 - } | null, 21 - ) => { 22 - // Reached the end 23 - if (previousPageData && !previousPageData.nextCursor) return null; 24 - 25 - // First page, we don't have previousPageData 26 - if (pageIndex === 0) return ["subscriptions", null] as const; 27 - 28 - // Add the cursor to the key 29 - return ["subscriptions", previousPageData?.nextCursor] as const; 30 - }; 31 - 32 - const { data, error, size, setSize, isValidating } = useSWRInfinite( 33 - getKey, 34 - ([_, cursor]) => getSubscriptions(cursor), 35 - { 36 - fallbackData: [ 37 - { subscriptions: props.publications, nextCursor: props.nextCursor }, 38 - ], 39 - revalidateFirstPage: false, 40 - }, 41 - ); 42 - 43 - const loadMoreRef = useRef<HTMLDivElement>(null); 44 - 45 - // Set up intersection observer to load more when trigger element is visible 46 - useEffect(() => { 47 - const observer = new IntersectionObserver( 48 - (entries) => { 49 - if (entries[0].isIntersecting && !isValidating) { 50 - const hasMore = data && data[data.length - 1]?.nextCursor; 51 - if (hasMore) { 52 - setSize(size + 1); 53 - } 54 - } 55 - }, 56 - { threshold: 0.1 }, 57 - ); 58 - 59 - if (loadMoreRef.current) { 60 - observer.observe(loadMoreRef.current); 61 - } 62 - 63 - return () => observer.disconnect(); 64 - }, [data, size, setSize, isValidating]); 65 - 66 - const allPublications = data 67 - ? data.flatMap((page) => page.subscriptions) 68 - : []; 69 - 70 - if (allPublications.length === 0 && !isValidating) 71 - return <SubscriptionsEmpty />; 72 - 73 - return ( 74 - <div className="relative"> 75 - <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 76 - {allPublications?.map((p, index) => <PubListing key={p.uri} {...p} />)} 77 - </div> 78 - {/* Trigger element for loading more subscriptions */} 79 - <div 80 - ref={loadMoreRef} 81 - className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 82 - aria-hidden="true" 83 - /> 84 - {isValidating && ( 85 - <div className="text-center text-tertiary py-4"> 86 - Loading more subscriptions... 87 - </div> 88 - )} 89 - </div> 90 - ); 91 - }; 92 - 93 - const SubscriptionsEmpty = () => { 94 - return ( 95 - <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center font-bold text-tertiary"> 96 - You haven't subscribed to any publications yet! 97 - <ButtonPrimary className="mx-auto place-self-center"> 98 - <DiscoverSmall /> Discover Publications 99 - </ButtonPrimary> 100 - </div> 101 - ); 102 - };
···
-106
app/reader/getReaderFeed.ts
··· 1 - "use server"; 2 - 3 - import { getIdentityData } from "actions/getIdentityData"; 4 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 - import { supabaseServerClient } from "supabase/serverClient"; 6 - import { IdResolver } from "@atproto/identity"; 7 - import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 8 - import Client from "ioredis"; 9 - import { AtUri } from "@atproto/api"; 10 - import { Json } from "supabase/database.types"; 11 - import { idResolver } from "./idResolver"; 12 - 13 - export type Cursor = { 14 - timestamp: string; 15 - uri: string; 16 - }; 17 - 18 - export async function getReaderFeed( 19 - cursor?: Cursor | null, 20 - ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 21 - let auth_res = await getIdentityData(); 22 - if (!auth_res?.atp_did) return { posts: [], nextCursor: null }; 23 - let query = supabaseServerClient 24 - .from("documents") 25 - .select( 26 - `*, 27 - comments_on_documents(count), 28 - document_mentions_in_bsky(count), 29 - documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 30 - ) 31 - .eq( 32 - "documents_in_publications.publications.publication_subscriptions.identity", 33 - auth_res.atp_did, 34 - ) 35 - .order("indexed_at", { ascending: false }) 36 - .order("uri", { ascending: false }) 37 - .limit(25); 38 - if (cursor) { 39 - query = query.or( 40 - `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 41 - ); 42 - } 43 - let { data: feed, error } = await query; 44 - 45 - let posts = await Promise.all( 46 - feed?.map(async (post) => { 47 - let pub = post.documents_in_publications[0].publications!; 48 - let uri = new AtUri(post.uri); 49 - let handle = await idResolver.did.resolve(uri.host); 50 - let p: Post = { 51 - publication: { 52 - href: getPublicationURL(pub), 53 - pubRecord: pub?.record || null, 54 - uri: pub?.uri || "", 55 - }, 56 - author: handle?.alsoKnownAs?.[0] 57 - ? `@${handle.alsoKnownAs[0].slice(5)}` 58 - : null, 59 - documents: { 60 - comments_on_documents: post.comments_on_documents, 61 - document_mentions_in_bsky: post.document_mentions_in_bsky, 62 - data: post.data, 63 - uri: post.uri, 64 - indexed_at: post.indexed_at, 65 - }, 66 - }; 67 - return p; 68 - }) || [], 69 - ); 70 - const nextCursor = 71 - posts.length > 0 72 - ? { 73 - timestamp: posts[posts.length - 1].documents.indexed_at, 74 - uri: posts[posts.length - 1].documents.uri, 75 - } 76 - : null; 77 - 78 - return { 79 - posts, 80 - nextCursor, 81 - }; 82 - } 83 - 84 - export type Post = { 85 - author: string | null; 86 - publication: { 87 - href: string; 88 - pubRecord: Json; 89 - uri: string; 90 - }; 91 - documents: { 92 - data: Json; 93 - uri: string; 94 - indexed_at: string; 95 - comments_on_documents: 96 - | { 97 - count: number; 98 - }[] 99 - | undefined; 100 - document_mentions_in_bsky: 101 - | { 102 - count: number; 103 - }[] 104 - | undefined; 105 - }; 106 - };
···
-70
app/reader/getSubscriptions.ts
··· 1 - "use server"; 2 - 3 - import { AtpAgent } from "@atproto/api"; 4 - import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 5 - import { getIdentityData } from "actions/getIdentityData"; 6 - import { Json } from "supabase/database.types"; 7 - import { supabaseServerClient } from "supabase/serverClient"; 8 - import { idResolver } from "./idResolver"; 9 - import { Cursor } from "./getReaderFeed"; 10 - 11 - export async function getSubscriptions(cursor?: Cursor | null): Promise<{ 12 - nextCursor: null | Cursor; 13 - subscriptions: PublicationSubscription[]; 14 - }> { 15 - let auth_res = await getIdentityData(); 16 - if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 17 - let query = supabaseServerClient 18 - .from("publication_subscriptions") 19 - .select(`*, publications(*, documents_in_publications(*, documents(*)))`) 20 - .order(`created_at`, { ascending: false }) 21 - .order(`uri`, { ascending: false }) 22 - .order("indexed_at", { 23 - ascending: false, 24 - referencedTable: "publications.documents_in_publications", 25 - }) 26 - .limit(1, { referencedTable: "publications.documents_in_publications" }) 27 - .limit(25) 28 - .eq("identity", auth_res.atp_did); 29 - 30 - if (cursor) { 31 - query = query.or( 32 - `created_at.lt.${cursor.timestamp},and(created_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 33 - ); 34 - } 35 - let { data: pubs, error } = await query; 36 - 37 - const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( 38 - pubs?.map(async (pub) => { 39 - let id = await idResolver.did.resolve(pub.publications?.identity_did!); 40 - return { 41 - ...pub.publications!, 42 - authorProfile: id?.alsoKnownAs?.[0] 43 - ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 44 - : undefined, 45 - }; 46 - }) || [], 47 - ); 48 - 49 - const nextCursor = 50 - pubs && pubs.length > 0 51 - ? { 52 - timestamp: pubs[pubs.length - 1].created_at, 53 - uri: pubs[pubs.length - 1].uri, 54 - } 55 - : null; 56 - 57 - return { 58 - subscriptions: hydratedSubscriptions, 59 - nextCursor, 60 - }; 61 - } 62 - 63 - export type PublicationSubscription = { 64 - authorProfile?: { handle: string }; 65 - record: Json; 66 - uri: string; 67 - documents_in_publications: { 68 - documents: { data?: Json; indexed_at: string } | null; 69 - }[]; 70 - };
···
-78
app/reader/idResolver.ts
··· 1 - import { IdResolver } from "@atproto/identity"; 2 - import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 3 - import Client from "ioredis"; 4 - // Create Redis client for DID caching 5 - let redisClient: Client | null = null; 6 - if (process.env.REDIS_URL) { 7 - redisClient = new Client(process.env.REDIS_URL); 8 - } 9 - 10 - // Redis-based DID cache implementation 11 - class RedisDidCache implements DidCache { 12 - private staleTTL: number; 13 - private maxTTL: number; 14 - 15 - constructor( 16 - private client: Client, 17 - staleTTL = 60 * 60, // 1 hour 18 - maxTTL = 60 * 60 * 24, // 24 hours 19 - ) { 20 - this.staleTTL = staleTTL; 21 - this.maxTTL = maxTTL; 22 - } 23 - 24 - async cacheDid(did: string, doc: DidDocument): Promise<void> { 25 - const cacheVal = { 26 - doc, 27 - updatedAt: Date.now(), 28 - }; 29 - await this.client.setex( 30 - `did:${did}`, 31 - this.maxTTL, 32 - JSON.stringify(cacheVal), 33 - ); 34 - } 35 - 36 - async checkCache(did: string): Promise<CacheResult | null> { 37 - const cached = await this.client.get(`did:${did}`); 38 - if (!cached) return null; 39 - 40 - const { doc, updatedAt } = JSON.parse(cached); 41 - const now = Date.now(); 42 - const age = now - updatedAt; 43 - 44 - return { 45 - did, 46 - doc, 47 - updatedAt, 48 - stale: age > this.staleTTL * 1000, 49 - expired: age > this.maxTTL * 1000, 50 - }; 51 - } 52 - 53 - async refreshCache( 54 - did: string, 55 - getDoc: () => Promise<DidDocument | null>, 56 - ): Promise<void> { 57 - const doc = await getDoc(); 58 - if (doc) { 59 - await this.cacheDid(did, doc); 60 - } 61 - } 62 - 63 - async clearEntry(did: string): Promise<void> { 64 - await this.client.del(`did:${did}`); 65 - } 66 - 67 - async clear(): Promise<void> { 68 - const keys = await this.client.keys("did:*"); 69 - if (keys.length > 0) { 70 - await this.client.del(...keys); 71 - } 72 - } 73 - } 74 - 75 - // Create IdResolver with Redis-based DID cache 76 - export const idResolver = new IdResolver({ 77 - didCache: redisClient ? new RedisDidCache(redisClient) : undefined, 78 - });
···
-91
app/reader/page.tsx
··· 1 - import { cookies } from "next/headers"; 2 - import { Fact, ReplicacheProvider } from "src/replicache"; 3 - import type { Attribute } from "src/replicache/attributes"; 4 - import { 5 - ThemeBackgroundProvider, 6 - ThemeProvider, 7 - } from "components/ThemeManager/ThemeProvider"; 8 - import { EntitySetProvider } from "components/EntitySetProvider"; 9 - import { getIdentityData } from "actions/getIdentityData"; 10 - import { supabaseServerClient } from "supabase/serverClient"; 11 - 12 - import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 13 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 14 - import { ReaderContent } from "./ReaderContent"; 15 - import { SubscriptionsContent } from "./SubscriptionsContent"; 16 - import { getReaderFeed } from "./getReaderFeed"; 17 - import { getSubscriptions } from "./getSubscriptions"; 18 - 19 - export default async function Reader(props: {}) { 20 - let cookieStore = await cookies(); 21 - let auth_res = await getIdentityData(); 22 - let identity: string | undefined; 23 - let permission_token = auth_res?.home_leaflet; 24 - if (!permission_token) 25 - return ( 26 - <NotFoundLayout> 27 - <p className="font-bold">Sorry, we can't find this page!</p> 28 - <p> 29 - This may be a glitch on our end. If the issue persists please{" "} 30 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 31 - </p> 32 - </NotFoundLayout> 33 - ); 34 - let [homeLeafletFacts] = await Promise.all([ 35 - supabaseServerClient.rpc("get_facts", { 36 - root: permission_token.root_entity, 37 - }), 38 - ]); 39 - let initialFacts = 40 - (homeLeafletFacts.data as unknown as Fact<Attribute>[]) || []; 41 - let root_entity = permission_token.root_entity; 42 - 43 - if (!auth_res?.atp_did) return; 44 - let posts = await getReaderFeed(); 45 - let publications = await getSubscriptions(); 46 - return ( 47 - <ReplicacheProvider 48 - rootEntity={root_entity} 49 - token={permission_token} 50 - name={root_entity} 51 - initialFacts={initialFacts} 52 - > 53 - <EntitySetProvider 54 - set={permission_token.permission_token_rights[0].entity_set} 55 - > 56 - <ThemeProvider entityID={root_entity}> 57 - <ThemeBackgroundProvider entityID={root_entity}> 58 - <DashboardLayout 59 - id="reader" 60 - cardBorderHidden={false} 61 - currentPage="reader" 62 - defaultTab="Read" 63 - actions={null} 64 - tabs={{ 65 - Read: { 66 - controls: null, 67 - content: ( 68 - <ReaderContent 69 - root_entity={root_entity} 70 - nextCursor={posts.nextCursor} 71 - posts={posts.posts} 72 - /> 73 - ), 74 - }, 75 - Subscriptions: { 76 - controls: null, 77 - content: ( 78 - <SubscriptionsContent 79 - publications={publications.subscriptions} 80 - nextCursor={publications.nextCursor} 81 - /> 82 - ), 83 - }, 84 - }} 85 - /> 86 - </ThemeBackgroundProvider> 87 - </ThemeProvider> 88 - </EntitySetProvider> 89 - </ReplicacheProvider> 90 - ); 91 - }
···
-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 - }
···
+61 -12
appview/index.ts
··· 9 PubLeafletGraphSubscription, 10 PubLeafletPublication, 11 PubLeafletComment, 12 } from "lexicons/api"; 13 import { 14 AppBskyEmbedExternal, ··· 44 ids.PubLeafletPublication, 45 ids.PubLeafletGraphSubscription, 46 ids.PubLeafletComment, 47 // ids.AppBskyActorProfile, 48 "app.bsky.feed.post", 49 ], ··· 100 data: record.value as Json, 101 }); 102 if (docResult.error) console.log(docResult.error); 103 - let publicationURI = new AtUri(record.value.publication); 104 105 - if (publicationURI.host !== evt.uri.host) { 106 - console.log("Unauthorized to create post!"); 107 - return; 108 } 109 - let docInPublicationResult = await supabase 110 - .from("documents_in_publications") 111 - .upsert({ 112 - publication: record.value.publication, 113 - document: evt.uri.toString(), 114 - }); 115 - if (docInPublicationResult.error) 116 - console.log(docInPublicationResult.error); 117 } 118 if (evt.event === "delete") { 119 await supabase.from("documents").delete().eq("uri", evt.uri.toString()); ··· 165 if (evt.event === "delete") { 166 await supabase 167 .from("comments_on_documents") 168 .delete() 169 .eq("uri", evt.uri.toString()); 170 }
··· 9 PubLeafletGraphSubscription, 10 PubLeafletPublication, 11 PubLeafletComment, 12 + PubLeafletPollVote, 13 + PubLeafletPollDefinition, 14 } from "lexicons/api"; 15 import { 16 AppBskyEmbedExternal, ··· 46 ids.PubLeafletPublication, 47 ids.PubLeafletGraphSubscription, 48 ids.PubLeafletComment, 49 + ids.PubLeafletPollVote, 50 + ids.PubLeafletPollDefinition, 51 // ids.AppBskyActorProfile, 52 "app.bsky.feed.post", 53 ], ··· 104 data: record.value as Json, 105 }); 106 if (docResult.error) console.log(docResult.error); 107 + if (record.value.publication) { 108 + let publicationURI = new AtUri(record.value.publication); 109 + 110 + if (publicationURI.host !== evt.uri.host) { 111 + console.log("Unauthorized to create post!"); 112 + return; 113 + } 114 + let docInPublicationResult = await supabase 115 + .from("documents_in_publications") 116 + .upsert({ 117 + publication: record.value.publication, 118 + document: evt.uri.toString(), 119 + }); 120 + await supabase 121 + .from("documents_in_publications") 122 + .delete() 123 + .neq("publication", record.value.publication) 124 + .eq("document", evt.uri.toString()); 125 126 + if (docInPublicationResult.error) 127 + console.log(docInPublicationResult.error); 128 } 129 } 130 if (evt.event === "delete") { 131 await supabase.from("documents").delete().eq("uri", evt.uri.toString()); ··· 177 if (evt.event === "delete") { 178 await supabase 179 .from("comments_on_documents") 180 + .delete() 181 + .eq("uri", evt.uri.toString()); 182 + } 183 + } 184 + if (evt.collection === ids.PubLeafletPollVote) { 185 + if (evt.event === "create" || evt.event === "update") { 186 + let record = PubLeafletPollVote.validateRecord(evt.record); 187 + if (!record.success) return; 188 + let { error } = await supabase.from("atp_poll_votes").upsert({ 189 + uri: evt.uri.toString(), 190 + voter_did: evt.did, 191 + poll_uri: record.value.poll.uri, 192 + poll_cid: record.value.poll.cid, 193 + record: record.value as Json, 194 + }); 195 + } 196 + if (evt.event === "delete") { 197 + await supabase 198 + .from("atp_poll_votes") 199 + .delete() 200 + .eq("uri", evt.uri.toString()); 201 + } 202 + } 203 + if (evt.collection === ids.PubLeafletPollDefinition) { 204 + if (evt.event === "create" || evt.event === "update") { 205 + let record = PubLeafletPollDefinition.validateRecord(evt.record); 206 + if (!record.success) return; 207 + let { error } = await supabase.from("atp_poll_records").upsert({ 208 + uri: evt.uri.toString(), 209 + cid: evt.cid.toString(), 210 + record: record.value as Json, 211 + }); 212 + if (error) console.log("Error upserting poll definition:", error); 213 + } 214 + if (evt.event === "delete") { 215 + await supabase 216 + .from("atp_poll_records") 217 .delete() 218 .eq("uri", evt.uri.toString()); 219 }
+29 -11
components/ActionBar/ActionButton.tsx
··· 3 import { useContext, useEffect } from "react"; 4 import { SidebarContext } from "./Sidebar"; 5 import React, { forwardRef, type JSX } from "react"; 6 - import { PopoverOpenContext } from "components/Popover"; 7 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9 10 export const ActionButton = ( 11 - props: ButtonProps & { 12 id?: string; 13 icon: React.ReactNode; 14 label: React.ReactNode; ··· 17 nav?: boolean; 18 className?: string; 19 subtext?: string; 20 }, 21 ) => { 22 - let { id, icon, label, primary, secondary, nav, ...buttonProps } = props; 23 let sidebar = useContext(SidebarContext); 24 let inOpenPopover = useContext(PopoverOpenContext); 25 useEffect(() => { ··· 30 }; 31 } 32 }, [sidebar, inOpenPopover]); 33 return ( 34 <button 35 {...buttonProps} ··· 38 rounded-md border 39 flex gap-2 items-start sm:justify-start justify-center 40 p-1 sm:mx-0 41 ${ 42 primary 43 - ? "w-full bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 mx-1 first:ml-0" 44 : secondary 45 - ? "sm:w-full w-max bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1 mx-1 first:ml-0" 46 : nav 47 - ? "w-full border-transparent text-secondary sm:hover:border-border justify-start!" 48 - : "sm:w-full border-transparent text-accent-contrast sm:hover:border-accent-contrast" 49 } 50 - ${props.className} 51 `} 52 > 53 <div className="shrink-0">{icon}</div> 54 <div 55 - className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : primary || secondary || nav ? "sm:hidden block" : "hidden"}`} 56 > 57 <div className="truncate text-left pt-[1px]">{label}</div> 58 - {props.subtext && ( 59 <div className="text-xs text-tertiary font-normal text-left"> 60 - {props.subtext} 61 </div> 62 )} 63 </div>
··· 3 import { useContext, useEffect } from "react"; 4 import { SidebarContext } from "./Sidebar"; 5 import React, { forwardRef, type JSX } from "react"; 6 + import { PopoverOpenContext } from "components/Popover/PopoverContext"; 7 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9 10 export const ActionButton = ( 11 + _props: ButtonProps & { 12 id?: string; 13 icon: React.ReactNode; 14 label: React.ReactNode; ··· 17 nav?: boolean; 18 className?: string; 19 subtext?: string; 20 + labelOnMobile?: boolean; 21 + z?: boolean; 22 }, 23 ) => { 24 + let { 25 + id, 26 + icon, 27 + label, 28 + primary, 29 + secondary, 30 + nav, 31 + labelOnMobile, 32 + subtext, 33 + className, 34 + ...buttonProps 35 + } = _props; 36 let sidebar = useContext(SidebarContext); 37 let inOpenPopover = useContext(PopoverOpenContext); 38 useEffect(() => { ··· 43 }; 44 } 45 }, [sidebar, inOpenPopover]); 46 + 47 + let showLabelOnMobile = 48 + labelOnMobile !== false && (primary || secondary || nav); 49 + 50 return ( 51 <button 52 {...buttonProps} ··· 55 rounded-md border 56 flex gap-2 items-start sm:justify-start justify-center 57 p-1 sm:mx-0 58 + ${showLabelOnMobile && !secondary ? "w-full" : "sm:w-full w-max"} 59 ${ 60 primary 61 + ? "bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 mx-1 first:ml-0" 62 : secondary 63 + ? " bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1 mx-1 first:ml-0" 64 : nav 65 + ? "border-transparent text-secondary sm:hover:border-border justify-start!" 66 + : "border-transparent text-accent-contrast sm:hover:border-accent-contrast" 67 } 68 + ${className} 69 `} 70 > 71 <div className="shrink-0">{icon}</div> 72 <div 73 + className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 74 > 75 <div className="truncate text-left pt-[1px]">{label}</div> 76 + {subtext && ( 77 <div className="text-xs text-tertiary font-normal text-left"> 78 + {subtext} 79 </div> 80 )} 81 </div>
+84 -56
components/ActionBar/Navigation.tsx
··· 11 ReaderReadSmall, 12 ReaderUnreadSmall, 13 } from "components/Icons/ReaderSmall"; 14 15 - export type navPages = "home" | "reader" | "pub" | "discover"; 16 17 export const DesktopNavigation = (props: { 18 currentPage: navPages; 19 publication?: string; 20 }) => { 21 return ( 22 - <div className="flex flex-col gap-4"> 23 <Sidebar alwaysOpen> 24 <NavigationOptions 25 currentPage={props.currentPage} 26 publication={props.publication} 27 /> 28 </Sidebar> 29 - {/*<Sidebar alwaysOpen> 30 - <ActionButton 31 - icon={ 32 - unreadNotifications ? ( 33 - <NotificationsUnreadSmall /> 34 - ) : ( 35 - <NotificationsReadSmall /> 36 - ) 37 - } 38 - label="Notifications" 39 - /> 40 - </Sidebar>*/} 41 </div> 42 ); 43 }; ··· 47 publication?: string; 48 }) => { 49 let { identity } = useIdentityData(); 50 - let thisPublication = identity?.publications?.find( 51 - (pub) => pub.uri === props.publication, 52 - ); 53 return ( 54 - <Popover 55 - onOpenAutoFocus={(e) => e.preventDefault()} 56 - asChild 57 - className="px-2! !max-w-[256px]" 58 - trigger={ 59 - <div className="shrink-0 p-1 pr-2 text-accent-contrast h-full flex gap-2 font-bold items-center"> 60 - <MenuSmall /> 61 - <div className="truncate max-w-[72px]"> 62 - {props.currentPage === "home" ? ( 63 - <>Home</> 64 - ) : props.currentPage === "reader" ? ( 65 - <>Reader</> 66 - ) : props.currentPage === "discover" ? ( 67 - <>Discover</> 68 - ) : props.currentPage === "pub" ? ( 69 - thisPublication && <>{thisPublication.name}</> 70 - ) : null} 71 </div> 72 - </div> 73 - } 74 - > 75 - <NavigationOptions 76 - currentPage={props.currentPage} 77 - publication={props.publication} 78 - /> 79 - </Popover> 80 ); 81 }; 82 83 const NavigationOptions = (props: { 84 currentPage: navPages; 85 publication?: string; 86 }) => { 87 let { identity } = useIdentityData(); 88 let thisPublication = identity?.publications?.find( ··· 93 <HomeButton current={props.currentPage === "home"} /> 94 <ReaderButton 95 current={props.currentPage === "reader"} 96 - subs={identity?.publication_subscriptions?.length !== 0} 97 /> 98 <DiscoverButton current={props.currentPage === "discover"} /> 99 100 <hr className="border-border-light my-1" /> 101 - <PublicationButtons currentPubUri={thisPublication?.uri} /> 102 </> 103 ); 104 }; 105 106 const HomeButton = (props: { current?: boolean }) => { 107 return ( 108 - <Link href={"/home"} className="hover:!no-underline"> 109 <ActionButton 110 nav 111 icon={<HomeSmall />} 112 label="Home" 113 className={props.current ? "bg-bg-page! border-border-light!" : ""} 114 /> 115 - </Link> 116 ); 117 }; 118 119 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 120 - let readerUnreads = false; 121 - 122 if (!props.subs) return; 123 return ( 124 - <Link href={"/reader"} className="hover:no-underline!"> 125 <ActionButton 126 nav 127 - icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 128 label="Reader" 129 - className={` 130 - ${readerUnreads && "text-accent-contrast!"} 131 - ${props.current && "border-accent-contrast!"} 132 - `} 133 /> 134 - </Link> 135 ); 136 }; 137 ··· 142 nav 143 icon={<DiscoverSmall />} 144 label="Discover" 145 - subtext={"Explore publications!"} 146 className={props.current ? "bg-bg-page! border-border-light!" : ""} 147 /> 148 </Link> 149 ); 150 };
··· 11 ReaderReadSmall, 12 ReaderUnreadSmall, 13 } from "components/Icons/ReaderSmall"; 14 + import { 15 + NotificationsReadSmall, 16 + NotificationsUnreadSmall, 17 + } from "components/Icons/NotificationSmall"; 18 + import { SpeedyLink } from "components/SpeedyLink"; 19 + import { Separator } from "components/Layout"; 20 21 + export type navPages = 22 + | "home" 23 + | "reader" 24 + | "pub" 25 + | "discover" 26 + | "notifications" 27 + | "looseleafs" 28 + | "tag"; 29 30 export const DesktopNavigation = (props: { 31 currentPage: navPages; 32 publication?: string; 33 }) => { 34 + let { identity } = useIdentityData(); 35 return ( 36 + <div className="flex flex-col gap-3"> 37 <Sidebar alwaysOpen> 38 <NavigationOptions 39 currentPage={props.currentPage} 40 publication={props.publication} 41 /> 42 </Sidebar> 43 + {identity?.atp_did && ( 44 + <Sidebar alwaysOpen> 45 + <NotificationButton current={props.currentPage === "notifications"} /> 46 + </Sidebar> 47 + )} 48 </div> 49 ); 50 }; ··· 54 publication?: string; 55 }) => { 56 let { identity } = useIdentityData(); 57 + 58 return ( 59 + <div className="flex gap-1 "> 60 + <Popover 61 + onOpenAutoFocus={(e) => e.preventDefault()} 62 + asChild 63 + className="px-2! !max-w-[256px]" 64 + trigger={ 65 + <div className="shrink-0 p-1 text-accent-contrast h-full flex gap-2 font-bold items-center"> 66 + <MenuSmall /> 67 </div> 68 + } 69 + > 70 + <NavigationOptions 71 + currentPage={props.currentPage} 72 + publication={props.publication} 73 + isMobile 74 + /> 75 + </Popover> 76 + {identity?.atp_did && ( 77 + <> 78 + <Separator /> 79 + <NotificationButton /> 80 + </> 81 + )} 82 + </div> 83 ); 84 }; 85 86 const NavigationOptions = (props: { 87 currentPage: navPages; 88 publication?: string; 89 + isMobile?: boolean; 90 }) => { 91 let { identity } = useIdentityData(); 92 let thisPublication = identity?.publications?.find( ··· 97 <HomeButton current={props.currentPage === "home"} /> 98 <ReaderButton 99 current={props.currentPage === "reader"} 100 + subs={ 101 + identity?.publication_subscriptions?.length !== 0 && 102 + identity?.publication_subscriptions?.length !== undefined 103 + } 104 /> 105 <DiscoverButton current={props.currentPage === "discover"} /> 106 107 <hr className="border-border-light my-1" /> 108 + <PublicationButtons 109 + currentPage={props.currentPage} 110 + currentPubUri={thisPublication?.uri} 111 + /> 112 </> 113 ); 114 }; 115 116 const HomeButton = (props: { current?: boolean }) => { 117 return ( 118 + <SpeedyLink href={"/home"} className="hover:!no-underline"> 119 <ActionButton 120 nav 121 icon={<HomeSmall />} 122 label="Home" 123 className={props.current ? "bg-bg-page! border-border-light!" : ""} 124 /> 125 + </SpeedyLink> 126 ); 127 }; 128 129 const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 130 if (!props.subs) return; 131 return ( 132 + <SpeedyLink href={"/reader"} className="hover:no-underline!"> 133 <ActionButton 134 nav 135 + icon={<ReaderUnreadSmall />} 136 label="Reader" 137 + className={props.current ? "bg-bg-page! border-border-light!" : ""} 138 /> 139 + </SpeedyLink> 140 ); 141 }; 142 ··· 147 nav 148 icon={<DiscoverSmall />} 149 label="Discover" 150 + subtext="" 151 className={props.current ? "bg-bg-page! border-border-light!" : ""} 152 /> 153 </Link> 154 ); 155 }; 156 + 157 + export function NotificationButton(props: { current?: boolean }) { 158 + let { identity } = useIdentityData(); 159 + let unreads = identity?.notifications[0]?.count; 160 + 161 + return ( 162 + <SpeedyLink href={"/notifications"} className="hover:no-underline!"> 163 + <ActionButton 164 + nav 165 + labelOnMobile={false} 166 + icon={ 167 + unreads ? ( 168 + <NotificationsUnreadSmall className="text-accent-contrast" /> 169 + ) : ( 170 + <NotificationsReadSmall /> 171 + ) 172 + } 173 + label="Notifications" 174 + className={`${props.current ? "bg-bg-page! border-border-light!" : ""} ${unreads ? "text-accent-contrast!" : ""}`} 175 + /> 176 + </SpeedyLink> 177 + ); 178 + }
+126 -34
components/ActionBar/Publications.tsx
··· 10 import { ActionButton } from "./ActionButton"; 11 import { SpeedyLink } from "components/SpeedyLink"; 12 import { PublishSmall } from "components/Icons/PublishSmall"; 13 14 export const PublicationButtons = (props: { 15 currentPubUri: string | undefined; 16 }) => { 17 let { identity } = useIdentityData(); 18 19 // don't show pub list button if not logged in or no pub list 20 // we show a "start a pub" banner instead 21 - if (!identity || !identity.atp_did) return <PubListEmpty />; 22 return ( 23 <div className="pubListWrapper w-full flex flex-col gap-1 sm:bg-transparent sm:border-0"> 24 - {identity.publications?.map((d) => { 25 - // console.log("thisURI : " + d.uri); 26 - // console.log("currentURI : " + props.currentPubUri); 27 28 return ( 29 <PublicationOption 30 {...d} 31 key={d.uri} 32 record={d.record} 33 - asActionButton 34 current={d.uri === props.currentPubUri} 35 /> 36 ); 37 })} 38 <Link 39 - href={"./lish/createPub"} 40 className="pubListCreateNew text-accent-contrast text-sm place-self-end hover:text-accent-contrast" 41 > 42 New ··· 49 uri: string; 50 name: string; 51 record: Json; 52 - asActionButton?: boolean; 53 current?: boolean; 54 }) => { 55 let record = props.record as PubLeafletPublication.Record | null; ··· 60 href={`${getBasePublicationURL(props)}/dashboard`} 61 className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 62 > 63 - {props.asActionButton ? ( 64 - <ActionButton 65 - label={record.name} 66 - icon={<PubIcon record={record} uri={props.uri} />} 67 - nav 68 - className={props.current ? "bg-bg-page! border-border!" : ""} 69 - /> 70 - ) : ( 71 - <> 72 - <PubIcon record={record} uri={props.uri} /> 73 - <div className="truncate">{record.name}</div> 74 - </> 75 - )} 76 </SpeedyLink> 77 ); 78 }; 79 80 const PubListEmpty = () => { 81 - return ( 82 - <SpeedyLink href={`lish/createPub`} className=" hover:no-underline!"> 83 <ActionButton 84 - label="Publish on ATP" 85 icon={<PublishSmall />} 86 nav 87 - subtext="Start a blog in the Atmosphere" 88 /> 89 - </SpeedyLink> 90 ); 91 }; 92 ··· 103 104 return props.record.icon ? ( 105 <div 106 - style={{ 107 - backgroundRepeat: "no-repeat", 108 - backgroundPosition: "center", 109 - backgroundSize: "cover", 110 - backgroundImage: `url(/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]})`, 111 - }} 112 - className={`${iconSizeClassName} ${props.className}`} 113 - /> 114 ) : ( 115 <div className={`${iconSizeClassName} bg-accent-1 relative`}> 116 <div 117 className={`${props.small ? "text-xs" : props.large ? "text-2xl" : "text-sm"} font-bold absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-accent-2`} 118 > 119 - {props.record?.name.slice(0, 1)} 120 </div> 121 </div> 122 );
··· 10 import { ActionButton } from "./ActionButton"; 11 import { SpeedyLink } from "components/SpeedyLink"; 12 import { PublishSmall } from "components/Icons/PublishSmall"; 13 + import { Popover } from "components/Popover"; 14 + import { BlueskyLogin } from "app/login/LoginForm"; 15 + import { ButtonSecondary } from "components/Buttons"; 16 + import { useIsMobile } from "src/hooks/isMobile"; 17 + import { useState } from "react"; 18 + import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 19 + import { navPages } from "./Navigation"; 20 21 export const PublicationButtons = (props: { 22 + currentPage: navPages; 23 currentPubUri: string | undefined; 24 }) => { 25 let { identity } = useIdentityData(); 26 + let hasLooseleafs = !!identity?.permission_token_on_homepage.find( 27 + (f) => 28 + f.permission_tokens.leaflets_to_documents && 29 + f.permission_tokens.leaflets_to_documents[0]?.document, 30 + ); 31 32 // don't show pub list button if not logged in or no pub list 33 // we show a "start a pub" banner instead 34 + if (!identity || !identity.atp_did || identity.publications.length === 0) 35 + return <PubListEmpty />; 36 + 37 return ( 38 <div className="pubListWrapper w-full flex flex-col gap-1 sm:bg-transparent sm:border-0"> 39 + {hasLooseleafs && ( 40 + <> 41 + <SpeedyLink 42 + href={`/looseleafs`} 43 + className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 44 + > 45 + {/*TODO How should i get if this is the current page or not? 46 + theres not "pub" to check the uri for. Do i need to add it as an option to NavPages? thats kinda annoying*/} 47 + <ActionButton 48 + label="Looseleafs" 49 + icon={<LooseLeafSmall />} 50 + nav 51 + className={ 52 + props.currentPage === "looseleafs" 53 + ? "bg-bg-page! border-border!" 54 + : "" 55 + } 56 + /> 57 + </SpeedyLink> 58 + <hr className="border-border-light border-dashed mx-1" /> 59 + </> 60 + )} 61 62 + {identity.publications?.map((d) => { 63 return ( 64 <PublicationOption 65 {...d} 66 key={d.uri} 67 record={d.record} 68 current={d.uri === props.currentPubUri} 69 /> 70 ); 71 })} 72 <Link 73 + href={"/lish/createPub"} 74 className="pubListCreateNew text-accent-contrast text-sm place-self-end hover:text-accent-contrast" 75 > 76 New ··· 83 uri: string; 84 name: string; 85 record: Json; 86 current?: boolean; 87 }) => { 88 let record = props.record as PubLeafletPublication.Record | null; ··· 93 href={`${getBasePublicationURL(props)}/dashboard`} 94 className="flex gap-2 items-start text-secondary font-bold hover:no-underline! hover:text-accent-contrast w-full" 95 > 96 + <ActionButton 97 + label={record.name} 98 + icon={<PubIcon record={record} uri={props.uri} />} 99 + nav 100 + className={props.current ? "bg-bg-page! border-border!" : ""} 101 + /> 102 </SpeedyLink> 103 ); 104 }; 105 106 const PubListEmpty = () => { 107 + let isMobile = useIsMobile(); 108 + 109 + let [state, setState] = useState<"default" | "info">("default"); 110 + if (isMobile && state == "default") 111 + return ( 112 <ActionButton 113 + label="Publish" 114 icon={<PublishSmall />} 115 nav 116 + subtext="Start a blog on ATProto!" 117 + onClick={() => { 118 + setState("info"); 119 + }} 120 /> 121 + ); 122 + 123 + if (isMobile && state === "info") return <PubListEmptyContent />; 124 + else 125 + return ( 126 + <Popover 127 + side="right" 128 + align="start" 129 + className="p-1! max-w-56" 130 + asChild 131 + trigger={ 132 + <ActionButton 133 + label="Publish" 134 + icon={<PublishSmall />} 135 + nav 136 + subtext="Start a blog on ATProto!" 137 + /> 138 + } 139 + > 140 + <PubListEmptyContent /> 141 + </Popover> 142 + ); 143 + }; 144 + 145 + export const PubListEmptyContent = (props: { compact?: boolean }) => { 146 + let { identity } = useIdentityData(); 147 + 148 + return ( 149 + <div 150 + className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`} 151 + > 152 + <div className="mx-auto pt-2 scale-90"> 153 + <PubListEmptyIllo /> 154 + </div> 155 + <div className="pt-1 font-bold">Publish on AT Proto</div> 156 + {identity && identity.atp_did ? ( 157 + // has ATProto account and no pubs 158 + <> 159 + <div className="pb-2 text-secondary text-xs"> 160 + Start a new publication <br /> 161 + on AT Proto 162 + </div> 163 + <SpeedyLink href={`lish/createPub`} className=" hover:no-underline!"> 164 + <ButtonSecondary className="text-sm mx-auto" compact> 165 + Start a Publication! 166 + </ButtonSecondary> 167 + </SpeedyLink> 168 + </> 169 + ) : ( 170 + // no ATProto account and no pubs 171 + <> 172 + <div className="pb-2 text-secondary text-xs"> 173 + Link a Bluesky account to start <br /> a new publication on AT Proto 174 + </div> 175 + 176 + <BlueskyLogin compact /> 177 + </> 178 + )} 179 + </div> 180 ); 181 }; 182 ··· 193 194 return props.record.icon ? ( 195 <div 196 + className={`${iconSizeClassName} ${props.className} relative overflow-hidden`} 197 + > 198 + <img 199 + src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`} 200 + alt={`${props.record.name} icon`} 201 + loading="lazy" 202 + fetchPriority="low" 203 + className="absolute inset-0 w-full h-full object-cover object-center" 204 + /> 205 + </div> 206 ) : ( 207 <div className={`${iconSizeClassName} bg-accent-1 relative`}> 208 <div 209 className={`${props.small ? "text-xs" : props.large ? "text-2xl" : "text-sm"} font-bold absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-accent-2`} 210 > 211 + {props.record?.name.slice(0, 1).toUpperCase()} 212 </div> 213 </div> 214 );
+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 + }
+28
components/Avatar.tsx
···
··· 1 + import { AccountTiny } from "./Icons/AccountTiny"; 2 + 3 + export const Avatar = (props: { 4 + src: string | undefined; 5 + displayName: string | undefined; 6 + tiny?: boolean; 7 + }) => { 8 + if (props.src) 9 + return ( 10 + <img 11 + className={`${props.tiny ? "w-4 h-4" : "w-5 h-5"} rounded-full shrink-0 border border-border-light`} 12 + src={props.src} 13 + alt={ 14 + props.displayName 15 + ? `${props.displayName}'s avatar` 16 + : "someone's avatar" 17 + } 18 + /> 19 + ); 20 + else 21 + return ( 22 + <div 23 + className={`bg-[var(--accent-light)] flex rounded-full shrink-0 border border-border-light place-items-center justify-center text-accent-1 ${props.tiny ? "w-4 h-4" : "w-5 h-5"}`} 24 + > 25 + <AccountTiny className={props.tiny ? "scale-80" : "scale-90"} /> 26 + </div> 27 + ); 28 + };
+43 -6
components/Blocks/BaseTextareaBlock.tsx
··· 5 import { BlockProps } from "./Block"; 6 import { getCoordinatesInTextarea } from "src/utils/getCoordinatesInTextarea"; 7 import { focusBlock } from "src/utils/focusBlock"; 8 9 - export function BaseTextareaBlock( 10 - props: AutosizeTextareaProps & { 11 - block: Pick<BlockProps, "previousBlock" | "nextBlock">; 12 - }, 13 - ) { 14 - let { block, ...passDownProps } = props; 15 return ( 16 <AsyncValueAutosizeTextarea 17 {...passDownProps} 18 noWrap 19 onKeyDown={(e) => { 20 if (e.key === "ArrowUp") { 21 let selection = e.currentTarget.selectionStart; 22
··· 5 import { BlockProps } from "./Block"; 6 import { getCoordinatesInTextarea } from "src/utils/getCoordinatesInTextarea"; 7 import { focusBlock } from "src/utils/focusBlock"; 8 + import { generateKeyBetween } from "fractional-indexing"; 9 + import { v7 } from "uuid"; 10 + import { elementId } from "src/utils/elementId"; 11 + import { Replicache } from "replicache"; 12 + import { ReplicacheMutators } from "src/replicache"; 13 14 + type BaseTextareaBlockProps = AutosizeTextareaProps & { 15 + block: Pick< 16 + BlockProps, 17 + "previousBlock" | "nextBlock" | "parent" | "position" | "nextPosition" 18 + >; 19 + rep?: Replicache<ReplicacheMutators> | null; 20 + permissionSet?: string; 21 + }; 22 + 23 + export function BaseTextareaBlock(props: BaseTextareaBlockProps) { 24 + let { block, rep, permissionSet, ...passDownProps } = props; 25 return ( 26 <AsyncValueAutosizeTextarea 27 {...passDownProps} 28 noWrap 29 onKeyDown={(e) => { 30 + // Shift-Enter or Ctrl-Enter: create new text block below and focus it 31 + if ( 32 + (e.shiftKey || e.ctrlKey || e.metaKey) && 33 + e.key === "Enter" && 34 + rep && 35 + permissionSet 36 + ) { 37 + e.preventDefault(); 38 + let newEntityID = v7(); 39 + rep.mutate.addBlock({ 40 + parent: block.parent, 41 + type: "text", 42 + factID: v7(), 43 + permission_set: permissionSet, 44 + position: generateKeyBetween( 45 + block.position, 46 + block.nextPosition || null, 47 + ), 48 + newEntityID, 49 + }); 50 + 51 + setTimeout(() => { 52 + document.getElementById(elementId.block(newEntityID).text)?.focus(); 53 + }, 10); 54 + return true; 55 + } 56 + 57 if (e.key === "ArrowUp") { 58 let selection = e.currentTarget.selectionStart; 59
+22 -3
components/Blocks/Block.tsx
··· 7 import { useBlockKeyboardHandlers } from "./useBlockKeyboardHandlers"; 8 import { useLongPress } from "src/hooks/useLongPress"; 9 import { focusBlock } from "src/utils/focusBlock"; 10 11 import { TextBlock } from "components/Blocks/TextBlock"; 12 import { ImageBlock } from "./ImageBlock"; ··· 15 import { EmbedBlock } from "./EmbedBlock"; 16 import { MailboxBlock } from "./MailboxBlock"; 17 import { AreYouSure } from "./DeleteBlock"; 18 - import { useEntitySetContext } from "components/EntitySetProvider"; 19 import { useIsMobile } from "src/hooks/isMobile"; 20 import { DateTimeBlock } from "./DateTimeBlock"; 21 import { RSVPBlock } from "./RSVPBlock"; ··· 30 import { CodeBlock } from "./CodeBlock"; 31 import { HorizontalRule } from "./HorizontalRule"; 32 import { deepEquals } from "src/utils/deepEquals"; 33 34 export type Block = { 35 factID: string; ··· 62 // and shared styling like padding and flex for list layouting 63 64 let mouseHandlers = useBlockMouseHandlers(props); 65 66 - // focus block on longpress, shouldnt the type be based on the block type (?) 67 let { isLongPress, handlers } = useLongPress(() => { 68 if (isLongPress.current) { 69 focusBlock( 70 { type: props.type, value: props.entityID, parent: props.parent }, ··· 92 {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})} 93 id={ 94 !props.preview ? elementId.block(props.entityID).container : undefined 95 } 96 className={` 97 blockWrapper relative ··· 411 className={`listMarker group/list-marker p-2 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 412 > 413 <div 414 - className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-1 outline-offset-1 415 ${ 416 folded 417 ? "outline-secondary"
··· 7 import { useBlockKeyboardHandlers } from "./useBlockKeyboardHandlers"; 8 import { useLongPress } from "src/hooks/useLongPress"; 9 import { focusBlock } from "src/utils/focusBlock"; 10 + import { useHandleDrop } from "./useHandleDrop"; 11 + import { useEntitySetContext } from "components/EntitySetProvider"; 12 13 import { TextBlock } from "components/Blocks/TextBlock"; 14 import { ImageBlock } from "./ImageBlock"; ··· 17 import { EmbedBlock } from "./EmbedBlock"; 18 import { MailboxBlock } from "./MailboxBlock"; 19 import { AreYouSure } from "./DeleteBlock"; 20 import { useIsMobile } from "src/hooks/isMobile"; 21 import { DateTimeBlock } from "./DateTimeBlock"; 22 import { RSVPBlock } from "./RSVPBlock"; ··· 31 import { CodeBlock } from "./CodeBlock"; 32 import { HorizontalRule } from "./HorizontalRule"; 33 import { deepEquals } from "src/utils/deepEquals"; 34 + import { isTextBlock } from "src/utils/isTextBlock"; 35 36 export type Block = { 37 factID: string; ··· 64 // and shared styling like padding and flex for list layouting 65 66 let mouseHandlers = useBlockMouseHandlers(props); 67 + let handleDrop = useHandleDrop({ 68 + parent: props.parent, 69 + position: props.position, 70 + nextPosition: props.nextPosition, 71 + }); 72 + let entity_set = useEntitySetContext(); 73 74 let { isLongPress, handlers } = useLongPress(() => { 75 + if (isTextBlock[props.type]) return; 76 if (isLongPress.current) { 77 focusBlock( 78 { type: props.type, value: props.entityID, parent: props.parent }, ··· 100 {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})} 101 id={ 102 !props.preview ? elementId.block(props.entityID).container : undefined 103 + } 104 + onDragOver={ 105 + !props.preview && entity_set.permissions.write 106 + ? (e) => { 107 + e.preventDefault(); 108 + e.stopPropagation(); 109 + } 110 + : undefined 111 + } 112 + onDrop={ 113 + !props.preview && entity_set.permissions.write ? handleDrop : undefined 114 } 115 className={` 116 blockWrapper relative ··· 430 className={`listMarker group/list-marker p-2 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 431 > 432 <div 433 + className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-offset-1 434 ${ 435 folded 436 ? "outline-secondary"
+32 -7
components/Blocks/BlockCommandBar.tsx
··· 6 import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider"; 7 import { UndoManager } from "src/undoManager"; 8 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 9 10 type Props = { 11 parent: string; ··· 32 let entity_set = useEntitySetContext(); 33 let { data: pub } = useLeafletPublicationData(); 34 35 let commandResults = blockCommands.filter((command) => { 36 - const matchesSearch = command.name 37 .toLocaleLowerCase() 38 - .includes(searchValue.toLocaleLowerCase()); 39 const isVisible = !pub || !command.hiddenInPublication; 40 return matchesSearch && isVisible; 41 }); ··· 50 setHighlighted(commandResults[0].name); 51 } 52 }, [commandResults, setHighlighted, highlighted]); 53 useEffect(() => { 54 let listener = async (e: KeyboardEvent) => { 55 let reverseDir = ref.current?.dataset.side === "top"; ··· 98 undoManager.endGroup(); 99 return; 100 } 101 - 102 - // radix menu component handles esc 103 - if (e.key === "Escape") return; 104 }; 105 window.addEventListener("keydown", listener); 106 107 return () => window.removeEventListener("keydown", listener); 108 }, [highlighted, setHighlighted, commandResults, rep, entity_set.set, props]); 109 110 return ( 111 - <Popover.Root open> 112 <Popover.Trigger className="absolute left-0"></Popover.Trigger> 113 <Popover.Portal> 114 <Popover.Content ··· 177 178 return ( 179 <button 180 - className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`} 181 onMouseOver={() => { 182 props.setHighlighted(props.name); 183 }}
··· 6 import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider"; 7 import { UndoManager } from "src/undoManager"; 8 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 9 + import { setEditorState, useEditorStates } from "src/state/useEditorState"; 10 11 type Props = { 12 parent: string; ··· 33 let entity_set = useEntitySetContext(); 34 let { data: pub } = useLeafletPublicationData(); 35 36 + // This clears '/' AND anything typed after it 37 + const clearCommandSearchText = () => { 38 + if (!props.entityID) return; 39 + const entityID = props.entityID; 40 + 41 + const existingState = useEditorStates.getState().editorStates[entityID]; 42 + if (!existingState) return; 43 + 44 + const tr = existingState.editor.tr; 45 + tr.deleteRange(1, tr.doc.content.size - 1); 46 + setEditorState(entityID, { editor: existingState.editor.apply(tr) }); 47 + }; 48 + 49 let commandResults = blockCommands.filter((command) => { 50 + const lowerSearchValue = searchValue.toLocaleLowerCase(); 51 + const matchesName = command.name 52 .toLocaleLowerCase() 53 + .includes(lowerSearchValue); 54 + const matchesAlternate = command.alternateNames?.some((altName) => 55 + altName.toLocaleLowerCase().includes(lowerSearchValue) 56 + ) ?? false; 57 + const matchesSearch = matchesName || matchesAlternate; 58 const isVisible = !pub || !command.hiddenInPublication; 59 return matchesSearch && isVisible; 60 }); ··· 69 setHighlighted(commandResults[0].name); 70 } 71 }, [commandResults, setHighlighted, highlighted]); 72 + 73 useEffect(() => { 74 let listener = async (e: KeyboardEvent) => { 75 let reverseDir = ref.current?.dataset.side === "top"; ··· 118 undoManager.endGroup(); 119 return; 120 } 121 }; 122 + 123 window.addEventListener("keydown", listener); 124 125 return () => window.removeEventListener("keydown", listener); 126 }, [highlighted, setHighlighted, commandResults, rep, entity_set.set, props]); 127 128 return ( 129 + <Popover.Root 130 + open 131 + onOpenChange={(open) => { 132 + if (!open) { 133 + clearCommandSearchText(); 134 + } 135 + }} 136 + > 137 <Popover.Trigger className="absolute left-0"></Popover.Trigger> 138 <Popover.Portal> 139 <Popover.Content ··· 202 203 return ( 204 <button 205 + className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`} 206 onMouseOver={() => { 207 props.setHighlighted(props.name); 208 }}
+17 -8
components/Blocks/BlockCommands.tsx
··· 2 import { useUIState } from "src/useUIState"; 3 4 import { generateKeyBetween } from "fractional-indexing"; 5 - import { focusPage } from "components/Pages"; 6 import { v7 } from "uuid"; 7 import { Replicache } from "replicache"; 8 import { useEditorStates } from "src/state/useEditorState"; 9 import { elementId } from "src/utils/elementId"; 10 import { UndoManager } from "src/undoManager"; 11 import { focusBlock } from "src/utils/focusBlock"; 12 - import { usePollBlockUIState } from "./PollBlock"; 13 - import { focusElement } from "components/Input"; 14 import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; 15 import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; 16 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; ··· 32 import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 33 import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 34 import { QuoteSmall } from "components/Icons/QuoteSmall"; 35 36 type Props = { 37 parent: string; ··· 102 name: string; 103 icon: React.ReactNode; 104 type: string; 105 hiddenInPublication?: boolean; 106 onSelect: ( 107 rep: Replicache<ReplicacheMutators>, ··· 125 name: "Title", 126 icon: <Header1Small />, 127 type: "text", 128 onSelect: async (rep, props, um) => { 129 await setHeaderCommand(1, rep, props); 130 }, ··· 133 name: "Header", 134 icon: <Header2Small />, 135 type: "text", 136 onSelect: async (rep, props, um) => { 137 await setHeaderCommand(2, rep, props); 138 }, ··· 141 name: "Subheader", 142 icon: <Header3Small />, 143 type: "text", 144 onSelect: async (rep, props, um) => { 145 await setHeaderCommand(3, rep, props); 146 }, ··· 204 name: "Button", 205 icon: <BlockButtonSmall />, 206 type: "block", 207 - hiddenInPublication: true, 208 onSelect: async (rep, props, um) => { 209 props.entityID && clearCommandSearchText(props.entityID); 210 await createBlockWithType(rep, props, "button"); ··· 235 name: "Poll", 236 icon: <BlockPollSmall />, 237 type: "block", 238 - hiddenInPublication: true, 239 onSelect: async (rep, props, um) => { 240 let entity = await createBlockWithType(rep, props, "poll"); 241 let pollOptionEntity = v7(); ··· 308 type: "block", 309 hiddenInPublication: false, 310 onSelect: async (rep, props) => { 311 - createBlockWithType(rep, props, "code"); 312 }, 313 }, 314 ··· 330 name: "New Page", 331 icon: <BlockDocPageSmall />, 332 type: "page", 333 - hiddenInPublication: true, 334 onSelect: async (rep, props, um) => { 335 props.entityID && clearCommandSearchText(props.entityID); 336 let entity = await createBlockWithType(rep, props, "card"); ··· 370 name: "New Canvas", 371 icon: <BlockCanvasPageSmall />, 372 type: "page", 373 - hiddenInPublication: true, 374 onSelect: async (rep, props, um) => { 375 props.entityID && clearCommandSearchText(props.entityID); 376 let entity = await createBlockWithType(rep, props, "card");
··· 2 import { useUIState } from "src/useUIState"; 3 4 import { generateKeyBetween } from "fractional-indexing"; 5 + import { focusPage } from "src/utils/focusPage"; 6 import { v7 } from "uuid"; 7 import { Replicache } from "replicache"; 8 import { useEditorStates } from "src/state/useEditorState"; 9 import { elementId } from "src/utils/elementId"; 10 import { UndoManager } from "src/undoManager"; 11 import { focusBlock } from "src/utils/focusBlock"; 12 + import { usePollBlockUIState } from "./PollBlock/pollBlockState"; 13 + import { focusElement } from "src/utils/focusElement"; 14 import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall"; 15 import { BlockButtonSmall } from "components/Icons/BlockButtonSmall"; 16 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; ··· 32 import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 33 import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 34 import { QuoteSmall } from "components/Icons/QuoteSmall"; 35 + import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 36 37 type Props = { 38 parent: string; ··· 103 name: string; 104 icon: React.ReactNode; 105 type: string; 106 + alternateNames?: string[]; 107 hiddenInPublication?: boolean; 108 onSelect: ( 109 rep: Replicache<ReplicacheMutators>, ··· 127 name: "Title", 128 icon: <Header1Small />, 129 type: "text", 130 + alternateNames: ["h1"], 131 onSelect: async (rep, props, um) => { 132 await setHeaderCommand(1, rep, props); 133 }, ··· 136 name: "Header", 137 icon: <Header2Small />, 138 type: "text", 139 + alternateNames: ["h2"], 140 onSelect: async (rep, props, um) => { 141 await setHeaderCommand(2, rep, props); 142 }, ··· 145 name: "Subheader", 146 icon: <Header3Small />, 147 type: "text", 148 + alternateNames: ["h3"], 149 onSelect: async (rep, props, um) => { 150 await setHeaderCommand(3, rep, props); 151 }, ··· 209 name: "Button", 210 icon: <BlockButtonSmall />, 211 type: "block", 212 onSelect: async (rep, props, um) => { 213 props.entityID && clearCommandSearchText(props.entityID); 214 await createBlockWithType(rep, props, "button"); ··· 239 name: "Poll", 240 icon: <BlockPollSmall />, 241 type: "block", 242 onSelect: async (rep, props, um) => { 243 let entity = await createBlockWithType(rep, props, "poll"); 244 let pollOptionEntity = v7(); ··· 311 type: "block", 312 hiddenInPublication: false, 313 onSelect: async (rep, props) => { 314 + let entity = await createBlockWithType(rep, props, "code"); 315 + let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY); 316 + if (lastLang) { 317 + await rep.mutate.assertFact({ 318 + entity, 319 + attribute: "block/code-language", 320 + data: { type: "string", value: lastLang }, 321 + }); 322 + } 323 }, 324 }, 325 ··· 341 name: "New Page", 342 icon: <BlockDocPageSmall />, 343 type: "page", 344 onSelect: async (rep, props, um) => { 345 props.entityID && clearCommandSearchText(props.entityID); 346 let entity = await createBlockWithType(rep, props, "card"); ··· 380 name: "New Canvas", 381 icon: <BlockCanvasPageSmall />, 382 type: "page", 383 onSelect: async (rep, props, um) => { 384 props.entityID && clearCommandSearchText(props.entityID); 385 let entity = await createBlockWithType(rep, props, "card");
+7 -5
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 125 className={`flex flex-col gap-1 relative w-full overflow-hidden sm:p-3 p-2 text-xs block-border`} 126 > 127 <div className="bskyAuthor w-full flex items-center gap-1"> 128 - <img 129 - src={record.author?.avatar} 130 - alt={`${record.author?.displayName}'s avatar`} 131 - className="shink-0 w-6 h-6 rounded-full border border-border-light" 132 - /> 133 <div className=" font-bold text-secondary"> 134 {record.author?.displayName} 135 </div>
··· 125 className={`flex flex-col gap-1 relative w-full overflow-hidden sm:p-3 p-2 text-xs block-border`} 126 > 127 <div className="bskyAuthor w-full flex items-center gap-1"> 128 + {record.author.avatar && ( 129 + <img 130 + src={record.author?.avatar} 131 + alt={`${record.author?.displayName}'s avatar`} 132 + className="shink-0 w-6 h-6 rounded-full border border-border-light" 133 + /> 134 + )} 135 <div className=" font-bold text-secondary"> 136 {record.author?.displayName} 137 </div>
+14 -15
components/Blocks/BlueskyPostBlock/index.tsx
··· 10 import { BlueskyPostEmpty } from "./BlueskyEmpty"; 11 import { BlueskyRichText } from "./BlueskyRichText"; 12 import { Separator } from "components/Layout"; 13 - import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 14 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 15 import { CommentTiny } from "components/Icons/CommentTiny"; 16 17 export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => { 18 let { permissions } = useEntitySetContext(); ··· 28 input?.focus(); 29 } else input?.blur(); 30 }, [isSelected, props.entityID, props.preview]); 31 - 32 - let initialPageLoad = useInitialPageLoad(); 33 34 switch (true) { 35 case !post: ··· 81 //getting the url to the post 82 let postId = post.post.uri.split("/")[4]; 83 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 84 - 85 - let datetimeFormatted = initialPageLoad 86 - ? new Date(timestamp ? timestamp : "").toLocaleString("en-US", { 87 - month: "short", 88 - day: "numeric", 89 - year: "numeric", 90 - hour: "numeric", 91 - minute: "numeric", 92 - hour12: true, 93 - }) 94 - : ""; 95 96 return ( 97 <div ··· 141 </> 142 )} 143 <div className="w-full flex gap-2 items-center justify-between"> 144 - <div className="text-xs text-tertiary">{datetimeFormatted}</div> 145 <div className="flex gap-2 items-center"> 146 {post.post.replyCount && post.post.replyCount > 0 && ( 147 <> ··· 166 ); 167 } 168 };
··· 10 import { BlueskyPostEmpty } from "./BlueskyEmpty"; 11 import { BlueskyRichText } from "./BlueskyRichText"; 12 import { Separator } from "components/Layout"; 13 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 14 import { CommentTiny } from "components/Icons/CommentTiny"; 15 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 17 export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => { 18 let { permissions } = useEntitySetContext(); ··· 28 input?.focus(); 29 } else input?.blur(); 30 }, [isSelected, props.entityID, props.preview]); 31 32 switch (true) { 33 case !post: ··· 79 //getting the url to the post 80 let postId = post.post.uri.split("/")[4]; 81 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 82 83 return ( 84 <div ··· 128 </> 129 )} 130 <div className="w-full flex gap-2 items-center justify-between"> 131 + {timestamp && <PostDate timestamp={timestamp} />} 132 <div className="flex gap-2 items-center"> 133 {post.post.replyCount && post.post.replyCount > 0 && ( 134 <> ··· 153 ); 154 } 155 }; 156 + 157 + function PostDate(props: { timestamp: string }) { 158 + const formattedDate = useLocalizedDate(props.timestamp, { 159 + month: "short", 160 + day: "numeric", 161 + year: "numeric", 162 + hour: "numeric", 163 + minute: "numeric", 164 + hour12: true, 165 + }); 166 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 167 + }
+9 -1
components/Blocks/CodeBlock.tsx
··· 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 import { flushSync } from "react-dom"; 15 import { elementId } from "src/utils/elementId"; 16 17 export function CodeBlock(props: BlockProps) { 18 let { rep, rootEntity } = useReplicache(); ··· 25 let focusedBlock = useUIState( 26 (s) => s.focusedEntity?.entityID === props.entityID, 27 ); 28 - let { permissions } = useEntitySetContext(); 29 const [html, setHTML] = useState<string | null>(null); 30 31 useLayoutEffect(() => { ··· 100 }} 101 value={lang} 102 onChange={async (e) => { 103 await rep?.mutate.assertFact({ 104 attribute: "block/code-language", 105 entity: props.entityID, ··· 123 data-entityid={props.entityID} 124 id={elementId.block(props.entityID).input} 125 block={props} 126 className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2" 127 value={content?.data.value} 128 onChange={async (e) => {
··· 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 import { flushSync } from "react-dom"; 15 import { elementId } from "src/utils/elementId"; 16 + import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 17 18 export function CodeBlock(props: BlockProps) { 19 let { rep, rootEntity } = useReplicache(); ··· 26 let focusedBlock = useUIState( 27 (s) => s.focusedEntity?.entityID === props.entityID, 28 ); 29 + let entity_set = useEntitySetContext(); 30 + let { permissions } = entity_set; 31 const [html, setHTML] = useState<string | null>(null); 32 33 useLayoutEffect(() => { ··· 102 }} 103 value={lang} 104 onChange={async (e) => { 105 + localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value); 106 await rep?.mutate.assertFact({ 107 attribute: "block/code-language", 108 entity: props.entityID, ··· 126 data-entityid={props.entityID} 127 id={elementId.block(props.entityID).input} 128 block={props} 129 + rep={rep} 130 + permissionSet={entity_set.set} 131 + spellCheck={false} 132 + autoCapitalize="none" 133 + autoCorrect="off" 134 className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2" 135 value={content?.data.value} 136 onChange={async (e) => {
+2 -2
components/Blocks/DateTimeBlock.tsx
··· 8 import { setHours, setMinutes } from "date-fns"; 9 import { Separator } from "react-aria-components"; 10 import { Checkbox } from "components/Checkbox"; 11 - import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 12 import { useSpring, animated } from "@react-spring/web"; 13 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 14 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; 15 16 export function DateTimeBlock(props: BlockProps) { 17 const [isClient, setIsClient] = useState(false); 18 - let initialPageLoad = useInitialPageLoad(); 19 20 useEffect(() => { 21 setIsClient(true);
··· 8 import { setHours, setMinutes } from "date-fns"; 9 import { Separator } from "react-aria-components"; 10 import { Checkbox } from "components/Checkbox"; 11 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 12 import { useSpring, animated } from "@react-spring/web"; 13 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 14 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; 15 16 export function DateTimeBlock(props: BlockProps) { 17 const [isClient, setIsClient] = useState(false); 18 + let initialPageLoad = useHasPageLoaded(); 19 20 useEffect(() => { 21 setIsClient(true);
+2 -120
components/Blocks/DeleteBlock.tsx
··· 1 - import { 2 - Fact, 3 - ReplicacheMutators, 4 - useEntity, 5 - useReplicache, 6 - } from "src/replicache"; 7 - import { Replicache } from "replicache"; 8 - import { useUIState } from "src/useUIState"; 9 - import { scanIndex } from "src/replicache/utils"; 10 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 - import { focusBlock } from "src/utils/focusBlock"; 12 import { ButtonPrimary } from "components/Buttons"; 13 import { CloseTiny } from "components/Icons/CloseTiny"; 14 15 export const AreYouSure = (props: { 16 entityID: string[] | string; ··· 82 ); 83 }; 84 85 - export async function deleteBlock( 86 - entities: string[], 87 - rep: Replicache<ReplicacheMutators>, 88 - ) { 89 - // get what pagess we need to close as a result of deleting this block 90 - let pagesToClose = [] as string[]; 91 - for (let entity of entities) { 92 - let [type] = await rep.query((tx) => 93 - scanIndex(tx).eav(entity, "block/type"), 94 - ); 95 - if (type.data.value === "card") { 96 - let [childPages] = await rep?.query( 97 - (tx) => scanIndex(tx).eav(entity, "block/card") || [], 98 - ); 99 - pagesToClose = [childPages?.data.value]; 100 - } 101 - if (type.data.value === "mailbox") { 102 - let [archive] = await rep?.query( 103 - (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], 104 - ); 105 - let [draft] = await rep?.query( 106 - (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], 107 - ); 108 - pagesToClose = [archive?.data.value, draft?.data.value]; 109 - } 110 - } 111 - 112 - // the next and previous blocks in the block list 113 - // if the focused thing is a page and not a block, return 114 - let focusedBlock = useUIState.getState().focusedEntity; 115 - let parent = 116 - focusedBlock?.entityType === "page" 117 - ? focusedBlock.entityID 118 - : focusedBlock?.parent; 119 - 120 - if (parent) { 121 - let parentType = await rep?.query((tx) => 122 - scanIndex(tx).eav(parent, "page/type"), 123 - ); 124 - if (parentType[0]?.data.value === "canvas") { 125 - useUIState 126 - .getState() 127 - .setFocusedBlock({ entityType: "page", entityID: parent }); 128 - useUIState.getState().setSelectedBlocks([]); 129 - } else { 130 - let siblings = 131 - (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 132 - 133 - let selectedBlocks = useUIState.getState().selectedBlocks; 134 - let firstSelected = selectedBlocks[0]; 135 - let lastSelected = selectedBlocks[entities.length - 1]; 136 - 137 - let prevBlock = 138 - siblings?.[ 139 - siblings.findIndex((s) => s.value === firstSelected?.value) - 1 140 - ]; 141 - let prevBlockType = await rep?.query((tx) => 142 - scanIndex(tx).eav(prevBlock?.value, "block/type"), 143 - ); 144 - 145 - let nextBlock = 146 - siblings?.[ 147 - siblings.findIndex((s) => s.value === lastSelected.value) + 1 148 - ]; 149 - let nextBlockType = await rep?.query((tx) => 150 - scanIndex(tx).eav(nextBlock?.value, "block/type"), 151 - ); 152 - 153 - if (prevBlock) { 154 - useUIState.getState().setSelectedBlock({ 155 - value: prevBlock.value, 156 - parent: prevBlock.parent, 157 - }); 158 - 159 - focusBlock( 160 - { 161 - value: prevBlock.value, 162 - type: prevBlockType?.[0].data.value, 163 - parent: prevBlock.parent, 164 - }, 165 - { type: "end" }, 166 - ); 167 - } else { 168 - useUIState.getState().setSelectedBlock({ 169 - value: nextBlock.value, 170 - parent: nextBlock.parent, 171 - }); 172 - 173 - focusBlock( 174 - { 175 - value: nextBlock.value, 176 - type: nextBlockType?.[0]?.data.value, 177 - parent: nextBlock.parent, 178 - }, 179 - { type: "start" }, 180 - ); 181 - } 182 - } 183 - } 184 - 185 - pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 186 - await Promise.all( 187 - entities.map((entity) => 188 - rep?.mutate.removeBlock({ 189 - blockEntity: entity, 190 - }), 191 - ), 192 - ); 193 - }
··· 1 + import { Fact, useReplicache } from "src/replicache"; 2 import { ButtonPrimary } from "components/Buttons"; 3 import { CloseTiny } from "components/Icons/CloseTiny"; 4 + import { deleteBlock } from "src/utils/deleteBlock"; 5 6 export const AreYouSure = (props: { 7 entityID: string[] | string; ··· 73 ); 74 }; 75
+65 -16
components/Blocks/EmbedBlock.tsx
··· 10 import { Input } from "components/Input"; 11 import { isUrl } from "src/utils/isURL"; 12 import { elementId } from "src/utils/elementId"; 13 - import { deleteBlock } from "./DeleteBlock"; 14 import { focusBlock } from "src/utils/focusBlock"; 15 import { useDrag } from "src/hooks/useDrag"; 16 import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall"; 17 import { CheckTiny } from "components/Icons/CheckTiny"; 18 19 export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => { 20 let { permissions } = useEntitySetContext(); ··· 132 133 let entity_set = useEntitySetContext(); 134 let [linkValue, setLinkValue] = useState(""); 135 let { rep } = useReplicache(); 136 let submit = async () => { 137 let entity = props.entityID; ··· 149 } 150 let link = linkValue; 151 if (!linkValue.startsWith("http")) link = `https://${linkValue}`; 152 - // these mutations = simpler subset of addLinkBlock 153 if (!rep) return; 154 - await rep.mutate.assertFact({ 155 - entity: entity, 156 - attribute: "block/type", 157 - data: { type: "block-type-union", value: "embed" }, 158 - }); 159 - await rep?.mutate.assertFact({ 160 - entity: entity, 161 - attribute: "embed/url", 162 - data: { 163 - type: "string", 164 - value: link, 165 - }, 166 - }); 167 }; 168 let smoker = useSmoker(); 169 ··· 171 <form 172 onSubmit={(e) => { 173 e.preventDefault(); 174 let rect = document 175 .getElementById("embed-block-submit") 176 ?.getBoundingClientRect(); ··· 212 <button 213 type="submit" 214 id="embed-block-submit" 215 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 216 onMouseDown={(e) => { 217 e.preventDefault(); 218 if (!linkValue || linkValue === "") { 219 smoker({ 220 error: true, ··· 234 submit(); 235 }} 236 > 237 - <CheckTiny /> 238 </button> 239 </div> 240 </form>
··· 10 import { Input } from "components/Input"; 11 import { isUrl } from "src/utils/isURL"; 12 import { elementId } from "src/utils/elementId"; 13 import { focusBlock } from "src/utils/focusBlock"; 14 import { useDrag } from "src/hooks/useDrag"; 15 import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall"; 16 import { CheckTiny } from "components/Icons/CheckTiny"; 17 + import { DotLoader } from "components/utils/DotLoader"; 18 + import { 19 + LinkPreviewBody, 20 + LinkPreviewMetadataResult, 21 + } from "app/api/link_previews/route"; 22 23 export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => { 24 let { permissions } = useEntitySetContext(); ··· 136 137 let entity_set = useEntitySetContext(); 138 let [linkValue, setLinkValue] = useState(""); 139 + let [loading, setLoading] = useState(false); 140 let { rep } = useReplicache(); 141 let submit = async () => { 142 let entity = props.entityID; ··· 154 } 155 let link = linkValue; 156 if (!linkValue.startsWith("http")) link = `https://${linkValue}`; 157 if (!rep) return; 158 + 159 + // Try to get embed URL from iframely, fallback to direct URL 160 + setLoading(true); 161 + try { 162 + let res = await fetch("/api/link_previews", { 163 + headers: { "Content-Type": "application/json" }, 164 + method: "POST", 165 + body: JSON.stringify({ url: link, type: "meta" } as LinkPreviewBody), 166 + }); 167 + 168 + let embedUrl = link; 169 + let embedHeight = 360; 170 + 171 + if (res.status === 200) { 172 + let data = await (res.json() as LinkPreviewMetadataResult); 173 + if (data.success && data.data.links?.player?.[0]) { 174 + let embed = data.data.links.player[0]; 175 + embedUrl = embed.href; 176 + embedHeight = embed.media?.height || 300; 177 + } 178 + } 179 + 180 + await rep.mutate.assertFact([ 181 + { 182 + entity: entity, 183 + attribute: "embed/url", 184 + data: { 185 + type: "string", 186 + value: embedUrl, 187 + }, 188 + }, 189 + { 190 + entity: entity, 191 + attribute: "embed/height", 192 + data: { 193 + type: "number", 194 + value: embedHeight, 195 + }, 196 + }, 197 + ]); 198 + } catch { 199 + // On any error, fallback to using the URL directly 200 + await rep.mutate.assertFact([ 201 + { 202 + entity: entity, 203 + attribute: "embed/url", 204 + data: { 205 + type: "string", 206 + value: link, 207 + }, 208 + }, 209 + ]); 210 + } finally { 211 + setLoading(false); 212 + } 213 }; 214 let smoker = useSmoker(); 215 ··· 217 <form 218 onSubmit={(e) => { 219 e.preventDefault(); 220 + if (loading) return; 221 let rect = document 222 .getElementById("embed-block-submit") 223 ?.getBoundingClientRect(); ··· 259 <button 260 type="submit" 261 id="embed-block-submit" 262 + disabled={loading} 263 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 264 onMouseDown={(e) => { 265 e.preventDefault(); 266 + if (loading) return; 267 if (!linkValue || linkValue === "") { 268 smoker({ 269 error: true, ··· 283 submit(); 284 }} 285 > 286 + {loading ? <DotLoader /> : <CheckTiny />} 287 </button> 288 </div> 289 </form>
+2 -1
components/Blocks/ExternalLinkBlock.tsx
··· 8 import { v7 } from "uuid"; 9 import { useSmoker } from "components/Toast"; 10 import { Separator } from "components/Layout"; 11 - import { focusElement, Input } from "components/Input"; 12 import { isUrl } from "src/utils/isURL"; 13 import { elementId } from "src/utils/elementId"; 14 import { focusBlock } from "src/utils/focusBlock";
··· 8 import { v7 } from "uuid"; 9 import { useSmoker } from "components/Toast"; 10 import { Separator } from "components/Layout"; 11 + import { Input } from "components/Input"; 12 + import { focusElement } from "src/utils/focusElement"; 13 import { isUrl } from "src/utils/isURL"; 14 import { elementId } from "src/utils/elementId"; 15 import { focusBlock } from "src/utils/focusBlock";
+49 -26
components/Blocks/ImageBlock.tsx
··· 51 } 52 }, [isSelected, props.preview, props.entityID]); 53 54 if (!image) { 55 if (!entity_set.permissions.write) return null; 56 return ( ··· 65 ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 66 ${props.pageType === "canvas" && "bg-bg-page"}`} 67 onMouseDown={(e) => e.preventDefault()} 68 > 69 <div className="flex gap-2"> 70 <BlockImageSmall ··· 79 accept="image/*" 80 onChange={async (e) => { 81 let file = e.currentTarget.files?.[0]; 82 - if (!file || !rep) return; 83 - let entity = props.entityID; 84 - if (!entity) { 85 - entity = v7(); 86 - await rep?.mutate.addBlock({ 87 - parent: props.parent, 88 - factID: v7(), 89 - permission_set: entity_set.set, 90 - type: "text", 91 - position: generateKeyBetween( 92 - props.position, 93 - props.nextPosition, 94 - ), 95 - newEntityID: entity, 96 - }); 97 - } 98 - await rep.mutate.assertFact({ 99 - entity, 100 - attribute: "block/type", 101 - data: { type: "block-type-union", value: "image" }, 102 - }); 103 - await addImage(file, rep, { 104 - entityID: entity, 105 - attribute: "block/image", 106 - }); 107 }} 108 /> 109 </label> ··· 140 ) : ( 141 <Image 142 alt={altText || ""} 143 - src={new URL(image.data.src).pathname.split("/").slice(5).join("/")} 144 height={image?.data.height} 145 width={image?.data.width} 146 className={className}
··· 51 } 52 }, [isSelected, props.preview, props.entityID]); 53 54 + const handleImageUpload = async (file: File) => { 55 + if (!rep) return; 56 + let entity = props.entityID; 57 + if (!entity) { 58 + entity = v7(); 59 + await rep?.mutate.addBlock({ 60 + parent: props.parent, 61 + factID: v7(), 62 + permission_set: entity_set.set, 63 + type: "text", 64 + position: generateKeyBetween( 65 + props.position, 66 + props.nextPosition, 67 + ), 68 + newEntityID: entity, 69 + }); 70 + } 71 + await rep.mutate.assertFact({ 72 + entity, 73 + attribute: "block/type", 74 + data: { type: "block-type-union", value: "image" }, 75 + }); 76 + await addImage(file, rep, { 77 + entityID: entity, 78 + attribute: "block/image", 79 + }); 80 + }; 81 + 82 if (!image) { 83 if (!entity_set.permissions.write) return null; 84 return ( ··· 93 ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 94 ${props.pageType === "canvas" && "bg-bg-page"}`} 95 onMouseDown={(e) => e.preventDefault()} 96 + onDragOver={(e) => { 97 + e.preventDefault(); 98 + e.stopPropagation(); 99 + }} 100 + onDrop={async (e) => { 101 + e.preventDefault(); 102 + e.stopPropagation(); 103 + if (isLocked) return; 104 + const files = e.dataTransfer.files; 105 + if (files && files.length > 0) { 106 + const file = files[0]; 107 + if (file.type.startsWith('image/')) { 108 + await handleImageUpload(file); 109 + } 110 + } 111 + }} 112 > 113 <div className="flex gap-2"> 114 <BlockImageSmall ··· 123 accept="image/*" 124 onChange={async (e) => { 125 let file = e.currentTarget.files?.[0]; 126 + if (!file) return; 127 + await handleImageUpload(file); 128 }} 129 /> 130 </label> ··· 161 ) : ( 162 <Image 163 alt={altText || ""} 164 + src={ 165 + "/" + new URL(image.data.src).pathname.split("/").slice(5).join("/") 166 + } 167 height={image?.data.height} 168 width={image?.data.width} 169 className={className}
+1 -62
components/Blocks/MailboxBlock.tsx
··· 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 - import { focusPage } from "components/Pages"; 13 import { v7 } from "uuid"; 14 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; ··· 369 )} 370 </div> 371 </> 372 - ); 373 - }; 374 - 375 - export const DraftPostOptions = (props: { mailboxEntity: string }) => { 376 - let toaster = useToaster(); 377 - let draft = useEntity(props.mailboxEntity, "mailbox/draft"); 378 - let { rep, permission_token } = useReplicache(); 379 - let entity_set = useEntitySetContext(); 380 - let pagetitle = usePageTitle(permission_token.root_entity); 381 - let subscriber_count = useEntity( 382 - props.mailboxEntity, 383 - "mailbox/subscriber-count", 384 - ); 385 - if (!draft) return null; 386 - 387 - // once the send button is clicked, close the page and show a toast. 388 - return ( 389 - <div className="flex justify-between items-center text-sm"> 390 - <div className="flex gap-2"> 391 - <em>Draft</em> 392 - </div> 393 - <button 394 - className="font-bold text-accent-2 bg-accent-1 border hover:bg-accent-2 hover:text-accent-1 rounded-md px-2" 395 - onClick={async () => { 396 - if (!rep) return; 397 - let blocks = 398 - (await rep?.query((tx) => 399 - getBlocksWithType(tx, draft.data.value), 400 - )) || []; 401 - let html = (await getBlocksAsHTML(rep, blocks))?.join("\n"); 402 - await sendPostToSubscribers({ 403 - title: pagetitle, 404 - permission_token, 405 - mailboxEntity: props.mailboxEntity, 406 - messageEntity: draft.data.value, 407 - contents: { 408 - html, 409 - markdown: htmlToMarkdown(html), 410 - }, 411 - }); 412 - 413 - rep?.mutate.archiveDraft({ 414 - entity_set: entity_set.set, 415 - mailboxEntity: props.mailboxEntity, 416 - newBlockEntity: v7(), 417 - archiveEntity: v7(), 418 - }); 419 - 420 - toaster({ 421 - content: <div className="font-bold">Sent Post to Readers!</div>, 422 - type: "success", 423 - }); 424 - }} 425 - > 426 - Send 427 - {!subscriber_count || 428 - (subscriber_count.data.value !== 0 && 429 - ` to ${subscriber_count.data.value} Reader${subscriber_count.data.value === 1 ? "" : "s"}`)} 430 - ! 431 - </button> 432 - </div> 433 ); 434 }; 435
··· 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 11 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 12 + import { focusPage } from "src/utils/focusPage"; 13 import { v7 } from "uuid"; 14 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 15 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; ··· 369 )} 370 </div> 371 </> 372 ); 373 }; 374
+3
components/Blocks/MathBlock.tsx
··· 35 <BaseTextareaBlock 36 id={elementId.block(props.entityID).input} 37 block={props} 38 className="bg-border-light rounded-md p-2 w-full min-h-[48px] whitespace-nowrap overflow-auto! border-border-light outline-border-light selected-outline" 39 placeholder="write some Tex here..." 40 value={content?.data.value}
··· 35 <BaseTextareaBlock 36 id={elementId.block(props.entityID).input} 37 block={props} 38 + spellCheck={false} 39 + autoCapitalize="none" 40 + autoCorrect="off" 41 className="bg-border-light rounded-md p-2 w-full min-h-[48px] whitespace-nowrap overflow-auto! border-border-light outline-border-light selected-outline" 42 placeholder="write some Tex here..." 43 value={content?.data.value}
+1 -1
components/Blocks/PageLinkBlock.tsx
··· 2 import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 import { focusBlock } from "src/utils/focusBlock"; 4 5 - import { focusPage } from "components/Pages"; 6 import { useEntity, useReplicache } from "src/replicache"; 7 import { useUIState } from "src/useUIState"; 8 import { RenderedTextBlock } from "components/Blocks/TextBlock";
··· 2 import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 import { focusBlock } from "src/utils/focusBlock"; 4 5 + import { focusPage } from "src/utils/focusPage"; 6 import { useEntity, useReplicache } from "src/replicache"; 7 import { useUIState } from "src/useUIState"; 8 import { RenderedTextBlock } from "components/Blocks/TextBlock";
+501
components/Blocks/PollBlock/index.tsx
···
··· 1 + import { useUIState } from "src/useUIState"; 2 + import { BlockProps } from "../Block"; 3 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 + import { useCallback, useEffect, useState } from "react"; 5 + import { Input } from "components/Input"; 6 + import { focusElement } from "src/utils/focusElement"; 7 + import { Separator } from "components/Layout"; 8 + import { useEntitySetContext } from "components/EntitySetProvider"; 9 + import { theme } from "tailwind.config"; 10 + import { useEntity, useReplicache } from "src/replicache"; 11 + import { v7 } from "uuid"; 12 + import { 13 + useLeafletPublicationData, 14 + usePollData, 15 + } from "components/PageSWRDataProvider"; 16 + import { voteOnPoll } from "actions/pollActions"; 17 + import { elementId } from "src/utils/elementId"; 18 + import { CheckTiny } from "components/Icons/CheckTiny"; 19 + import { CloseTiny } from "components/Icons/CloseTiny"; 20 + import { PublicationPollBlock } from "../PublicationPollBlock"; 21 + import { usePollBlockUIState } from "./pollBlockState"; 22 + 23 + export const PollBlock = (props: BlockProps) => { 24 + let { data: pub } = useLeafletPublicationData(); 25 + if (!pub) return <LeafletPollBlock {...props} />; 26 + return <PublicationPollBlock {...props} />; 27 + }; 28 + 29 + export const LeafletPollBlock = (props: BlockProps) => { 30 + let isSelected = useUIState((s) => 31 + s.selectedBlocks.find((b) => b.value === props.entityID), 32 + ); 33 + let { permissions } = useEntitySetContext(); 34 + 35 + let { data: pollData } = usePollData(); 36 + let hasVoted = 37 + pollData?.voter_token && 38 + pollData.polls.find( 39 + (v) => 40 + v.poll_votes_on_entity.voter_token === pollData.voter_token && 41 + v.poll_votes_on_entity.poll_entity === props.entityID, 42 + ); 43 + 44 + let pollState = usePollBlockUIState((s) => s[props.entityID]?.state); 45 + if (!pollState) { 46 + if (hasVoted) pollState = "results"; 47 + else pollState = "voting"; 48 + } 49 + 50 + const setPollState = useCallback( 51 + (state: "editing" | "voting" | "results") => { 52 + usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } })); 53 + }, 54 + [], 55 + ); 56 + 57 + let votes = 58 + pollData?.polls.filter( 59 + (v) => v.poll_votes_on_entity.poll_entity === props.entityID, 60 + ) || []; 61 + let totalVotes = votes.length; 62 + 63 + return ( 64 + <div 65 + className={`poll flex flex-col gap-2 p-3 w-full 66 + ${isSelected ? "block-border-selected " : "block-border"}`} 67 + style={{ 68 + backgroundColor: 69 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 70 + }} 71 + > 72 + {pollState === "editing" ? ( 73 + <EditPoll 74 + totalVotes={totalVotes} 75 + votes={votes.map((v) => v.poll_votes_on_entity)} 76 + entityID={props.entityID} 77 + close={() => { 78 + if (hasVoted) setPollState("results"); 79 + else setPollState("voting"); 80 + }} 81 + /> 82 + ) : pollState === "results" ? ( 83 + <PollResults 84 + entityID={props.entityID} 85 + pollState={pollState} 86 + setPollState={setPollState} 87 + hasVoted={!!hasVoted} 88 + /> 89 + ) : ( 90 + <PollVote 91 + entityID={props.entityID} 92 + onSubmit={() => setPollState("results")} 93 + pollState={pollState} 94 + setPollState={setPollState} 95 + hasVoted={!!hasVoted} 96 + /> 97 + )} 98 + </div> 99 + ); 100 + }; 101 + 102 + const PollVote = (props: { 103 + entityID: string; 104 + onSubmit: () => void; 105 + pollState: "editing" | "voting" | "results"; 106 + setPollState: (pollState: "editing" | "voting" | "results") => void; 107 + hasVoted: boolean; 108 + }) => { 109 + let { data, mutate } = usePollData(); 110 + let { permissions } = useEntitySetContext(); 111 + 112 + let pollOptions = useEntity(props.entityID, "poll/options"); 113 + let currentVotes = data?.voter_token 114 + ? data.polls 115 + .filter( 116 + (p) => 117 + p.poll_votes_on_entity.poll_entity === props.entityID && 118 + p.poll_votes_on_entity.voter_token === data.voter_token, 119 + ) 120 + .map((v) => v.poll_votes_on_entity.option_entity) 121 + : []; 122 + let [selectedPollOptions, setSelectedPollOptions] = 123 + useState<string[]>(currentVotes); 124 + 125 + return ( 126 + <> 127 + {pollOptions.map((option, index) => ( 128 + <PollVoteButton 129 + key={option.data.value} 130 + selected={selectedPollOptions.includes(option.data.value)} 131 + toggleSelected={() => 132 + setSelectedPollOptions((s) => 133 + s.includes(option.data.value) 134 + ? s.filter((s) => s !== option.data.value) 135 + : [...s, option.data.value], 136 + ) 137 + } 138 + entityID={option.data.value} 139 + /> 140 + ))} 141 + <div className="flex justify-between items-center"> 142 + <div className="flex justify-end gap-2"> 143 + {permissions.write && ( 144 + <button 145 + className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 146 + onClick={() => { 147 + props.setPollState("editing"); 148 + }} 149 + > 150 + Edit Options 151 + </button> 152 + )} 153 + 154 + {permissions.write && <Separator classname="h-6" />} 155 + <PollStateToggle 156 + setPollState={props.setPollState} 157 + pollState={props.pollState} 158 + hasVoted={props.hasVoted} 159 + /> 160 + </div> 161 + <ButtonPrimary 162 + className="place-self-end" 163 + onClick={async () => { 164 + await voteOnPoll(props.entityID, selectedPollOptions); 165 + mutate((oldState) => { 166 + if (!oldState || !oldState.voter_token) return; 167 + return { 168 + ...oldState, 169 + polls: [ 170 + ...oldState.polls.filter( 171 + (p) => 172 + !( 173 + p.poll_votes_on_entity.voter_token === 174 + oldState.voter_token && 175 + p.poll_votes_on_entity.poll_entity == props.entityID 176 + ), 177 + ), 178 + ...selectedPollOptions.map((option_entity) => ({ 179 + poll_votes_on_entity: { 180 + option_entity, 181 + entities: { set: "" }, 182 + poll_entity: props.entityID, 183 + voter_token: oldState.voter_token!, 184 + }, 185 + })), 186 + ], 187 + }; 188 + }); 189 + props.onSubmit(); 190 + }} 191 + disabled={ 192 + selectedPollOptions.length === 0 || 193 + (selectedPollOptions.length === currentVotes.length && 194 + selectedPollOptions.every((s) => currentVotes.includes(s))) 195 + } 196 + > 197 + Vote! 198 + </ButtonPrimary> 199 + </div> 200 + </> 201 + ); 202 + }; 203 + const PollVoteButton = (props: { 204 + entityID: string; 205 + selected: boolean; 206 + toggleSelected: () => void; 207 + }) => { 208 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 209 + if (!optionName) return null; 210 + if (props.selected) 211 + return ( 212 + <div className="flex gap-2 items-center"> 213 + <ButtonPrimary 214 + className={`pollOption grow max-w-full flex`} 215 + onClick={() => { 216 + props.toggleSelected(); 217 + }} 218 + > 219 + {optionName} 220 + </ButtonPrimary> 221 + </div> 222 + ); 223 + return ( 224 + <div className="flex gap-2 items-center"> 225 + <ButtonSecondary 226 + className={`pollOption grow max-w-full flex`} 227 + onClick={() => { 228 + props.toggleSelected(); 229 + }} 230 + > 231 + {optionName} 232 + </ButtonSecondary> 233 + </div> 234 + ); 235 + }; 236 + 237 + const PollResults = (props: { 238 + entityID: string; 239 + pollState: "editing" | "voting" | "results"; 240 + setPollState: (pollState: "editing" | "voting" | "results") => void; 241 + hasVoted: boolean; 242 + }) => { 243 + let { data } = usePollData(); 244 + let { permissions } = useEntitySetContext(); 245 + let pollOptions = useEntity(props.entityID, "poll/options"); 246 + let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID); 247 + let votesByOptions = pollData?.votesByOption || {}; 248 + let highestVotes = Math.max(...Object.values(votesByOptions)); 249 + let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>( 250 + (winningEntities, [entity, votes]) => { 251 + if (votes === highestVotes) winningEntities.push(entity); 252 + return winningEntities; 253 + }, 254 + [], 255 + ); 256 + return ( 257 + <> 258 + {pollOptions.map((p) => ( 259 + <PollResult 260 + key={p.id} 261 + winner={winningOptionEntities.includes(p.data.value)} 262 + entityID={p.data.value} 263 + totalVotes={pollData?.unique_votes || 0} 264 + votes={pollData?.votesByOption[p.data.value] || 0} 265 + /> 266 + ))} 267 + <div className="flex gap-2"> 268 + {permissions.write && ( 269 + <button 270 + className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 271 + onClick={() => { 272 + props.setPollState("editing"); 273 + }} 274 + > 275 + Edit Options 276 + </button> 277 + )} 278 + 279 + {permissions.write && <Separator classname="h-6" />} 280 + <PollStateToggle 281 + setPollState={props.setPollState} 282 + pollState={props.pollState} 283 + hasVoted={props.hasVoted} 284 + /> 285 + </div> 286 + </> 287 + ); 288 + }; 289 + 290 + const PollResult = (props: { 291 + entityID: string; 292 + votes: number; 293 + totalVotes: number; 294 + winner: boolean; 295 + }) => { 296 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 297 + return ( 298 + <div 299 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 300 + > 301 + <div 302 + style={{ 303 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`, 304 + paintOrder: "stroke fill", 305 + }} 306 + className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`} 307 + > 308 + <div className="grow max-w-full truncate">{optionName}</div> 309 + <div>{props.votes}</div> 310 + </div> 311 + <div 312 + className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`} 313 + > 314 + <div 315 + className={`bg-accent-contrast rounded-[2px] m-0.5`} 316 + style={{ 317 + maskImage: "var(--hatchSVG)", 318 + maskRepeat: "repeat repeat", 319 + 320 + ...(props.votes === 0 321 + ? { width: "4px" } 322 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 323 + }} 324 + /> 325 + <div /> 326 + </div> 327 + </div> 328 + ); 329 + }; 330 + 331 + const EditPoll = (props: { 332 + votes: { option_entity: string }[]; 333 + totalVotes: number; 334 + entityID: string; 335 + close: () => void; 336 + }) => { 337 + let pollOptions = useEntity(props.entityID, "poll/options"); 338 + let { rep } = useReplicache(); 339 + let permission_set = useEntitySetContext(); 340 + let [localPollOptionNames, setLocalPollOptionNames] = useState<{ 341 + [k: string]: string; 342 + }>({}); 343 + return ( 344 + <> 345 + {props.totalVotes > 0 && ( 346 + <div className="text-sm italic text-tertiary"> 347 + You can&apos;t edit options people already voted for! 348 + </div> 349 + )} 350 + 351 + {pollOptions.length === 0 && ( 352 + <div className="text-center italic text-tertiary text-sm"> 353 + no options yet... 354 + </div> 355 + )} 356 + {pollOptions.map((p) => ( 357 + <EditPollOption 358 + key={p.id} 359 + entityID={p.data.value} 360 + pollEntity={props.entityID} 361 + disabled={!!props.votes.find((v) => v.option_entity === p.data.value)} 362 + localNameState={localPollOptionNames[p.data.value]} 363 + setLocalNameState={setLocalPollOptionNames} 364 + /> 365 + ))} 366 + 367 + <button 368 + className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 369 + onClick={async () => { 370 + let pollOptionEntity = v7(); 371 + await rep?.mutate.addPollOption({ 372 + pollEntity: props.entityID, 373 + pollOptionEntity, 374 + pollOptionName: "", 375 + permission_set: permission_set.set, 376 + factID: v7(), 377 + }); 378 + 379 + focusElement( 380 + document.getElementById( 381 + elementId.block(props.entityID).pollInput(pollOptionEntity), 382 + ) as HTMLInputElement | null, 383 + ); 384 + }} 385 + > 386 + Add an Option 387 + </button> 388 + 389 + <hr className="border-border" /> 390 + <ButtonPrimary 391 + className="place-self-end" 392 + onClick={async () => { 393 + // remove any poll options that have no name 394 + // look through the localPollOptionNames object and remove any options that have no name 395 + let emptyOptions = Object.entries(localPollOptionNames).filter( 396 + ([optionEntity, optionName]) => optionName === "", 397 + ); 398 + await Promise.all( 399 + emptyOptions.map( 400 + async ([entity]) => 401 + await rep?.mutate.removePollOption({ 402 + optionEntity: entity, 403 + }), 404 + ), 405 + ); 406 + 407 + await rep?.mutate.assertFact( 408 + Object.entries(localPollOptionNames) 409 + .filter(([, name]) => !!name) 410 + .map(([entity, name]) => ({ 411 + entity, 412 + attribute: "poll-option/name", 413 + data: { type: "string", value: name }, 414 + })), 415 + ); 416 + props.close(); 417 + }} 418 + > 419 + Save <CheckTiny /> 420 + </ButtonPrimary> 421 + </> 422 + ); 423 + }; 424 + 425 + const EditPollOption = (props: { 426 + entityID: string; 427 + pollEntity: string; 428 + localNameState: string | undefined; 429 + setLocalNameState: ( 430 + s: (s: { [k: string]: string }) => { [k: string]: string }, 431 + ) => void; 432 + disabled: boolean; 433 + }) => { 434 + let { rep } = useReplicache(); 435 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 436 + useEffect(() => { 437 + props.setLocalNameState((s) => ({ 438 + ...s, 439 + [props.entityID]: optionName || "", 440 + })); 441 + }, [optionName, props.setLocalNameState, props.entityID]); 442 + 443 + return ( 444 + <div className="flex gap-2 items-center"> 445 + <Input 446 + id={elementId.block(props.pollEntity).pollInput(props.entityID)} 447 + type="text" 448 + className="pollOptionInput w-full input-with-border" 449 + placeholder="Option here..." 450 + disabled={props.disabled} 451 + value={ 452 + props.localNameState === undefined ? optionName : props.localNameState 453 + } 454 + onChange={(e) => { 455 + props.setLocalNameState((s) => ({ 456 + ...s, 457 + [props.entityID]: e.target.value, 458 + })); 459 + }} 460 + onKeyDown={(e) => { 461 + if (e.key === "Backspace" && !e.currentTarget.value) { 462 + e.preventDefault(); 463 + rep?.mutate.removePollOption({ optionEntity: props.entityID }); 464 + } 465 + }} 466 + /> 467 + 468 + <button 469 + tabIndex={-1} 470 + disabled={props.disabled} 471 + className="text-accent-contrast disabled:text-border" 472 + onMouseDown={async () => { 473 + await rep?.mutate.removePollOption({ optionEntity: props.entityID }); 474 + }} 475 + > 476 + <CloseTiny /> 477 + </button> 478 + </div> 479 + ); 480 + }; 481 + 482 + const PollStateToggle = (props: { 483 + setPollState: (pollState: "editing" | "voting" | "results") => void; 484 + hasVoted: boolean; 485 + pollState: "editing" | "voting" | "results"; 486 + }) => { 487 + return ( 488 + <button 489 + className="text-sm text-accent-contrast sm:hover:underline" 490 + onClick={() => { 491 + props.setPollState(props.pollState === "voting" ? "results" : "voting"); 492 + }} 493 + > 494 + {props.pollState === "voting" 495 + ? "See Results" 496 + : props.hasVoted 497 + ? "Change Vote" 498 + : "Back to Poll"} 499 + </button> 500 + ); 501 + };
+8
components/Blocks/PollBlock/pollBlockState.ts
···
··· 1 + import { create } from "zustand"; 2 + 3 + export let usePollBlockUIState = create( 4 + () => 5 + ({}) as { 6 + [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 7 + }, 8 + );
-496
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 { usePollData } from "components/PageSWRDataProvider"; 12 - import { voteOnPoll } from "actions/pollActions"; 13 - import { create } from "zustand"; 14 - import { elementId } from "src/utils/elementId"; 15 - import { CheckTiny } from "components/Icons/CheckTiny"; 16 - import { CloseTiny } from "components/Icons/CloseTiny"; 17 - 18 - export let usePollBlockUIState = create( 19 - () => 20 - ({}) as { 21 - [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 22 - }, 23 - ); 24 - export const PollBlock = (props: BlockProps) => { 25 - let isSelected = useUIState((s) => 26 - s.selectedBlocks.find((b) => b.value === props.entityID), 27 - ); 28 - let { permissions } = useEntitySetContext(); 29 - 30 - let { data: pollData } = usePollData(); 31 - let hasVoted = 32 - pollData?.voter_token && 33 - pollData.polls.find( 34 - (v) => 35 - v.poll_votes_on_entity.voter_token === pollData.voter_token && 36 - v.poll_votes_on_entity.poll_entity === props.entityID, 37 - ); 38 - 39 - let pollState = usePollBlockUIState((s) => s[props.entityID]?.state); 40 - if (!pollState) { 41 - if (hasVoted) pollState = "results"; 42 - else pollState = "voting"; 43 - } 44 - 45 - const setPollState = useCallback( 46 - (state: "editing" | "voting" | "results") => { 47 - usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } })); 48 - }, 49 - [], 50 - ); 51 - 52 - let votes = 53 - pollData?.polls.filter( 54 - (v) => v.poll_votes_on_entity.poll_entity === props.entityID, 55 - ) || []; 56 - let totalVotes = votes.length; 57 - 58 - return ( 59 - <div 60 - className={`poll flex flex-col gap-2 p-3 w-full 61 - ${isSelected ? "block-border-selected " : "block-border"}`} 62 - style={{ 63 - backgroundColor: 64 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 65 - }} 66 - > 67 - {pollState === "editing" ? ( 68 - <EditPoll 69 - totalVotes={totalVotes} 70 - votes={votes.map((v) => v.poll_votes_on_entity)} 71 - entityID={props.entityID} 72 - close={() => { 73 - if (hasVoted) setPollState("results"); 74 - else setPollState("voting"); 75 - }} 76 - /> 77 - ) : pollState === "results" ? ( 78 - <PollResults 79 - entityID={props.entityID} 80 - pollState={pollState} 81 - setPollState={setPollState} 82 - hasVoted={!!hasVoted} 83 - /> 84 - ) : ( 85 - <PollVote 86 - entityID={props.entityID} 87 - onSubmit={() => setPollState("results")} 88 - pollState={pollState} 89 - setPollState={setPollState} 90 - hasVoted={!!hasVoted} 91 - /> 92 - )} 93 - </div> 94 - ); 95 - }; 96 - 97 - const PollVote = (props: { 98 - entityID: string; 99 - onSubmit: () => void; 100 - pollState: "editing" | "voting" | "results"; 101 - setPollState: (pollState: "editing" | "voting" | "results") => void; 102 - hasVoted: boolean; 103 - }) => { 104 - let { data, mutate } = usePollData(); 105 - let { permissions } = useEntitySetContext(); 106 - 107 - let pollOptions = useEntity(props.entityID, "poll/options"); 108 - let currentVotes = data?.voter_token 109 - ? data.polls 110 - .filter( 111 - (p) => 112 - p.poll_votes_on_entity.poll_entity === props.entityID && 113 - p.poll_votes_on_entity.voter_token === data.voter_token, 114 - ) 115 - .map((v) => v.poll_votes_on_entity.option_entity) 116 - : []; 117 - let [selectedPollOptions, setSelectedPollOptions] = 118 - useState<string[]>(currentVotes); 119 - 120 - return ( 121 - <> 122 - {pollOptions.map((option, index) => ( 123 - <PollVoteButton 124 - key={option.data.value} 125 - selected={selectedPollOptions.includes(option.data.value)} 126 - toggleSelected={() => 127 - setSelectedPollOptions((s) => 128 - s.includes(option.data.value) 129 - ? s.filter((s) => s !== option.data.value) 130 - : [...s, option.data.value], 131 - ) 132 - } 133 - entityID={option.data.value} 134 - /> 135 - ))} 136 - <div className="flex justify-between items-center"> 137 - <div className="flex justify-end gap-2"> 138 - {permissions.write && ( 139 - <button 140 - className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 141 - onClick={() => { 142 - props.setPollState("editing"); 143 - }} 144 - > 145 - Edit Options 146 - </button> 147 - )} 148 - 149 - {permissions.write && <Separator classname="h-6" />} 150 - <PollStateToggle 151 - setPollState={props.setPollState} 152 - pollState={props.pollState} 153 - hasVoted={props.hasVoted} 154 - /> 155 - </div> 156 - <ButtonPrimary 157 - className="place-self-end" 158 - onClick={async () => { 159 - await voteOnPoll(props.entityID, selectedPollOptions); 160 - mutate((oldState) => { 161 - if (!oldState || !oldState.voter_token) return; 162 - return { 163 - ...oldState, 164 - polls: [ 165 - ...oldState.polls.filter( 166 - (p) => 167 - !( 168 - p.poll_votes_on_entity.voter_token === 169 - oldState.voter_token && 170 - p.poll_votes_on_entity.poll_entity == props.entityID 171 - ), 172 - ), 173 - ...selectedPollOptions.map((option_entity) => ({ 174 - poll_votes_on_entity: { 175 - option_entity, 176 - entities: { set: "" }, 177 - poll_entity: props.entityID, 178 - voter_token: oldState.voter_token!, 179 - }, 180 - })), 181 - ], 182 - }; 183 - }); 184 - props.onSubmit(); 185 - }} 186 - disabled={ 187 - selectedPollOptions.length === 0 || 188 - (selectedPollOptions.length === currentVotes.length && 189 - selectedPollOptions.every((s) => currentVotes.includes(s))) 190 - } 191 - > 192 - Vote! 193 - </ButtonPrimary> 194 - </div> 195 - </> 196 - ); 197 - }; 198 - const PollVoteButton = (props: { 199 - entityID: string; 200 - selected: boolean; 201 - toggleSelected: () => void; 202 - }) => { 203 - let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 204 - if (!optionName) return null; 205 - if (props.selected) 206 - return ( 207 - <div className="flex gap-2 items-center"> 208 - <ButtonPrimary 209 - className={`pollOption grow max-w-full flex`} 210 - onClick={() => { 211 - props.toggleSelected(); 212 - }} 213 - > 214 - {optionName} 215 - </ButtonPrimary> 216 - </div> 217 - ); 218 - return ( 219 - <div className="flex gap-2 items-center"> 220 - <ButtonSecondary 221 - className={`pollOption grow max-w-full flex`} 222 - onClick={() => { 223 - props.toggleSelected(); 224 - }} 225 - > 226 - {optionName} 227 - </ButtonSecondary> 228 - </div> 229 - ); 230 - }; 231 - 232 - const PollResults = (props: { 233 - entityID: string; 234 - pollState: "editing" | "voting" | "results"; 235 - setPollState: (pollState: "editing" | "voting" | "results") => void; 236 - hasVoted: boolean; 237 - }) => { 238 - let { data } = usePollData(); 239 - let { permissions } = useEntitySetContext(); 240 - let pollOptions = useEntity(props.entityID, "poll/options"); 241 - let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID); 242 - let votesByOptions = pollData?.votesByOption || {}; 243 - let highestVotes = Math.max(...Object.values(votesByOptions)); 244 - let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>( 245 - (winningEntities, [entity, votes]) => { 246 - if (votes === highestVotes) winningEntities.push(entity); 247 - return winningEntities; 248 - }, 249 - [], 250 - ); 251 - return ( 252 - <> 253 - {pollOptions.map((p) => ( 254 - <PollResult 255 - key={p.id} 256 - winner={winningOptionEntities.includes(p.data.value)} 257 - entityID={p.data.value} 258 - totalVotes={pollData?.unique_votes || 0} 259 - votes={pollData?.votesByOption[p.data.value] || 0} 260 - /> 261 - ))} 262 - <div className="flex gap-2"> 263 - {permissions.write && ( 264 - <button 265 - className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 266 - onClick={() => { 267 - props.setPollState("editing"); 268 - }} 269 - > 270 - Edit Options 271 - </button> 272 - )} 273 - 274 - {permissions.write && <Separator classname="h-6" />} 275 - <PollStateToggle 276 - setPollState={props.setPollState} 277 - pollState={props.pollState} 278 - hasVoted={props.hasVoted} 279 - /> 280 - </div> 281 - </> 282 - ); 283 - }; 284 - 285 - const PollResult = (props: { 286 - entityID: string; 287 - votes: number; 288 - totalVotes: number; 289 - winner: boolean; 290 - }) => { 291 - let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 292 - return ( 293 - <div 294 - className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 295 - > 296 - <div 297 - style={{ 298 - WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`, 299 - paintOrder: "stroke fill", 300 - }} 301 - className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`} 302 - > 303 - <div className="grow max-w-full truncate">{optionName}</div> 304 - <div>{props.votes}</div> 305 - </div> 306 - <div 307 - className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`} 308 - > 309 - <div 310 - className={`bg-accent-contrast rounded-[2px] m-0.5`} 311 - style={{ 312 - maskImage: "var(--hatchSVG)", 313 - maskRepeat: "repeat repeat", 314 - 315 - ...(props.votes === 0 316 - ? { width: "4px" } 317 - : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 318 - }} 319 - /> 320 - <div /> 321 - </div> 322 - </div> 323 - ); 324 - }; 325 - 326 - const EditPoll = (props: { 327 - votes: { option_entity: string }[]; 328 - totalVotes: number; 329 - entityID: string; 330 - close: () => void; 331 - }) => { 332 - let pollOptions = useEntity(props.entityID, "poll/options"); 333 - let { rep } = useReplicache(); 334 - let permission_set = useEntitySetContext(); 335 - let [localPollOptionNames, setLocalPollOptionNames] = useState<{ 336 - [k: string]: string; 337 - }>({}); 338 - return ( 339 - <> 340 - {props.totalVotes > 0 && ( 341 - <div className="text-sm italic text-tertiary"> 342 - You can&apos;t edit options people already voted for! 343 - </div> 344 - )} 345 - 346 - {pollOptions.length === 0 && ( 347 - <div className="text-center italic text-tertiary text-sm"> 348 - no options yet... 349 - </div> 350 - )} 351 - {pollOptions.map((p) => ( 352 - <EditPollOption 353 - key={p.id} 354 - entityID={p.data.value} 355 - pollEntity={props.entityID} 356 - disabled={!!props.votes.find((v) => v.option_entity === p.data.value)} 357 - localNameState={localPollOptionNames[p.data.value]} 358 - setLocalNameState={setLocalPollOptionNames} 359 - /> 360 - ))} 361 - 362 - <button 363 - className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 364 - onClick={async () => { 365 - let pollOptionEntity = v7(); 366 - await rep?.mutate.addPollOption({ 367 - pollEntity: props.entityID, 368 - pollOptionEntity, 369 - pollOptionName: "", 370 - permission_set: permission_set.set, 371 - factID: v7(), 372 - }); 373 - 374 - focusElement( 375 - document.getElementById( 376 - elementId.block(props.entityID).pollInput(pollOptionEntity), 377 - ) as HTMLInputElement | null, 378 - ); 379 - }} 380 - > 381 - Add an Option 382 - </button> 383 - 384 - <hr className="border-border" /> 385 - <ButtonPrimary 386 - className="place-self-end" 387 - onClick={async () => { 388 - // remove any poll options that have no name 389 - // look through the localPollOptionNames object and remove any options that have no name 390 - let emptyOptions = Object.entries(localPollOptionNames).filter( 391 - ([optionEntity, optionName]) => optionName === "", 392 - ); 393 - await Promise.all( 394 - emptyOptions.map( 395 - async ([entity]) => 396 - await rep?.mutate.removePollOption({ 397 - optionEntity: entity, 398 - }), 399 - ), 400 - ); 401 - 402 - await rep?.mutate.assertFact( 403 - Object.entries(localPollOptionNames) 404 - .filter(([, name]) => !!name) 405 - .map(([entity, name]) => ({ 406 - entity, 407 - attribute: "poll-option/name", 408 - data: { type: "string", value: name }, 409 - })), 410 - ); 411 - props.close(); 412 - }} 413 - > 414 - Save <CheckTiny /> 415 - </ButtonPrimary> 416 - </> 417 - ); 418 - }; 419 - 420 - const EditPollOption = (props: { 421 - entityID: string; 422 - pollEntity: string; 423 - localNameState: string | undefined; 424 - setLocalNameState: ( 425 - s: (s: { [k: string]: string }) => { [k: string]: string }, 426 - ) => void; 427 - disabled: boolean; 428 - }) => { 429 - let { rep } = useReplicache(); 430 - let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 431 - useEffect(() => { 432 - props.setLocalNameState((s) => ({ 433 - ...s, 434 - [props.entityID]: optionName || "", 435 - })); 436 - }, [optionName, props.setLocalNameState, props.entityID]); 437 - 438 - return ( 439 - <div className="flex gap-2 items-center"> 440 - <Input 441 - id={elementId.block(props.pollEntity).pollInput(props.entityID)} 442 - type="text" 443 - className="pollOptionInput w-full input-with-border" 444 - placeholder="Option here..." 445 - disabled={props.disabled} 446 - value={ 447 - props.localNameState === undefined ? optionName : props.localNameState 448 - } 449 - onChange={(e) => { 450 - props.setLocalNameState((s) => ({ 451 - ...s, 452 - [props.entityID]: e.target.value, 453 - })); 454 - }} 455 - onKeyDown={(e) => { 456 - if (e.key === "Backspace" && !e.currentTarget.value) { 457 - e.preventDefault(); 458 - rep?.mutate.removePollOption({ optionEntity: props.entityID }); 459 - } 460 - }} 461 - /> 462 - 463 - <button 464 - tabIndex={-1} 465 - disabled={props.disabled} 466 - className="text-accent-contrast disabled:text-border" 467 - onMouseDown={async () => { 468 - await rep?.mutate.removePollOption({ optionEntity: props.entityID }); 469 - }} 470 - > 471 - <CloseTiny /> 472 - </button> 473 - </div> 474 - ); 475 - }; 476 - 477 - const PollStateToggle = (props: { 478 - setPollState: (pollState: "editing" | "voting" | "results") => void; 479 - hasVoted: boolean; 480 - pollState: "editing" | "voting" | "results"; 481 - }) => { 482 - return ( 483 - <button 484 - className="text-sm text-accent-contrast sm:hover:underline" 485 - onClick={() => { 486 - props.setPollState(props.pollState === "voting" ? "results" : "voting"); 487 - }} 488 - > 489 - {props.pollState === "voting" 490 - ? "See Results" 491 - : props.hasVoted 492 - ? "Change Vote" 493 - : "Back to Poll"} 494 - </button> 495 - ); 496 - };
···
+186
components/Blocks/PublicationPollBlock.tsx
···
··· 1 + import { useUIState } from "src/useUIState"; 2 + import { BlockProps } from "./Block"; 3 + import { useMemo } from "react"; 4 + import { AsyncValueInput } from "components/Input"; 5 + import { focusElement } from "src/utils/focusElement"; 6 + import { useEntitySetContext } from "components/EntitySetProvider"; 7 + import { useEntity, useReplicache } from "src/replicache"; 8 + import { v7 } from "uuid"; 9 + import { elementId } from "src/utils/elementId"; 10 + import { CloseTiny } from "components/Icons/CloseTiny"; 11 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 12 + import { 13 + PubLeafletBlocksPoll, 14 + PubLeafletDocument, 15 + PubLeafletPagesLinearDocument, 16 + } from "lexicons/api"; 17 + import { ids } from "lexicons/api/lexicons"; 18 + 19 + /** 20 + * PublicationPollBlock is used for editing polls in publication documents. 21 + * It allows adding/editing options when the poll hasn't been published yet, 22 + * but disables adding new options once the poll record exists (indicated by pollUri). 23 + */ 24 + export const PublicationPollBlock = (props: BlockProps) => { 25 + let { data: publicationData } = useLeafletPublicationData(); 26 + let isSelected = useUIState((s) => 27 + s.selectedBlocks.find((b) => b.value === props.entityID), 28 + ); 29 + // Check if this poll has been published in a publication document 30 + const isPublished = useMemo(() => { 31 + if (!publicationData?.documents?.data) return false; 32 + 33 + const docRecord = publicationData.documents 34 + .data as PubLeafletDocument.Record; 35 + 36 + // Search through all pages and blocks to find if this poll entity has been published 37 + for (const page of docRecord.pages || []) { 38 + if (page.$type === "pub.leaflet.pages.linearDocument") { 39 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 40 + for (const blockWrapper of linearPage.blocks || []) { 41 + if (blockWrapper.block?.$type === ids.PubLeafletBlocksPoll) { 42 + const pollBlock = blockWrapper.block as PubLeafletBlocksPoll.Main; 43 + // Check if this poll's rkey matches our entity ID 44 + const rkey = pollBlock.pollRef.uri.split("/").pop(); 45 + if (rkey === props.entityID) { 46 + return true; 47 + } 48 + } 49 + } 50 + } 51 + } 52 + return false; 53 + }, [publicationData, props.entityID]); 54 + 55 + return ( 56 + <div 57 + className={`poll flex flex-col gap-2 p-3 w-full 58 + ${isSelected ? "block-border-selected " : "block-border"}`} 59 + style={{ 60 + backgroundColor: 61 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 62 + }} 63 + > 64 + <EditPollForPublication 65 + entityID={props.entityID} 66 + isPublished={isPublished} 67 + /> 68 + </div> 69 + ); 70 + }; 71 + 72 + const EditPollForPublication = (props: { 73 + entityID: string; 74 + isPublished: boolean; 75 + }) => { 76 + let pollOptions = useEntity(props.entityID, "poll/options"); 77 + let { rep } = useReplicache(); 78 + let permission_set = useEntitySetContext(); 79 + 80 + return ( 81 + <> 82 + {props.isPublished && ( 83 + <div className="text-sm italic text-tertiary"> 84 + This poll has been published. You can't edit the options. 85 + </div> 86 + )} 87 + 88 + {pollOptions.length === 0 && !props.isPublished && ( 89 + <div className="text-center italic text-tertiary text-sm"> 90 + no options yet... 91 + </div> 92 + )} 93 + 94 + {pollOptions.map((p) => ( 95 + <EditPollOptionForPublication 96 + key={p.id} 97 + entityID={p.data.value} 98 + pollEntity={props.entityID} 99 + disabled={props.isPublished} 100 + canDelete={!props.isPublished} 101 + /> 102 + ))} 103 + 104 + {!props.isPublished && permission_set.permissions.write && ( 105 + <button 106 + className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 107 + onClick={async () => { 108 + let pollOptionEntity = v7(); 109 + await rep?.mutate.addPollOption({ 110 + pollEntity: props.entityID, 111 + pollOptionEntity, 112 + pollOptionName: "", 113 + permission_set: permission_set.set, 114 + factID: v7(), 115 + }); 116 + 117 + focusElement( 118 + document.getElementById( 119 + elementId.block(props.entityID).pollInput(pollOptionEntity), 120 + ) as HTMLInputElement | null, 121 + ); 122 + }} 123 + > 124 + Add an Option 125 + </button> 126 + )} 127 + </> 128 + ); 129 + }; 130 + 131 + const EditPollOptionForPublication = (props: { 132 + entityID: string; 133 + pollEntity: string; 134 + disabled: boolean; 135 + canDelete: boolean; 136 + }) => { 137 + let { rep } = useReplicache(); 138 + let { permissions } = useEntitySetContext(); 139 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 140 + 141 + return ( 142 + <div className="flex gap-2 items-center"> 143 + <AsyncValueInput 144 + id={elementId.block(props.pollEntity).pollInput(props.entityID)} 145 + type="text" 146 + className="pollOptionInput w-full input-with-border" 147 + placeholder="Option here..." 148 + disabled={props.disabled || !permissions.write} 149 + value={optionName || ""} 150 + onChange={async (e) => { 151 + await rep?.mutate.assertFact([ 152 + { 153 + entity: props.entityID, 154 + attribute: "poll-option/name", 155 + data: { type: "string", value: e.currentTarget.value }, 156 + }, 157 + ]); 158 + }} 159 + onKeyDown={(e) => { 160 + if ( 161 + props.canDelete && 162 + e.key === "Backspace" && 163 + !e.currentTarget.value 164 + ) { 165 + e.preventDefault(); 166 + rep?.mutate.removePollOption({ optionEntity: props.entityID }); 167 + } 168 + }} 169 + /> 170 + 171 + {permissions.write && props.canDelete && ( 172 + <button 173 + tabIndex={-1} 174 + className="text-accent-contrast" 175 + onMouseDown={async () => { 176 + await rep?.mutate.removePollOption({ 177 + optionEntity: props.entityID, 178 + }); 179 + }} 180 + > 181 + <CloseTiny /> 182 + </button> 183 + )} 184 + </div> 185 + ); 186 + };
-59
components/Blocks/QuoteEmbedBlock.tsx
··· 1 - import { GoToArrow } from "components/Icons/GoToArrow"; 2 - import { ExternalLinkBlock } from "./ExternalLinkBlock"; 3 - import { Separator } from "components/Layout"; 4 - 5 - export const QuoteEmbedBlockLine = () => { 6 - return ( 7 - <div className="quoteEmbedBlock flex sm:mx-4 mx-3 my-3 sm:my-4 text-secondary text-sm italic"> 8 - <div className="w-2 h-full bg-border" /> 9 - <div className="flex flex-col pl-4"> 10 - <div className="quoteEmbedContent "> 11 - Hello, this is a long quote that I am writing to you! I am so excited 12 - that you decided to quote my stuff. I would love to take a moments and 13 - just say whatever the heck i feel like. Unforunately for you, it is a 14 - rather boring todo list. I need to add an author and pub name, i need 15 - to add a back link, and i need to link about text formatting, if we 16 - want to handle it. 17 - </div> 18 - <div className="quoteEmbedFooter flex gap-2 pt-2 "> 19 - <div className="flex flex-col leading-tight grow"> 20 - <div className="font-bold ">This was made to be quoted</div> 21 - <div className="text-tertiary text-xs">celine</div> 22 - </div> 23 - </div> 24 - </div> 25 - </div> 26 - ); 27 - }; 28 - 29 - export const QuoteEmbedBlock = () => { 30 - return ( 31 - <div className="quoteEmbedBlock transparent-container sm:mx-4 mx-3 my-3 sm:my-4 text-secondary text-sm"> 32 - <div className="quoteEmbedContent p-3"> 33 - Hello, this is a long quote that I am writing to you! I am so excited 34 - that you decided to quote my stuff. I would love to take a moments and 35 - just say whatever the heck i feel like. Unforunately for you, it is a 36 - rather boring todo list. I need to add an author and pub name, i need to 37 - add a back link, and i need to link about text formatting, if we want to 38 - handle it. 39 - </div> 40 - <hr className="border-border-light" /> 41 - <a 42 - className="quoteEmbedFooter flex max-w-full gap-2 px-3 py-2 hover:no-underline! text-secondary" 43 - href="#" 44 - > 45 - <div className="flex flex-col w-[calc(100%-28px)] grow"> 46 - <div className="font-bold w-full truncate"> 47 - This was made to be quoted and if it's very long, to truncate 48 - </div> 49 - <div className="flex gap-[6px] text-tertiary text-xs items-center"> 50 - <div className="underline">lab.leaflet.pub</div> 51 - <Separator classname="h-2" /> 52 - <div>celine</div> 53 - </div> 54 - </div> 55 - <div className=" shrink-0 pt-px bg-test w-5 h-5 rounded-full"></div> 56 - </a> 57 - </div> 58 - ); 59 - };
···
+3 -3
components/Blocks/RSVPBlock/ContactDetailsForm.tsx
··· 12 import { Separator } from "components/Layout"; 13 import { createPhoneAuthToken } from "actions/phone_auth/request_phone_auth_token"; 14 import { Input, InputWithLabel } from "components/Input"; 15 - import { IPLocationContext } from "components/Providers/IPLocationProvider"; 16 import { Popover } from "components/Popover"; 17 import { theme } from "tailwind.config"; 18 import { InfoSmall } from "components/Icons/InfoSmall"; ··· 41 data.authToken.phone_number === rsvp.phone_number, 42 )?.plus_ones || 0, 43 ); 44 - let ipLocation = useContext(IPLocationContext) || "US"; 45 const [formState, setFormState] = useState({ 46 country_code: 47 - countryCodes.find((c) => c[1].toUpperCase() === ipLocation)?.[2] || "1", 48 phone_number: "", 49 confirmationCode: "", 50 });
··· 12 import { Separator } from "components/Layout"; 13 import { createPhoneAuthToken } from "actions/phone_auth/request_phone_auth_token"; 14 import { Input, InputWithLabel } from "components/Input"; 15 + import { RequestHeadersContext } from "components/Providers/RequestHeadersProvider"; 16 import { Popover } from "components/Popover"; 17 import { theme } from "tailwind.config"; 18 import { InfoSmall } from "components/Icons/InfoSmall"; ··· 41 data.authToken.phone_number === rsvp.phone_number, 42 )?.plus_ones || 0, 43 ); 44 + let requestHeaders = useContext(RequestHeadersContext); 45 const [formState, setFormState] = useState({ 46 country_code: 47 + countryCodes.find((c) => c[1].toUpperCase() === (requestHeaders.country || "US"))?.[2] || "1", 48 phone_number: "", 49 confirmationCode: "", 50 });
+2 -2
components/Blocks/RSVPBlock/SendUpdate.tsx
··· 9 import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; 10 import { useReplicache } from "src/replicache"; 11 import { Checkbox } from "components/Checkbox"; 12 - import { usePublishLink } from "components/ShareOptions"; 13 14 export function SendUpdateButton(props: { entityID: string }) { 15 - let publishLink = usePublishLink(); 16 let { permissions } = useEntitySetContext(); 17 let { permission_token } = useReplicache(); 18 let [input, setInput] = useState("");
··· 9 import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; 10 import { useReplicache } from "src/replicache"; 11 import { Checkbox } from "components/Checkbox"; 12 + import { useReadOnlyShareLink } from "app/[leaflet_id]/actions/ShareOptions"; 13 14 export function SendUpdateButton(props: { entityID: string }) { 15 + let publishLink = useReadOnlyShareLink(); 16 let { permissions } = useEntitySetContext(); 17 let { permission_token } = useReplicache(); 18 let [input, setInput] = useState("");
+36 -32
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 3 import { CSSProperties, Fragment } from "react"; 4 import { theme } from "tailwind.config"; 5 import * as base64 from "base64-js"; 6 7 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 8 export function RenderYJSFragment({ ··· 27 return ( 28 <BlockWrapper wrapper={wrapper} attrs={attrs}> 29 {children.length === 0 ? ( 30 - <div /> 31 ) : ( 32 node.toArray().map((node, index) => { 33 if (node.constructor === XmlText) { ··· 60 ); 61 } 62 63 return null; 64 }) 65 )} ··· 97 } 98 }; 99 100 - export type Delta = { 101 - insert: string; 102 - attributes?: { 103 - strong?: {}; 104 - code?: {}; 105 - em?: {}; 106 - underline?: {}; 107 - strikethrough?: {}; 108 - highlight?: { color: string }; 109 - link?: { href: string }; 110 - }; 111 - }; 112 - 113 function attributesToStyle(d: Delta) { 114 let props = { 115 style: {}, ··· 140 return props; 141 } 142 143 - export function YJSFragmentToString( 144 - node: XmlElement | XmlText | XmlHook, 145 - ): string { 146 - if (node.constructor === XmlElement) { 147 - return node 148 - .toArray() 149 - .map((f) => YJSFragmentToString(f)) 150 - .join(""); 151 - } 152 - if (node.constructor === XmlText) { 153 - return (node.toDelta() as Delta[]) 154 - .map((d) => { 155 - return d.insert; 156 - }) 157 - .join(""); 158 - } 159 - return ""; 160 - }
··· 3 import { CSSProperties, Fragment } from "react"; 4 import { theme } from "tailwind.config"; 5 import * as base64 from "base64-js"; 6 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7 + import { AtMentionLink } from "components/AtMentionLink"; 8 + import { Delta } from "src/utils/yjsFragmentToString"; 9 10 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 11 export function RenderYJSFragment({ ··· 30 return ( 31 <BlockWrapper wrapper={wrapper} attrs={attrs}> 32 {children.length === 0 ? ( 33 + <br /> 34 ) : ( 35 node.toArray().map((node, index) => { 36 if (node.constructor === XmlText) { ··· 63 ); 64 } 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 + 98 return null; 99 }) 100 )} ··· 132 } 133 }; 134 135 function attributesToStyle(d: Delta) { 136 let props = { 137 style: {}, ··· 162 return props; 163 } 164
+121 -195
components/Blocks/TextBlock/index.tsx
··· 1 - import { useRef, useEffect, useState, useLayoutEffect } from "react"; 2 import { elementId } from "src/utils/elementId"; 3 - import { baseKeymap } from "prosemirror-commands"; 4 - import { keymap } from "prosemirror-keymap"; 5 - import * as Y from "yjs"; 6 - import * as base64 from "base64-js"; 7 - import { useReplicache, useEntity, ReplicacheMutators } from "src/replicache"; 8 import { isVisible } from "src/utils/isVisible"; 9 - 10 import { EditorState, TextSelection } from "prosemirror-state"; 11 import { EditorView } from "prosemirror-view"; 12 - 13 - import { ySyncPlugin } from "y-prosemirror"; 14 - import { Replicache } from "replicache"; 15 import { RenderYJSFragment } from "./RenderYJSFragment"; 16 - import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 17 import { BlockProps } from "../Block"; 18 import { focusBlock } from "src/utils/focusBlock"; 19 - import { TextBlockKeymap } from "./keymap"; 20 - import { multiBlockSchema, schema } from "./schema"; 21 import { useUIState } from "src/useUIState"; 22 import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; 23 import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 24 import { useEditorStates } from "src/state/useEditorState"; 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 - import { useHandlePaste } from "./useHandlePaste"; 27 - import { highlightSelectionPlugin } from "./plugins"; 28 - import { inputrules } from "./inputRules"; 29 - import { autolink } from "./autolink-plugin"; 30 import { TooltipButton } from "components/Buttons"; 31 import { blockCommands } from "../BlockCommands"; 32 import { betterIsUrl } from "src/utils/isURL"; ··· 37 import { isIOS } from "src/utils/isDevice"; 38 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 39 import { DotLoader } from "components/utils/DotLoader"; 40 41 const HeadingStyle = { 42 1: "text-xl font-bold", ··· 51 }, 52 ) { 53 let isLocked = useEntity(props.entityID, "block/is-locked"); 54 - let initialized = useInitialPageLoad(); 55 let first = props.previousBlock === null; 56 let permission = useEntitySetContext().permissions.write; 57 ··· 67 className={props.className} 68 first={first} 69 pageType={props.pageType} 70 /> 71 )} 72 {permission && !props.preview && !isLocked?.data.value && ( ··· 124 first?: boolean; 125 pageType?: "canvas" | "doc"; 126 type: BlockProps["type"]; 127 }) { 128 let initialFact = useEntity(props.entityID, "block/text"); 129 let headingLevel = useEntity(props.entityID, "block/heading-level"); ··· 165 style={{ wordBreak: "break-word" }} // better than tailwind break-all! 166 className={` 167 ${alignmentClass} 168 - ${props.type === "blockquote" ? " blockquote " : ""} 169 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 170 w-full whitespace-pre-wrap outline-hidden ${props.className} `} 171 > ··· 175 } 176 177 export function BaseTextBlock(props: BlockProps & { className?: string }) { 178 - let mountRef = useRef<HTMLPreElement | null>(null); 179 - let actionTimeout = useRef<number | null>(null); 180 - let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 181 let headingLevel = useEntity(props.entityID, "block/heading-level"); 182 - let entity_set = useEntitySetContext(); 183 let alignment = 184 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 185 - let propsRef = useRef({ ...props, entity_set, alignment }); 186 - useEffect(() => { 187 - propsRef.current = { ...props, entity_set, alignment }; 188 - }, [props, entity_set, alignment]); 189 let rep = useReplicache(); 190 - useEffect(() => { 191 - repRef.current = rep.rep; 192 - }, [rep?.rep]); 193 194 let selected = useUIState( 195 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), ··· 201 center: "text-center", 202 justify: "text-justify", 203 }[alignment]; 204 - 205 - let value = useYJSValue(props.entityID); 206 207 let editorState = useEditorStates( 208 (s) => s.editorStates[props.entityID], 209 )?.editor; 210 - let handlePaste = useHandlePaste(props.entityID, propsRef); 211 - useLayoutEffect(() => { 212 - if (!mountRef.current) return; 213 - let km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 214 - let editor = EditorState.create({ 215 - schema: schema, 216 - plugins: [ 217 - ySyncPlugin(value), 218 - keymap(km), 219 - inputrules(propsRef, repRef), 220 - keymap(baseKeymap), 221 - highlightSelectionPlugin, 222 - autolink({ 223 - type: schema.marks.link, 224 - shouldAutoLink: () => true, 225 - defaultProtocol: "https", 226 - }), 227 - ], 228 - }); 229 - 230 - let unsubscribe = useEditorStates.subscribe((s) => { 231 - let editorState = s.editorStates[props.entityID]; 232 - if (editorState?.initial) return; 233 - if (editorState?.editor) 234 - editorState.view?.updateState(editorState.editor); 235 - }); 236 - let view = new EditorView( 237 - { mount: mountRef.current }, 238 - { 239 - state: editor, 240 - handlePaste, 241 - handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 242 - if (!direct) return; 243 - if (node.nodeSize - 2 <= _pos) return; 244 - let mark = 245 - node 246 - .nodeAt(_pos - 1) 247 - ?.marks.find((f) => f.type === schema.marks.link) || 248 - node 249 - .nodeAt(Math.max(_pos - 2, 0)) 250 - ?.marks.find((f) => f.type === schema.marks.link); 251 - if (mark) { 252 - window.open(mark.attrs.href, "_blank"); 253 - } 254 - }, 255 - dispatchTransaction(tr) { 256 - useEditorStates.setState((s) => { 257 - let oldEditorState = this.state; 258 - let newState = this.state.apply(tr); 259 - let addToHistory = tr.getMeta("addToHistory"); 260 - let isBulkOp = tr.getMeta("bulkOp"); 261 - let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 262 - if (addToHistory !== false && docHasChanges) { 263 - if (actionTimeout.current) { 264 - window.clearTimeout(actionTimeout.current); 265 - } else { 266 - if (!isBulkOp) rep.undoManager.startGroup(); 267 - } 268 269 - if (!isBulkOp) 270 - actionTimeout.current = window.setTimeout(() => { 271 - rep.undoManager.endGroup(); 272 - actionTimeout.current = null; 273 - }, 200); 274 - rep.undoManager.add({ 275 - redo: () => { 276 - useEditorStates.setState((oldState) => { 277 - let view = oldState.editorStates[props.entityID]?.view; 278 - if (!view?.hasFocus() && !isBulkOp) view?.focus(); 279 - return { 280 - editorStates: { 281 - ...oldState.editorStates, 282 - [props.entityID]: { 283 - ...oldState.editorStates[props.entityID]!, 284 - editor: newState, 285 - }, 286 - }, 287 - }; 288 - }); 289 - }, 290 - undo: () => { 291 - useEditorStates.setState((oldState) => { 292 - let view = oldState.editorStates[props.entityID]?.view; 293 - if (!view?.hasFocus() && !isBulkOp) view?.focus(); 294 - return { 295 - editorStates: { 296 - ...oldState.editorStates, 297 - [props.entityID]: { 298 - ...oldState.editorStates[props.entityID]!, 299 - editor: oldEditorState, 300 - }, 301 - }, 302 - }; 303 - }); 304 - }, 305 - }); 306 - } 307 - 308 - return { 309 - editorStates: { 310 - ...s.editorStates, 311 - [props.entityID]: { 312 - editor: newState, 313 - view: this as unknown as EditorView, 314 - initial: false, 315 - keymap: km, 316 - }, 317 - }, 318 - }; 319 - }); 320 - }, 321 - }, 322 - ); 323 - return () => { 324 - unsubscribe(); 325 - view.destroy(); 326 - useEditorStates.setState((s) => ({ 327 - ...s, 328 - editorStates: { 329 - ...s.editorStates, 330 - [props.entityID]: undefined, 331 - }, 332 - })); 333 - }; 334 - }, [props.entityID, props.parent, value, handlePaste, rep]); 335 336 return ( 337 <> 338 <div 339 className={`flex items-center justify-between w-full 340 ${selected && props.pageType === "canvas" && "bg-bg-page rounded-md"} 341 - ${props.type === "blockquote" ? " blockquote " : ""} 342 - `} 343 > 344 <pre 345 data-entityid={props.entityID} ··· 362 } 363 }} 364 onFocus={() => { 365 setTimeout(() => { 366 useUIState.getState().setSelectedBlock(props); 367 useUIState.setState(() => ({ ··· 387 ${props.className}`} 388 ref={mountRef} 389 /> 390 {editorState?.doc.textContent.length === 0 && 391 props.previousBlock === null && 392 props.nextBlock === null ? ( ··· 577 ); 578 }; 579 580 - function useYJSValue(entityID: string) { 581 - const [ydoc] = useState(new Y.Doc()); 582 - const docStateFromReplicache = useEntity(entityID, "block/text"); 583 - let rep = useReplicache(); 584 - const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 585 586 - if (docStateFromReplicache) { 587 - const update = base64.toByteArray(docStateFromReplicache.data.value); 588 - Y.applyUpdate(ydoc, update); 589 - } 590 591 useEffect(() => { 592 - if (!rep.rep) return; 593 - let timeout = null as null | number; 594 - const updateReplicache = async () => { 595 - const update = Y.encodeStateAsUpdate(ydoc); 596 - await rep.rep?.mutate.assertFact({ 597 - //These undos are handled above in the Prosemirror context 598 - ignoreUndo: true, 599 - entity: entityID, 600 - attribute: "block/text", 601 - data: { 602 - value: base64.fromByteArray(update), 603 - type: "text", 604 - }, 605 }); 606 - }; 607 - const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 608 - if (!transaction.origin) return; 609 - if (timeout) clearTimeout(timeout); 610 - timeout = window.setTimeout(async () => { 611 - updateReplicache(); 612 - }, 300); 613 - }; 614 615 - yText.observeDeep(f); 616 - return () => { 617 - yText.unobserveDeep(f); 618 - }; 619 - }, [yText, entityID, rep, ydoc]); 620 - return yText; 621 - }
··· 1 + import { useRef, useEffect, useState, useCallback } from "react"; 2 import { elementId } from "src/utils/elementId"; 3 + import { useReplicache, useEntity } from "src/replicache"; 4 import { isVisible } from "src/utils/isVisible"; 5 import { EditorState, TextSelection } from "prosemirror-state"; 6 import { EditorView } from "prosemirror-view"; 7 import { RenderYJSFragment } from "./RenderYJSFragment"; 8 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 9 import { BlockProps } from "../Block"; 10 import { focusBlock } from "src/utils/focusBlock"; 11 import { useUIState } from "src/useUIState"; 12 import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; 13 import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 14 import { useEditorStates } from "src/state/useEditorState"; 15 import { useEntitySetContext } from "components/EntitySetProvider"; 16 import { TooltipButton } from "components/Buttons"; 17 import { blockCommands } from "../BlockCommands"; 18 import { betterIsUrl } from "src/utils/isURL"; ··· 23 import { isIOS } from "src/utils/isDevice"; 24 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 25 import { DotLoader } from "components/utils/DotLoader"; 26 + import { useMountProsemirror } from "./mountProsemirror"; 27 + import { schema } from "./schema"; 28 + 29 + import { Mention, MentionAutocomplete } from "components/Mention"; 30 + import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 31 32 const HeadingStyle = { 33 1: "text-xl font-bold", ··· 42 }, 43 ) { 44 let isLocked = useEntity(props.entityID, "block/is-locked"); 45 + let initialized = useHasPageLoaded(); 46 let first = props.previousBlock === null; 47 let permission = useEntitySetContext().permissions.write; 48 ··· 58 className={props.className} 59 first={first} 60 pageType={props.pageType} 61 + previousBlock={props.previousBlock} 62 /> 63 )} 64 {permission && !props.preview && !isLocked?.data.value && ( ··· 116 first?: boolean; 117 pageType?: "canvas" | "doc"; 118 type: BlockProps["type"]; 119 + previousBlock?: BlockProps["previousBlock"]; 120 }) { 121 let initialFact = useEntity(props.entityID, "block/text"); 122 let headingLevel = useEntity(props.entityID, "block/heading-level"); ··· 158 style={{ wordBreak: "break-word" }} // better than tailwind break-all! 159 className={` 160 ${alignmentClass} 161 + ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""} 162 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 163 w-full whitespace-pre-wrap outline-hidden ${props.className} `} 164 > ··· 168 } 169 170 export function BaseTextBlock(props: BlockProps & { className?: string }) { 171 let headingLevel = useEntity(props.entityID, "block/heading-level"); 172 let alignment = 173 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 174 + 175 let rep = useReplicache(); 176 177 let selected = useUIState( 178 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), ··· 184 center: "text-center", 185 justify: "text-justify", 186 }[alignment]; 187 188 let editorState = useEditorStates( 189 (s) => s.editorStates[props.entityID], 190 )?.editor; 191 + const { 192 + viewRef, 193 + mentionOpen, 194 + mentionCoords, 195 + openMentionAutocomplete, 196 + handleMentionSelect, 197 + handleMentionOpenChange, 198 + } = useMentionState(props.entityID); 199 200 + let { mountRef, actionTimeout } = useMountProsemirror({ 201 + props, 202 + openMentionAutocomplete, 203 + }); 204 205 return ( 206 <> 207 <div 208 className={`flex items-center justify-between w-full 209 ${selected && props.pageType === "canvas" && "bg-bg-page rounded-md"} 210 + ${ 211 + props.type === "blockquote" 212 + ? props.previousBlock?.type === "blockquote" && !props.listData 213 + ? "blockquote pt-3" 214 + : "blockquote" 215 + : "" 216 + }`} 217 > 218 <pre 219 data-entityid={props.entityID} ··· 236 } 237 }} 238 onFocus={() => { 239 + handleMentionOpenChange(false); 240 setTimeout(() => { 241 useUIState.getState().setSelectedBlock(props); 242 useUIState.setState(() => ({ ··· 262 ${props.className}`} 263 ref={mountRef} 264 /> 265 + {focused && ( 266 + <MentionAutocomplete 267 + open={mentionOpen} 268 + onOpenChange={handleMentionOpenChange} 269 + view={viewRef} 270 + onSelect={handleMentionSelect} 271 + coords={mentionCoords} 272 + /> 273 + )} 274 {editorState?.doc.textContent.length === 0 && 275 props.previousBlock === null && 276 props.nextBlock === null ? ( ··· 461 ); 462 }; 463 464 + const useMentionState = (entityID: string) => { 465 + let view = useEditorStates((s) => s.editorStates[entityID])?.view; 466 + let viewRef = useRef(view || null); 467 + viewRef.current = view || null; 468 469 + const [mentionOpen, setMentionOpen] = useState(false); 470 + const [mentionCoords, setMentionCoords] = useState<{ 471 + top: number; 472 + left: number; 473 + } | null>(null); 474 + const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 475 476 + // Close autocomplete when this block is no longer focused 477 + const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID); 478 useEffect(() => { 479 + if (!isFocused) { 480 + setMentionOpen(false); 481 + setMentionCoords(null); 482 + setMentionInsertPos(null); 483 + } 484 + }, [isFocused]); 485 + 486 + const openMentionAutocomplete = useCallback(() => { 487 + const view = useEditorStates.getState().editorStates[entityID]?.view; 488 + if (!view) return; 489 + 490 + // Get the position right after the @ we just inserted 491 + const pos = view.state.selection.from; 492 + setMentionInsertPos(pos); 493 + 494 + // Get coordinates for the popup relative to the positioned parent 495 + const coords = view.coordsAtPos(pos - 1); // Position of the @ 496 + 497 + // Find the relative positioned parent container 498 + const editorEl = view.dom; 499 + const container = editorEl.closest('.relative') as HTMLElement | null; 500 + 501 + if (container) { 502 + const containerRect = container.getBoundingClientRect(); 503 + setMentionCoords({ 504 + top: coords.bottom - containerRect.top, 505 + left: coords.left - containerRect.left, 506 + }); 507 + } else { 508 + setMentionCoords({ 509 + top: coords.bottom, 510 + left: coords.left, 511 }); 512 + } 513 + setMentionOpen(true); 514 + }, [entityID]); 515 516 + const handleMentionSelect = useCallback( 517 + (mention: Mention) => { 518 + const view = useEditorStates.getState().editorStates[entityID]?.view; 519 + if (!view || mentionInsertPos === null) return; 520 + 521 + // The @ is at mentionInsertPos - 1, we need to replace it with the mention 522 + const from = mentionInsertPos - 1; 523 + const to = mentionInsertPos; 524 + 525 + addMentionToEditor(mention, { from, to }, view); 526 + view.focus(); 527 + }, 528 + [entityID, mentionInsertPos], 529 + ); 530 + 531 + const handleMentionOpenChange = useCallback((open: boolean) => { 532 + setMentionOpen(open); 533 + if (!open) { 534 + setMentionCoords(null); 535 + setMentionInsertPos(null); 536 + } 537 + }, []); 538 + 539 + return { 540 + viewRef, 541 + mentionOpen, 542 + mentionCoords, 543 + openMentionAutocomplete, 544 + handleMentionSelect, 545 + handleMentionOpenChange, 546 + }; 547 + };
+32 -3
components/Blocks/TextBlock/inputRules.ts
··· 11 import { schema } from "./schema"; 12 import { useUIState } from "src/useUIState"; 13 import { flushSync } from "react-dom"; 14 export const inputrules = ( 15 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 16 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 17 ) => 18 inputRules({ 19 //Strikethrough ··· 108 109 // Code Block 110 new InputRule(/^```\s$/, (state, match) => { 111 - flushSync(() => 112 repRef.current?.mutate.assertFact({ 113 entity: propsRef.current.entityID, 114 attribute: "block/type", 115 data: { type: "block-type-union", value: "code" }, 116 - }), 117 - ); 118 setTimeout(() => { 119 focusBlock({ ...propsRef.current, type: "code" }, { type: "start" }); 120 }, 20); ··· 180 data: { type: "number", value: headingLevel }, 181 }); 182 return tr; 183 }), 184 ], 185 });
··· 11 import { schema } from "./schema"; 12 import { useUIState } from "src/useUIState"; 13 import { flushSync } from "react-dom"; 14 + import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 15 export const inputrules = ( 16 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 17 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 18 + openMentionAutocomplete?: () => void, 19 ) => 20 inputRules({ 21 //Strikethrough ··· 110 111 // Code Block 112 new InputRule(/^```\s$/, (state, match) => { 113 + flushSync(() => { 114 repRef.current?.mutate.assertFact({ 115 entity: propsRef.current.entityID, 116 attribute: "block/type", 117 data: { type: "block-type-union", value: "code" }, 118 + }); 119 + let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY); 120 + if (lastLang) { 121 + repRef.current?.mutate.assertFact({ 122 + entity: propsRef.current.entityID, 123 + attribute: "block/code-language", 124 + data: { type: "string", value: lastLang }, 125 + }); 126 + } 127 + }); 128 setTimeout(() => { 129 focusBlock({ ...propsRef.current, type: "code" }, { type: "start" }); 130 }, 20); ··· 190 data: { type: "number", value: headingLevel }, 191 }); 192 return tr; 193 + }), 194 + 195 + // Mention - @ at start of line, after space, or after hard break 196 + new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 197 + if (!openMentionAutocomplete) return null; 198 + // Schedule opening the autocomplete after the transaction is applied 199 + setTimeout(() => openMentionAutocomplete(), 0); 200 + return null; // Let the @ be inserted normally 201 + }), 202 + // Mention - @ immediately after a hard break (hard breaks are nodes, not text) 203 + new InputRule(/@$/, (state, match, start, end) => { 204 + if (!openMentionAutocomplete) return null; 205 + // Check if the character before @ is a hard break node 206 + const $pos = state.doc.resolve(start); 207 + const nodeBefore = $pos.nodeBefore; 208 + if (nodeBefore && nodeBefore.type.name === "hard_break") { 209 + setTimeout(() => openMentionAutocomplete(), 0); 210 + } 211 + return null; // Let the @ be inserted normally 212 }), 213 ], 214 });
+10 -13
components/Blocks/TextBlock/keymap.ts
··· 17 import { schema } from "./schema"; 18 import { useUIState } from "src/useUIState"; 19 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20 - import { focusPage } from "components/Pages"; 21 import { v7 } from "uuid"; 22 import { scanIndex } from "src/replicache/utils"; 23 import { indent, outdent } from "src/utils/list-operations"; 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 import { UndoManager } from "src/undoManager"; 27 - 28 type PropsRef = RefObject< 29 BlockProps & { 30 entity_set: { set: string }; ··· 35 propsRef: PropsRef, 36 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 37 um: UndoManager, 38 - multiLine?: boolean, 39 ) => 40 ({ 41 "Meta-b": toggleMark(schema.marks.strong), ··· 138 ), 139 "Shift-Backspace": backspace(propsRef, repRef), 140 Enter: (state, dispatch, view) => { 141 - if (multiLine && state.doc.content.size - state.selection.anchor > 1) 142 - return false; 143 - return um.withUndoGroup(() => 144 - enter(propsRef, repRef)(state, dispatch, view), 145 - ); 146 }, 147 "Shift-Enter": (state, dispatch, view) => { 148 - if (multiLine) { 149 - return baseKeymap.Enter(state, dispatch, view); 150 } 151 - return um.withUndoGroup(() => 152 - enter(propsRef, repRef)(state, dispatch, view), 153 - ); 154 }, 155 "Ctrl-Enter": CtrlEnter(propsRef, repRef), 156 "Meta-Enter": CtrlEnter(propsRef, repRef),
··· 17 import { schema } from "./schema"; 18 import { useUIState } from "src/useUIState"; 19 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20 + import { focusPage } from "src/utils/focusPage"; 21 import { v7 } from "uuid"; 22 import { scanIndex } from "src/replicache/utils"; 23 import { indent, outdent } from "src/utils/list-operations"; 24 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 import { UndoManager } from "src/undoManager"; 27 type PropsRef = RefObject< 28 BlockProps & { 29 entity_set: { set: string }; ··· 34 propsRef: PropsRef, 35 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 36 um: UndoManager, 37 + openMentionAutocomplete: () => void, 38 ) => 39 ({ 40 "Meta-b": toggleMark(schema.marks.strong), ··· 137 ), 138 "Shift-Backspace": backspace(propsRef, repRef), 139 Enter: (state, dispatch, view) => { 140 + return um.withUndoGroup(() => { 141 + return enter(propsRef, repRef)(state, dispatch, view); 142 + }); 143 }, 144 "Shift-Enter": (state, dispatch, view) => { 145 + // Insert a hard break 146 + let hardBreak = schema.nodes.hard_break.create(); 147 + if (dispatch) { 148 + dispatch(state.tr.replaceSelectionWith(hardBreak).scrollIntoView()); 149 } 150 + return true; 151 }, 152 "Ctrl-Enter": CtrlEnter(propsRef, repRef), 153 "Meta-Enter": CtrlEnter(propsRef, repRef),
+239
components/Blocks/TextBlock/mountProsemirror.ts
···
··· 1 + import { useLayoutEffect, useRef, useEffect, useState } from "react"; 2 + import { EditorState } from "prosemirror-state"; 3 + import { EditorView } from "prosemirror-view"; 4 + import { baseKeymap } from "prosemirror-commands"; 5 + import { keymap } from "prosemirror-keymap"; 6 + import { ySyncPlugin } from "y-prosemirror"; 7 + import * as Y from "yjs"; 8 + import * as base64 from "base64-js"; 9 + import { Replicache } from "replicache"; 10 + import { produce } from "immer"; 11 + 12 + import { schema } from "./schema"; 13 + import { TextBlockKeymap } from "./keymap"; 14 + import { inputrules } from "./inputRules"; 15 + import { highlightSelectionPlugin } from "./plugins"; 16 + import { autolink } from "./autolink-plugin"; 17 + import { useEditorStates } from "src/state/useEditorState"; 18 + import { 19 + useEntity, 20 + useReplicache, 21 + type ReplicacheMutators, 22 + } from "src/replicache"; 23 + import { useHandlePaste } from "./useHandlePaste"; 24 + import { BlockProps } from "../Block"; 25 + import { useEntitySetContext } from "components/EntitySetProvider"; 26 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 27 + 28 + export function useMountProsemirror({ 29 + props, 30 + openMentionAutocomplete, 31 + }: { 32 + props: BlockProps; 33 + openMentionAutocomplete: () => void; 34 + }) { 35 + let { entityID, parent } = props; 36 + let rep = useReplicache(); 37 + let mountRef = useRef<HTMLPreElement | null>(null); 38 + const repRef = useRef<Replicache<ReplicacheMutators> | null>(null); 39 + let value = useYJSValue(entityID); 40 + let entity_set = useEntitySetContext(); 41 + let alignment = 42 + useEntity(entityID, "block/text-alignment")?.data.value || "left"; 43 + let propsRef = useRef({ ...props, entity_set, alignment }); 44 + let handlePaste = useHandlePaste(entityID, propsRef); 45 + 46 + const actionTimeout = useRef<number | null>(null); 47 + 48 + propsRef.current = { ...props, entity_set, alignment }; 49 + repRef.current = rep.rep; 50 + 51 + useLayoutEffect(() => { 52 + if (!mountRef.current) return; 53 + 54 + const km = TextBlockKeymap( 55 + propsRef, 56 + repRef, 57 + rep.undoManager, 58 + openMentionAutocomplete, 59 + ); 60 + const editor = EditorState.create({ 61 + schema: schema, 62 + plugins: [ 63 + ySyncPlugin(value), 64 + keymap(km), 65 + inputrules(propsRef, repRef, openMentionAutocomplete), 66 + keymap(baseKeymap), 67 + highlightSelectionPlugin, 68 + autolink({ 69 + type: schema.marks.link, 70 + shouldAutoLink: () => true, 71 + defaultProtocol: "https", 72 + }), 73 + ], 74 + }); 75 + 76 + const view = new EditorView( 77 + { mount: mountRef.current }, 78 + { 79 + state: editor, 80 + handlePaste, 81 + handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 82 + if (!direct) return; 83 + if (node.nodeSize - 2 <= _pos) return; 84 + 85 + // Check for marks at the clicked position 86 + const nodeAt1 = node.nodeAt(_pos - 1); 87 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 88 + 89 + // Check for link marks 90 + let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 91 + nodeAt2?.marks.find((f) => f.type === schema.marks.link); 92 + if (linkMark) { 93 + window.open(linkMark.attrs.href, "_blank"); 94 + return; 95 + } 96 + 97 + // Check for didMention inline nodes 98 + if (nodeAt1?.type === schema.nodes.didMention) { 99 + window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer"); 100 + return; 101 + } 102 + if (nodeAt2?.type === schema.nodes.didMention) { 103 + window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer"); 104 + return; 105 + } 106 + 107 + // Check for atMention inline nodes 108 + if (nodeAt1?.type === schema.nodes.atMention) { 109 + const url = atUriToUrl(nodeAt1.attrs.atURI); 110 + window.open(url, "_blank", "noopener,noreferrer"); 111 + return; 112 + } 113 + if (nodeAt2?.type === schema.nodes.atMention) { 114 + const url = atUriToUrl(nodeAt2.attrs.atURI); 115 + window.open(url, "_blank", "noopener,noreferrer"); 116 + return; 117 + } 118 + }, 119 + dispatchTransaction, 120 + }, 121 + ); 122 + 123 + const unsubscribe = useEditorStates.subscribe((s) => { 124 + let editorState = s.editorStates[entityID]; 125 + if (editorState?.initial) return; 126 + if (editorState?.editor) 127 + editorState.view?.updateState(editorState.editor); 128 + }); 129 + 130 + let editorState = useEditorStates.getState().editorStates[entityID]; 131 + if (editorState?.editor && !editorState.initial) 132 + editorState.view?.updateState(editorState.editor); 133 + 134 + return () => { 135 + unsubscribe(); 136 + view.destroy(); 137 + useEditorStates.setState((s) => ({ 138 + ...s, 139 + editorStates: { 140 + ...s.editorStates, 141 + [entityID]: undefined, 142 + }, 143 + })); 144 + }; 145 + 146 + function dispatchTransaction(this: EditorView, tr: any) { 147 + useEditorStates.setState((s) => { 148 + let oldEditorState = this.state; 149 + let newState = this.state.apply(tr); 150 + let addToHistory = tr.getMeta("addToHistory"); 151 + let isBulkOp = tr.getMeta("bulkOp"); 152 + let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 153 + 154 + // Handle undo/redo history with timeout-based grouping 155 + if (addToHistory !== false && docHasChanges) { 156 + if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 157 + else if (!isBulkOp) rep.undoManager.startGroup(); 158 + 159 + if (!isBulkOp) { 160 + actionTimeout.current = window.setTimeout(() => { 161 + rep.undoManager.endGroup(); 162 + actionTimeout.current = null; 163 + }, 200); 164 + } 165 + 166 + let setState = (s: EditorState) => () => 167 + useEditorStates.setState( 168 + produce((draft) => { 169 + let view = draft.editorStates[entityID]?.view; 170 + if (!view?.hasFocus() && !isBulkOp) view?.focus(); 171 + draft.editorStates[entityID]!.editor = s; 172 + }), 173 + ); 174 + 175 + rep.undoManager.add({ 176 + redo: setState(newState), 177 + undo: setState(oldEditorState), 178 + }); 179 + } 180 + 181 + return { 182 + editorStates: { 183 + ...s.editorStates, 184 + [entityID]: { 185 + editor: newState, 186 + view: this as unknown as EditorView, 187 + initial: false, 188 + keymap: km, 189 + }, 190 + }, 191 + }; 192 + }); 193 + } 194 + }, [entityID, parent, value, handlePaste, rep]); 195 + return { mountRef, actionTimeout }; 196 + } 197 + 198 + function useYJSValue(entityID: string) { 199 + const [ydoc] = useState(new Y.Doc()); 200 + const docStateFromReplicache = useEntity(entityID, "block/text"); 201 + let rep = useReplicache(); 202 + const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 203 + 204 + if (docStateFromReplicache) { 205 + const update = base64.toByteArray(docStateFromReplicache.data.value); 206 + Y.applyUpdate(ydoc, update); 207 + } 208 + 209 + useEffect(() => { 210 + if (!rep.rep) return; 211 + let timeout = null as null | number; 212 + const updateReplicache = async () => { 213 + const update = Y.encodeStateAsUpdate(ydoc); 214 + await rep.rep?.mutate.assertFact({ 215 + //These undos are handled above in the Prosemirror context 216 + ignoreUndo: true, 217 + entity: entityID, 218 + attribute: "block/text", 219 + data: { 220 + value: base64.fromByteArray(update), 221 + type: "text", 222 + }, 223 + }); 224 + }; 225 + const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 226 + if (!transaction.origin) return; 227 + if (timeout) clearTimeout(timeout); 228 + timeout = window.setTimeout(async () => { 229 + updateReplicache(); 230 + }, 300); 231 + }; 232 + 233 + yText.observeDeep(f); 234 + return () => { 235 + yText.unobserveDeep(f); 236 + }; 237 + }, [yText, entityID, rep, ydoc]); 238 + return yText; 239 + }
+107 -1
components/Blocks/TextBlock/schema.ts
··· 1 - import { Schema, Node, MarkSpec } from "prosemirror-model"; 2 import { marks } from "prosemirror-schema-basic"; 3 import { theme } from "tailwind.config"; 4 ··· 115 text: { 116 group: "inline", 117 }, 118 }, 119 }; 120 export const schema = new Schema(baseSchema);
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; 3 import { marks } from "prosemirror-schema-basic"; 4 import { theme } from "tailwind.config"; 5 ··· 116 text: { 117 group: "inline", 118 }, 119 + hard_break: { 120 + group: "inline", 121 + inline: true, 122 + selectable: false, 123 + parseDOM: [{ tag: "br" }], 124 + toDOM: () => ["br"] as const, 125 + }, 126 + atMention: { 127 + attrs: { 128 + atURI: {}, 129 + text: { default: "" }, 130 + }, 131 + group: "inline", 132 + inline: true, 133 + atom: true, 134 + selectable: true, 135 + draggable: true, 136 + parseDOM: [ 137 + { 138 + tag: "span.atMention", 139 + getAttrs(dom: HTMLElement) { 140 + return { 141 + atURI: dom.getAttribute("data-at-uri"), 142 + text: dom.textContent || "", 143 + }; 144 + }, 145 + }, 146 + ], 147 + toDOM(node) { 148 + // NOTE: This rendering should match the AtMentionLink component in 149 + // components/AtMentionLink.tsx. If you update one, update the other. 150 + let className = "atMention 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, 224 }, 225 }; 226 export const schema = new Schema(baseSchema);
+1 -1
components/Blocks/TextBlock/useHandlePaste.ts
··· 389 let oldEntityID = child.getAttribute("data-entityid") as string; 390 let factsData = child.getAttribute("data-facts"); 391 if (factsData) { 392 - let facts = JSON.parse(atob(factsData)) as Fact<any>[]; 393 394 let oldEntityIDToNewID = {} as { [k: string]: string }; 395 let oldEntities = facts.reduce((acc, f) => {
··· 389 let oldEntityID = child.getAttribute("data-entityid") as string; 390 let factsData = child.getAttribute("data-facts"); 391 if (factsData) { 392 + let facts = JSON.parse(factsData) as Fact<any>[]; 393 394 let oldEntityIDToNewID = {} as { [k: string]: string }; 395 let oldEntities = facts.reduce((acc, f) => {
+11 -1
components/Blocks/index.tsx
··· 16 import { Block } from "./Block"; 17 import { useEffect } from "react"; 18 import { addShortcut } from "src/shortcuts"; 19 - import { QuoteEmbedBlock } from "./QuoteEmbedBlock"; 20 21 export function Blocks(props: { entityID: string }) { 22 let rep = useReplicache(); ··· 231 }) => { 232 let { rep } = useReplicache(); 233 let entity_set = useEntitySetContext(); 234 235 if (!entity_set.permissions.write) return; 236 return ( ··· 267 }, 10); 268 } 269 }} 270 /> 271 ); 272 };
··· 16 import { Block } from "./Block"; 17 import { useEffect } from "react"; 18 import { addShortcut } from "src/shortcuts"; 19 + import { useHandleDrop } from "./useHandleDrop"; 20 21 export function Blocks(props: { entityID: string }) { 22 let rep = useReplicache(); ··· 231 }) => { 232 let { rep } = useReplicache(); 233 let entity_set = useEntitySetContext(); 234 + let handleDrop = useHandleDrop({ 235 + parent: props.entityID, 236 + position: props.lastRootBlock?.position || null, 237 + nextPosition: null, 238 + }); 239 240 if (!entity_set.permissions.write) return; 241 return ( ··· 272 }, 10); 273 } 274 }} 275 + onDragOver={(e) => { 276 + e.preventDefault(); 277 + e.stopPropagation(); 278 + }} 279 + onDrop={handleDrop} 280 /> 281 ); 282 };
+1 -1
components/Blocks/useBlockKeyboardHandlers.ts
··· 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 import { Replicache } from "replicache"; 15 - import { deleteBlock } from "./DeleteBlock"; 16 import { entities } from "drizzle/schema"; 17 import { scanIndex } from "src/replicache/utils"; 18
··· 12 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 import { useEntitySetContext } from "components/EntitySetProvider"; 14 import { Replicache } from "replicache"; 15 + import { deleteBlock } from "src/utils/deleteBlock"; 16 import { entities } from "drizzle/schema"; 17 import { scanIndex } from "src/replicache/utils"; 18
+1 -1
components/Blocks/useBlockMouseHandlers.ts
··· 1 - import { useSelectingMouse } from "components/SelectionManager"; 2 import { MouseEvent, useCallback, useRef } from "react"; 3 import { useUIState } from "src/useUIState"; 4 import { Block } from "./Block";
··· 1 + import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 import { MouseEvent, useCallback, useRef } from "react"; 3 import { useUIState } from "src/useUIState"; 4 import { Block } from "./Block";
+233
components/Blocks/useHandleCanvasDrop.ts
···
··· 1 + import { useCallback } from "react"; 2 + import { useReplicache, useEntity } from "src/replicache"; 3 + import { useEntitySetContext } from "components/EntitySetProvider"; 4 + import { v7 } from "uuid"; 5 + import { supabaseBrowserClient } from "supabase/browserClient"; 6 + import { localImages } from "src/utils/addImage"; 7 + import { rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; 8 + 9 + // Helper function to load image dimensions and thumbhash 10 + const processImage = async ( 11 + file: File, 12 + ): Promise<{ 13 + width: number; 14 + height: number; 15 + thumbhash: string; 16 + }> => { 17 + // Load image to get dimensions 18 + const img = new Image(); 19 + const url = URL.createObjectURL(file); 20 + 21 + const dimensions = await new Promise<{ width: number; height: number }>( 22 + (resolve, reject) => { 23 + img.onload = () => { 24 + resolve({ width: img.width, height: img.height }); 25 + }; 26 + img.onerror = reject; 27 + img.src = url; 28 + }, 29 + ); 30 + 31 + // Generate thumbhash 32 + const arrayBuffer = await file.arrayBuffer(); 33 + const blob = new Blob([arrayBuffer], { type: file.type }); 34 + const imageBitmap = await createImageBitmap(blob); 35 + 36 + const canvas = document.createElement("canvas"); 37 + const context = canvas.getContext("2d") as CanvasRenderingContext2D; 38 + const maxDimension = 100; 39 + let width = imageBitmap.width; 40 + let height = imageBitmap.height; 41 + 42 + if (width > height) { 43 + if (width > maxDimension) { 44 + height *= maxDimension / width; 45 + width = maxDimension; 46 + } 47 + } else { 48 + if (height > maxDimension) { 49 + width *= maxDimension / height; 50 + height = maxDimension; 51 + } 52 + } 53 + 54 + canvas.width = width; 55 + canvas.height = height; 56 + context.drawImage(imageBitmap, 0, 0, width, height); 57 + 58 + const imageData = context.getImageData(0, 0, width, height); 59 + const thumbhash = thumbHashToDataURL( 60 + rgbaToThumbHash(imageData.width, imageData.height, imageData.data), 61 + ); 62 + 63 + URL.revokeObjectURL(url); 64 + 65 + return { 66 + width: dimensions.width, 67 + height: dimensions.height, 68 + thumbhash, 69 + }; 70 + }; 71 + 72 + export const useHandleCanvasDrop = (entityID: string) => { 73 + let { rep } = useReplicache(); 74 + let entity_set = useEntitySetContext(); 75 + let blocks = useEntity(entityID, "canvas/block"); 76 + 77 + return useCallback( 78 + async (e: React.DragEvent) => { 79 + e.preventDefault(); 80 + e.stopPropagation(); 81 + 82 + if (!rep) return; 83 + 84 + const files = e.dataTransfer.files; 85 + if (!files || files.length === 0) return; 86 + 87 + // Filter for image files only 88 + const imageFiles = Array.from(files).filter((file) => 89 + file.type.startsWith("image/"), 90 + ); 91 + 92 + if (imageFiles.length === 0) return; 93 + 94 + const parentRect = e.currentTarget.getBoundingClientRect(); 95 + const dropX = Math.max(e.clientX - parentRect.left, 0); 96 + const dropY = Math.max(e.clientY - parentRect.top, 0); 97 + 98 + const SPACING = 0; 99 + const DEFAULT_WIDTH = 360; 100 + 101 + // Process all images to get dimensions and thumbhashes 102 + const processedImages = await Promise.all( 103 + imageFiles.map((file) => processImage(file)), 104 + ); 105 + 106 + // Calculate grid dimensions based on image count 107 + const COLUMNS = Math.ceil(Math.sqrt(imageFiles.length)); 108 + 109 + // Calculate the width and height for each column and row 110 + const colWidths: number[] = []; 111 + const rowHeights: number[] = []; 112 + 113 + for (let i = 0; i < imageFiles.length; i++) { 114 + const col = i % COLUMNS; 115 + const row = Math.floor(i / COLUMNS); 116 + const dims = processedImages[i]; 117 + 118 + // Scale image to fit within DEFAULT_WIDTH while maintaining aspect ratio 119 + const scale = DEFAULT_WIDTH / dims.width; 120 + const scaledWidth = DEFAULT_WIDTH; 121 + const scaledHeight = dims.height * scale; 122 + 123 + // Track max width for each column and max height for each row 124 + colWidths[col] = Math.max(colWidths[col] || 0, scaledWidth); 125 + rowHeights[row] = Math.max(rowHeights[row] || 0, scaledHeight); 126 + } 127 + 128 + const client = supabaseBrowserClient(); 129 + const cache = await caches.open("minilink-user-assets"); 130 + 131 + // Calculate positions and prepare data for all images 132 + const imageBlocks = imageFiles.map((file, index) => { 133 + const entity = v7(); 134 + const fileID = v7(); 135 + const row = Math.floor(index / COLUMNS); 136 + const col = index % COLUMNS; 137 + 138 + // Calculate x position by summing all previous column widths 139 + let x = dropX; 140 + for (let c = 0; c < col; c++) { 141 + x += colWidths[c] + SPACING; 142 + } 143 + 144 + // Calculate y position by summing all previous row heights 145 + let y = dropY; 146 + for (let r = 0; r < row; r++) { 147 + y += rowHeights[r] + SPACING; 148 + } 149 + 150 + const url = client.storage 151 + .from("minilink-user-assets") 152 + .getPublicUrl(fileID).data.publicUrl; 153 + 154 + return { 155 + file, 156 + entity, 157 + fileID, 158 + url, 159 + position: { x, y }, 160 + dimensions: processedImages[index], 161 + }; 162 + }); 163 + 164 + // Create all blocks with image facts 165 + for (const block of imageBlocks) { 166 + // Add to cache for immediate display 167 + await cache.put( 168 + new URL(block.url + "?local"), 169 + new Response(block.file, { 170 + headers: { 171 + "Content-Type": block.file.type, 172 + "Content-Length": block.file.size.toString(), 173 + }, 174 + }), 175 + ); 176 + localImages.set(block.url, true); 177 + 178 + // Create canvas block 179 + await rep.mutate.addCanvasBlock({ 180 + newEntityID: block.entity, 181 + parent: entityID, 182 + position: block.position, 183 + factID: v7(), 184 + type: "image", 185 + permission_set: entity_set.set, 186 + }); 187 + 188 + // Add image fact with local version for immediate display 189 + if (navigator.serviceWorker) { 190 + await rep.mutate.assertFact({ 191 + entity: block.entity, 192 + attribute: "block/image", 193 + data: { 194 + fallback: block.dimensions.thumbhash, 195 + type: "image", 196 + local: rep.clientID, 197 + src: block.url, 198 + height: block.dimensions.height, 199 + width: block.dimensions.width, 200 + }, 201 + }); 202 + } 203 + } 204 + 205 + // Upload all files to storage in parallel 206 + await Promise.all( 207 + imageBlocks.map(async (block) => { 208 + await client.storage 209 + .from("minilink-user-assets") 210 + .upload(block.fileID, block.file, { 211 + cacheControl: "public, max-age=31560000, immutable", 212 + }); 213 + 214 + // Update fact with final version 215 + await rep.mutate.assertFact({ 216 + entity: block.entity, 217 + attribute: "block/image", 218 + data: { 219 + fallback: block.dimensions.thumbhash, 220 + type: "image", 221 + src: block.url, 222 + height: block.dimensions.height, 223 + width: block.dimensions.width, 224 + }, 225 + }); 226 + }), 227 + ); 228 + 229 + return true; 230 + }, 231 + [rep, entityID, entity_set.set, blocks], 232 + ); 233 + };
+74
components/Blocks/useHandleDrop.ts
···
··· 1 + import { useCallback } from "react"; 2 + import { useReplicache } from "src/replicache"; 3 + import { generateKeyBetween } from "fractional-indexing"; 4 + import { addImage } from "src/utils/addImage"; 5 + import { useEntitySetContext } from "components/EntitySetProvider"; 6 + import { v7 } from "uuid"; 7 + 8 + export const useHandleDrop = (params: { 9 + parent: string; 10 + position: string | null; 11 + nextPosition: string | null; 12 + }) => { 13 + let { rep } = useReplicache(); 14 + let entity_set = useEntitySetContext(); 15 + 16 + return useCallback( 17 + async (e: React.DragEvent) => { 18 + e.preventDefault(); 19 + e.stopPropagation(); 20 + 21 + if (!rep) return; 22 + 23 + const files = e.dataTransfer.files; 24 + if (!files || files.length === 0) return; 25 + 26 + // Filter for image files only 27 + const imageFiles = Array.from(files).filter((file) => 28 + file.type.startsWith("image/"), 29 + ); 30 + 31 + if (imageFiles.length === 0) return; 32 + 33 + let currentPosition = params.position; 34 + 35 + // Calculate positions for all images first 36 + const imageBlocks = imageFiles.map((file) => { 37 + const entity = v7(); 38 + const position = generateKeyBetween( 39 + currentPosition, 40 + params.nextPosition, 41 + ); 42 + currentPosition = position; 43 + return { file, entity, position }; 44 + }); 45 + 46 + // Create all blocks in parallel 47 + await Promise.all( 48 + imageBlocks.map((block) => 49 + rep.mutate.addBlock({ 50 + parent: params.parent, 51 + factID: v7(), 52 + permission_set: entity_set.set, 53 + type: "image", 54 + position: block.position, 55 + newEntityID: block.entity, 56 + }), 57 + ), 58 + ); 59 + 60 + // Upload all images in parallel 61 + await Promise.all( 62 + imageBlocks.map((block) => 63 + addImage(block.file, rep, { 64 + entityID: block.entity, 65 + attribute: "block/image", 66 + }), 67 + ), 68 + ); 69 + 70 + return true; 71 + }, 72 + [rep, params.position, params.nextPosition, params.parent, entity_set.set], 73 + ); 74 + };
+35 -21
components/Buttons.tsx
··· 10 import { PopoverArrow } from "./Icons/PopoverArrow"; 11 12 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 13 export const ButtonPrimary = forwardRef< 14 HTMLButtonElement, 15 ButtonProps & { ··· 35 m-0 h-max 36 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 37 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 38 - bg-accent-1 outline-transparent border border-accent-1 39 - rounded-md text-base font-bold text-accent-2 40 flex gap-2 items-center justify-center shrink-0 41 - transparent-outline focus:outline-accent-1 hover:outline-accent-1 outline-offset-1 42 - disabled:bg-border-light disabled:border-border-light disabled:text-border disabled:hover:text-border 43 ${className} 44 `} 45 > ··· 70 <button 71 {...buttonProps} 72 ref={ref} 73 - className={`m-0 h-max 74 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 75 - ${props.compact ? "py-0 px-1" : "px-2 py-0.5 "} 76 - bg-bg-page outline-transparent 77 - rounded-md text-base font-bold text-accent-contrast 78 - flex gap-2 items-center justify-center shrink-0 79 - transparent-outline focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 80 - border border-accent-contrast 81 - disabled:bg-border-light disabled:text-border disabled:hover:text-border 82 - ${props.className} 83 - `} 84 > 85 {props.children} 86 </button> ··· 92 HTMLButtonElement, 93 { 94 fullWidth?: boolean; 95 children: React.ReactNode; 96 compact?: boolean; 97 } & ButtonProps 98 >((props, ref) => { 99 - let { fullWidth, children, compact, ...buttonProps } = props; 100 return ( 101 <button 102 {...buttonProps} 103 ref={ref} 104 - className={`m-0 h-max ${fullWidth ? "w-full" : "w-max"} ${compact ? "px-0" : "px-1"} 105 - bg-transparent text-base font-bold text-accent-contrast 106 - flex gap-2 items-center justify-center shrink-0 107 - hover:underline disabled:text-border 108 - ${props.className} 109 - `} 110 > 111 {children} 112 </button>
··· 10 import { PopoverArrow } from "./Icons/PopoverArrow"; 11 12 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 13 + 14 export const ButtonPrimary = forwardRef< 15 HTMLButtonElement, 16 ButtonProps & { ··· 36 m-0 h-max 37 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 38 ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 39 + bg-accent-1 disabled:bg-border-light 40 + border border-accent-1 rounded-md disabled:border-border-light 41 + outline outline-transparent outline-offset-1 focus:outline-accent-1 hover:outline-accent-1 42 + text-base font-bold text-accent-2 disabled:text-border disabled:hover:text-border 43 flex gap-2 items-center justify-center shrink-0 44 ${className} 45 `} 46 > ··· 71 <button 72 {...buttonProps} 73 ref={ref} 74 + className={` 75 + m-0 h-max 76 ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 77 + ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 78 + bg-bg-page disabled:bg-border-light 79 + border border-accent-contrast rounded-md 80 + outline outline-transparent focus:outline-accent-contrast hover:outline-accent-contrast outline-offset-1 81 + text-base font-bold text-accent-contrast disabled:text-border disabled:hover:text-border 82 + flex gap-2 items-center justify-center shrink-0 83 + ${props.className} 84 + `} 85 > 86 {props.children} 87 </button> ··· 93 HTMLButtonElement, 94 { 95 fullWidth?: boolean; 96 + fullWidthOnMobile?: boolean; 97 children: React.ReactNode; 98 compact?: boolean; 99 } & ButtonProps 100 >((props, ref) => { 101 + let { 102 + className, 103 + fullWidth, 104 + fullWidthOnMobile, 105 + compact, 106 + children, 107 + ...buttonProps 108 + } = props; 109 return ( 110 <button 111 {...buttonProps} 112 ref={ref} 113 + className={` 114 + m-0 h-max 115 + ${fullWidth ? "w-full" : fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 116 + ${compact ? "py-0 px-1" : "px-2 py-0.5 "} 117 + bg-transparent hover:bg-[var(--accent-light)] 118 + border border-transparent rounded-md hover:border-[var(--accent-light)] 119 + outline outline-transparent focus:outline-[var(--accent-light)] hover:outline-[var(--accent-light)] outline-offset-1 120 + text-base font-bold text-accent-contrast disabled:text-border 121 + flex gap-2 items-center justify-center shrink-0 122 + ${props.className} 123 + `} 124 > 125 {children} 126 </button>
+67 -34
components/Canvas.tsx
··· 14 import { TooltipButton } from "./Buttons"; 15 import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers"; 16 import { AddSmall } from "./Icons/AddSmall"; 17 18 - export function Canvas(props: { entityID: string; preview?: boolean }) { 19 let entity_set = useEntitySetContext(); 20 let ref = useRef<HTMLDivElement>(null); 21 useEffect(() => { ··· 44 return () => abort.abort(); 45 }); 46 47 - let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data 48 - .value; 49 - 50 return ( 51 <div 52 ref={ref} 53 id={elementId.page(props.entityID).canvasScrollArea} 54 className={` 55 canvasWrapper 56 - h-full w-fit mx-auto 57 - max-w-[calc(100vw-12px)] 58 - ${!narrowWidth ? "sm:max-w-[calc(100vw-128px)] lg:max-w-[calc(var(--page-width-units)*2 + 24px))]" : " sm:max-w-(--page-width-units)"} 59 - rounded-lg 60 overflow-y-scroll 61 `} 62 > 63 <AddCanvasBlockButton entityID={props.entityID} entity_set={entity_set} /> 64 <CanvasContent {...props} /> 65 - <CanvasWidthHandle entityID={props.entityID} /> 66 </div> 67 ); 68 } ··· 72 let { rep } = useReplicache(); 73 let entity_set = useEntitySetContext(); 74 let height = Math.max(...blocks.map((f) => f.data.position.y), 0); 75 return ( 76 <div 77 onClick={async (e) => { ··· 109 ); 110 } 111 }} 112 style={{ 113 minHeight: height + 512, 114 contain: "size layout paint", ··· 139 ); 140 } 141 142 - function CanvasWidthHandle(props: { entityID: string }) { 143 - let canvasFocused = useUIState((s) => s.focusedEntity?.entityType === "page"); 144 - let { rep } = useReplicache(); 145 - let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data 146 - .value; 147 return ( 148 - <button 149 - onClick={() => { 150 - rep?.mutate.assertFact({ 151 - entity: props.entityID, 152 - attribute: "canvas/narrow-width", 153 - data: { 154 - type: "boolean", 155 - value: !narrowWidth, 156 - }, 157 - }); 158 - }} 159 - className={`resizeHandle 160 - ${narrowWidth ? "cursor-e-resize" : "cursor-w-resize"} shrink-0 z-10 161 - ${canvasFocused ? "sm:block hidden" : "hidden"} 162 - w-[8px] h-12 163 - absolute top-1/2 right-0 -translate-y-1/2 translate-x-[3px] 164 - rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 165 - /> 166 ); 167 - } 168 169 const AddCanvasBlockButton = (props: { 170 entityID: string; ··· 176 177 if (!permissions.write) return null; 178 return ( 179 - <div className="absolute right-2 sm:top-4 sm:right-4 bottom-2 sm:bottom-auto z-10 flex flex-col gap-1 justify-center"> 180 <TooltipButton 181 side="left" 182 open={blocks.length === 0 ? true : undefined}
··· 14 import { TooltipButton } from "./Buttons"; 15 import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers"; 16 import { AddSmall } from "./Icons/AddSmall"; 17 + import { InfoSmall } from "./Icons/InfoSmall"; 18 + import { Popover } from "./Popover"; 19 + import { Separator } from "./Layout"; 20 + import { CommentTiny } from "./Icons/CommentTiny"; 21 + import { QuoteTiny } from "./Icons/QuoteTiny"; 22 + import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 + import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 + import { 25 + PubLeafletPublication, 26 + PubLeafletPublicationRecord, 27 + } from "lexicons/api"; 28 + import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 29 30 + export function Canvas(props: { 31 + entityID: string; 32 + preview?: boolean; 33 + first?: boolean; 34 + }) { 35 let entity_set = useEntitySetContext(); 36 let ref = useRef<HTMLDivElement>(null); 37 useEffect(() => { ··· 60 return () => abort.abort(); 61 }); 62 63 return ( 64 <div 65 ref={ref} 66 id={elementId.page(props.entityID).canvasScrollArea} 67 className={` 68 canvasWrapper 69 + h-full w-fit 70 overflow-y-scroll 71 `} 72 > 73 <AddCanvasBlockButton entityID={props.entityID} entity_set={entity_set} /> 74 + 75 + <CanvasMetadata isSubpage={!props.first} /> 76 + 77 <CanvasContent {...props} /> 78 </div> 79 ); 80 } ··· 84 let { rep } = useReplicache(); 85 let entity_set = useEntitySetContext(); 86 let height = Math.max(...blocks.map((f) => f.data.position.y), 0); 87 + let handleDrop = useHandleCanvasDrop(props.entityID); 88 + 89 return ( 90 <div 91 onClick={async (e) => { ··· 123 ); 124 } 125 }} 126 + onDragOver={ 127 + !props.preview && entity_set.permissions.write 128 + ? (e) => { 129 + e.preventDefault(); 130 + e.stopPropagation(); 131 + } 132 + : undefined 133 + } 134 + onDrop={ 135 + !props.preview && entity_set.permissions.write ? handleDrop : undefined 136 + } 137 style={{ 138 minHeight: height + 512, 139 contain: "size layout paint", ··· 164 ); 165 } 166 167 + const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 168 + let { data: pub } = useLeafletPublicationData(); 169 + if (!pub || !pub.publications) return null; 170 + 171 + let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 + let showComments = pubRecord.preferences?.showComments; 173 + 174 return ( 175 + <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 176 + {showComments && ( 177 + <div className="flex gap-1 text-tertiary items-center"> 178 + <CommentTiny className="text-border" /> โ€” 179 + </div> 180 + )} 181 + <div className="flex gap-1 text-tertiary items-center"> 182 + <QuoteTiny className="text-border" /> โ€” 183 + </div> 184 + 185 + {!props.isSubpage && ( 186 + <> 187 + <Separator classname="h-5" /> 188 + <Popover 189 + side="left" 190 + align="start" 191 + className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 192 + trigger={<InfoSmall />} 193 + > 194 + <PublicationMetadata /> 195 + </Popover> 196 + </> 197 + )} 198 + </div> 199 ); 200 + }; 201 202 const AddCanvasBlockButton = (props: { 203 entityID: string; ··· 209 210 if (!permissions.write) return null; 211 return ( 212 + <div className="absolute right-2 sm:bottom-4 sm:right-4 bottom-2 sm:top-auto z-10 flex flex-col gap-1 justify-center"> 213 <TooltipButton 214 side="left" 215 open={blocks.length === 0 ? true : undefined}
+1 -1
components/DesktopFooter.tsx
··· 16 return ( 17 <Media 18 mobile={false} 19 - className="absolute bottom-4 w-full z-10 pointer-events-none" 20 > 21 {focusedEntity && 22 focusedEntity.entityType === "block" &&
··· 16 return ( 17 <Media 18 mobile={false} 19 + className="absolute bottom-[40px] w-full z-10 pointer-events-none" 20 > 21 {focusedEntity && 22 focusedEntity.entityType === "block" &&
-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 - };
···
+19
components/Icons/AccountTiny.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const AccountTiny = (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="M11.9995 11.6042C12.2359 11.3531 12.6319 11.3406 12.8833 11.5768C13.1345 11.8133 13.1469 12.2102 12.9106 12.4616C10.9942 14.4996 8.48343 14.9669 5.82467 14.4899C5.48536 14.4287 5.25917 14.1047 5.31979 13.7653C5.38075 13.4255 5.7066 13.1985 6.04635 13.2594C8.41545 13.6844 10.4511 13.2509 11.9995 11.6042ZM7.40377 1.64517C7.57942 1.34822 7.96315 1.25 8.26022 1.42544C8.55725 1.60111 8.65554 1.98479 8.47995 2.28189L4.62155 8.80923C4.68119 8.84969 4.74613 8.89372 4.81686 8.93716C5.20557 9.17585 5.72696 9.42535 6.30123 9.51724C7.59938 9.72475 8.32429 9.55762 8.60495 9.41959C8.91451 9.26714 9.28927 9.39433 9.44186 9.70376C9.59429 10.0133 9.46707 10.3881 9.15768 10.5407C8.55667 10.8366 7.53939 10.9811 6.10397 10.7516C5.31168 10.6249 4.63266 10.2913 4.16256 10.0026C3.92499 9.85669 3.73326 9.71756 3.60006 9.61392C3.53354 9.56215 3.48092 9.51848 3.44381 9.48697C3.42534 9.47127 3.41058 9.45834 3.39987 9.44888C3.39453 9.44418 3.38953 9.44016 3.3862 9.43716C3.38469 9.43579 3.38337 9.43423 3.38229 9.43326L3.38034 9.43228V9.4313H3.37936C3.16132 9.23186 3.11298 8.90647 3.26315 8.65201L7.40377 1.64517ZM12.4995 2.25259C13.2777 2.19942 13.9584 2.87497 14.019 3.76138C14.0795 4.64775 13.4974 5.40938 12.7192 5.46255C11.941 5.51572 11.2612 4.84018 11.2006 3.95376C11.1401 3.06754 11.7215 2.306 12.4995 2.25259ZM2.08444 2.98501C2.35274 2.19505 3.03678 1.71257 3.61178 1.90787C4.18673 2.1032 4.43574 2.90212 4.16745 3.69205C3.89911 4.48193 3.21507 4.9635 2.6401 4.76822C2.06529 4.57291 1.81644 3.77476 2.08444 2.98501Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+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 + };
+1
components/Icons/GoBackSmall.tsx
··· 8 viewBox="0 0 24 24" 9 fill="none" 10 xmlns="http://www.w3.org/2000/svg" 11 > 12 <path 13 d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12.6826 5.96582C12.2921 5.57556 11.659 5.57557 11.2686 5.96582L5.94141 11.293C5.55114 11.6834 5.55114 12.3166 5.94141 12.707L11.2686 18.0332L11.3438 18.1025C11.7365 18.4229 12.3165 18.3993 12.6826 18.0332C13.0484 17.6671 13.0712 17.088 12.751 16.6953L12.6826 16.6191L9.06348 13H17.9473L18.0498 12.9951C18.5538 12.9438 18.9471 12.5175 18.9473 12C18.9472 11.4824 18.5538 11.0563 18.0498 11.0049L17.9473 11H9.06152L12.6826 7.37988C13.0729 6.98941 13.0729 6.35629 12.6826 5.96582Z"
··· 8 viewBox="0 0 24 24" 9 fill="none" 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 > 13 <path 14 d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12.6826 5.96582C12.2921 5.57556 11.659 5.57557 11.2686 5.96582L5.94141 11.293C5.55114 11.6834 5.55114 12.3166 5.94141 12.707L11.2686 18.0332L11.3438 18.1025C11.7365 18.4229 12.3165 18.3993 12.6826 18.0332C13.0484 17.6671 13.0712 17.088 12.751 16.6953L12.6826 16.6191L9.06348 13H17.9473L18.0498 12.9951C18.5538 12.9438 18.9471 12.5175 18.9473 12C18.9472 11.4824 18.5538 11.0563 18.0498 11.0049L17.9473 11H9.06152L12.6826 7.37988C13.0729 6.98941 13.0729 6.35629 12.6826 5.96582Z"
+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 + };
+21
components/Icons/MentionTiny.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const MentionTiny = (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 + fillRule="evenodd" 15 + clipRule="evenodd" 16 + d="M8.7548 1.48131C8.37164 1.12732 7.78076 1.12733 7.39761 1.48131L6.31216 2.48412C6.11579 2.66553 5.85496 2.76077 5.58789 2.74856L4.02324 2.67702C3.42714 2.64976 2.93999 3.14727 2.97979 3.74267L3.04008 4.64469C3.06279 4.98446 2.91104 5.31244 2.63737 5.51507L1.84867 6.09903C1.30874 6.4988 1.30874 7.30663 1.84867 7.7064L2.63737 8.29036C2.91104 8.49299 3.06279 8.82097 3.04008 9.16074L2.97979 10.0628C2.93999 10.6582 3.42714 11.1557 4.02324 11.1284L5.58789 11.0569C5.85496 11.0447 6.11579 11.1399 6.31216 11.3213L7.39761 12.3241C7.78076 12.6781 8.37165 12.6781 8.7548 12.3241L9.84025 11.3213C9.8673 11.2963 9.89557 11.273 9.92492 11.2513C10.379 11.5423 10.9394 11.8764 11.3808 12.0072C12.1456 12.2339 12.9198 12.3728 13.6513 12.2853C14.4861 12.1855 14.9021 12.0899 15.3797 11.8006C14.3597 11.4989 13.8748 11.0143 13.4688 10.4865C13.2705 10.2287 13.1568 9.97205 13.0619 9.71255L12.8396 9.00919C12.8217 8.74234 13.0797 8.57625 13.3239 8.41908C13.3906 8.37613 13.4563 8.33385 13.515 8.29036L14.3037 7.7064C14.8437 7.30663 14.8437 6.4988 14.3037 6.09903L13.515 5.51507C13.2414 5.31244 13.0896 4.98447 13.1123 4.6447L13.1726 3.74267C13.2124 3.14727 12.7253 2.64976 12.1292 2.67702L10.5645 2.74856C10.2975 2.76077 10.0366 2.66553 9.84025 2.48412L8.7548 1.48131ZM0.724555 8.54935C0.893092 8.33061 1.20705 8.2899 1.42579 8.45844L1.95221 8.86403C2.17095 9.03256 2.21166 9.34652 2.04312 9.56526C1.87458 9.78401 1.56063 9.82471 1.34188 9.65618L0.815467 9.25059C0.596721 9.08206 0.556018 8.7681 0.724555 8.54935ZM2.22796 11.7918C2.40295 11.5782 2.71798 11.5469 2.9316 11.7219C3.05506 11.823 3.21912 11.8981 3.3887 11.9473C3.55945 11.9968 3.70151 12.0103 3.75507 12.0103C4.04231 12.0103 4.26845 11.9927 4.45796 11.9729C4.51258 11.9672 4.56967 11.9606 4.62708 11.954C4.75105 11.9396 4.8765 11.9251 4.98174 11.9192C5.15574 11.9095 5.34286 11.9165 5.54475 11.9825C5.74373 12.0475 5.92168 12.158 6.10009 12.3035C6.28301 12.4526 6.47827 12.6379 6.65933 12.8097C6.70174 12.8499 6.74338 12.8894 6.7839 12.9276C7.01119 13.1415 7.20968 13.3186 7.38302 13.4332C7.50037 13.5108 7.73051 13.5859 8.01215 13.6062C8.29295 13.6264 8.53643 13.5857 8.67785 13.5166C8.81401 13.4501 8.97286 13.3418 9.18171 13.1869C9.235 13.1474 9.2917 13.1048 9.35123 13.06L9.3513 13.0599L9.35133 13.0599C9.51197 12.9391 9.69328 12.8027 9.88425 12.6704C10.0346 12.5664 10.2298 12.5526 10.3932 12.6347C11.1162 12.9977 11.6692 13.1581 12.1996 13.2235C12.7423 13.2903 13.2802 13.261 14.0061 13.217C14.2817 13.2003 14.5187 13.4102 14.5354 13.6858C14.5521 13.9615 14.3422 14.1984 14.0666 14.2151C13.3566 14.2582 12.7265 14.2959 12.0773 14.216C11.492 14.1438 10.9056 13.9787 10.2184 13.6606C10.133 13.7233 10.0503 13.7855 9.96791 13.8475L9.96771 13.8477C9.90441 13.8953 9.84129 13.9428 9.77726 13.9902C9.56766 14.1456 9.34378 14.3043 9.11669 14.4152C8.76159 14.5886 8.32363 14.6312 7.94034 14.6036C7.55787 14.5761 7.14126 14.4722 6.83145 14.2673C6.57763 14.0995 6.32221 13.8663 6.09857 13.6558C6.05019 13.6103 6.00322 13.5657 5.9575 13.5224L5.95731 13.5222L5.9573 13.5222C5.77867 13.3528 5.61903 13.2015 5.46819 13.0785C5.34858 12.981 5.27842 12.9475 5.23423 12.933C5.19296 12.9196 5.14185 12.9118 5.03761 12.9176C4.96094 12.9219 4.88552 12.9308 4.78482 12.9425L4.78461 12.9426L4.78437 12.9426C4.72196 12.9499 4.64983 12.9583 4.56169 12.9675C4.34798 12.9898 4.08593 13.0103 3.75507 13.0103C3.59643 13.0103 3.36037 12.9803 3.11004 12.9077C2.85853 12.8347 2.55853 12.7089 2.29792 12.4955C2.0843 12.3205 2.05298 12.0054 2.22796 11.7918ZM7.36287 5.60901L7.868 7.84218H8.29336L8.79849 5.60901V3.81006H7.36287V5.60901ZM8.89597 9.99561V8.4182H7.25653V9.99561H8.89597Z" 17 + fill="currentColor" 18 + /> 19 + </svg> 20 + ); 21 + };
+17
components/Icons/NotificationSmall.tsx
··· 26 viewBox="0 0 24 24" 27 fill="none" 28 xmlns="http://www.w3.org/2000/svg" 29 {...props} 30 > 31 <path
··· 26 viewBox="0 0 24 24" 27 fill="none" 28 xmlns="http://www.w3.org/2000/svg" 29 + > 30 + <path 31 + d="M12.3779 0.890636C13.5297 0.868361 14.2312 1.35069 14.6104 1.8047C15.1942 2.50387 15.2636 3.34086 15.2129 3.95314C17.7074 4.96061 18.8531 7.45818 19.375 10.3975C19.5903 11.1929 20.0262 11.5635 20.585 11.9336C21.1502 12.3079 22.0847 12.7839 22.5879 13.7998C23.4577 15.556 22.8886 17.8555 20.9297 19.083C20.1439 19.5754 19.2029 20.1471 17.8496 20.5869C17.1962 20.7993 16.454 20.9768 15.5928 21.1055C15.2068 22.4811 13.9287 23.4821 12.4238 23.4824C10.9225 23.4824 9.64464 22.4867 9.25489 21.1162C8.37384 20.9871 7.61998 20.8046 6.95899 20.5869C5.62158 20.1464 4.69688 19.5723 3.91602 19.083C1.95717 17.8555 1.38802 15.556 2.25782 13.7998C2.76329 12.7794 3.60199 12.3493 4.18653 12.0068C4.7551 11.6737 5.1753 11.386 5.45606 10.7432C5.62517 9.31217 5.93987 8.01645 6.4668 6.92482C7.1312 5.54855 8.13407 4.49633 9.56251 3.92482C9.53157 3.34709 9.6391 2.63284 10.1133 1.98927C10.1972 1.87543 10.4043 1.594 10.7822 1.34669C11.1653 1.09611 11.6872 0.904101 12.3779 0.890636ZM14.1709 21.2608C13.6203 21.3007 13.0279 21.3242 12.3887 21.3242C11.7757 21.3242 11.2072 21.3024 10.6777 21.2656C11.0335 21.8421 11.6776 22.2324 12.4238 22.2324C13.1718 22.2321 13.816 21.8396 14.1709 21.2608ZM12.4004 2.38966C11.9872 2.39776 11.7419 2.50852 11.5996 2.60157C11.4528 2.6977 11.3746 2.801 11.3193 2.87599C11.088 3.19 11.031 3.56921 11.0664 3.92677C11.084 4.10311 11.1233 4.258 11.1631 4.37013C11.1875 4.43883 11.205 4.47361 11.21 4.48341C11.452 4.78119 11.4299 5.22068 11.1484 5.49415C10.8507 5.78325 10.3748 5.77716 10.0869 5.48048C10.0533 5.44582 10.0231 5.40711 9.99415 5.3672C9.0215 5.79157 8.31886 6.53162 7.81641 7.57228C7.21929 8.80941 6.91013 10.4656 6.82129 12.4746L6.81934 12.5137L6.81446 12.5518C6.73876 13.0607 6.67109 13.5103 6.53418 13.9121C6.38567 14.3476 6.16406 14.7061 5.82032 15.0899C5.54351 15.3988 5.06973 15.4268 4.76172 15.1514C4.45392 14.8758 4.42871 14.4019 4.70508 14.0928C4.93763 13.8332 5.04272 13.6453 5.11524 13.4326C5.14365 13.3492 5.16552 13.2588 5.18848 13.1553C5.10586 13.2062 5.02441 13.2544 4.94532 13.3008C4.28651 13.6868 3.87545 13.9129 3.60157 14.4658C3.08548 15.5082 3.38433 16.9793 4.71192 17.8115C5.4776 18.2913 6.27423 18.7818 7.42872 19.1621C8.58507 19.543 10.1358 19.8242 12.3887 19.8242C14.6416 19.8242 16.2108 19.5429 17.3857 19.1611C18.5582 18.7801 19.3721 18.2882 20.1328 17.8115C21.4611 16.9793 21.7595 15.5084 21.2432 14.4658C20.9668 13.9081 20.515 13.6867 19.7568 13.1846C19.7553 13.1835 19.7535 13.1827 19.752 13.1817C19.799 13.3591 19.8588 13.5202 19.9287 13.6514C20.021 13.8244 20.1034 13.8927 20.1533 13.917C20.5249 14.0981 20.6783 14.5465 20.4961 14.919C20.3135 15.2913 19.8639 15.4467 19.4922 15.2656C19.0607 15.0553 18.7821 14.6963 18.6035 14.3613C18.4238 14.0242 18.3154 13.6559 18.2471 13.3379C18.1778 13.0155 18.1437 12.7147 18.127 12.4971C18.1185 12.3873 18.1145 12.2956 18.1123 12.2305C18.1115 12.2065 18.1107 12.1856 18.1104 12.169C18.0569 11.6585 17.9885 11.1724 17.9082 10.7109C17.9002 10.6794 17.8913 10.6476 17.8838 10.6152L17.8906 10.6133C17.4166 7.97573 16.4732 6.17239 14.791 5.40821C14.5832 5.64607 14.2423 5.73912 13.9365 5.61036C13.5557 5.44988 13.3777 5.01056 13.5391 4.62892C13.5394 4.62821 13.5397 4.62699 13.54 4.62599C13.5425 4.61977 13.5479 4.6087 13.5537 4.59278C13.5658 4.55999 13.5837 4.50758 13.6035 4.44142C13.6438 4.30713 13.6903 4.12034 13.7139 3.91212C13.7631 3.47644 13.7038 3.06402 13.457 2.76857C13.3434 2.63264 13.0616 2.37678 12.4004 2.38966ZM10.1055 16.625C11.6872 16.8411 12.8931 16.8585 13.8174 16.7539C14.2287 16.7076 14.5997 17.0028 14.6465 17.4141C14.693 17.8256 14.3969 18.1976 13.9854 18.2442C12.9038 18.3665 11.5684 18.3389 9.90235 18.1113C9.49223 18.0551 9.20488 17.6768 9.26075 17.2666C9.3168 16.8563 9.6952 16.5691 10.1055 16.625ZM16.3887 16.3047C16.7403 16.086 17.203 16.1935 17.4219 16.5449C17.6406 16.8967 17.5324 17.3594 17.1807 17.5781C16.9689 17.7097 16.6577 17.8424 16.4033 17.9131C16.0045 18.0237 15.5914 17.7904 15.4805 17.3916C15.3696 16.9926 15.6031 16.5788 16.002 16.4678C16.1344 16.431 16.3112 16.3527 16.3887 16.3047Z" 32 + fill="currentColor" 33 + /> 34 + </svg> 35 + ); 36 + }; 37 + 38 + export const ReaderUnread = (props: Props) => { 39 + return ( 40 + <svg 41 + width="24" 42 + height="24" 43 + viewBox="0 0 24 24" 44 + fill="none" 45 + xmlns="http://www.w3.org/2000/svg" 46 {...props} 47 > 48 <path
+20
components/Icons/ReplyTiny.tsx
···
··· 1 + import { Props } from "./Props"; 2 + 3 + export const ReplyTiny = (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 + fillRule="evenodd" 14 + clipRule="evenodd" 15 + d="M10.7767 3.01749C11.6289 3.39627 12.1593 3.79765 12.4801 4.2201C12.7868 4.62405 12.9578 5.12048 12.9578 5.81175C12.9578 6.45434 12.7165 7.17288 12.2111 7.72195C11.7245 8.25058 10.9456 8.67427 9.75117 8.67427L4.45638 8.67427L6.97173 6.15892C7.36226 5.7684 7.36226 5.13523 6.97173 4.74471C6.58121 4.35418 5.94804 4.35418 5.55752 4.74471L1.33513 8.9671C0.944605 9.35762 0.944605 9.99079 1.33513 10.3813L5.55752 14.6037C5.94804 14.9942 6.58121 14.9942 6.97173 14.6037C7.36226 14.2132 7.36226 13.58 6.97173 13.1895L4.45652 10.6743L9.75117 10.6743C11.4697 10.6743 12.7941 10.0416 13.6826 9.07646C14.5522 8.13173 14.9578 6.91901 14.9578 5.81175C14.9578 4.75316 14.6829 3.81405 14.073 3.01069C13.4771 2.22581 12.62 1.64809 11.589 1.18986C11.0843 0.965558 10.4933 1.19285 10.269 1.69754C10.0447 2.20222 10.272 2.79318 10.7767 3.01749Z" 16 + fill="currentColor" 17 + /> 18 + </svg> 19 + ); 20 + };
+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 + };
+32 -2
components/IdentityProvider.tsx
··· 1 "use client"; 2 import { getIdentityData } from "actions/getIdentityData"; 3 - import { createContext, useContext } from "react"; 4 import useSWR, { KeyedMutator, mutate } from "swr"; 5 import { DashboardState } from "./PageLayouts/DashboardLayout"; 6 7 export type InterfaceState = { 8 dashboards: { [id: string]: DashboardState | undefined }; 9 }; 10 - type Identity = Awaited<ReturnType<typeof getIdentityData>>; 11 let IdentityContext = createContext({ 12 identity: null as Identity, 13 mutate: (() => {}) as KeyedMutator<Identity>, 14 }); 15 export const useIdentityData = () => useContext(IdentityContext); 16 export function IdentityContextProvider(props: { 17 children: React.ReactNode; 18 initialValue: Identity; ··· 20 let { data: identity, mutate } = useSWR("identity", () => getIdentityData(), { 21 fallbackData: props.initialValue, 22 }); 23 return ( 24 <IdentityContext.Provider value={{ identity, mutate }}> 25 {props.children}
··· 1 "use client"; 2 import { getIdentityData } from "actions/getIdentityData"; 3 + import { createContext, useContext, useEffect } from "react"; 4 import useSWR, { KeyedMutator, mutate } from "swr"; 5 import { DashboardState } from "./PageLayouts/DashboardLayout"; 6 + import { supabaseBrowserClient } from "supabase/browserClient"; 7 + import { produce, Draft } from "immer"; 8 9 export type InterfaceState = { 10 dashboards: { [id: string]: DashboardState | undefined }; 11 }; 12 + export type Identity = Awaited<ReturnType<typeof getIdentityData>>; 13 let IdentityContext = createContext({ 14 identity: null as Identity, 15 mutate: (() => {}) as KeyedMutator<Identity>, 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 + } 31 export function IdentityContextProvider(props: { 32 children: React.ReactNode; 33 initialValue: Identity; ··· 35 let { data: identity, mutate } = useSWR("identity", () => getIdentityData(), { 36 fallbackData: props.initialValue, 37 }); 38 + useEffect(() => { 39 + mutate(props.initialValue); 40 + }, [props.initialValue]); 41 + useEffect(() => { 42 + if (!identity?.atp_did) return; 43 + let supabase = supabaseBrowserClient(); 44 + let channel = supabase.channel(`identity.atp_did:${identity.atp_did}`); 45 + channel.on("broadcast", { event: "notification" }, () => { 46 + mutate(); 47 + }); 48 + channel.subscribe(); 49 + return () => { 50 + channel.unsubscribe(); 51 + }; 52 + }, [identity?.atp_did]); 53 return ( 54 <IdentityContext.Provider value={{ identity, mutate }}> 55 {props.children}
+2 -2
components/InitialPageLoadProvider.tsx
··· 2 import { useEffect } from "react"; 3 import { create } from "zustand"; 4 5 - export const useInitialPageLoad = create(() => false); 6 export function InitialPageLoad(props: { children: React.ReactNode }) { 7 useEffect(() => { 8 setTimeout(() => { 9 - useInitialPageLoad.setState(() => true); 10 }, 80); 11 }, []); 12 return <>{props.children}</>;
··· 2 import { useEffect } from "react"; 3 import { create } from "zustand"; 4 5 + export const useHasPageLoaded = create(() => false); 6 export function InitialPageLoad(props: { children: React.ReactNode }) { 7 useEffect(() => { 8 setTimeout(() => { 9 + useHasPageLoaded.setState(() => true); 10 }, 80); 11 }, []); 12 return <>{props.children}</>;
+11 -36
components/Input.tsx
··· 2 import { useEffect, useRef, useState, type JSX } from "react"; 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 import { isIOS } from "src/utils/isDevice"; 5 6 export const Input = ( 7 props: { ··· 58 ); 59 }; 60 61 - export const focusElement = (el?: HTMLInputElement | null) => { 62 - if (!isIOS()) { 63 - el?.focus(); 64 - return; 65 - } 66 - 67 - let fakeInput = document.createElement("input"); 68 - fakeInput.setAttribute("type", "text"); 69 - fakeInput.style.position = "fixed"; 70 - fakeInput.style.height = "0px"; 71 - fakeInput.style.width = "0px"; 72 - fakeInput.style.fontSize = "16px"; // disable auto zoom 73 - document.body.appendChild(fakeInput); 74 - fakeInput.focus(); 75 - setTimeout(() => { 76 - if (!el) return; 77 - el.style.transform = "translateY(-2000px)"; 78 - el?.focus(); 79 - fakeInput.remove(); 80 - el.value = " "; 81 - el.setSelectionRange(1, 1); 82 - requestAnimationFrame(() => { 83 - if (el) { 84 - el.style.transform = ""; 85 - } 86 - }); 87 - setTimeout(() => { 88 - if (!el) return; 89 - el.value = ""; 90 - el.setSelectionRange(0, 0); 91 - }, 50); 92 - }, 20); 93 - }; 94 - 95 export const InputWithLabel = ( 96 props: { 97 label: string; ··· 100 JSX.IntrinsicElements["textarea"], 101 ) => { 102 let { label, textarea, ...inputProps } = props; 103 - let style = `appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 ${props.className} outline-hidden resize-none`; 104 return ( 105 - <label className=" input-with-border flex flex-col gap-px text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]!"> 106 {props.label} 107 {textarea ? ( 108 <textarea {...inputProps} className={style} />
··· 2 import { useEffect, useRef, useState, type JSX } from "react"; 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 import { isIOS } from "src/utils/isDevice"; 5 + import { focusElement } from "src/utils/focusElement"; 6 7 export const Input = ( 8 props: { ··· 59 ); 60 }; 61 62 export const InputWithLabel = ( 63 props: { 64 label: string; ··· 67 JSX.IntrinsicElements["textarea"], 68 ) => { 69 let { label, textarea, ...inputProps } = props; 70 + let style = ` 71 + appearance-none resize-none w-full 72 + bg-transparent 73 + outline-hidden focus:outline-0 74 + font-normal not-italic text-base text-primary disabled:text-tertiary 75 + disabled:cursor-not-allowed 76 + ${props.className}`; 77 return ( 78 + <label 79 + className={`input-with-border flex flex-col gap-px text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]! ${props.disabled && "bg-border-light! cursor-not-allowed! hover:border-border!"}`} 80 + > 81 {props.label} 82 {textarea ? ( 83 <textarea {...inputProps} className={style} />
+114
components/InteractionsPreview.tsx
···
··· 1 + "use client"; 2 + import { Separator } from "./Layout"; 3 + import { CommentTiny } from "./Icons/CommentTiny"; 4 + import { QuoteTiny } from "./Icons/QuoteTiny"; 5 + import { useSmoker } from "./Toast"; 6 + import { Tag } from "./Tags"; 7 + import { Popover } from "./Popover"; 8 + import { TagTiny } from "./Icons/TagTiny"; 9 + import { SpeedyLink } from "./SpeedyLink"; 10 + 11 + export const InteractionPreview = (props: { 12 + quotesCount: number; 13 + commentsCount: number; 14 + tags?: string[]; 15 + postUrl: string; 16 + showComments: boolean | undefined; 17 + share?: boolean; 18 + }) => { 19 + let smoker = useSmoker(); 20 + let interactionsAvailable = 21 + props.quotesCount > 0 || 22 + (props.showComments !== false && props.commentsCount > 0); 23 + 24 + const tagsCount = props.tags?.length || 0; 25 + 26 + return ( 27 + <div 28 + className={`flex gap-2 text-tertiary text-sm items-center self-start`} 29 + > 30 + {tagsCount === 0 ? null : ( 31 + <> 32 + <TagPopover tags={props.tags!} /> 33 + {interactionsAvailable || props.share ? ( 34 + <Separator classname="h-4!" /> 35 + ) : null} 36 + </> 37 + )} 38 + 39 + {props.quotesCount === 0 ? null : ( 40 + <SpeedyLink 41 + aria-label="Post quotes" 42 + href={`${props.postUrl}?interactionDrawer=quotes`} 43 + className="flex flex-row gap-1 text-sm items-center text-accent-contrast!" 44 + > 45 + <QuoteTiny /> {props.quotesCount} 46 + </SpeedyLink> 47 + )} 48 + {props.showComments === false || props.commentsCount === 0 ? null : ( 49 + <SpeedyLink 50 + aria-label="Post comments" 51 + href={`${props.postUrl}?interactionDrawer=comments`} 52 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 53 + > 54 + <CommentTiny /> {props.commentsCount} 55 + </SpeedyLink> 56 + )} 57 + {interactionsAvailable && props.share ? ( 58 + <Separator classname="h-4! !min-h-0" /> 59 + ) : null} 60 + {props.share && ( 61 + <> 62 + <button 63 + id={`copy-post-link-${props.postUrl}`} 64 + className="flex gap-1 items-center hover:text-accent-contrast relative" 65 + onClick={(e) => { 66 + e.stopPropagation(); 67 + e.preventDefault(); 68 + let mouseX = e.clientX; 69 + let mouseY = e.clientY; 70 + 71 + if (!props.postUrl) return; 72 + navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 73 + 74 + smoker({ 75 + text: <strong>Copied Link!</strong>, 76 + position: { 77 + y: mouseY, 78 + x: mouseX, 79 + }, 80 + }); 81 + }} 82 + > 83 + Share 84 + </button> 85 + </> 86 + )} 87 + </div> 88 + ); 89 + }; 90 + 91 + const TagPopover = (props: { tags: string[] }) => { 92 + return ( 93 + <Popover 94 + className="p-2! max-w-xs" 95 + trigger={ 96 + <div className="relative flex gap-1 items-center hover:text-accent-contrast "> 97 + <TagTiny /> {props.tags.length} 98 + </div> 99 + } 100 + > 101 + <TagList tags={props.tags} className="text-secondary!" /> 102 + </Popover> 103 + ); 104 + }; 105 + 106 + const TagList = (props: { tags: string[]; className?: string }) => { 107 + return ( 108 + <div className="flex gap-1 flex-wrap"> 109 + {props.tags.map((tag, index) => ( 110 + <Tag name={tag} key={index} className={props.className} /> 111 + ))} 112 + </div> 113 + ); 114 + };
+6 -12
components/Layout.tsx
··· 1 import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 2 import { theme } from "tailwind.config"; 3 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 4 import { PopoverArrow } from "./Icons/PopoverArrow"; 5 - import { PopoverOpenContext } from "./Popover"; 6 import { useState } from "react"; 7 8 export const Separator = (props: { classname?: string }) => { 9 - return ( 10 - <div className={`min-h-full border-r border-border ${props.classname}`} /> 11 - ); 12 }; 13 14 export const Menu = (props: { ··· 45 alignOffset={props.alignOffset ? props.alignOffset : undefined} 46 sideOffset={4} 47 collisionPadding={16} 48 - className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`} 49 > 50 {props.children} 51 <DropdownMenu.Arrow ··· 86 props.onSelect(event); 87 }} 88 className={` 89 - MenuItem 90 - font-bold z-10 py-1 px-3 91 - text-left text-secondary 92 flex gap-2 93 - data-highlighted:bg-border-light data-highlighted:text-secondary 94 - hover:bg-border-light hover:text-secondary 95 - outline-hidden 96 - cursor-pointer 97 ${props.className} 98 `} 99 >
··· 1 + "use client"; 2 import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 3 import { theme } from "tailwind.config"; 4 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 import { PopoverArrow } from "./Icons/PopoverArrow"; 6 + import { PopoverOpenContext } from "./Popover/PopoverContext"; 7 import { useState } from "react"; 8 9 export const Separator = (props: { classname?: string }) => { 10 + return <div className={`h-full border-r border-border ${props.classname}`} />; 11 }; 12 13 export const Menu = (props: { ··· 44 alignOffset={props.alignOffset ? props.alignOffset : undefined} 45 sideOffset={4} 46 collisionPadding={16} 47 + className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`} 48 > 49 {props.children} 50 <DropdownMenu.Arrow ··· 85 props.onSelect(event); 86 }} 87 className={` 88 + menuItem 89 + z-10 py-1! px-2! 90 flex gap-2 91 ${props.className} 92 `} 93 >
+58
components/LeafletLayout.tsx
···
··· 1 + export const LeafletLayout = (props: { 2 + children: React.ReactNode; 3 + className?: string; 4 + }) => { 5 + return ( 6 + <div 7 + className={` 8 + leafetLayout 9 + w-full h-full relative 10 + mx-auto pwa-padding 11 + flex items-stretch grow`} 12 + id="page-carousel" 13 + > 14 + {/* if you adjust this padding, remember to adjust the negative margins on page in components/Pages/Page.tsx in pageScrollWrapper when card borders are hidden */} 15 + <div 16 + id="pages" 17 + className={`pagesWrapper 18 + w-full h-full 19 + flex gap-0 20 + py-2 sm:py-6 21 + overflow-y-hidden overflow-x-scroll snap-x snap-mandatory no-scrollbar 22 + ${props.className}`} 23 + > 24 + {props.children} 25 + </div> 26 + </div> 27 + ); 28 + }; 29 + 30 + export const BookendSpacer = (props: { 31 + onClick?: (e: React.MouseEvent) => void; 32 + children?: React.ReactNode; 33 + }) => { 34 + // these spacers go at the end of the first and last pages so that those pages can be scrolled to the center of the screen 35 + return ( 36 + <div 37 + className="spacer shrink-0 flex justify-end items-start" 38 + style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }} 39 + onClick={props.onClick ? props.onClick : () => {}} 40 + > 41 + {props.children} 42 + </div> 43 + ); 44 + }; 45 + 46 + export const SandwichSpacer = (props: { 47 + onClick?: (e: React.MouseEvent) => void; 48 + noWidth?: boolean; 49 + className?: string; 50 + }) => { 51 + // these spacers are used between pages so that the page carousel can fit two pages side by side by snapping in between pages 52 + return ( 53 + <div 54 + onClick={props.onClick} 55 + className={`spacer shrink-0 lg:snap-center ${props.noWidth ? "w-0" : "w-6"} ${props.className}`} 56 + /> 57 + ); 58 + };
+2
components/LoginButton.tsx
··· 29 return ( 30 <Popover 31 asChild 32 trigger={ 33 <ActionButton secondary icon={<AccountSmall />} label="Sign In" /> 34 }
··· 29 return ( 30 <Popover 31 asChild 32 + align="start" 33 + side="right" 34 trigger={ 35 <ActionButton secondary icon={<AccountSmall />} label="Sign In" /> 36 }
+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 + }
+31 -29
components/PageLayouts/DashboardLayout.tsx
··· 8 DesktopNavigation, 9 MobileNavigation, 10 navPages, 11 } from "components/ActionBar/Navigation"; 12 import { create } from "zustand"; 13 import { Popover } from "components/Popover"; ··· 32 drafts: boolean; 33 published: boolean; 34 docs: boolean; 35 - templates: boolean; 36 }; 37 }; 38 ··· 44 const defaultDashboardState: DashboardState = { 45 display: undefined, 46 sort: undefined, 47 - filter: { drafts: false, published: false, docs: false, templates: false }, 48 }; 49 50 export const useDashboardStore = create<DashboardStore>((set, get) => ({ 51 dashboards: {}, 52 setDashboard: (id: string, partial: Partial<DashboardState>) => { 53 - console.log(partial); 54 set((state) => ({ 55 dashboards: { 56 ...state.dashboards, ··· 139 const tabParam = searchParams.get("tab"); 140 141 // Initialize tab from search param if valid, otherwise use default 142 - const initialTab = tabParam && props.tabs[tabParam] ? tabParam : props.defaultTab; 143 let [tab, setTab] = useState<keyof T>(initialTab); 144 145 // Custom setter that updates both state and URL ··· 165 className={`dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6`} 166 > 167 <MediaContents mobile={false}> 168 - <div className="flex flex-col gap-4 my-6"> 169 <DesktopNavigation 170 currentPage={props.currentPage} 171 publication={props.publication} ··· 254 hasBackgroundImage: boolean; 255 defaultDisplay: Exclude<DashboardState["display"], undefined>; 256 hasPubs: boolean; 257 - hasTemplates: boolean; 258 }) => { 259 let { display, sort } = useDashboardState(); 260 - console.log({ display, props }); 261 display = display || props.defaultDisplay; 262 let setState = useSetDashboardState(); 263 264 let { identity } = useIdentityData(); 265 - console.log(props); 266 267 return ( 268 <div className="dashboardControls w-full flex gap-4"> ··· 277 <DisplayToggle setState={setState} display={display} /> 278 <Separator classname="h-4 min-h-4!" /> 279 280 - {props.hasPubs || props.hasTemplates ? ( 281 <> 282 - {props.hasPubs} 283 - {props.hasTemplates} 284 <FilterOptions 285 hasPubs={props.hasPubs} 286 - hasTemplates={props.hasTemplates} 287 /> 288 <Separator classname="h-4 min-h-4!" />{" "} 289 </> ··· 301 defaultDisplay: Exclude<DashboardState["display"], undefined>; 302 }) => { 303 let { display, sort } = useDashboardState(); 304 - console.log({ display, props }); 305 display = display || props.defaultDisplay; 306 let setState = useSetDashboardState(); 307 return ( ··· 371 ); 372 } 373 374 - const FilterOptions = (props: { hasPubs: boolean; hasTemplates: boolean }) => { 375 let { filter } = useDashboardState(); 376 let setState = useSetDashboardState(); 377 let filterCount = Object.values(filter).filter(Boolean).length; ··· 408 </> 409 )} 410 411 - {props.hasTemplates && ( 412 - <> 413 - <Checkbox 414 - small 415 - checked={filter.templates} 416 - onChange={(e) => 417 - setState({ 418 - filter: { ...filter, templates: !!e.target.checked }, 419 - }) 420 - } 421 - > 422 - Templates 423 - </Checkbox> 424 - </> 425 )} 426 <Checkbox 427 small ··· 443 docs: false, 444 published: false, 445 drafts: false, 446 - templates: false, 447 }, 448 }); 449 }}
··· 8 DesktopNavigation, 9 MobileNavigation, 10 navPages, 11 + NotificationButton, 12 } from "components/ActionBar/Navigation"; 13 import { create } from "zustand"; 14 import { Popover } from "components/Popover"; ··· 33 drafts: boolean; 34 published: boolean; 35 docs: boolean; 36 + archived: boolean; 37 }; 38 }; 39 ··· 45 const defaultDashboardState: DashboardState = { 46 display: undefined, 47 sort: undefined, 48 + filter: { 49 + drafts: false, 50 + published: false, 51 + docs: false, 52 + archived: false, 53 + }, 54 }; 55 56 export const useDashboardStore = create<DashboardStore>((set, get) => ({ 57 dashboards: {}, 58 setDashboard: (id: string, partial: Partial<DashboardState>) => { 59 set((state) => ({ 60 dashboards: { 61 ...state.dashboards, ··· 144 const tabParam = searchParams.get("tab"); 145 146 // Initialize tab from search param if valid, otherwise use default 147 + const initialTab = 148 + tabParam && props.tabs[tabParam] ? tabParam : props.defaultTab; 149 let [tab, setTab] = useState<keyof T>(initialTab); 150 151 // Custom setter that updates both state and URL ··· 171 className={`dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6`} 172 > 173 <MediaContents mobile={false}> 174 + <div className="flex flex-col gap-3 my-6"> 175 <DesktopNavigation 176 currentPage={props.currentPage} 177 publication={props.publication} ··· 260 hasBackgroundImage: boolean; 261 defaultDisplay: Exclude<DashboardState["display"], undefined>; 262 hasPubs: boolean; 263 + hasArchived: boolean; 264 }) => { 265 let { display, sort } = useDashboardState(); 266 display = display || props.defaultDisplay; 267 let setState = useSetDashboardState(); 268 269 let { identity } = useIdentityData(); 270 271 return ( 272 <div className="dashboardControls w-full flex gap-4"> ··· 281 <DisplayToggle setState={setState} display={display} /> 282 <Separator classname="h-4 min-h-4!" /> 283 284 + {props.hasPubs ? ( 285 <> 286 <FilterOptions 287 hasPubs={props.hasPubs} 288 + hasArchived={props.hasArchived} 289 /> 290 <Separator classname="h-4 min-h-4!" />{" "} 291 </> ··· 303 defaultDisplay: Exclude<DashboardState["display"], undefined>; 304 }) => { 305 let { display, sort } = useDashboardState(); 306 display = display || props.defaultDisplay; 307 let setState = useSetDashboardState(); 308 return ( ··· 372 ); 373 } 374 375 + const FilterOptions = (props: { 376 + hasPubs: boolean; 377 + hasArchived: boolean; 378 + }) => { 379 let { filter } = useDashboardState(); 380 let setState = useSetDashboardState(); 381 let filterCount = Object.values(filter).filter(Boolean).length; ··· 412 </> 413 )} 414 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> 427 )} 428 <Checkbox 429 small ··· 445 docs: false, 446 published: false, 447 drafts: false, 448 + archived: false, 449 }, 450 }); 451 }}
+52 -6
components/PageSWRDataProvider.tsx
··· 7 import { getPollData } from "actions/pollActions"; 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 import { createContext, useContext } from "react"; 10 11 export const StaticLeafletDataContext = createContext< 12 null | GetLeafletDataReturnType["result"]["data"] ··· 66 }; 67 export function useLeafletPublicationData() { 68 let { data, mutate } = useLeafletData(); 69 return { 70 - data: 71 - data?.leaflets_in_publications?.[0] || 72 - data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 73 - (p) => p.leaflets_in_publications.length, 74 - )?.leaflets_in_publications?.[0] || 75 - null, 76 mutate, 77 }; 78 } ··· 80 let { data, mutate } = useLeafletData(); 81 return { data: data?.custom_domain_routes, mutate: mutate }; 82 }
··· 7 import { getPollData } from "actions/pollActions"; 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 import { createContext, useContext } from "react"; 10 + import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 11 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 12 + import { AtUri } from "@atproto/syntax"; 13 14 export const StaticLeafletDataContext = createContext< 15 null | GetLeafletDataReturnType["result"]["data"] ··· 69 }; 70 export function useLeafletPublicationData() { 71 let { data, mutate } = useLeafletData(); 72 + 73 + // First check for leaflets in publications 74 + let pubData = getPublicationMetadataFromLeafletData(data); 75 + 76 return { 77 + data: pubData || null, 78 mutate, 79 }; 80 } ··· 82 let { data, mutate } = useLeafletData(); 83 return { data: data?.custom_domain_routes, mutate: mutate }; 84 } 85 + 86 + export function useLeafletPublicationStatus() { 87 + const data = useContext(StaticLeafletDataContext); 88 + if (!data) return null; 89 + 90 + const publishedInPublication = data.leaflets_in_publications?.find( 91 + (l) => l.doc, 92 + ); 93 + const publishedStandalone = data.leaflets_to_documents?.find( 94 + (l) => !!l.documents, 95 + ); 96 + 97 + const documentUri = 98 + publishedInPublication?.documents?.uri ?? publishedStandalone?.document; 99 + 100 + // Compute the full post URL for sharing 101 + let postShareLink: string | undefined; 102 + if (publishedInPublication?.publications && publishedInPublication.documents) { 103 + // Published in a publication - use publication URL + document rkey 104 + const docUri = new AtUri(publishedInPublication.documents.uri); 105 + postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`; 106 + } else if (publishedStandalone?.document) { 107 + // Standalone published post - use /p/{did}/{rkey} format 108 + const docUri = new AtUri(publishedStandalone.document); 109 + postShareLink = `/p/${docUri.host}/${docUri.rkey}`; 110 + } 111 + 112 + return { 113 + token: data, 114 + leafletId: data.root_entity, 115 + shareLink: data.id, 116 + // Draft state - in a publication but not yet published 117 + draftInPublication: 118 + data.leaflets_in_publications?.[0]?.publication ?? undefined, 119 + // Published state 120 + isPublished: !!(publishedInPublication || publishedStandalone), 121 + publishedAt: 122 + publishedInPublication?.documents?.indexed_at ?? 123 + publishedStandalone?.documents?.indexed_at, 124 + documentUri, 125 + // Full URL for sharing published posts 126 + postShareLink, 127 + }; 128 + }
+215
components/Pages/Page.tsx
···
··· 1 + "use client"; 2 + 3 + import React from "react"; 4 + import { useUIState } from "src/useUIState"; 5 + 6 + import { elementId } from "src/utils/elementId"; 7 + 8 + import { useEntity, useReferenceToEntity, useReplicache } from "src/replicache"; 9 + 10 + import { DesktopPageFooter } from "../DesktopFooter"; 11 + import { Canvas } from "../Canvas"; 12 + import { Blocks } from "components/Blocks"; 13 + import { PublicationMetadata } from "./PublicationMetadata"; 14 + import { useCardBorderHidden } from "./useCardBorderHidden"; 15 + import { focusPage } from "src/utils/focusPage"; 16 + import { PageOptions } from "./PageOptions"; 17 + import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 18 + import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; 19 + import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 20 + 21 + export function Page(props: { 22 + entityID: string; 23 + first?: boolean; 24 + fullPageScroll: boolean; 25 + }) { 26 + let { rep } = useReplicache(); 27 + 28 + let isFocused = useUIState((s) => { 29 + let focusedElement = s.focusedEntity; 30 + let focusedPageID = 31 + focusedElement?.entityType === "page" 32 + ? focusedElement.entityID 33 + : focusedElement?.parent; 34 + return focusedPageID === props.entityID; 35 + }); 36 + let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 37 + let cardBorderHidden = useCardBorderHidden(props.entityID); 38 + 39 + let drawerOpen = useDrawerOpen(props.entityID); 40 + return ( 41 + <CardThemeProvider entityID={props.entityID}> 42 + <PageWrapper 43 + onClickAction={(e) => { 44 + if (e.defaultPrevented) return; 45 + if (rep) { 46 + if (isFocused) return; 47 + focusPage(props.entityID, rep); 48 + } 49 + }} 50 + id={elementId.page(props.entityID).container} 51 + drawerOpen={!!drawerOpen} 52 + cardBorderHidden={!!cardBorderHidden} 53 + isFocused={isFocused} 54 + fullPageScroll={props.fullPageScroll} 55 + pageType={pageType} 56 + pageOptions={ 57 + <PageOptions 58 + entityID={props.entityID} 59 + first={props.first} 60 + isFocused={isFocused} 61 + /> 62 + } 63 + > 64 + {props.first && pageType === "doc" && ( 65 + <> 66 + <PublicationMetadata /> 67 + </> 68 + )} 69 + <PageContent entityID={props.entityID} first={props.first} /> 70 + </PageWrapper> 71 + <DesktopPageFooter pageID={props.entityID} /> 72 + </CardThemeProvider> 73 + ); 74 + } 75 + 76 + export const PageWrapper = (props: { 77 + id: string; 78 + children: React.ReactNode; 79 + pageOptions?: React.ReactNode; 80 + cardBorderHidden: boolean; 81 + fullPageScroll: boolean; 82 + isFocused?: boolean; 83 + onClickAction?: (e: React.MouseEvent) => void; 84 + pageType: "canvas" | "doc"; 85 + drawerOpen: boolean | undefined; 86 + }) => { 87 + let { ref } = usePreserveScroll<HTMLDivElement>(props.id); 88 + return ( 89 + // this div wraps the contents AND the page options. 90 + // it needs to be its own div because this container does NOT scroll, and therefore doesn't clip the absolutely positioned pageOptions 91 + <div 92 + className={`pageWrapper relative shrink-0 ${props.fullPageScroll ? "w-full" : "w-max"}`} 93 + > 94 + {/* 95 + this div is the scrolling container that wraps only the contents div. 96 + 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 98 + */} 99 + <div 100 + ref={ref} 101 + onClick={props.onClickAction} 102 + id={props.id} 103 + className={` 104 + pageScrollWrapper 105 + grow 106 + shrink-0 snap-center 107 + overflow-y-scroll 108 + ${ 109 + !props.cardBorderHidden && 110 + `h-full border 111 + bg-[rgba(var(--bg-page),var(--bg-page-alpha))] 112 + ${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"} 113 + ${props.isFocused ? "shadow-md border-border" : "border-border-light"}` 114 + } 115 + ${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 116 + ${props.fullPageScroll && "max-w-full "} 117 + ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 118 + ${ 119 + props.pageType === "canvas" && 120 + !props.fullPageScroll && 121 + "max-w-[var(--page-width-units)] sm:max-w-[calc(100vw-128px)] lg:max-w-fit lg:w-[calc(var(--page-width-units)*2 + 24px))]" 122 + } 123 + 124 + `} 125 + > 126 + <div 127 + className={`postPageContent 128 + ${props.fullPageScroll ? "sm:max-w-[var(--page-width-units)] mx-auto" : "w-full h-full"} 129 + `} 130 + > 131 + {props.children} 132 + {props.pageType === "doc" && <div className="h-4 sm:h-6 w-full" />} 133 + </div> 134 + </div> 135 + {props.pageOptions} 136 + </div> 137 + ); 138 + }; 139 + 140 + const PageContent = (props: { entityID: string; first?: boolean }) => { 141 + let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 142 + if (pageType === "doc") return <DocContent entityID={props.entityID} />; 143 + return <Canvas entityID={props.entityID} first={props.first} />; 144 + }; 145 + 146 + const DocContent = (props: { entityID: string }) => { 147 + let { rootEntity } = useReplicache(); 148 + 149 + let cardBorderHidden = useCardBorderHidden(props.entityID); 150 + let rootBackgroundImage = useEntity( 151 + rootEntity, 152 + "theme/card-background-image", 153 + ); 154 + let rootBackgroundRepeat = useEntity( 155 + rootEntity, 156 + "theme/card-background-image-repeat", 157 + ); 158 + let rootBackgroundOpacity = useEntity( 159 + rootEntity, 160 + "theme/card-background-image-opacity", 161 + ); 162 + 163 + let cardBackgroundImage = useEntity( 164 + props.entityID, 165 + "theme/card-background-image", 166 + ); 167 + 168 + let cardBackgroundImageRepeat = useEntity( 169 + props.entityID, 170 + "theme/card-background-image-repeat", 171 + ); 172 + 173 + let cardBackgroundImageOpacity = useEntity( 174 + props.entityID, 175 + "theme/card-background-image-opacity", 176 + ); 177 + 178 + let backgroundImage = cardBackgroundImage || rootBackgroundImage; 179 + let backgroundImageRepeat = cardBackgroundImage 180 + ? cardBackgroundImageRepeat?.data?.value 181 + : rootBackgroundRepeat?.data.value; 182 + let backgroundImageOpacity = cardBackgroundImage 183 + ? cardBackgroundImageOpacity?.data.value 184 + : rootBackgroundOpacity?.data.value || 1; 185 + 186 + return ( 187 + <> 188 + {!cardBorderHidden ? ( 189 + <div 190 + className={`pageBackground 191 + absolute top-0 left-0 right-0 bottom-0 192 + pointer-events-none 193 + rounded-lg 194 + `} 195 + style={{ 196 + backgroundImage: backgroundImage 197 + ? `url(${backgroundImage.data.src}), url(${backgroundImage.data.fallback})` 198 + : undefined, 199 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 200 + backgroundPosition: "center", 201 + backgroundSize: !backgroundImageRepeat 202 + ? "cover" 203 + : backgroundImageRepeat, 204 + opacity: backgroundImage?.data.src ? backgroundImageOpacity : 1, 205 + }} 206 + /> 207 + ) : null} 208 + <Blocks entityID={props.entityID} /> 209 + <div className="h-4 sm:h-6 w-full" /> 210 + {/* we handle page bg in this sepate div so that 211 + we can apply an opacity the background image 212 + without affecting the opacity of the rest of the page */} 213 + </> 214 + ); 215 + };
+217
components/Pages/PageOptions.tsx
···
··· 1 + "use client"; 2 + 3 + import React, { JSX, useState } from "react"; 4 + import { useUIState } from "src/useUIState"; 5 + import { useEntitySetContext } from "../EntitySetProvider"; 6 + 7 + import { useReplicache } from "src/replicache"; 8 + 9 + import { Media } from "../Media"; 10 + import { MenuItem, Menu } from "../Layout"; 11 + import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 12 + import { PageShareMenu } from "./PageShareMenu"; 13 + import { useUndoState } from "src/undoManager"; 14 + import { CloseTiny } from "components/Icons/CloseTiny"; 15 + import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 16 + import { PaintSmall } from "components/Icons/PaintSmall"; 17 + import { ShareSmall } from "components/Icons/ShareSmall"; 18 + import { useCardBorderHidden } from "./useCardBorderHidden"; 19 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 20 + 21 + export const PageOptionButton = ({ 22 + children, 23 + secondary, 24 + cardBorderHidden, 25 + className, 26 + disabled, 27 + ...props 28 + }: { 29 + children: React.ReactNode; 30 + secondary?: boolean; 31 + cardBorderHidden: boolean | undefined; 32 + className?: string; 33 + disabled?: boolean; 34 + } & Omit<JSX.IntrinsicElements["button"], "content">) => { 35 + return ( 36 + <button 37 + className={` 38 + pageOptionsTrigger 39 + shrink-0 40 + pt-[2px] h-5 w-5 p-0.5 mx-auto 41 + border border-border 42 + ${secondary ? "bg-border text-bg-page" : "bg-bg-page text-border"} 43 + ${disabled && "opacity-50"} 44 + ${cardBorderHidden ? "rounded-md" : `rounded-b-md sm:rounded-l-none sm:rounded-r-md`} 45 + flex items-center justify-center 46 + ${className} 47 + 48 + `} 49 + {...props} 50 + > 51 + {children} 52 + </button> 53 + ); 54 + }; 55 + 56 + export const PageOptions = (props: { 57 + entityID: string; 58 + first: boolean | undefined; 59 + isFocused: boolean; 60 + }) => { 61 + let cardBorderHidden = useCardBorderHidden(props.entityID); 62 + 63 + return ( 64 + <div 65 + className={`pageOptions w-fit z-10 66 + ${props.isFocused ? "block" : "sm:hidden block"} 67 + absolute sm:-right-[20px] right-3 sm:top-3 top-0 68 + flex sm:flex-col flex-row-reverse gap-1 items-start`} 69 + > 70 + {!props.first && ( 71 + <PageOptionButton 72 + cardBorderHidden={cardBorderHidden} 73 + secondary 74 + onClick={() => { 75 + useUIState.getState().closePage(props.entityID); 76 + }} 77 + > 78 + <CloseTiny /> 79 + </PageOptionButton> 80 + )} 81 + <OptionsMenu 82 + entityID={props.entityID} 83 + first={!!props.first} 84 + cardBorderHidden={cardBorderHidden} 85 + /> 86 + <UndoButtons cardBorderHidden={cardBorderHidden} /> 87 + </div> 88 + ); 89 + }; 90 + 91 + export const UndoButtons = (props: { 92 + cardBorderHidden: boolean | undefined; 93 + }) => { 94 + let undoState = useUndoState(); 95 + let { undoManager } = useReplicache(); 96 + return ( 97 + <Media mobile> 98 + {undoState.canUndo && ( 99 + <div className="gap-1 flex sm:flex-col"> 100 + <PageOptionButton 101 + secondary 102 + cardBorderHidden={props.cardBorderHidden} 103 + onClick={() => undoManager.undo()} 104 + > 105 + <UndoTiny /> 106 + </PageOptionButton> 107 + 108 + <PageOptionButton 109 + secondary 110 + cardBorderHidden={props.cardBorderHidden} 111 + onClick={() => undoManager.undo()} 112 + disabled={!undoState.canRedo} 113 + > 114 + <RedoTiny /> 115 + </PageOptionButton> 116 + </div> 117 + )} 118 + </Media> 119 + ); 120 + }; 121 + 122 + export const OptionsMenu = (props: { 123 + entityID: string; 124 + first: boolean; 125 + cardBorderHidden: boolean | undefined; 126 + }) => { 127 + let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 128 + let { permissions } = useEntitySetContext(); 129 + if (!permissions.write) return null; 130 + 131 + let { data: pub, mutate } = useLeafletPublicationData(); 132 + if (pub && props.first) return; 133 + return ( 134 + <Menu 135 + align="end" 136 + asChild 137 + onOpenChange={(open) => { 138 + if (!open) setState("normal"); 139 + }} 140 + trigger={ 141 + <PageOptionButton 142 + cardBorderHidden={props.cardBorderHidden} 143 + className="!w-8 !h-5 sm:!w-5 sm:!h-8" 144 + > 145 + <MoreOptionsTiny className="sm:rotate-90" /> 146 + </PageOptionButton> 147 + } 148 + > 149 + {state === "normal" ? ( 150 + <> 151 + {!props.first && ( 152 + <MenuItem 153 + onSelect={(e) => { 154 + e.preventDefault(); 155 + setState("share"); 156 + }} 157 + > 158 + <ShareSmall /> Share Page 159 + </MenuItem> 160 + )} 161 + {!pub && ( 162 + <MenuItem 163 + onSelect={(e) => { 164 + e.preventDefault(); 165 + setState("theme"); 166 + }} 167 + > 168 + <PaintSmall /> Theme Page 169 + </MenuItem> 170 + )} 171 + </> 172 + ) : state === "theme" ? ( 173 + <PageThemeSetter entityID={props.entityID} /> 174 + ) : state === "share" ? ( 175 + <PageShareMenu entityID={props.entityID} /> 176 + ) : null} 177 + </Menu> 178 + ); 179 + }; 180 + 181 + const UndoTiny = () => { 182 + return ( 183 + <svg 184 + width="16" 185 + height="16" 186 + viewBox="0 0 16 16" 187 + fill="none" 188 + xmlns="http://www.w3.org/2000/svg" 189 + > 190 + <path 191 + fillRule="evenodd" 192 + clipRule="evenodd" 193 + d="M5.98775 3.14543C6.37828 2.75491 6.37828 2.12174 5.98775 1.73122C5.59723 1.34069 4.96407 1.34069 4.57354 1.73122L1.20732 5.09744C0.816798 5.48796 0.816798 6.12113 1.20732 6.51165L4.57354 9.87787C4.96407 10.2684 5.59723 10.2684 5.98775 9.87787C6.37828 9.48735 6.37828 8.85418 5.98775 8.46366L4.32865 6.80456H9.6299C12.1732 6.80456 13.0856 8.27148 13.0856 9.21676C13.0856 9.84525 12.8932 10.5028 12.5318 10.9786C12.1942 11.4232 11.6948 11.7367 10.9386 11.7367H9.43173C8.87944 11.7367 8.43173 12.1844 8.43173 12.7367C8.43173 13.2889 8.87944 13.7367 9.43173 13.7367H10.9386C12.3587 13.7367 13.4328 13.0991 14.1246 12.1883C14.7926 11.3086 15.0856 10.2062 15.0856 9.21676C15.0856 6.92612 13.0205 4.80456 9.6299 4.80456L4.32863 4.80456L5.98775 3.14543Z" 194 + fill="currentColor" 195 + /> 196 + </svg> 197 + ); 198 + }; 199 + 200 + const RedoTiny = () => { 201 + return ( 202 + <svg 203 + width="16" 204 + height="16" 205 + viewBox="0 0 16 16" 206 + fill="none" 207 + xmlns="http://www.w3.org/2000/svg" 208 + > 209 + <path 210 + fillRule="evenodd" 211 + clipRule="evenodd" 212 + d="M10.0122 3.14543C9.62172 2.75491 9.62172 2.12174 10.0122 1.73122C10.4028 1.34069 11.0359 1.34069 11.4265 1.73122L14.7927 5.09744C15.1832 5.48796 15.1832 6.12113 14.7927 6.51165L11.4265 9.87787C11.0359 10.2684 10.4028 10.2684 10.0122 9.87787C9.62172 9.48735 9.62172 8.85418 10.0122 8.46366L11.6713 6.80456H6.3701C3.82678 6.80456 2.91443 8.27148 2.91443 9.21676C2.91443 9.84525 3.10681 10.5028 3.46817 10.9786C3.8058 11.4232 4.30523 11.7367 5.06143 11.7367H6.56827C7.12056 11.7367 7.56827 12.1844 7.56827 12.7367C7.56827 13.2889 7.12056 13.7367 6.56827 13.7367H5.06143C3.6413 13.7367 2.56723 13.0991 1.87544 12.1883C1.20738 11.3086 0.914429 10.2062 0.914429 9.21676C0.914429 6.92612 2.97946 4.80456 6.3701 4.80456L11.6714 4.80456L10.0122 3.14543Z" 213 + fill="currentColor" 214 + /> 215 + </svg> 216 + ); 217 + };
+7 -6
components/Pages/PageShareMenu.tsx
··· 1 import { useLeafletDomains } from "components/PageSWRDataProvider"; 2 - import { ShareButton, usePublishLink } from "components/ShareOptions"; 3 import { useEffect, useState } from "react"; 4 5 export const PageShareMenu = (props: { entityID: string }) => { 6 - let publishLink = usePublishLink(); 7 let { data: domains } = useLeafletDomains(); 8 let [collabLink, setCollabLink] = useState<null | string>(null); 9 useEffect(() => { ··· 14 <div> 15 <ShareButton 16 text="Share Edit Link" 17 - subtext="" 18 - helptext="recipients can edit the full Leaflet" 19 smokerText="Collab link copied!" 20 id="get-page-collab-link" 21 link={`${collabLink}?page=${props.entityID}`} 22 /> 23 <ShareButton 24 text="Share View Link" 25 - subtext="" 26 - helptext="recipients can view the full Leaflet" 27 smokerText="Publish link copied!" 28 id="get-page-publish-link" 29 fullLink={
··· 1 import { useLeafletDomains } from "components/PageSWRDataProvider"; 2 + import { 3 + ShareButton, 4 + useReadOnlyShareLink, 5 + } from "app/[leaflet_id]/actions/ShareOptions"; 6 import { useEffect, useState } from "react"; 7 8 export const PageShareMenu = (props: { entityID: string }) => { 9 + let publishLink = useReadOnlyShareLink(); 10 let { data: domains } = useLeafletDomains(); 11 let [collabLink, setCollabLink] = useState<null | string>(null); 12 useEffect(() => { ··· 17 <div> 18 <ShareButton 19 text="Share Edit Link" 20 + subtext="Recipients can edit the full Leaflet" 21 smokerText="Collab link copied!" 22 id="get-page-collab-link" 23 link={`${collabLink}?page=${props.entityID}`} 24 /> 25 <ShareButton 26 text="Share View Link" 27 + subtext="Recipients can view the full Leaflet" 28 smokerText="Publish link copied!" 29 id="get-page-publish-link" 30 fullLink={
+161 -83
components/Pages/PublicationMetadata.tsx
··· 1 import Link from "next/link"; 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 - import { useRef } from "react"; 4 import { useReplicache } from "src/replicache"; 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 import { Separator } from "components/Layout"; 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument } from "lexicons/api"; 9 import { 10 getBasePublicationURL, 11 getPublicationURL, ··· 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 import { timeAgo } from "src/utils/timeAgo"; 16 - export const PublicationMetadata = ({ 17 - cardBorderHidden, 18 - }: { 19 - cardBorderHidden: boolean; 20 - }) => { 21 let { rep } = useReplicache(); 22 let { data: pub } = useLeafletPublicationData(); 23 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 24 let description = useSubscribe(rep, (tx) => 25 tx.get<string>("publication_description"), 26 ); 27 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 28 let publishedAt = record?.publishedAt; 29 30 - if (!pub || !pub.publications) return null; 31 32 if (typeof title !== "string") { 33 title = pub?.title || ""; ··· 35 if (typeof description !== "string") { 36 description = pub?.description || ""; 37 } 38 return ( 39 - <div 40 - className={`flex flex-col px-3 sm:px-4 pb-5 ${cardBorderHidden ? "sm:pt-6 pt-0" : "sm:pt-3 pt-2"}`} 41 - > 42 - <div className="flex gap-2"> 43 - <Link 44 - href={`${getBasePublicationURL(pub.publications)}/dashboard`} 45 - className="text-accent-contrast font-bold hover:no-underline" 46 - > 47 - {pub.publications?.name} 48 - </Link> 49 - <div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md "> 50 - Editor 51 - </div> 52 - </div> 53 - <TextField 54 - className="text-xl font-bold outline-hidden bg-transparent" 55 - value={title} 56 - onChange={async (newTitle) => { 57 - await rep?.mutate.updatePublicationDraft({ 58 - title: newTitle, 59 - description, 60 - }); 61 - }} 62 - placeholder="Untitled" 63 - /> 64 - <TextField 65 - placeholder="add an optional description..." 66 - className="italic text-secondary outline-hidden bg-transparent" 67 - value={description} 68 - onChange={async (newDescription) => { 69 - await rep?.mutate.updatePublicationDraft({ 70 - title, 71 - description: newDescription, 72 - }); 73 - }} 74 - /> 75 - {pub.doc ? ( 76 - <div className="flex flex-row items-center gap-2 pt-3"> 77 - <p className="text-sm text-tertiary"> 78 - Published {publishedAt && timeAgo(publishedAt)} 79 - </p> 80 - <Separator classname="h-4" /> 81 - <Link 82 - target="_blank" 83 - className="text-sm" 84 - href={`${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`} 85 - > 86 - View Post 87 - </Link> 88 </div> 89 - ) : ( 90 - <p className="text-sm text-tertiary pt-2">Draft</p> 91 - )} 92 - </div> 93 ); 94 }; 95 ··· 169 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 170 let publishedAt = record?.publishedAt; 171 172 - if (!pub || !pub.publications) return null; 173 174 return ( 175 - <div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}> 176 - <div className="text-accent-contrast font-bold hover:no-underline"> 177 - {pub.publications?.name} 178 - </div> 179 180 - <div 181 - className={`text-xl font-bold outline-hidden bg-transparent ${!pub.title && "text-tertiary italic"}`} 182 - > 183 - {pub.title ? pub.title : "Untitled"} 184 - </div> 185 - <div className="italic text-secondary outline-hidden bg-transparent"> 186 - {pub.description} 187 - </div> 188 189 - {pub.doc ? ( 190 - <div className="flex flex-row items-center gap-2 pt-3"> 191 - <p className="text-sm text-tertiary"> 192 - Published {publishedAt && timeAgo(publishedAt)} 193 - </p> 194 </div> 195 - ) : ( 196 - <p className="text-sm text-tertiary pt-2">Draft</p> 197 - )} 198 - </div> 199 ); 200 };
··· 1 import Link from "next/link"; 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 + import { useRef, useState } from "react"; 4 import { useReplicache } from "src/replicache"; 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 import { Separator } from "components/Layout"; 7 import { AtUri } from "@atproto/syntax"; 8 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 import { 10 getBasePublicationURL, 11 getPublicationURL, ··· 13 import { useSubscribe } from "src/replicache/useSubscribe"; 14 import { useEntitySetContext } from "components/EntitySetProvider"; 15 import { timeAgo } from "src/utils/timeAgo"; 16 + import { CommentTiny } from "components/Icons/CommentTiny"; 17 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 18 + import { TagTiny } from "components/Icons/TagTiny"; 19 + import { Popover } from "components/Popover"; 20 + import { TagSelector } from "components/Tags"; 21 + import { useIdentityData } from "components/IdentityProvider"; 22 + import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 23 + export const PublicationMetadata = () => { 24 let { rep } = useReplicache(); 25 let { data: pub } = useLeafletPublicationData(); 26 + let { identity } = useIdentityData(); 27 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 28 let description = useSubscribe(rep, (tx) => 29 tx.get<string>("publication_description"), 30 ); 31 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 32 + let pubRecord = pub?.publications?.record as 33 + | PubLeafletPublication.Record 34 + | undefined; 35 let publishedAt = record?.publishedAt; 36 37 + if (!pub) return null; 38 39 if (typeof title !== "string") { 40 title = pub?.title || ""; ··· 42 if (typeof description !== "string") { 43 description = pub?.description || ""; 44 } 45 + let tags = true; 46 + 47 return ( 48 + <PostHeaderLayout 49 + pubLink={ 50 + <div className="flex gap-2 items-center"> 51 + {pub.publications && ( 52 + <Link 53 + href={ 54 + identity?.atp_did === pub.publications?.identity_did 55 + ? `${getBasePublicationURL(pub.publications)}/dashboard` 56 + : getPublicationURL(pub.publications) 57 + } 58 + className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 59 + > 60 + {pub.publications?.name} 61 + </Link> 62 + )} 63 + <div className="font-bold text-tertiary px-1 h-[20px] text-sm flex place-items-center bg-border-light rounded-md "> 64 + DRAFT 65 + </div> 66 </div> 67 + } 68 + postTitle={ 69 + <TextField 70 + className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent" 71 + value={title} 72 + onChange={async (newTitle) => { 73 + await rep?.mutate.updatePublicationDraft({ 74 + title: newTitle, 75 + description, 76 + }); 77 + }} 78 + placeholder="Untitled" 79 + /> 80 + } 81 + postDescription={ 82 + <TextField 83 + placeholder="add an optional description..." 84 + className="pt-1 italic text-secondary outline-hidden bg-transparent" 85 + value={description} 86 + onChange={async (newDescription) => { 87 + await rep?.mutate.updatePublicationDraft({ 88 + title, 89 + description: newDescription, 90 + }); 91 + }} 92 + /> 93 + } 94 + postInfo={ 95 + <> 96 + {pub.doc ? ( 97 + <div className="flex gap-2 items-center"> 98 + <p className="text-sm text-tertiary"> 99 + Published {publishedAt && timeAgo(publishedAt)} 100 + </p> 101 + 102 + <Link 103 + target="_blank" 104 + className="text-sm" 105 + href={ 106 + pub.publications 107 + ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 108 + : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 109 + } 110 + > 111 + View 112 + </Link> 113 + </div> 114 + ) : ( 115 + <p>Draft</p> 116 + )} 117 + <div className="flex gap-2 text-border items-center"> 118 + {tags && ( 119 + <> 120 + <AddTags /> 121 + <Separator classname="h-4!" /> 122 + </> 123 + )} 124 + <div className="flex gap-1 items-center"> 125 + <QuoteTiny />โ€” 126 + </div> 127 + {pubRecord?.preferences?.showComments && ( 128 + <div className="flex gap-1 items-center"> 129 + <CommentTiny />โ€” 130 + </div> 131 + )} 132 + </div> 133 + </> 134 + } 135 + /> 136 ); 137 }; 138 ··· 212 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 213 let publishedAt = record?.publishedAt; 214 215 + if (!pub) return null; 216 217 return ( 218 + <PostHeaderLayout 219 + pubLink={ 220 + <div className="text-accent-contrast font-bold hover:no-underline"> 221 + {pub.publications?.name} 222 + </div> 223 + } 224 + postTitle={pub.title} 225 + postDescription={pub.description} 226 + postInfo={ 227 + pub.doc ? ( 228 + <p>Published {publishedAt && timeAgo(publishedAt)}</p> 229 + ) : ( 230 + <p>Draft</p> 231 + ) 232 + } 233 + /> 234 + ); 235 + }; 236 237 + const AddTags = () => { 238 + let { data: pub } = useLeafletPublicationData(); 239 + let { rep } = useReplicache(); 240 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 241 + 242 + // Get tags from Replicache local state or published document 243 + let replicacheTags = useSubscribe(rep, (tx) => 244 + tx.get<string[]>("publication_tags"), 245 + ); 246 + 247 + // Determine which tags to use - prioritize Replicache state 248 + let tags: string[] = []; 249 + if (Array.isArray(replicacheTags)) { 250 + tags = replicacheTags; 251 + } else if (record?.tags && Array.isArray(record.tags)) { 252 + tags = record.tags as string[]; 253 + } 254 + 255 + // Update tags in replicache local state 256 + const handleTagsChange = async (newTags: string[]) => { 257 + // Store tags in replicache for next publish/update 258 + await rep?.mutate.updatePublicationDraft({ 259 + tags: newTags, 260 + }); 261 + }; 262 263 + return ( 264 + <Popover 265 + className="p-2! w-full min-w-xs" 266 + trigger={ 267 + <div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary"> 268 + <TagTiny />{" "} 269 + {tags.length > 0 270 + ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` 271 + : "Add Tags"} 272 </div> 273 + } 274 + > 275 + <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 276 + </Popover> 277 ); 278 };
+28 -473
components/Pages/index.tsx
··· 1 "use client"; 2 3 - import React, { JSX, useState } from "react"; 4 import { useUIState } from "src/useUIState"; 5 - import { useEntitySetContext } from "../EntitySetProvider"; 6 import { useSearchParams } from "next/navigation"; 7 8 - import { focusBlock } from "src/utils/focusBlock"; 9 - import { elementId } from "src/utils/elementId"; 10 - 11 - import { Replicache } from "replicache"; 12 - import { 13 - Fact, 14 - ReplicacheMutators, 15 - useEntity, 16 - useReferenceToEntity, 17 - useReplicache, 18 - } from "src/replicache"; 19 20 - import { Media } from "../Media"; 21 - import { DesktopPageFooter } from "../DesktopFooter"; 22 - import { ThemePopover } from "../ThemeManager/ThemeSetter"; 23 - import { Canvas } from "../Canvas"; 24 - import { DraftPostOptions } from "../Blocks/MailboxBlock"; 25 - import { Blocks } from "components/Blocks"; 26 - import { MenuItem, Menu } from "../Layout"; 27 - import { scanIndex } from "src/replicache/utils"; 28 - import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 29 - import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; 30 - import { PageShareMenu } from "./PageShareMenu"; 31 - import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 32 - import { useUndoState } from "src/undoManager"; 33 - import { CloseTiny } from "components/Icons/CloseTiny"; 34 - import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 35 - import { PaintSmall } from "components/Icons/PaintSmall"; 36 - import { ShareSmall } from "components/Icons/ShareSmall"; 37 - import { PublicationMetadata } from "./PublicationMetadata"; 38 import { useCardBorderHidden } from "./useCardBorderHidden"; 39 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 40 41 export function Pages(props: { rootPage: string }) { 42 let rootPage = useEntity(props.rootPage, "root/page")[0]; ··· 44 let params = useSearchParams(); 45 let queryRoot = params.get("page"); 46 let firstPage = queryRoot || rootPage?.data.value || props.rootPage; 47 - 48 - return ( 49 - <> 50 - <div className="flex items-stretch"> 51 - <CardThemeProvider entityID={firstPage}> 52 - <Page entityID={firstPage} first /> 53 - </CardThemeProvider> 54 - </div> 55 - {pages.map((page) => ( 56 - <div className="flex items-stretch" key={page}> 57 - <CardThemeProvider entityID={page}> 58 - <Page entityID={page} /> 59 - </CardThemeProvider> 60 - </div> 61 - ))} 62 - <div 63 - className="spacer" 64 - style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }} 65 - onClick={(e) => { 66 - e.currentTarget === e.target && blurPage(); 67 - }} 68 - /> 69 - </> 70 - ); 71 - } 72 - 73 - export const LeafletOptions = (props: { entityID: string }) => { 74 - return ( 75 - <> 76 - <ThemePopover entityID={props.entityID} /> 77 - </> 78 - ); 79 - }; 80 - 81 - function Page(props: { entityID: string; first?: boolean }) { 82 - let { rep, rootEntity } = useReplicache(); 83 - let isDraft = useReferenceToEntity("mailbox/draft", props.entityID); 84 85 - let isFocused = useUIState((s) => { 86 - let focusedElement = s.focusedEntity; 87 - let focusedPageID = 88 - focusedElement?.entityType === "page" 89 - ? focusedElement.entityID 90 - : focusedElement?.parent; 91 - return focusedPageID === props.entityID; 92 - }); 93 - let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 94 - let cardBorderHidden = useCardBorderHidden(props.entityID); 95 return ( 96 <> 97 - {!props.first && ( 98 - <div 99 - className="w-6 lg:snap-center" 100 onClick={(e) => { 101 e.currentTarget === e.target && blurPage(); 102 }} 103 /> 104 )} 105 - <div className="pageWrapper w-fit flex relative snap-center"> 106 - <div 107 onClick={(e) => { 108 - if (e.defaultPrevented) return; 109 - if (rep) { 110 - if (isFocused) return; 111 - focusPage(props.entityID, rep); 112 - } 113 - }} 114 - id={elementId.page(props.entityID).container} 115 - style={{ 116 - width: pageType === "doc" ? "var(--page-width-units)" : undefined, 117 - backgroundColor: cardBorderHidden 118 - ? "" 119 - : "rgba(var(--bg-page), var(--bg-page-alpha))", 120 - }} 121 - className={` 122 - ${pageType === "canvas" ? "!lg:max-w-[1152px]" : "max-w-(--page-width-units)"} 123 - page 124 - grow flex flex-col 125 - overscroll-y-none 126 - overflow-y-auto 127 - ${cardBorderHidden ? "border-0 shadow-none! sm:-mt-6 sm:-mb-12 -mt-2 -mb-1 pt-3 " : "border rounded-lg"} 128 - ${isFocused ? "shadow-md border-border" : "border-border-light"} 129 - `} 130 - > 131 - <Media mobile={true}> 132 - <PageOptions entityID={props.entityID} first={props.first} /> 133 - </Media> 134 - <DesktopPageFooter pageID={props.entityID} /> 135 - {isDraft.length > 0 && ( 136 - <div 137 - className={`pageStatus pt-[6px] pb-1 ${!props.first ? "pr-10 pl-3 sm:px-4" : "px-3 sm:px-4"} border-b border-border text-tertiary`} 138 - style={{ 139 - backgroundColor: 140 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 141 - }} 142 - > 143 - <DraftPostOptions mailboxEntity={isDraft[0].entity} /> 144 - </div> 145 - )} 146 - 147 - <PageContent entityID={props.entityID} /> 148 - </div> 149 - <Media mobile={false}> 150 - {isFocused && ( 151 - <PageOptions entityID={props.entityID} first={props.first} /> 152 - )} 153 - </Media> 154 - </div> 155 - </> 156 - ); 157 - } 158 - 159 - const PageContent = (props: { entityID: string }) => { 160 - let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 161 - if (pageType === "doc") return <DocContent entityID={props.entityID} />; 162 - return <Canvas entityID={props.entityID} />; 163 - }; 164 - 165 - const DocContent = (props: { entityID: string }) => { 166 - let { rootEntity } = useReplicache(); 167 - let isFocused = useUIState((s) => { 168 - let focusedElement = s.focusedEntity; 169 - let focusedPageID = 170 - focusedElement?.entityType === "page" 171 - ? focusedElement.entityID 172 - : focusedElement?.parent; 173 - return focusedPageID === props.entityID; 174 - }); 175 - 176 - let cardBorderHidden = useCardBorderHidden(props.entityID); 177 - let rootBackgroundImage = useEntity( 178 - rootEntity, 179 - "theme/card-background-image", 180 - ); 181 - let rootBackgroundRepeat = useEntity( 182 - rootEntity, 183 - "theme/card-background-image-repeat", 184 - ); 185 - let rootBackgroundOpacity = useEntity( 186 - rootEntity, 187 - "theme/card-background-image-opacity", 188 - ); 189 - 190 - let cardBackgroundImage = useEntity( 191 - props.entityID, 192 - "theme/card-background-image", 193 - ); 194 - 195 - let cardBackgroundImageRepeat = useEntity( 196 - props.entityID, 197 - "theme/card-background-image-repeat", 198 - ); 199 - 200 - let cardBackgroundImageOpacity = useEntity( 201 - props.entityID, 202 - "theme/card-background-image-opacity", 203 - ); 204 - 205 - let backgroundImage = cardBackgroundImage || rootBackgroundImage; 206 - let backgroundImageRepeat = cardBackgroundImage 207 - ? cardBackgroundImageRepeat?.data?.value 208 - : rootBackgroundRepeat?.data.value; 209 - let backgroundImageOpacity = cardBackgroundImage 210 - ? cardBackgroundImageOpacity?.data.value 211 - : rootBackgroundOpacity?.data.value || 1; 212 - 213 - return ( 214 - <> 215 - {!cardBorderHidden ? ( 216 - <div 217 - className={`pageBackground 218 - absolute top-0 left-0 right-0 bottom-0 219 - pointer-events-none 220 - rounded-lg border 221 - ${isFocused ? " border-border" : "border-border-light"} 222 - `} 223 - style={{ 224 - backgroundImage: backgroundImage 225 - ? `url(${backgroundImage.data.src}), url(${backgroundImage.data.fallback})` 226 - : undefined, 227 - backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 228 - backgroundPosition: "center", 229 - backgroundSize: !backgroundImageRepeat 230 - ? "cover" 231 - : backgroundImageRepeat, 232 - opacity: backgroundImage?.data.src ? backgroundImageOpacity : 1, 233 }} 234 /> 235 - ) : null} 236 - <PublicationMetadata cardBorderHidden={!!cardBorderHidden} /> 237 - <Blocks entityID={props.entityID} /> 238 - {/* we handle page bg in this sepate div so that 239 - we can apply an opacity the background image 240 - without affecting the opacity of the rest of the page */} 241 </> 242 ); 243 - }; 244 - 245 - const PageOptionButton = ({ 246 - children, 247 - secondary, 248 - cardBorderHidden, 249 - className, 250 - disabled, 251 - ...props 252 - }: { 253 - children: React.ReactNode; 254 - secondary?: boolean; 255 - cardBorderHidden: boolean | undefined; 256 - className?: string; 257 - disabled?: boolean; 258 - } & Omit<JSX.IntrinsicElements["button"], "content">) => { 259 - return ( 260 - <button 261 - className={` 262 - pageOptionsTrigger 263 - shrink-0 264 - pt-[2px] h-5 w-5 p-0.5 mx-auto 265 - border border-border 266 - ${secondary ? "bg-border text-bg-page" : "bg-bg-page text-border"} 267 - ${disabled && "opacity-50"} 268 - ${cardBorderHidden ? "rounded-md" : `rounded-b-md sm:rounded-l-none sm:rounded-r-md`} 269 - flex items-center justify-center 270 - ${className} 271 - 272 - `} 273 - {...props} 274 - > 275 - {children} 276 - </button> 277 - ); 278 - }; 279 - 280 - const PageOptions = (props: { 281 - entityID: string; 282 - first: boolean | undefined; 283 - }) => { 284 - let { rootEntity } = useReplicache(); 285 - let cardBorderHidden = useCardBorderHidden(props.entityID); 286 - 287 - return ( 288 - <div 289 - className={`z-10 w-fit absolute ${cardBorderHidden ? "top-1" : "sm:top-3"} sm:-right-[19px] top-0 right-3 flex sm:flex-col flex-row-reverse gap-1 items-start`} 290 - > 291 - {!props.first && ( 292 - <PageOptionButton 293 - cardBorderHidden={cardBorderHidden} 294 - secondary 295 - onClick={() => { 296 - useUIState.getState().closePage(props.entityID); 297 - }} 298 - > 299 - <CloseTiny /> 300 - </PageOptionButton> 301 - )} 302 - <OptionsMenu 303 - entityID={props.entityID} 304 - first={!!props.first} 305 - cardBorderHidden={cardBorderHidden} 306 - /> 307 - <UndoButtons cardBorderHidden={cardBorderHidden} /> 308 - </div> 309 - ); 310 - }; 311 - 312 - const UndoButtons = (props: { cardBorderHidden: boolean | undefined }) => { 313 - let undoState = useUndoState(); 314 - let { undoManager } = useReplicache(); 315 - return ( 316 - <Media mobile> 317 - {undoState.canUndo && ( 318 - <div className="gap-1 flex sm:flex-col"> 319 - <PageOptionButton 320 - secondary 321 - cardBorderHidden={props.cardBorderHidden} 322 - onClick={() => undoManager.undo()} 323 - > 324 - <UndoTiny /> 325 - </PageOptionButton> 326 - 327 - <PageOptionButton 328 - secondary 329 - cardBorderHidden={props.cardBorderHidden} 330 - onClick={() => undoManager.undo()} 331 - disabled={!undoState.canRedo} 332 - > 333 - <RedoTiny /> 334 - </PageOptionButton> 335 - </div> 336 - )} 337 - </Media> 338 - ); 339 - }; 340 - 341 - const OptionsMenu = (props: { 342 - entityID: string; 343 - first: boolean; 344 - cardBorderHidden: boolean | undefined; 345 - }) => { 346 - let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 347 - let { permissions } = useEntitySetContext(); 348 - if (!permissions.write) return null; 349 - 350 - let { data: pub, mutate } = useLeafletPublicationData(); 351 - if (pub && props.first) return; 352 - return ( 353 - <Menu 354 - align="end" 355 - asChild 356 - onOpenChange={(open) => { 357 - if (!open) setState("normal"); 358 - }} 359 - trigger={ 360 - <PageOptionButton 361 - cardBorderHidden={props.cardBorderHidden} 362 - className="w-8! h-5! sm:w-5! sm:h-8!" 363 - > 364 - <MoreOptionsTiny className="sm:rotate-90" /> 365 - </PageOptionButton> 366 - } 367 - > 368 - {state === "normal" ? ( 369 - <> 370 - {!props.first && ( 371 - <MenuItem 372 - onSelect={(e) => { 373 - e.preventDefault(); 374 - setState("share"); 375 - }} 376 - > 377 - <ShareSmall /> Share Page 378 - </MenuItem> 379 - )} 380 - {!pub && ( 381 - <MenuItem 382 - onSelect={(e) => { 383 - e.preventDefault(); 384 - setState("theme"); 385 - }} 386 - > 387 - <PaintSmall /> Theme Page 388 - </MenuItem> 389 - )} 390 - </> 391 - ) : state === "theme" ? ( 392 - <PageThemeSetter entityID={props.entityID} /> 393 - ) : state === "share" ? ( 394 - <PageShareMenu entityID={props.entityID} /> 395 - ) : null} 396 - </Menu> 397 - ); 398 - }; 399 - 400 - export async function focusPage( 401 - pageID: string, 402 - rep: Replicache<ReplicacheMutators>, 403 - focusFirstBlock?: "focusFirstBlock", 404 - ) { 405 - // if this page is already focused, 406 - let focusedBlock = useUIState.getState().focusedEntity; 407 - // else set this page as focused 408 - useUIState.setState(() => ({ 409 - focusedEntity: { 410 - entityType: "page", 411 - entityID: pageID, 412 - }, 413 - })); 414 - 415 - setTimeout(async () => { 416 - //scroll to page 417 - 418 - scrollIntoViewIfNeeded( 419 - document.getElementById(elementId.page(pageID).container), 420 - false, 421 - "smooth", 422 - ); 423 - 424 - // if we asked that the function focus the first block, focus the first block 425 - if (focusFirstBlock === "focusFirstBlock") { 426 - let firstBlock = await rep.query(async (tx) => { 427 - let type = await scanIndex(tx).eav(pageID, "page/type"); 428 - let blocks = await scanIndex(tx).eav( 429 - pageID, 430 - type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 431 - ); 432 - 433 - let firstBlock = blocks[0]; 434 - 435 - if (!firstBlock) { 436 - return null; 437 - } 438 - 439 - let blockType = ( 440 - await tx 441 - .scan< 442 - Fact<"block/type"> 443 - >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 444 - .toArray() 445 - )[0]; 446 - 447 - if (!blockType) return null; 448 - 449 - return { 450 - value: firstBlock.data.value, 451 - type: blockType.data.value, 452 - parent: firstBlock.entity, 453 - position: firstBlock.data.position, 454 - }; 455 - }); 456 - 457 - if (firstBlock) { 458 - setTimeout(() => { 459 - focusBlock(firstBlock, { type: "start" }); 460 - }, 500); 461 - } 462 - } 463 - }, 50); 464 } 465 466 const blurPage = () => { ··· 469 selectedBlocks: [], 470 })); 471 }; 472 - const UndoTiny = () => { 473 - return ( 474 - <svg 475 - width="16" 476 - height="16" 477 - viewBox="0 0 16 16" 478 - fill="none" 479 - xmlns="http://www.w3.org/2000/svg" 480 - > 481 - <path 482 - fillRule="evenodd" 483 - clipRule="evenodd" 484 - d="M5.98775 3.14543C6.37828 2.75491 6.37828 2.12174 5.98775 1.73122C5.59723 1.34069 4.96407 1.34069 4.57354 1.73122L1.20732 5.09744C0.816798 5.48796 0.816798 6.12113 1.20732 6.51165L4.57354 9.87787C4.96407 10.2684 5.59723 10.2684 5.98775 9.87787C6.37828 9.48735 6.37828 8.85418 5.98775 8.46366L4.32865 6.80456H9.6299C12.1732 6.80456 13.0856 8.27148 13.0856 9.21676C13.0856 9.84525 12.8932 10.5028 12.5318 10.9786C12.1942 11.4232 11.6948 11.7367 10.9386 11.7367H9.43173C8.87944 11.7367 8.43173 12.1844 8.43173 12.7367C8.43173 13.2889 8.87944 13.7367 9.43173 13.7367H10.9386C12.3587 13.7367 13.4328 13.0991 14.1246 12.1883C14.7926 11.3086 15.0856 10.2062 15.0856 9.21676C15.0856 6.92612 13.0205 4.80456 9.6299 4.80456L4.32863 4.80456L5.98775 3.14543Z" 485 - fill="currentColor" 486 - /> 487 - </svg> 488 - ); 489 - }; 490 - 491 - const RedoTiny = () => { 492 - return ( 493 - <svg 494 - width="16" 495 - height="16" 496 - viewBox="0 0 16 16" 497 - fill="none" 498 - xmlns="http://www.w3.org/2000/svg" 499 - > 500 - <path 501 - fillRule="evenodd" 502 - clipRule="evenodd" 503 - d="M10.0122 3.14543C9.62172 2.75491 9.62172 2.12174 10.0122 1.73122C10.4028 1.34069 11.0359 1.34069 11.4265 1.73122L14.7927 5.09744C15.1832 5.48796 15.1832 6.12113 14.7927 6.51165L11.4265 9.87787C11.0359 10.2684 10.4028 10.2684 10.0122 9.87787C9.62172 9.48735 9.62172 8.85418 10.0122 8.46366L11.6713 6.80456H6.3701C3.82678 6.80456 2.91443 8.27148 2.91443 9.21676C2.91443 9.84525 3.10681 10.5028 3.46817 10.9786C3.8058 11.4232 4.30523 11.7367 5.06143 11.7367H6.56827C7.12056 11.7367 7.56827 12.1844 7.56827 12.7367C7.56827 13.2889 7.12056 13.7367 6.56827 13.7367H5.06143C3.6413 13.7367 2.56723 13.0991 1.87544 12.1883C1.20738 11.3086 0.914429 10.2062 0.914429 9.21676C0.914429 6.92612 2.97946 4.80456 6.3701 4.80456L11.6714 4.80456L10.0122 3.14543Z" 504 - fill="currentColor" 505 - /> 506 - </svg> 507 - ); 508 - };
··· 1 "use client"; 2 3 + import React from "react"; 4 import { useUIState } from "src/useUIState"; 5 import { useSearchParams } from "next/navigation"; 6 7 + import { useEntity } from "src/replicache"; 8 9 import { useCardBorderHidden } from "./useCardBorderHidden"; 10 + import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout"; 11 + import { LeafletSidebar } from "app/[leaflet_id]/Sidebar"; 12 + import { Page } from "./Page"; 13 14 export function Pages(props: { rootPage: string }) { 15 let rootPage = useEntity(props.rootPage, "root/page")[0]; ··· 17 let params = useSearchParams(); 18 let queryRoot = params.get("page"); 19 let firstPage = queryRoot || rootPage?.data.value || props.rootPage; 20 + let cardBorderHidden = useCardBorderHidden(rootPage.id); 21 + let firstPageIsCanvas = useEntity(firstPage, "page/type"); 22 + let fullPageScroll = 23 + !!cardBorderHidden && pages.length === 0 && !firstPageIsCanvas; 24 25 return ( 26 <> 27 + <LeafletSidebar /> 28 + {!fullPageScroll && ( 29 + <BookendSpacer 30 onClick={(e) => { 31 e.currentTarget === e.target && blurPage(); 32 }} 33 /> 34 )} 35 + 36 + <Page entityID={firstPage} first fullPageScroll={fullPageScroll} /> 37 + {pages.map((page) => ( 38 + <React.Fragment key={page}> 39 + <SandwichSpacer 40 + onClick={(e) => { 41 + e.currentTarget === e.target && blurPage(); 42 + }} 43 + /> 44 + <Page entityID={page} fullPageScroll={false} /> 45 + </React.Fragment> 46 + ))} 47 + {!fullPageScroll && ( 48 + <BookendSpacer 49 onClick={(e) => { 50 + e.currentTarget === e.target && blurPage(); 51 }} 52 /> 53 + )} 54 </> 55 ); 56 } 57 58 const blurPage = () => { ··· 61 selectedBlocks: [], 62 })); 63 };
+1 -1
components/Pages/useCardBorderHidden.ts
··· 2 import { PubLeafletPublication } from "lexicons/api"; 3 import { useEntity, useReplicache } from "src/replicache"; 4 5 - export function useCardBorderHidden(entityID: string) { 6 let { rootEntity } = useReplicache(); 7 let { data: pub } = useLeafletPublicationData(); 8 let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
··· 2 import { PubLeafletPublication } from "lexicons/api"; 3 import { useEntity, useReplicache } from "src/replicache"; 4 5 + export function useCardBorderHidden(entityID: string | null) { 6 let { rootEntity } = useReplicache(); 7 let { data: pub } = useLeafletPublicationData(); 8 let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
+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 + };
-81
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, 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 - return ( 26 - <RadixPopover.Root 27 - open={props.open} 28 - onOpenChange={(o) => { 29 - setOpen(o); 30 - props.onOpenChange?.(open); 31 - }} 32 - > 33 - <PopoverOpenContext value={open}> 34 - <RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}> 35 - {props.trigger} 36 - </RadixPopover.Trigger> 37 - <RadixPopover.Portal> 38 - <NestedCardThemeProvider> 39 - <RadixPopover.Content 40 - className={` 41 - z-20 bg-bg-page 42 - px-3 py-2 43 - max-w-(--radix-popover-content-available-width) 44 - max-h-(--radix-popover-content-available-height) 45 - border border-border rounded-md shadow-md 46 - overflow-y-scroll no-scrollbar 47 - ${props.className} 48 - `} 49 - side={props.side} 50 - align={props.align ? props.align : "center"} 51 - sideOffset={4} 52 - collisionPadding={16} 53 - onOpenAutoFocus={props.onOpenAutoFocus} 54 - > 55 - {props.children} 56 - <RadixPopover.Arrow 57 - asChild 58 - width={16} 59 - height={8} 60 - viewBox="0 0 16 8" 61 - > 62 - <PopoverArrow 63 - arrowFill={ 64 - props.arrowFill 65 - ? props.arrowFill 66 - : props.background 67 - ? props.background 68 - : theme.colors["bg-page"] 69 - } 70 - arrowStroke={ 71 - props.border ? props.border : theme.colors["border"] 72 - } 73 - /> 74 - </RadixPopover.Arrow> 75 - </RadixPopover.Content> 76 - </NestedCardThemeProvider> 77 - </RadixPopover.Portal> 78 - </PopoverOpenContext> 79 - </RadixPopover.Root> 80 - ); 81 - };
···
+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 + };
-14
components/Providers/IPLocationProvider.tsx
··· 1 - "use client"; 2 - import { createContext } from "react"; 3 - 4 - export const IPLocationContext = createContext<string | null>(null); 5 - export const IPLocationProvider = (props: { 6 - country: string | null; 7 - children: React.ReactNode; 8 - }) => { 9 - return ( 10 - <IPLocationContext.Provider value={props.country}> 11 - {props.children} 12 - </IPLocationContext.Provider> 13 - ); 14 - };
···
+33
components/Providers/RequestHeadersProvider.tsx
···
··· 1 + "use client"; 2 + import { createContext } from "react"; 3 + 4 + export type RequestHeaders = { 5 + country: string | null; 6 + language: string | null; 7 + timezone: string | null; 8 + }; 9 + 10 + export const RequestHeadersContext = createContext<RequestHeaders>({ 11 + country: null, 12 + language: null, 13 + timezone: null, 14 + }); 15 + 16 + export const RequestHeadersProvider = (props: { 17 + country: string | null; 18 + language: string | null; 19 + timezone: string | null; 20 + children: React.ReactNode; 21 + }) => { 22 + return ( 23 + <RequestHeadersContext.Provider 24 + value={{ 25 + country: props.country, 26 + language: props.language, 27 + timezone: props.timezone, 28 + }} 29 + > 30 + {props.children} 31 + </RequestHeadersContext.Provider> 32 + ); 33 + };
+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 + };
-764
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 - if (isMobile) return; 40 - const getSortedSelectionBound = getSortedSelection.bind(null, rep); 41 - let shortcuts: Shortcut[] = [ 42 - { 43 - metaKey: true, 44 - key: "ArrowUp", 45 - handler: async () => { 46 - let [firstBlock] = 47 - (await rep?.query((tx) => 48 - getBlocksWithType( 49 - tx, 50 - useUIState.getState().selectedBlocks[0].parent, 51 - ), 52 - )) || []; 53 - if (firstBlock) focusBlock(firstBlock, { type: "start" }); 54 - }, 55 - }, 56 - { 57 - metaKey: true, 58 - key: "ArrowDown", 59 - handler: async () => { 60 - let blocks = 61 - (await rep?.query((tx) => 62 - getBlocksWithType( 63 - tx, 64 - useUIState.getState().selectedBlocks[0].parent, 65 - ), 66 - )) || []; 67 - let folded = useUIState.getState().foldedBlocks; 68 - blocks = blocks.filter( 69 - (f) => 70 - !f.listData || 71 - !f.listData.path.find( 72 - (path) => 73 - folded.includes(path.entity) && f.value !== path.entity, 74 - ), 75 - ); 76 - let lastBlock = blocks[blocks.length - 1]; 77 - if (lastBlock) focusBlock(lastBlock, { type: "end" }); 78 - }, 79 - }, 80 - { 81 - metaKey: true, 82 - altKey: true, 83 - key: ["l", "ยฌ"], 84 - handler: async () => { 85 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 86 - for (let block of sortedBlocks) { 87 - if (!block.listData) { 88 - await rep?.mutate.assertFact({ 89 - entity: block.value, 90 - attribute: "block/is-list", 91 - data: { type: "boolean", value: true }, 92 - }); 93 - } else { 94 - outdentFull(block, rep); 95 - } 96 - } 97 - }, 98 - }, 99 - { 100 - metaKey: true, 101 - shift: true, 102 - key: ["ArrowDown", "J"], 103 - handler: async () => { 104 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 105 - let block = sortedBlocks[0]; 106 - let nextBlock = siblings 107 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 108 - .find( 109 - (f) => 110 - f.listData && 111 - block.listData && 112 - !f.listData.path.find((f) => f.entity === block.value), 113 - ); 114 - if ( 115 - nextBlock?.listData && 116 - block.listData && 117 - nextBlock.listData.depth === block.listData.depth - 1 118 - ) { 119 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 120 - useUIState.getState().toggleFold(nextBlock.value); 121 - rep?.mutate.moveBlock({ 122 - block: block.value, 123 - oldParent: block.listData?.parent, 124 - newParent: nextBlock.value, 125 - position: { type: "first" }, 126 - }); 127 - } else { 128 - rep?.mutate.moveBlockDown({ 129 - entityID: block.value, 130 - parent: block.listData?.parent || block.parent, 131 - }); 132 - } 133 - }, 134 - }, 135 - { 136 - metaKey: true, 137 - shift: true, 138 - key: ["ArrowUp", "K"], 139 - handler: async () => { 140 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 141 - let block = sortedBlocks[0]; 142 - let previousBlock = 143 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 144 - if (previousBlock.value === block.listData?.parent) { 145 - previousBlock = 146 - siblings?.[ 147 - siblings.findIndex((s) => s.value === block.value) - 2 148 - ]; 149 - } 150 - 151 - if ( 152 - previousBlock?.listData && 153 - block.listData && 154 - block.listData.depth > 1 && 155 - !previousBlock.listData.path.find( 156 - (f) => f.entity === block.listData?.parent, 157 - ) 158 - ) { 159 - let depth = block.listData.depth; 160 - let newParent = previousBlock.listData.path.find( 161 - (f) => f.depth === depth - 1, 162 - ); 163 - if (!newParent) return; 164 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 165 - useUIState.getState().toggleFold(newParent.entity); 166 - rep?.mutate.moveBlock({ 167 - block: block.value, 168 - oldParent: block.listData?.parent, 169 - newParent: newParent.entity, 170 - position: { type: "end" }, 171 - }); 172 - } else { 173 - rep?.mutate.moveBlockUp({ 174 - entityID: block.value, 175 - parent: block.listData?.parent || block.parent, 176 - }); 177 - } 178 - }, 179 - }, 180 - 181 - { 182 - metaKey: true, 183 - shift: true, 184 - key: "Enter", 185 - handler: async () => { 186 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 187 - if (!sortedBlocks[0].listData) return; 188 - useUIState.getState().toggleFold(sortedBlocks[0].value); 189 - }, 190 - }, 191 - ]; 192 - if (moreThanOneSelected) 193 - shortcuts = shortcuts.concat([ 194 - { 195 - metaKey: true, 196 - key: "u", 197 - handler: async () => { 198 - let [sortedBlocks] = await getSortedSelectionBound(); 199 - toggleMarkInBlocks( 200 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 201 - schema.marks.underline, 202 - ); 203 - }, 204 - }, 205 - { 206 - metaKey: true, 207 - key: "i", 208 - handler: async () => { 209 - let [sortedBlocks] = await getSortedSelectionBound(); 210 - toggleMarkInBlocks( 211 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 212 - schema.marks.em, 213 - ); 214 - }, 215 - }, 216 - { 217 - metaKey: true, 218 - key: "b", 219 - handler: async () => { 220 - let [sortedBlocks] = await getSortedSelectionBound(); 221 - toggleMarkInBlocks( 222 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 223 - schema.marks.strong, 224 - ); 225 - }, 226 - }, 227 - { 228 - metaAndCtrl: true, 229 - key: "h", 230 - handler: async () => { 231 - let [sortedBlocks] = await getSortedSelectionBound(); 232 - toggleMarkInBlocks( 233 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 234 - schema.marks.highlight, 235 - { 236 - color: useUIState.getState().lastUsedHighlight, 237 - }, 238 - ); 239 - }, 240 - }, 241 - { 242 - metaAndCtrl: true, 243 - key: "x", 244 - handler: async () => { 245 - let [sortedBlocks] = await getSortedSelectionBound(); 246 - toggleMarkInBlocks( 247 - sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 248 - schema.marks.strikethrough, 249 - ); 250 - }, 251 - }, 252 - ]); 253 - let removeListener = addShortcut( 254 - shortcuts.map((shortcut) => ({ 255 - ...shortcut, 256 - handler: () => undoManager.withUndoGroup(() => shortcut.handler()), 257 - })), 258 - ); 259 - let listener = async (e: KeyboardEvent) => 260 - undoManager.withUndoGroup(async () => { 261 - //used here and in cut 262 - const deleteBlocks = async () => { 263 - if (!entity_set.permissions.write) return; 264 - if (moreThanOneSelected) { 265 - e.preventDefault(); 266 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 267 - let selectedBlocks = useUIState.getState().selectedBlocks; 268 - let firstBlock = sortedBlocks[0]; 269 - 270 - await rep?.mutate.removeBlock( 271 - selectedBlocks.map((block) => ({ blockEntity: block.value })), 272 - ); 273 - useUIState.getState().closePage(selectedBlocks.map((b) => b.value)); 274 - 275 - let nextBlock = 276 - siblings?.[ 277 - siblings.findIndex((s) => s.value === firstBlock.value) - 1 278 - ]; 279 - if (nextBlock) { 280 - useUIState.getState().setSelectedBlock({ 281 - value: nextBlock.value, 282 - parent: nextBlock.parent, 283 - }); 284 - let type = await rep?.query((tx) => 285 - scanIndex(tx).eav(nextBlock.value, "block/type"), 286 - ); 287 - if (!type?.[0]) return; 288 - if ( 289 - type[0]?.data.value === "text" || 290 - type[0]?.data.value === "heading" 291 - ) 292 - focusBlock( 293 - { 294 - value: nextBlock.value, 295 - type: "text", 296 - parent: nextBlock.parent, 297 - }, 298 - { type: "end" }, 299 - ); 300 - } 301 - } 302 - }; 303 - if (e.key === "Backspace" || e.key === "Delete") { 304 - deleteBlocks(); 305 - } 306 - if (e.key === "ArrowUp") { 307 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 308 - let focusedBlock = useUIState.getState().focusedEntity; 309 - if (!e.shiftKey && !e.ctrlKey) { 310 - if (e.defaultPrevented) return; 311 - if (sortedBlocks.length === 1) return; 312 - let firstBlock = sortedBlocks[0]; 313 - if (!firstBlock) return; 314 - let type = await rep?.query((tx) => 315 - scanIndex(tx).eav(firstBlock.value, "block/type"), 316 - ); 317 - if (!type?.[0]) return; 318 - useUIState.getState().setSelectedBlock(firstBlock); 319 - focusBlock( 320 - { ...firstBlock, type: type[0].data.value }, 321 - { type: "start" }, 322 - ); 323 - } else { 324 - if (e.defaultPrevented) return; 325 - if ( 326 - sortedBlocks.length <= 1 || 327 - !focusedBlock || 328 - focusedBlock.entityType === "page" 329 - ) 330 - return; 331 - let b = focusedBlock; 332 - let focusedBlockIndex = sortedBlocks.findIndex( 333 - (s) => s.value == b.entityID, 334 - ); 335 - if (focusedBlockIndex === 0) { 336 - let index = siblings.findIndex((s) => s.value === b.entityID); 337 - let nextSelectedBlock = siblings[index - 1]; 338 - if (!nextSelectedBlock) return; 339 - 340 - scrollIntoViewIfNeeded( 341 - document.getElementById( 342 - elementId.block(nextSelectedBlock.value).container, 343 - ), 344 - false, 345 - ); 346 - useUIState.getState().addBlockToSelection({ 347 - ...nextSelectedBlock, 348 - }); 349 - useUIState.getState().setFocusedBlock({ 350 - entityType: "block", 351 - parent: nextSelectedBlock.parent, 352 - entityID: nextSelectedBlock.value, 353 - }); 354 - } else { 355 - let nextBlock = sortedBlocks[sortedBlocks.length - 2]; 356 - useUIState.getState().setFocusedBlock({ 357 - entityType: "block", 358 - parent: b.parent, 359 - entityID: nextBlock.value, 360 - }); 361 - scrollIntoViewIfNeeded( 362 - document.getElementById( 363 - elementId.block(nextBlock.value).container, 364 - ), 365 - false, 366 - ); 367 - if (sortedBlocks.length === 2) { 368 - useEditorStates 369 - .getState() 370 - .editorStates[nextBlock.value]?.view?.focus(); 371 - } 372 - useUIState 373 - .getState() 374 - .removeBlockFromSelection(sortedBlocks[focusedBlockIndex]); 375 - } 376 - } 377 - } 378 - if (e.key === "ArrowLeft") { 379 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 380 - if (sortedSelection.length === 1) return; 381 - let firstBlock = sortedSelection[0]; 382 - if (!firstBlock) return; 383 - let type = await rep?.query((tx) => 384 - scanIndex(tx).eav(firstBlock.value, "block/type"), 385 - ); 386 - if (!type?.[0]) return; 387 - useUIState.getState().setSelectedBlock(firstBlock); 388 - focusBlock( 389 - { ...firstBlock, type: type[0].data.value }, 390 - { type: "start" }, 391 - ); 392 - } 393 - if (e.key === "ArrowRight") { 394 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 395 - if (sortedSelection.length === 1) return; 396 - let lastBlock = sortedSelection[sortedSelection.length - 1]; 397 - if (!lastBlock) return; 398 - let type = await rep?.query((tx) => 399 - scanIndex(tx).eav(lastBlock.value, "block/type"), 400 - ); 401 - if (!type?.[0]) return; 402 - useUIState.getState().setSelectedBlock(lastBlock); 403 - focusBlock( 404 - { ...lastBlock, type: type[0].data.value }, 405 - { type: "end" }, 406 - ); 407 - } 408 - if (e.key === "Tab") { 409 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 410 - if (sortedSelection.length <= 1) return; 411 - e.preventDefault(); 412 - if (e.shiftKey) { 413 - for (let i = siblings.length - 1; i >= 0; i--) { 414 - let block = siblings[i]; 415 - if (!sortedSelection.find((s) => s.value === block.value)) 416 - continue; 417 - if ( 418 - sortedSelection.find((s) => s.value === block.listData?.parent) 419 - ) 420 - continue; 421 - let parentoffset = 1; 422 - let previousBlock = siblings[i - parentoffset]; 423 - while ( 424 - previousBlock && 425 - sortedSelection.find((s) => previousBlock.value === s.value) 426 - ) { 427 - parentoffset += 1; 428 - previousBlock = siblings[i - parentoffset]; 429 - } 430 - if (!block.listData || !previousBlock.listData) continue; 431 - outdent(block, previousBlock, rep); 432 - } 433 - } else { 434 - for (let i = 0; i < siblings.length; i++) { 435 - let block = siblings[i]; 436 - if (!sortedSelection.find((s) => s.value === block.value)) 437 - continue; 438 - if ( 439 - sortedSelection.find((s) => s.value === block.listData?.parent) 440 - ) 441 - continue; 442 - let parentoffset = 1; 443 - let previousBlock = siblings[i - parentoffset]; 444 - while ( 445 - previousBlock && 446 - sortedSelection.find((s) => previousBlock.value === s.value) 447 - ) { 448 - parentoffset += 1; 449 - previousBlock = siblings[i - parentoffset]; 450 - } 451 - if (!block.listData || !previousBlock.listData) continue; 452 - indent(block, previousBlock, rep); 453 - } 454 - } 455 - } 456 - if (e.key === "ArrowDown") { 457 - let [sortedSelection, siblings] = await getSortedSelectionBound(); 458 - let focusedBlock = useUIState.getState().focusedEntity; 459 - if (!e.shiftKey) { 460 - if (sortedSelection.length === 1) return; 461 - let lastBlock = sortedSelection[sortedSelection.length - 1]; 462 - if (!lastBlock) return; 463 - let type = await rep?.query((tx) => 464 - scanIndex(tx).eav(lastBlock.value, "block/type"), 465 - ); 466 - if (!type?.[0]) return; 467 - useUIState.getState().setSelectedBlock(lastBlock); 468 - focusBlock( 469 - { ...lastBlock, type: type[0].data.value }, 470 - { type: "end" }, 471 - ); 472 - } 473 - if (e.shiftKey) { 474 - if (e.defaultPrevented) return; 475 - if ( 476 - sortedSelection.length <= 1 || 477 - !focusedBlock || 478 - focusedBlock.entityType === "page" 479 - ) 480 - return; 481 - let b = focusedBlock; 482 - let focusedBlockIndex = sortedSelection.findIndex( 483 - (s) => s.value == b.entityID, 484 - ); 485 - if (focusedBlockIndex === sortedSelection.length - 1) { 486 - let index = siblings.findIndex((s) => s.value === b.entityID); 487 - let nextSelectedBlock = siblings[index + 1]; 488 - if (!nextSelectedBlock) return; 489 - useUIState.getState().addBlockToSelection({ 490 - ...nextSelectedBlock, 491 - }); 492 - 493 - scrollIntoViewIfNeeded( 494 - document.getElementById( 495 - elementId.block(nextSelectedBlock.value).container, 496 - ), 497 - false, 498 - ); 499 - useUIState.getState().setFocusedBlock({ 500 - entityType: "block", 501 - parent: nextSelectedBlock.parent, 502 - entityID: nextSelectedBlock.value, 503 - }); 504 - } else { 505 - let nextBlock = sortedSelection[1]; 506 - useUIState 507 - .getState() 508 - .removeBlockFromSelection({ value: b.entityID }); 509 - scrollIntoViewIfNeeded( 510 - document.getElementById( 511 - elementId.block(nextBlock.value).container, 512 - ), 513 - false, 514 - ); 515 - useUIState.getState().setFocusedBlock({ 516 - entityType: "block", 517 - parent: b.parent, 518 - entityID: nextBlock.value, 519 - }); 520 - if (sortedSelection.length === 2) { 521 - useEditorStates 522 - .getState() 523 - .editorStates[nextBlock.value]?.view?.focus(); 524 - } 525 - } 526 - } 527 - } 528 - if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) { 529 - if (!rep) return; 530 - if (e.shiftKey || (e.metaKey && e.ctrlKey)) return; 531 - let [, , selectionWithFoldedChildren] = 532 - await getSortedSelectionBound(); 533 - if (!selectionWithFoldedChildren) return; 534 - let el = document.activeElement as HTMLElement; 535 - if ( 536 - el?.tagName === "LABEL" || 537 - el?.tagName === "INPUT" || 538 - el?.tagName === "TEXTAREA" 539 - ) { 540 - return; 541 - } 542 - 543 - if ( 544 - el.contentEditable === "true" && 545 - selectionWithFoldedChildren.length <= 1 546 - ) 547 - return; 548 - e.preventDefault(); 549 - await copySelection(rep, selectionWithFoldedChildren); 550 - if (e.key === "x") deleteBlocks(); 551 - } 552 - }); 553 - window.addEventListener("keydown", listener); 554 - return () => { 555 - removeListener(); 556 - window.removeEventListener("keydown", listener); 557 - }; 558 - }, [moreThanOneSelected, rep, entity_set.permissions.write, isMobile]); 559 - 560 - let [mouseDown, setMouseDown] = useState(false); 561 - let initialContentEditableParent = useRef<null | Node>(null); 562 - let savedSelection = useRef<SavedRange[] | null>(undefined); 563 - useEffect(() => { 564 - if (isMobile) return; 565 - if (!entity_set.permissions.write) return; 566 - let mouseDownListener = (e: MouseEvent) => { 567 - if ((e.target as Element).getAttribute("data-draggable")) return; 568 - let contentEditableParent = getContentEditableParent(e.target as Node); 569 - if (contentEditableParent) { 570 - setMouseDown(true); 571 - let entityID = (contentEditableParent as Element).getAttribute( 572 - "data-entityid", 573 - ); 574 - useSelectingMouse.setState({ start: entityID }); 575 - } 576 - initialContentEditableParent.current = contentEditableParent; 577 - }; 578 - let mouseUpListener = (e: MouseEvent) => { 579 - savedSelection.current = null; 580 - if ( 581 - initialContentEditableParent.current && 582 - !(e.target as Element).getAttribute("data-draggable") && 583 - getContentEditableParent(e.target as Node) !== 584 - initialContentEditableParent.current 585 - ) { 586 - setTimeout(() => { 587 - window.getSelection()?.removeAllRanges(); 588 - }, 5); 589 - } 590 - initialContentEditableParent.current = null; 591 - useSelectingMouse.setState({ start: null }); 592 - setMouseDown(false); 593 - }; 594 - window.addEventListener("mousedown", mouseDownListener); 595 - window.addEventListener("mouseup", mouseUpListener); 596 - return () => { 597 - window.removeEventListener("mousedown", mouseDownListener); 598 - window.removeEventListener("mouseup", mouseUpListener); 599 - }; 600 - }, [entity_set.permissions.write, isMobile]); 601 - useEffect(() => { 602 - if (!mouseDown) return; 603 - if (isMobile) return; 604 - let mouseMoveListener = (e: MouseEvent) => { 605 - if (e.buttons !== 1) return; 606 - if (initialContentEditableParent.current) { 607 - if ( 608 - initialContentEditableParent.current === 609 - getContentEditableParent(e.target as Node) 610 - ) { 611 - if (savedSelection.current) { 612 - restoreSelection(savedSelection.current); 613 - } 614 - savedSelection.current = null; 615 - return; 616 - } 617 - if (!savedSelection.current) savedSelection.current = saveSelection(); 618 - window.getSelection()?.removeAllRanges(); 619 - } 620 - }; 621 - window.addEventListener("mousemove", mouseMoveListener); 622 - return () => { 623 - window.removeEventListener("mousemove", mouseMoveListener); 624 - }; 625 - }, [mouseDown, isMobile]); 626 - return null; 627 - } 628 - 629 - type SavedRange = { 630 - startContainer: Node; 631 - startOffset: number; 632 - endContainer: Node; 633 - endOffset: number; 634 - direction: "forward" | "backward"; 635 - }; 636 - export function saveSelection() { 637 - let selection = window.getSelection(); 638 - if (selection && selection.rangeCount > 0) { 639 - let ranges: SavedRange[] = []; 640 - for (let i = 0; i < selection.rangeCount; i++) { 641 - let range = selection.getRangeAt(i); 642 - ranges.push({ 643 - startContainer: range.startContainer, 644 - startOffset: range.startOffset, 645 - endContainer: range.endContainer, 646 - endOffset: range.endOffset, 647 - direction: 648 - selection.anchorNode === range.startContainer && 649 - selection.anchorOffset === range.startOffset 650 - ? "forward" 651 - : "backward", 652 - }); 653 - } 654 - return ranges; 655 - } 656 - return []; 657 - } 658 - 659 - export function restoreSelection(savedRanges: SavedRange[]) { 660 - if (savedRanges && savedRanges.length > 0) { 661 - let selection = window.getSelection(); 662 - if (!selection) return; 663 - selection.removeAllRanges(); 664 - for (let i = 0; i < savedRanges.length; i++) { 665 - let range = document.createRange(); 666 - range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset); 667 - range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset); 668 - 669 - selection.addRange(range); 670 - 671 - // If the direction is backward, collapse the selection to the end and then extend it backward 672 - if (savedRanges[i].direction === "backward") { 673 - selection.collapseToEnd(); 674 - selection.extend( 675 - savedRanges[i].startContainer, 676 - savedRanges[i].startOffset, 677 - ); 678 - } 679 - } 680 - } 681 - } 682 - 683 - function getContentEditableParent(e: Node | null): Node | null { 684 - let element: Node | null = e; 685 - while (element && element !== document) { 686 - if ( 687 - (element as HTMLElement).contentEditable === "true" || 688 - (element as HTMLElement).getAttribute("data-editable-block") 689 - ) { 690 - return element; 691 - } 692 - element = element.parentNode; 693 - } 694 - return null; 695 - } 696 - 697 - export const getSortedSelection = async ( 698 - rep: Replicache<ReplicacheMutators>, 699 - ) => { 700 - let selectedBlocks = useUIState.getState().selectedBlocks; 701 - let foldedBlocks = useUIState.getState().foldedBlocks; 702 - if (!selectedBlocks[0]) return [[], []]; 703 - let siblings = 704 - (await rep?.query((tx) => 705 - getBlocksWithType(tx, selectedBlocks[0].parent), 706 - )) || []; 707 - let sortedBlocks = siblings.filter((s) => { 708 - let selected = selectedBlocks.find((sb) => sb.value === s.value); 709 - return selected; 710 - }); 711 - let sortedBlocksWithChildren = siblings.filter((s) => { 712 - let selected = selectedBlocks.find((sb) => sb.value === s.value); 713 - if (s.listData && !selected) { 714 - //Select the children of folded list blocks (in order to copy them) 715 - return s.listData.path.find( 716 - (p) => 717 - selectedBlocks.find((sb) => sb.value === p.entity) && 718 - foldedBlocks.includes(p.entity), 719 - ); 720 - } 721 - return selected; 722 - }); 723 - return [ 724 - sortedBlocks, 725 - siblings.filter( 726 - (f) => 727 - !f.listData || 728 - !f.listData.path.find( 729 - (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value, 730 - ), 731 - ), 732 - sortedBlocksWithChildren, 733 - ]; 734 - }; 735 - 736 - function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 737 - let everyBlockHasMark = blocks.reduce((acc, block) => { 738 - let editor = useEditorStates.getState().editorStates[block]; 739 - if (!editor) return acc; 740 - let { view } = editor; 741 - let from = 0; 742 - let to = view.state.doc.content.size; 743 - let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark); 744 - return acc && hasMarkInRange; 745 - }, true); 746 - for (let block of blocks) { 747 - let editor = useEditorStates.getState().editorStates[block]; 748 - if (!editor) return; 749 - let { view } = editor; 750 - let tr = view.state.tr; 751 - 752 - let from = 0; 753 - let to = view.state.doc.content.size; 754 - 755 - tr.setMeta("bulkOp", true); 756 - if (everyBlockHasMark) { 757 - tr.removeMark(from, to, mark); 758 - } else { 759 - tr.addMark(from, to, mark.create(attrs)); 760 - } 761 - 762 - view.dispatch(tr); 763 - } 764 - }
···
-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/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 + };
+110 -147
components/ThemeManager/PubThemeSetter.tsx
··· 10 import { useLocalPubTheme } from "./PublicationThemeProvider"; 11 import { BaseThemeProvider } from "./ThemeProvider"; 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 - import { ButtonSecondary } from "components/Buttons"; 14 import { updatePublicationTheme } from "app/lish/createPub/updatePublication"; 15 - import { DotLoader } from "components/utils/DotLoader"; 16 import { PagePickers } from "./PubPickers/PubTextPickers"; 17 import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers"; 18 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 19 import { Separator } from "components/Layout"; 20 21 export type ImageState = { 22 src: string; 23 file?: File; 24 repeat: number | null; 25 }; 26 - export const PubThemeSetter = () => { 27 - let [loading, setLoading] = useState(false); 28 let [sample, setSample] = useState<"pub" | "post">("pub"); 29 let [openPicker, setOpenPicker] = useState<pickers>("null"); 30 let { data, mutate } = usePublicationData(); ··· 37 theme: localPubTheme, 38 setTheme, 39 changes, 40 - } = useLocalPubTheme(record, showPageBackground); 41 let [image, setImage] = useState<ImageState | null>( 42 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) 43 ? { ··· 58 return ( 59 <BaseThemeProvider local {...localPubTheme}> 60 <form 61 - className="bg-accent-1 -mx-3 -mt-2 px-3 py-1 mb-1 flex justify-between items-center" 62 onSubmit={async (e) => { 63 e.preventDefault(); 64 if (!pub) return; 65 - setLoading(true); 66 let result = await updatePublicationTheme({ 67 uri: pub.uri, 68 theme: { ··· 79 }, 80 }); 81 mutate((pub) => { 82 - if (result?.publication && pub) 83 - return { ...pub, record: result.publication.record }; 84 return pub; 85 }, false); 86 - setLoading(false); 87 }} 88 > 89 - <h4 className="text-accent-2">Publication Theme</h4> 90 - <ButtonSecondary compact> 91 - {loading ? <DotLoader /> : "Update"} 92 - </ButtonSecondary> 93 </form> 94 95 - <div> 96 - <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 97 - <div className="themeBGLeaflet flex"> 98 - <div 99 - className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 100 - > 101 - <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 102 - <BackgroundPicker 103 - bgImage={image} 104 - setBgImage={setImage} 105 - backgroundColor={localPubTheme.bgLeaflet} 106 - pageBackground={localPubTheme.bgPage} 107 - setPageBackground={(color) => { 108 - setTheme((t) => ({ ...t, bgPage: color })); 109 - }} 110 - setBackgroundColor={(color) => { 111 - setTheme((t) => ({ ...t, bgLeaflet: color })); 112 - }} 113 - openPicker={openPicker} 114 - setOpenPicker={setOpenPicker} 115 - hasPageBackground={!!showPageBackground} 116 - setHasPageBackground={setShowPageBackground} 117 - /> 118 - </div> 119 - 120 - <SectionArrow 121 - fill="white" 122 - stroke="#CCCCCC" 123 - className="ml-2 -mt-px" 124 - /> 125 - </div> 126 - </div> 127 - 128 <div 129 - style={{ 130 - backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 131 - backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 132 - backgroundPosition: "center", 133 - backgroundSize: !leafletBGRepeat 134 - ? "cover" 135 - : `calc(${leafletBGRepeat}px / 2 )`, 136 - }} 137 - className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 138 > 139 - <div className={`flex flex-col gap-3 z-10`}> 140 - <PagePickers 141 pageBackground={localPubTheme.bgPage} 142 - primary={localPubTheme.primary} 143 setPageBackground={(color) => { 144 setTheme((t) => ({ ...t, bgPage: color })); 145 }} 146 - setPrimary={(color) => { 147 - setTheme((t) => ({ ...t, primary: color })); 148 - }} 149 - openPicker={openPicker} 150 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 151 - hasPageBackground={showPageBackground} 152 - /> 153 - <PubAccentPickers 154 - accent1={localPubTheme.accent1} 155 - setAccent1={(color) => { 156 - setTheme((t) => ({ ...t, accent1: color })); 157 - }} 158 - accent2={localPubTheme.accent2} 159 - setAccent2={(color) => { 160 - setTheme((t) => ({ ...t, accent2: color })); 161 }} 162 openPicker={openPicker} 163 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 164 /> 165 </div> 166 </div> 167 - <div className="flex flex-col mt-4 "> 168 - <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 169 - <div className="text-sm">Preview</div> 170 - <Separator classname="h-4!" />{" "} 171 - <button 172 - className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 173 - onClick={() => setSample("pub")} 174 - > 175 - Pub 176 - </button> 177 - <button 178 - className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 179 - onClick={() => setSample("post")} 180 - > 181 - Post 182 - </button> 183 - </div> 184 - {sample === "pub" ? ( 185 - <SamplePub 186 - pubBGImage={pubBGImage} 187 - pubBGRepeat={leafletBGRepeat} 188 - showPageBackground={showPageBackground} 189 - /> 190 - ) : ( 191 - <SamplePost 192 - pubBGImage={pubBGImage} 193 - pubBGRepeat={leafletBGRepeat} 194 - showPageBackground={showPageBackground} 195 - /> 196 - )} 197 </div> 198 </div> 199 </div> 200 </BaseThemeProvider> ··· 339 </div> 340 ); 341 }; 342 - 343 - export function ColorToRGBA(color: Color) { 344 - if (!color) 345 - return { 346 - $type: "pub.leaflet.theme.color#rgba" as const, 347 - r: 0, 348 - g: 0, 349 - b: 0, 350 - a: 1, 351 - }; 352 - let c = color.toFormat("rgba"); 353 - const r = c.getChannelValue("red"); 354 - const g = c.getChannelValue("green"); 355 - const b = c.getChannelValue("blue"); 356 - const a = c.getChannelValue("alpha"); 357 - return { 358 - $type: "pub.leaflet.theme.color#rgba" as const, 359 - r: Math.round(r), 360 - g: Math.round(g), 361 - b: Math.round(b), 362 - a: Math.round(a * 100), 363 - }; 364 - } 365 - function ColorToRGB(color: Color) { 366 - if (!color) 367 - return { 368 - $type: "pub.leaflet.theme.color#rgb" as const, 369 - r: 0, 370 - g: 0, 371 - b: 0, 372 - }; 373 - let c = color.toFormat("rgb"); 374 - const r = c.getChannelValue("red"); 375 - const g = c.getChannelValue("green"); 376 - const b = c.getChannelValue("blue"); 377 - return { 378 - $type: "pub.leaflet.theme.color#rgb" as const, 379 - r: Math.round(r), 380 - g: Math.round(g), 381 - b: Math.round(b), 382 - }; 383 - }
··· 10 import { useLocalPubTheme } from "./PublicationThemeProvider"; 11 import { BaseThemeProvider } from "./ThemeProvider"; 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 import { updatePublicationTheme } from "app/lish/createPub/updatePublication"; 14 import { PagePickers } from "./PubPickers/PubTextPickers"; 15 import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers"; 16 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 17 import { Separator } from "components/Layout"; 18 + import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; 19 + import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; 20 21 export type ImageState = { 22 src: string; 23 file?: File; 24 repeat: number | null; 25 }; 26 + export const PubThemeSetter = (props: { 27 + backToMenu: () => void; 28 + loading: boolean; 29 + setLoading: (l: boolean) => void; 30 + }) => { 31 let [sample, setSample] = useState<"pub" | "post">("pub"); 32 let [openPicker, setOpenPicker] = useState<pickers>("null"); 33 let { data, mutate } = usePublicationData(); ··· 40 theme: localPubTheme, 41 setTheme, 42 changes, 43 + } = useLocalPubTheme(record?.theme, showPageBackground); 44 let [image, setImage] = useState<ImageState | null>( 45 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) 46 ? { ··· 61 return ( 62 <BaseThemeProvider local {...localPubTheme}> 63 <form 64 onSubmit={async (e) => { 65 e.preventDefault(); 66 if (!pub) return; 67 + props.setLoading(true); 68 let result = await updatePublicationTheme({ 69 uri: pub.uri, 70 theme: { ··· 81 }, 82 }); 83 mutate((pub) => { 84 + if (result?.publication && pub?.publication) 85 + return { 86 + ...pub, 87 + publication: { ...pub.publication, ...result.publication }, 88 + }; 89 return pub; 90 }, false); 91 + props.setLoading(false); 92 }} 93 > 94 + <PubSettingsHeader 95 + loading={props.loading} 96 + setLoadingAction={props.setLoading} 97 + backToMenuAction={props.backToMenu} 98 + state={"theme"} 99 + /> 100 </form> 101 102 + <div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 "> 103 + <div className="themeBGLeaflet flex"> 104 <div 105 + className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 106 > 107 + <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 108 + <BackgroundPicker 109 + bgImage={image} 110 + setBgImage={setImage} 111 + backgroundColor={localPubTheme.bgLeaflet} 112 pageBackground={localPubTheme.bgPage} 113 setPageBackground={(color) => { 114 setTheme((t) => ({ ...t, bgPage: color })); 115 }} 116 + setBackgroundColor={(color) => { 117 + setTheme((t) => ({ ...t, bgLeaflet: color })); 118 }} 119 openPicker={openPicker} 120 + setOpenPicker={setOpenPicker} 121 + hasPageBackground={!!showPageBackground} 122 + setHasPageBackground={setShowPageBackground} 123 /> 124 </div> 125 + 126 + <SectionArrow 127 + fill="white" 128 + stroke="#CCCCCC" 129 + className="ml-2 -mt-[1px]" 130 + /> 131 </div> 132 + </div> 133 + 134 + <div 135 + style={{ 136 + backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 137 + backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 138 + backgroundPosition: "center", 139 + backgroundSize: !leafletBGRepeat 140 + ? "cover" 141 + : `calc(${leafletBGRepeat}px / 2 )`, 142 + }} 143 + className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 144 + > 145 + <div className={`flex flex-col gap-3 z-10`}> 146 + <PagePickers 147 + pageBackground={localPubTheme.bgPage} 148 + primary={localPubTheme.primary} 149 + setPageBackground={(color) => { 150 + setTheme((t) => ({ ...t, bgPage: color })); 151 + }} 152 + setPrimary={(color) => { 153 + setTheme((t) => ({ ...t, primary: color })); 154 + }} 155 + openPicker={openPicker} 156 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 157 + hasPageBackground={showPageBackground} 158 + /> 159 + <PubAccentPickers 160 + accent1={localPubTheme.accent1} 161 + setAccent1={(color) => { 162 + setTheme((t) => ({ ...t, accent1: color })); 163 + }} 164 + accent2={localPubTheme.accent2} 165 + setAccent2={(color) => { 166 + setTheme((t) => ({ ...t, accent2: color })); 167 + }} 168 + openPicker={openPicker} 169 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 170 + /> 171 </div> 172 + </div> 173 + <div className="flex flex-col mt-4 "> 174 + <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 175 + <div className="text-sm">Preview</div> 176 + <Separator classname="h-4!" />{" "} 177 + <button 178 + className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 179 + onClick={() => setSample("pub")} 180 + > 181 + Pub 182 + </button> 183 + <button 184 + className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 185 + onClick={() => setSample("post")} 186 + > 187 + Post 188 + </button> 189 + </div> 190 + {sample === "pub" ? ( 191 + <SamplePub 192 + pubBGImage={pubBGImage} 193 + pubBGRepeat={leafletBGRepeat} 194 + showPageBackground={showPageBackground} 195 + /> 196 + ) : ( 197 + <SamplePost 198 + pubBGImage={pubBGImage} 199 + pubBGRepeat={leafletBGRepeat} 200 + showPageBackground={showPageBackground} 201 + /> 202 + )} 203 </div> 204 </div> 205 </BaseThemeProvider> ··· 344 </div> 345 ); 346 };
+39 -28
components/ThemeManager/PublicationThemeProvider.tsx
··· 2 import { useMemo, useState } from "react"; 3 import { parseColor } from "react-aria-components"; 4 import { useEntity } from "src/replicache"; 5 - import { getColorContrast } from "./ThemeProvider"; 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 import { BaseThemeProvider } from "./ThemeProvider"; 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; ··· 16 accentText: "#FFFFFF", 17 accentBackground: "#0000FF", 18 }; 19 function parseThemeColor( 20 c: PubLeafletThemeColor.Rgb | PubLeafletThemeColor.Rgba, 21 ) { ··· 26 } 27 28 let useColor = ( 29 - record: PubLeafletPublication.Record | null | undefined, 30 c: keyof typeof PubThemeDefaults, 31 ) => { 32 return useMemo(() => { 33 - let v = record?.theme?.[c]; 34 if (isColor(v)) { 35 return parseThemeColor(v); 36 } else return parseColor(PubThemeDefaults[c]); 37 - }, [record?.theme?.[c]]); 38 }; 39 let isColor = ( 40 c: any, ··· 47 48 export function PublicationThemeProviderDashboard(props: { 49 children: React.ReactNode; 50 - record?: PubLeafletPublication.Record | null; 51 }) { 52 let { data } = usePublicationData(); 53 let { publication: pub } = data || {}; 54 return ( 55 <PublicationThemeProvider 56 pub_creator={pub?.identity_did || ""} 57 - record={pub?.record as PubLeafletPublication.Record} 58 > 59 <PublicationBackgroundProvider 60 - record={pub?.record as PubLeafletPublication.Record} 61 pub_creator={pub?.identity_did || ""} 62 > 63 {props.children} ··· 67 } 68 69 export function PublicationBackgroundProvider(props: { 70 - record?: PubLeafletPublication.Record | null; 71 pub_creator: string; 72 className?: string; 73 children: React.ReactNode; 74 }) { 75 - let backgroundImage = props.record?.theme?.backgroundImage?.image?.ref 76 - ? blobRefToSrc( 77 - props.record?.theme?.backgroundImage?.image?.ref, 78 - props.pub_creator, 79 - ) 80 : null; 81 82 - let backgroundImageRepeat = props.record?.theme?.backgroundImage?.repeat; 83 - let backgroundImageSize = props.record?.theme?.backgroundImage?.width || 500; 84 return ( 85 <div 86 className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 87 style={{ 88 - backgroundImage: `url(${backgroundImage})`, 89 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 90 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 91 }} ··· 97 export function PublicationThemeProvider(props: { 98 local?: boolean; 99 children: React.ReactNode; 100 - record?: PubLeafletPublication.Record | null; 101 pub_creator: string; 102 }) { 103 - let colors = usePubTheme(props.record); 104 return ( 105 <BaseThemeProvider local={props.local} {...colors}> 106 {props.children} ··· 108 ); 109 } 110 111 - export const usePubTheme = (record?: PubLeafletPublication.Record | null) => { 112 - let bgLeaflet = useColor(record, "backgroundColor"); 113 - let bgPage = useColor(record, "pageBackground"); 114 - bgPage = record?.theme?.pageBackground ? bgPage : bgLeaflet; 115 - let showPageBackground = record?.theme?.showPageBackground; 116 117 - let primary = useColor(record, "primary"); 118 119 - let accent1 = useColor(record, "accentBackground"); 120 - let accent2 = useColor(record, "accentText"); 121 122 let highlight1 = useEntity(null, "theme/highlight-1")?.data.value; 123 let highlight2 = useColorAttribute(null, "theme/highlight-2"); ··· 137 }; 138 139 export const useLocalPubTheme = ( 140 - record: PubLeafletPublication.Record | undefined, 141 showPageBackground?: boolean, 142 ) => { 143 - const pubTheme = usePubTheme(record); 144 const [localOverrides, setTheme] = useState<Partial<typeof pubTheme>>({}); 145 146 const mergedTheme = useMemo(() => {
··· 2 import { useMemo, useState } from "react"; 3 import { parseColor } from "react-aria-components"; 4 import { useEntity } from "src/replicache"; 5 + import { getColorContrast } from "./themeUtils"; 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 import { BaseThemeProvider } from "./ThemeProvider"; 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; ··· 16 accentText: "#FFFFFF", 17 accentBackground: "#0000FF", 18 }; 19 + 20 + // Default page background for standalone leaflets (matches editor default) 21 + const StandalonePageBackground = "#FFFFFF"; 22 function parseThemeColor( 23 c: PubLeafletThemeColor.Rgb | PubLeafletThemeColor.Rgba, 24 ) { ··· 29 } 30 31 let useColor = ( 32 + theme: PubLeafletPublication.Record["theme"] | null | undefined, 33 c: keyof typeof PubThemeDefaults, 34 ) => { 35 return useMemo(() => { 36 + let v = theme?.[c]; 37 if (isColor(v)) { 38 return parseThemeColor(v); 39 } else return parseColor(PubThemeDefaults[c]); 40 + }, [theme?.[c]]); 41 }; 42 let isColor = ( 43 c: any, ··· 50 51 export function PublicationThemeProviderDashboard(props: { 52 children: React.ReactNode; 53 }) { 54 let { data } = usePublicationData(); 55 let { publication: pub } = data || {}; 56 return ( 57 <PublicationThemeProvider 58 pub_creator={pub?.identity_did || ""} 59 + theme={(pub?.record as PubLeafletPublication.Record)?.theme} 60 > 61 <PublicationBackgroundProvider 62 + theme={(pub?.record as PubLeafletPublication.Record)?.theme} 63 pub_creator={pub?.identity_did || ""} 64 > 65 {props.children} ··· 69 } 70 71 export function PublicationBackgroundProvider(props: { 72 + theme?: PubLeafletPublication.Record["theme"] | null; 73 pub_creator: string; 74 className?: string; 75 children: React.ReactNode; 76 }) { 77 + let backgroundImage = props.theme?.backgroundImage?.image?.ref 78 + ? blobRefToSrc(props.theme?.backgroundImage?.image?.ref, props.pub_creator) 79 : null; 80 81 + let backgroundImageRepeat = props.theme?.backgroundImage?.repeat; 82 + let backgroundImageSize = props.theme?.backgroundImage?.width || 500; 83 return ( 84 <div 85 className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch" 86 style={{ 87 + backgroundImage: backgroundImage 88 + ? `url(${backgroundImage})` 89 + : undefined, 90 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 91 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 92 }} ··· 98 export function PublicationThemeProvider(props: { 99 local?: boolean; 100 children: React.ReactNode; 101 + theme?: PubLeafletPublication.Record["theme"] | null; 102 pub_creator: string; 103 + isStandalone?: boolean; 104 }) { 105 + let colors = usePubTheme(props.theme, props.isStandalone); 106 return ( 107 <BaseThemeProvider local={props.local} {...colors}> 108 {props.children} ··· 110 ); 111 } 112 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; 127 128 + let primary = useColor(theme, "primary"); 129 130 + let accent1 = useColor(theme, "accentBackground"); 131 + let accent2 = useColor(theme, "accentText"); 132 133 let highlight1 = useEntity(null, "theme/highlight-1")?.data.value; 134 let highlight2 = useColorAttribute(null, "theme/highlight-2"); ··· 148 }; 149 150 export const useLocalPubTheme = ( 151 + theme: PubLeafletPublication.Record["theme"] | undefined, 152 showPageBackground?: boolean, 153 ) => { 154 + const pubTheme = usePubTheme(theme); 155 const [localOverrides, setTheme] = useState<Partial<typeof pubTheme>>({}); 156 157 const mergedTheme = useMemo(() => {
+5 -42
components/ThemeManager/ThemeProvider.tsx
··· 5 CSSProperties, 6 useContext, 7 useEffect, 8 - useMemo, 9 - useState, 10 } from "react"; 11 import { 12 colorToString, ··· 14 useColorAttributeNullable, 15 } from "./useColorAttribute"; 16 import { Color as AriaColor, parseColor } from "react-aria-components"; 17 - import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 18 19 import { useEntity } from "src/replicache"; 20 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 23 PublicationThemeProvider, 24 } from "./PublicationThemeProvider"; 25 import { PubLeafletPublication } from "lexicons/api"; 26 - 27 - type CSSVariables = { 28 - "--bg-leaflet": string; 29 - "--bg-page": string; 30 - "--primary": string; 31 - "--accent-1": string; 32 - "--accent-2": string; 33 - "--accent-contrast": string; 34 - "--highlight-1": string; 35 - "--highlight-2": string; 36 - "--highlight-3": string; 37 - }; 38 - 39 - // define the color defaults for everything 40 - export const ThemeDefaults = { 41 - "theme/page-background": "#F0F7FA", 42 - "theme/card-background": "#FFFFFF", 43 - "theme/primary": "#272727", 44 - "theme/highlight-1": "#FFFFFF", 45 - "theme/highlight-2": "#EDD280", 46 - "theme/highlight-3": "#FFCDC3", 47 - 48 - //everywhere else, accent-background = accent-1 and accent-text = accent-2. 49 - // we just need to create a migration pipeline before we can change this 50 - "theme/accent-text": "#FFFFFF", 51 - "theme/accent-background": "#0000FF", 52 - "theme/accent-contrast": "#0000FF", 53 - }; 54 55 // define a function to set an Aria Color to a CSS Variable in RGB 56 function setCSSVariableToColor( ··· 73 return ( 74 <PublicationThemeProvider 75 {...props} 76 - record={pub.publications?.record as PubLeafletPublication.Record} 77 pub_creator={pub.publications?.identity_did} 78 /> 79 ); ··· 339 return ( 340 <PublicationBackgroundProvider 341 pub_creator={pub?.publications.identity_did || ""} 342 - record={pub?.publications.record as PubLeafletPublication.Record} 343 > 344 {props.children} 345 </PublicationBackgroundProvider> ··· 366 ); 367 }; 368 369 - // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 370 - export function getColorContrast(color1: string, color2: string) { 371 - ColorSpace.register(sRGB); 372 - 373 - let parsedColor1 = parse(`rgb(${color1})`); 374 - let parsedColor2 = parse(`rgb(${color2})`); 375 - 376 - return contrastLstar(parsedColor1, parsedColor2); 377 - }
··· 5 CSSProperties, 6 useContext, 7 useEffect, 8 } from "react"; 9 import { 10 colorToString, ··· 12 useColorAttributeNullable, 13 } from "./useColorAttribute"; 14 import { Color as AriaColor, parseColor } from "react-aria-components"; 15 16 import { useEntity } from "src/replicache"; 17 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; ··· 20 PublicationThemeProvider, 21 } from "./PublicationThemeProvider"; 22 import { PubLeafletPublication } from "lexicons/api"; 23 + import { getColorContrast } from "./themeUtils"; 24 25 // define a function to set an Aria Color to a CSS Variable in RGB 26 function setCSSVariableToColor( ··· 43 return ( 44 <PublicationThemeProvider 45 {...props} 46 + theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme} 47 pub_creator={pub.publications?.identity_did} 48 /> 49 ); ··· 309 return ( 310 <PublicationBackgroundProvider 311 pub_creator={pub?.publications.identity_did || ""} 312 + theme={ 313 + (pub.publications?.record as PubLeafletPublication.Record)?.theme 314 + } 315 > 316 {props.children} 317 </PublicationBackgroundProvider> ··· 338 ); 339 }; 340
+101 -79
components/ThemeManager/ThemeSetter.tsx
··· 70 }, [rep, props.entityID]); 71 72 if (!permission) return null; 73 - if (pub) return null; 74 75 return ( 76 <> ··· 82 align={isMobile ? "center" : "start"} 83 trigger={<ActionButton icon={<PaintSmall />} label="Theme" />} 84 > 85 - <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 86 - <div className="themeBGLeaflet flex"> 87 - <div 88 - className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 89 - > 90 - <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md"> 91 - <LeafletBGPicker 92 - entityID={props.entityID} 93 - thisPicker={"leaflet"} 94 - openPicker={openPicker} 95 - setOpenPicker={setOpenPicker} 96 - closePicker={() => setOpenPicker("null")} 97 - setValue={set("theme/page-background")} 98 - /> 99 - <PageBackgroundPicker 100 - entityID={props.entityID} 101 - setValue={set("theme/card-background")} 102 - openPicker={openPicker} 103 - setOpenPicker={setOpenPicker} 104 - home={props.home} 105 - /> 106 - <hr className=" border-[#CCCCCC]" /> 107 - <PageBorderHider 108 - entityID={props.entityID} 109 - openPicker={openPicker} 110 - setOpenPicker={setOpenPicker} 111 - /> 112 - </div> 113 114 - <SectionArrow 115 - fill="white" 116 - stroke="#CCCCCC" 117 - className="ml-2 -mt-px" 118 - /> 119 - </div> 120 - </div> 121 122 - <div 123 - onClick={(e) => { 124 - e.currentTarget === e.target && setOpenPicker("leaflet"); 125 - }} 126 - style={{ 127 - backgroundImage: leafletBGImage 128 - ? `url(${leafletBGImage.data.src})` 129 - : undefined, 130 - backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 131 - backgroundPosition: "center", 132 - backgroundSize: !leafletBGRepeat 133 - ? "cover" 134 - : `calc(${leafletBGRepeat.data.value}px / 2 )`, 135 - }} 136 - className={`bg-bg-leaflet px-3 pt-4 pb-0 mb-2 flex flex-col gap-4 rounded-md border border-border`} 137 - > 138 - <PageThemePickers 139 entityID={props.entityID} 140 openPicker={openPicker} 141 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 142 /> 143 - <div className="flex flex-col -gap-[6px]"> 144 - <div className={`flex flex-col z-10 -mb-[6px] `}> 145 - <AccentPickers 146 - entityID={props.entityID} 147 - openPicker={openPicker} 148 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 149 - /> 150 - <SectionArrow 151 - fill={theme.colors["accent-2"]} 152 - stroke={theme.colors["accent-1"]} 153 - className="ml-2" 154 - /> 155 - </div> 156 - 157 - <SampleButton 158 - entityID={props.entityID} 159 - setOpenPicker={setOpenPicker} 160 - /> 161 - </div> 162 - 163 - <SamplePage 164 setOpenPicker={setOpenPicker} 165 home={props.home} 166 entityID={props.entityID} 167 /> 168 </div> 169 - {!props.home && <WatermarkSetter entityID={props.entityID} />} 170 </div> 171 - </Popover> 172 - </> 173 ); 174 }; 175 - 176 function WatermarkSetter(props: { entityID: string }) { 177 let { rep } = useReplicache(); 178 let checked = useEntity(props.entityID, "theme/page-leaflet-watermark");
··· 70 }, [rep, props.entityID]); 71 72 if (!permission) return null; 73 + if (pub?.publications) return null; 74 75 return ( 76 <> ··· 82 align={isMobile ? "center" : "start"} 83 trigger={<ActionButton icon={<PaintSmall />} label="Theme" />} 84 > 85 + <ThemeSetterContent {...props} /> 86 + </Popover> 87 + </> 88 + ); 89 + }; 90 91 + export const ThemeSetterContent = (props: { 92 + entityID: string; 93 + home?: boolean; 94 + }) => { 95 + let { rep } = useReplicache(); 96 + let { data: pub } = useLeafletPublicationData(); 97 98 + // I need to get these variables from replicache and then write them to the DB. I also need to parse them into a state that can be used here. 99 + let permission = useEntitySetContext().permissions.write; 100 + let leafletBGImage = useEntity(props.entityID, "theme/background-image"); 101 + let leafletBGRepeat = useEntity( 102 + props.entityID, 103 + "theme/background-image-repeat", 104 + ); 105 + 106 + let [openPicker, setOpenPicker] = useState<pickers>( 107 + props.home === true ? "leaflet" : "null", 108 + ); 109 + let set = useMemo(() => { 110 + return setColorAttribute(rep, props.entityID); 111 + }, [rep, props.entityID]); 112 + 113 + if (!permission) return null; 114 + if (pub?.publications) return null; 115 + return ( 116 + <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 117 + <div className="themeBGLeaflet flex"> 118 + <div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}> 119 + <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md"> 120 + <LeafletBGPicker 121 entityID={props.entityID} 122 + thisPicker={"leaflet"} 123 openPicker={openPicker} 124 + setOpenPicker={setOpenPicker} 125 + closePicker={() => setOpenPicker("null")} 126 + setValue={set("theme/page-background")} 127 /> 128 + <PageBackgroundPicker 129 + entityID={props.entityID} 130 + setValue={set("theme/card-background")} 131 + openPicker={openPicker} 132 setOpenPicker={setOpenPicker} 133 home={props.home} 134 + /> 135 + <hr className=" border-[#CCCCCC]" /> 136 + <PageBorderHider 137 entityID={props.entityID} 138 + openPicker={openPicker} 139 + setOpenPicker={setOpenPicker} 140 /> 141 </div> 142 + 143 + <SectionArrow fill="white" stroke="#CCCCCC" className="ml-2 -mt-px" /> 144 </div> 145 + </div> 146 + 147 + <div 148 + onClick={(e) => { 149 + e.currentTarget === e.target && setOpenPicker("leaflet"); 150 + }} 151 + style={{ 152 + backgroundImage: leafletBGImage 153 + ? `url(${leafletBGImage.data.src})` 154 + : undefined, 155 + backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 156 + backgroundPosition: "center", 157 + backgroundSize: !leafletBGRepeat 158 + ? "cover" 159 + : `calc(${leafletBGRepeat.data.value}px / 2 )`, 160 + }} 161 + className={`bg-bg-leaflet px-3 pt-4 pb-0 mb-2 flex flex-col gap-4 rounded-md border border-border`} 162 + > 163 + <PageThemePickers 164 + entityID={props.entityID} 165 + openPicker={openPicker} 166 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 167 + /> 168 + <div className="flex flex-col -gap-[6px]"> 169 + <div className={`flex flex-col z-10 -mb-[6px] `}> 170 + <AccentPickers 171 + entityID={props.entityID} 172 + openPicker={openPicker} 173 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 174 + /> 175 + <SectionArrow 176 + fill={theme.colors["accent-2"]} 177 + stroke={theme.colors["accent-1"]} 178 + className="ml-2" 179 + /> 180 + </div> 181 + 182 + <SampleButton 183 + entityID={props.entityID} 184 + setOpenPicker={setOpenPicker} 185 + /> 186 + </div> 187 + 188 + <SamplePage 189 + setOpenPicker={setOpenPicker} 190 + home={props.home} 191 + entityID={props.entityID} 192 + /> 193 + </div> 194 + {!props.home && <WatermarkSetter entityID={props.entityID} />} 195 + </div> 196 ); 197 }; 198 function WatermarkSetter(props: { entityID: string }) { 199 let { rep } = useReplicache(); 200 let checked = useEntity(props.entityID, "theme/page-leaflet-watermark");
+44
components/ThemeManager/colorToLexicons.ts
···
··· 1 + import { Color } from "react-aria-components"; 2 + 3 + export function ColorToRGBA(color: Color) { 4 + if (!color) 5 + return { 6 + $type: "pub.leaflet.theme.color#rgba" as const, 7 + r: 0, 8 + g: 0, 9 + b: 0, 10 + a: 1, 11 + }; 12 + let c = color.toFormat("rgba"); 13 + const r = c.getChannelValue("red"); 14 + const g = c.getChannelValue("green"); 15 + const b = c.getChannelValue("blue"); 16 + const a = c.getChannelValue("alpha"); 17 + return { 18 + $type: "pub.leaflet.theme.color#rgba" as const, 19 + r: Math.round(r), 20 + g: Math.round(g), 21 + b: Math.round(b), 22 + a: Math.round(a * 100), 23 + }; 24 + } 25 + 26 + export function ColorToRGB(color: Color) { 27 + if (!color) 28 + return { 29 + $type: "pub.leaflet.theme.color#rgb" as const, 30 + r: 0, 31 + g: 0, 32 + b: 0, 33 + }; 34 + let c = color.toFormat("rgb"); 35 + const r = c.getChannelValue("red"); 36 + const g = c.getChannelValue("green"); 37 + const b = c.getChannelValue("blue"); 38 + return { 39 + $type: "pub.leaflet.theme.color#rgb" as const, 40 + r: Math.round(r), 41 + g: Math.round(g), 42 + b: Math.round(b), 43 + }; 44 + }
+27
components/ThemeManager/themeUtils.ts
···
··· 1 + import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 2 + 3 + // define the color defaults for everything 4 + export const ThemeDefaults = { 5 + "theme/page-background": "#FDFCFA", 6 + "theme/card-background": "#FFFFFF", 7 + "theme/primary": "#272727", 8 + "theme/highlight-1": "#FFFFFF", 9 + "theme/highlight-2": "#EDD280", 10 + "theme/highlight-3": "#FFCDC3", 11 + 12 + //everywhere else, accent-background = accent-1 and accent-text = accent-2. 13 + // we just need to create a migration pipeline before we can change this 14 + "theme/accent-text": "#FFFFFF", 15 + "theme/accent-background": "#0000FF", 16 + "theme/accent-contrast": "#0000FF", 17 + }; 18 + 19 + // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 20 + export function getColorContrast(color1: string, color2: string) { 21 + ColorSpace.register(sRGB); 22 + 23 + let parsedColor1 = parse(`rgb(${color1})`); 24 + let parsedColor2 = parse(`rgb(${color2})`); 25 + 26 + return contrastLstar(parsedColor1, parsedColor2); 27 + }
+1 -1
components/ThemeManager/useColorAttribute.ts
··· 2 import { Color, parseColor } from "react-aria-components"; 3 import { useEntity, useReplicache } from "src/replicache"; 4 import { FilterAttributes } from "src/replicache/attributes"; 5 - import { ThemeDefaults } from "./ThemeProvider"; 6 7 export function useColorAttribute( 8 entity: string | null,
··· 2 import { Color, parseColor } from "react-aria-components"; 3 import { useEntity, useReplicache } from "src/replicache"; 4 import { FilterAttributes } from "src/replicache/attributes"; 5 + import { ThemeDefaults } from "./themeUtils"; 6 7 export function useColorAttribute( 8 entity: string | null,
+2 -6
components/Toast.tsx
··· 95 from: { top: -40 }, 96 enter: { top: 8 }, 97 leave: { top: -40 }, 98 - config: { 99 - mass: 8, 100 - friction: 150, 101 - tension: 2000, 102 - }, 103 }); 104 105 return transitions((style, item) => { 106 return item ? ( 107 <animated.div 108 style={style} 109 - className={`toastAnimationWrapper fixed bottom-0 right-0 left-0 z-50 h-fit`} 110 > 111 <div 112 className={`toast absolute right-2 w-max shadow-md px-3 py-1 flex flex-row gap-2 rounded-full border text-center ${
··· 95 from: { top: -40 }, 96 enter: { top: 8 }, 97 leave: { top: -40 }, 98 + config: {}, 99 }); 100 101 return transitions((style, item) => { 102 return item ? ( 103 <animated.div 104 style={style} 105 + className={`toastAnimationWrapper fixed top-0 bottom-0 right-0 left-0 z-50 h-fit`} 106 > 107 <div 108 className={`toast absolute right-2 w-max shadow-md px-3 py-1 flex flex-row gap-2 rounded-full border text-center ${
+5 -14
components/Toolbar/BlockToolbar.tsx
··· 2 import { ToolbarButton } from "."; 3 import { Separator, ShortcutKey } from "components/Layout"; 4 import { metaKey } from "src/utils/metaKey"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 import { useUIState } from "src/useUIState"; 7 import { LockBlockButton } from "./LockBlockButton"; 8 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 9 import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 10 import { DeleteSmall } from "components/Icons/DeleteSmall"; 11 12 export const BlockToolbar = (props: { 13 setToolbarState: ( ··· 66 67 const MoveBlockButtons = () => { 68 let { rep } = useReplicache(); 69 - const getSortedSelection = async () => { 70 - let selectedBlocks = useUIState.getState().selectedBlocks; 71 - let siblings = 72 - (await rep?.query((tx) => 73 - getBlocksWithType(tx, selectedBlocks[0].parent), 74 - )) || []; 75 - let sortedBlocks = siblings.filter((s) => 76 - selectedBlocks.find((sb) => sb.value === s.value), 77 - ); 78 - return [sortedBlocks, siblings]; 79 - }; 80 return ( 81 <> 82 <ToolbarButton 83 hiddenOnCanvas 84 onClick={async () => { 85 - let [sortedBlocks, siblings] = await getSortedSelection(); 86 if (sortedBlocks.length > 1) return; 87 let block = sortedBlocks[0]; 88 let previousBlock = ··· 139 <ToolbarButton 140 hiddenOnCanvas 141 onClick={async () => { 142 - let [sortedBlocks, siblings] = await getSortedSelection(); 143 if (sortedBlocks.length > 1) return; 144 let block = sortedBlocks[0]; 145 let nextBlock = siblings
··· 2 import { ToolbarButton } from "."; 3 import { Separator, ShortcutKey } from "components/Layout"; 4 import { metaKey } from "src/utils/metaKey"; 5 import { useUIState } from "src/useUIState"; 6 import { LockBlockButton } from "./LockBlockButton"; 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 10 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 11 12 export const BlockToolbar = (props: { 13 setToolbarState: ( ··· 66 67 const MoveBlockButtons = () => { 68 let { rep } = useReplicache(); 69 return ( 70 <> 71 <ToolbarButton 72 hiddenOnCanvas 73 onClick={async () => { 74 + if (!rep) return; 75 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 76 if (sortedBlocks.length > 1) return; 77 let block = sortedBlocks[0]; 78 let previousBlock = ··· 129 <ToolbarButton 130 hiddenOnCanvas 131 onClick={async () => { 132 + if (!rep) return; 133 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 134 if (sortedBlocks.length > 1) return; 135 let block = sortedBlocks[0]; 136 let nextBlock = siblings
+1 -1
components/Toolbar/MultiSelectToolbar.tsx
··· 8 import { LockBlockButton } from "./LockBlockButton"; 9 import { Props } from "components/Icons/Props"; 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { getSortedSelection } from "components/SelectionManager"; 12 13 export const MultiselectToolbar = (props: { 14 setToolbarState: (
··· 8 import { LockBlockButton } from "./LockBlockButton"; 9 import { Props } from "components/Icons/Props"; 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 12 13 export const MultiselectToolbar = (props: { 14 setToolbarState: (
+2 -1
components/Toolbar/index.tsx
··· 13 import { TextToolbar } from "./TextToolbar"; 14 import { BlockToolbar } from "./BlockToolbar"; 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 - import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock"; 17 import { TooltipButton } from "components/Buttons"; 18 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 19 import { useIsMobile } from "src/hooks/isMobile";
··· 13 import { TextToolbar } from "./TextToolbar"; 14 import { BlockToolbar } from "./BlockToolbar"; 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 + import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 + import { deleteBlock } from "src/utils/deleteBlock"; 18 import { TooltipButton } from "components/Buttons"; 19 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 20 import { useIsMobile } from "src/hooks/isMobile";
+1 -1
components/utils/AddLeafletToHomepage.tsx
··· 1 "use client"; 2 3 - import { addDocToHome } from "app/home/storage"; 4 import { useIdentityData } from "components/IdentityProvider"; 5 import { useEffect } from "react"; 6 import { useReplicache } from "src/replicache";
··· 1 "use client"; 2 3 + import { addDocToHome } from "app/(home-pages)/home/storage"; 4 import { useIdentityData } from "components/IdentityProvider"; 5 import { useEffect } from "react"; 6 import { useReplicache } from "src/replicache";
+1 -1
components/utils/UpdateLeafletTitle.tsx
··· 8 import { useEntity, useReplicache } from "src/replicache"; 9 import * as Y from "yjs"; 10 import * as base64 from "base64-js"; 11 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 12 import { useParams, useRouter, useSearchParams } from "next/navigation"; 13 import { focusBlock } from "src/utils/focusBlock"; 14 import { useIsMobile } from "src/hooks/isMobile";
··· 8 import { useEntity, useReplicache } from "src/replicache"; 9 import * as Y from "yjs"; 10 import * as base64 from "base64-js"; 11 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 12 import { useParams, useRouter, useSearchParams } from "next/navigation"; 13 import { focusBlock } from "src/utils/focusBlock"; 14 import { useIsMobile } from "src/hooks/isMobile";
+117 -79
drizzle/relations.ts
··· 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, bsky_profiles, publications, documents, comments_on_documents, entities, facts, entity_sets, permission_tokens, email_subscriptions_to_entity, email_auth_tokens, custom_domains, phone_rsvps_to_entity, custom_domain_routes, poll_votes_on_entity, subscribers_to_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, documents_in_publications, publication_domains, publication_subscriptions, leaflets_in_publications, permission_token_rights } from "./schema"; 3 4 - export const bsky_profilesRelations = relations(bsky_profiles, ({one, many}) => ({ 5 identity: one(identities, { 6 - fields: [bsky_profiles.did], 7 references: [identities.atp_did] 8 }), 9 - comments_on_documents: many(comments_on_documents), 10 })); 11 12 export const identitiesRelations = relations(identities, ({one, many}) => ({ 13 - bsky_profiles: many(bsky_profiles), 14 publications: many(publications), 15 permission_token: one(permission_tokens, { 16 fields: [identities.home_page], 17 references: [permission_tokens.id] 18 }), 19 - email_auth_tokens: many(email_auth_tokens), 20 custom_domains_identity: many(custom_domains, { 21 relationName: "custom_domains_identity_identities_email" 22 }), 23 custom_domains_identity_id: many(custom_domains, { 24 relationName: "custom_domains_identity_id_identities_id" 25 }), 26 subscribers_to_publications: many(subscribers_to_publications), 27 permission_token_on_homepages: many(permission_token_on_homepage), 28 publication_domains: many(publication_domains), ··· 37 subscribers_to_publications: many(subscribers_to_publications), 38 documents_in_publications: many(documents_in_publications), 39 publication_domains: many(publication_domains), 40 - publication_subscriptions: many(publication_subscriptions), 41 leaflets_in_publications: many(leaflets_in_publications), 42 })); 43 44 export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({ ··· 54 55 export const documentsRelations = relations(documents, ({many}) => ({ 56 comments_on_documents: many(comments_on_documents), 57 - document_mentions_in_bskies: many(document_mentions_in_bsky), 58 documents_in_publications: many(documents_in_publications), 59 leaflets_in_publications: many(leaflets_in_publications), 60 })); 61 62 - export const factsRelations = relations(facts, ({one}) => ({ 63 - entity: one(entities, { 64 - fields: [facts.entity], 65 - references: [entities.id] 66 }), 67 })); 68 69 export const entitiesRelations = relations(entities, ({one, many}) => ({ 70 - facts: many(facts), 71 entity_set: one(entity_sets, { 72 fields: [entities.set], 73 references: [entity_sets.id] 74 }), 75 - permission_tokens: many(permission_tokens), 76 - email_subscriptions_to_entities: many(email_subscriptions_to_entity), 77 - phone_rsvps_to_entities: many(phone_rsvps_to_entity), 78 poll_votes_on_entities_option_entity: many(poll_votes_on_entity, { 79 relationName: "poll_votes_on_entity_option_entity_entities_id" 80 }), 81 poll_votes_on_entities_poll_entity: many(poll_votes_on_entity, { 82 relationName: "poll_votes_on_entity_poll_entity_entities_id" 83 }), 84 })); 85 86 export const entity_setsRelations = relations(entity_sets, ({many}) => ({ ··· 88 permission_token_rights: many(permission_token_rights), 89 })); 90 91 export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ 92 entity: one(entities, { 93 fields: [permission_tokens.root_entity], 94 references: [entities.id] 95 }), 96 identities: many(identities), 97 - email_subscriptions_to_entities: many(email_subscriptions_to_entity), 98 custom_domain_routes_edit_permission_token: many(custom_domain_routes, { 99 relationName: "custom_domain_routes_edit_permission_token_permission_tokens_id" 100 }), 101 custom_domain_routes_view_permission_token: many(custom_domain_routes, { 102 relationName: "custom_domain_routes_view_permission_token_permission_tokens_id" 103 }), 104 permission_token_on_homepages: many(permission_token_on_homepage), 105 leaflets_in_publications: many(leaflets_in_publications), 106 permission_token_rights: many(permission_token_rights), 107 })); 108 109 - export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ 110 entity: one(entities, { 111 - fields: [email_subscriptions_to_entity.entity], 112 references: [entities.id] 113 }), 114 - permission_token: one(permission_tokens, { 115 - fields: [email_subscriptions_to_entity.token], 116 - references: [permission_tokens.id] 117 - }), 118 })); 119 120 - export const email_auth_tokensRelations = relations(email_auth_tokens, ({one}) => ({ 121 - identity: one(identities, { 122 - fields: [email_auth_tokens.identity], 123 - references: [identities.id] 124 }), 125 })); 126 127 export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ 128 identity_identity: one(identities, { 129 fields: [custom_domains.identity], 130 references: [identities.email], ··· 135 references: [identities.id], 136 relationName: "custom_domains_identity_id_identities_id" 137 }), 138 - custom_domain_routes: many(custom_domain_routes), 139 publication_domains: many(publication_domains), 140 })); 141 142 - export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ 143 entity: one(entities, { 144 - fields: [phone_rsvps_to_entity.entity], 145 references: [entities.id] 146 }), 147 })); 148 149 - export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ 150 - custom_domain: one(custom_domains, { 151 - fields: [custom_domain_routes.domain], 152 - references: [custom_domains.domain] 153 - }), 154 - permission_token_edit_permission_token: one(permission_tokens, { 155 - fields: [custom_domain_routes.edit_permission_token], 156 - references: [permission_tokens.id], 157 - relationName: "custom_domain_routes_edit_permission_token_permission_tokens_id" 158 }), 159 - permission_token_view_permission_token: one(permission_tokens, { 160 - fields: [custom_domain_routes.view_permission_token], 161 - references: [permission_tokens.id], 162 - relationName: "custom_domain_routes_view_permission_token_permission_tokens_id" 163 - }), 164 })); 165 166 - export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ 167 - entity_option_entity: one(entities, { 168 - fields: [poll_votes_on_entity.option_entity], 169 - references: [entities.id], 170 - relationName: "poll_votes_on_entity_option_entity_entities_id" 171 }), 172 - entity_poll_entity: one(entities, { 173 - fields: [poll_votes_on_entity.poll_entity], 174 - references: [entities.id], 175 - relationName: "poll_votes_on_entity_poll_entity_entities_id" 176 }), 177 })); 178 ··· 187 }), 188 })); 189 190 - export const document_mentions_in_bskyRelations = relations(document_mentions_in_bsky, ({one}) => ({ 191 - document: one(documents, { 192 - fields: [document_mentions_in_bsky.document], 193 - references: [documents.uri] 194 - }), 195 - bsky_post: one(bsky_posts, { 196 - fields: [document_mentions_in_bsky.uri], 197 - references: [bsky_posts.uri] 198 - }), 199 - })); 200 - 201 - export const bsky_postsRelations = relations(bsky_posts, ({many}) => ({ 202 - document_mentions_in_bskies: many(document_mentions_in_bsky), 203 - })); 204 - 205 export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ 206 identity: one(identities, { 207 fields: [permission_token_on_homepage.identity], ··· 224 }), 225 })); 226 227 export const publication_domainsRelations = relations(publication_domains, ({one}) => ({ 228 custom_domain: one(custom_domains, { 229 fields: [publication_domains.domain], ··· 239 }), 240 })); 241 242 - export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ 243 - identity: one(identities, { 244 - fields: [publication_subscriptions.identity], 245 - references: [identities.atp_did] 246 - }), 247 - publication: one(publications, { 248 - fields: [publication_subscriptions.publication], 249 - references: [publications.uri] 250 - }), 251 - })); 252 - 253 export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ 254 document: one(documents, { 255 fields: [leaflets_in_publications.doc], ··· 261 }), 262 publication: one(publications, { 263 fields: [leaflets_in_publications.publication], 264 references: [publications.uri] 265 }), 266 }));
··· 1 import { relations } from "drizzle-orm/relations"; 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 4 + export const notificationsRelations = relations(notifications, ({one}) => ({ 5 identity: one(identities, { 6 + fields: [notifications.recipient], 7 references: [identities.atp_did] 8 }), 9 })); 10 11 export const identitiesRelations = relations(identities, ({one, many}) => ({ 12 + notifications: many(notifications), 13 publications: many(publications), 14 + email_auth_tokens: many(email_auth_tokens), 15 + bsky_profiles: many(bsky_profiles), 16 permission_token: one(permission_tokens, { 17 fields: [identities.home_page], 18 references: [permission_tokens.id] 19 }), 20 custom_domains_identity: many(custom_domains, { 21 relationName: "custom_domains_identity_identities_email" 22 }), 23 custom_domains_identity_id: many(custom_domains, { 24 relationName: "custom_domains_identity_id_identities_id" 25 }), 26 + bsky_follows_follows: many(bsky_follows, { 27 + relationName: "bsky_follows_follows_identities_atp_did" 28 + }), 29 + bsky_follows_identity: many(bsky_follows, { 30 + relationName: "bsky_follows_identity_identities_atp_did" 31 + }), 32 subscribers_to_publications: many(subscribers_to_publications), 33 permission_token_on_homepages: many(permission_token_on_homepage), 34 publication_domains: many(publication_domains), ··· 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), 47 + publication_subscriptions: many(publication_subscriptions), 48 })); 49 50 export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({ ··· 60 61 export const documentsRelations = relations(documents, ({many}) => ({ 62 comments_on_documents: many(comments_on_documents), 63 documents_in_publications: many(documents_in_publications), 64 + document_mentions_in_bskies: many(document_mentions_in_bsky), 65 leaflets_in_publications: many(leaflets_in_publications), 66 })); 67 68 + export const bsky_profilesRelations = relations(bsky_profiles, ({one, many}) => ({ 69 + comments_on_documents: many(comments_on_documents), 70 + identity: one(identities, { 71 + fields: [bsky_profiles.did], 72 + references: [identities.atp_did] 73 }), 74 })); 75 76 export const entitiesRelations = relations(entities, ({one, many}) => ({ 77 entity_set: one(entity_sets, { 78 fields: [entities.set], 79 references: [entity_sets.id] 80 }), 81 + facts: many(facts), 82 poll_votes_on_entities_option_entity: many(poll_votes_on_entity, { 83 relationName: "poll_votes_on_entity_option_entity_entities_id" 84 }), 85 poll_votes_on_entities_poll_entity: many(poll_votes_on_entity, { 86 relationName: "poll_votes_on_entity_poll_entity_entities_id" 87 }), 88 + permission_tokens: many(permission_tokens), 89 + phone_rsvps_to_entities: many(phone_rsvps_to_entity), 90 + email_subscriptions_to_entities: many(email_subscriptions_to_entity), 91 })); 92 93 export const entity_setsRelations = relations(entity_sets, ({many}) => ({ ··· 95 permission_token_rights: many(permission_token_rights), 96 })); 97 98 + export const factsRelations = relations(facts, ({one}) => ({ 99 + entity: one(entities, { 100 + fields: [facts.entity], 101 + references: [entities.id] 102 + }), 103 + })); 104 + 105 + export const email_auth_tokensRelations = relations(email_auth_tokens, ({one}) => ({ 106 + identity: one(identities, { 107 + fields: [email_auth_tokens.identity], 108 + references: [identities.id] 109 + }), 110 + })); 111 + 112 + export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ 113 + entity_option_entity: one(entities, { 114 + fields: [poll_votes_on_entity.option_entity], 115 + references: [entities.id], 116 + relationName: "poll_votes_on_entity_option_entity_entities_id" 117 + }), 118 + entity_poll_entity: one(entities, { 119 + fields: [poll_votes_on_entity.poll_entity], 120 + references: [entities.id], 121 + relationName: "poll_votes_on_entity_poll_entity_entities_id" 122 + }), 123 + })); 124 + 125 export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ 126 entity: one(entities, { 127 fields: [permission_tokens.root_entity], 128 references: [entities.id] 129 }), 130 identities: many(identities), 131 custom_domain_routes_edit_permission_token: many(custom_domain_routes, { 132 relationName: "custom_domain_routes_edit_permission_token_permission_tokens_id" 133 }), 134 custom_domain_routes_view_permission_token: many(custom_domain_routes, { 135 relationName: "custom_domain_routes_view_permission_token_permission_tokens_id" 136 }), 137 + email_subscriptions_to_entities: many(email_subscriptions_to_entity), 138 permission_token_on_homepages: many(permission_token_on_homepage), 139 leaflets_in_publications: many(leaflets_in_publications), 140 permission_token_rights: many(permission_token_rights), 141 })); 142 143 + export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ 144 entity: one(entities, { 145 + fields: [phone_rsvps_to_entity.entity], 146 references: [entities.id] 147 }), 148 })); 149 150 + export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ 151 + custom_domain: one(custom_domains, { 152 + fields: [custom_domain_routes.domain], 153 + references: [custom_domains.domain] 154 + }), 155 + permission_token_edit_permission_token: one(permission_tokens, { 156 + fields: [custom_domain_routes.edit_permission_token], 157 + references: [permission_tokens.id], 158 + relationName: "custom_domain_routes_edit_permission_token_permission_tokens_id" 159 + }), 160 + permission_token_view_permission_token: one(permission_tokens, { 161 + fields: [custom_domain_routes.view_permission_token], 162 + references: [permission_tokens.id], 163 + relationName: "custom_domain_routes_view_permission_token_permission_tokens_id" 164 }), 165 })); 166 167 export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ 168 + custom_domain_routes: many(custom_domain_routes), 169 identity_identity: one(identities, { 170 fields: [custom_domains.identity], 171 references: [identities.email], ··· 176 references: [identities.id], 177 relationName: "custom_domains_identity_id_identities_id" 178 }), 179 publication_domains: many(publication_domains), 180 })); 181 182 + export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ 183 entity: one(entities, { 184 + fields: [email_subscriptions_to_entity.entity], 185 references: [entities.id] 186 }), 187 + permission_token: one(permission_tokens, { 188 + fields: [email_subscriptions_to_entity.token], 189 + references: [permission_tokens.id] 190 + }), 191 })); 192 193 + export const atp_poll_votesRelations = relations(atp_poll_votes, ({one}) => ({ 194 + atp_poll_record: one(atp_poll_records, { 195 + fields: [atp_poll_votes.poll_uri], 196 + references: [atp_poll_records.uri] 197 }), 198 + })); 199 + 200 + export const atp_poll_recordsRelations = relations(atp_poll_records, ({many}) => ({ 201 + atp_poll_votes: many(atp_poll_votes), 202 })); 203 204 + export const bsky_followsRelations = relations(bsky_follows, ({one}) => ({ 205 + identity_follows: one(identities, { 206 + fields: [bsky_follows.follows], 207 + references: [identities.atp_did], 208 + relationName: "bsky_follows_follows_identities_atp_did" 209 }), 210 + identity_identity: one(identities, { 211 + fields: [bsky_follows.identity], 212 + references: [identities.atp_did], 213 + relationName: "bsky_follows_identity_identities_atp_did" 214 }), 215 })); 216 ··· 225 }), 226 })); 227 228 export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ 229 identity: one(identities, { 230 fields: [permission_token_on_homepage.identity], ··· 247 }), 248 })); 249 250 + export const document_mentions_in_bskyRelations = relations(document_mentions_in_bsky, ({one}) => ({ 251 + document: one(documents, { 252 + fields: [document_mentions_in_bsky.document], 253 + references: [documents.uri] 254 + }), 255 + bsky_post: one(bsky_posts, { 256 + fields: [document_mentions_in_bsky.uri], 257 + references: [bsky_posts.uri] 258 + }), 259 + })); 260 + 261 + export const bsky_postsRelations = relations(bsky_posts, ({many}) => ({ 262 + document_mentions_in_bskies: many(document_mentions_in_bsky), 263 + })); 264 + 265 export const publication_domainsRelations = relations(publication_domains, ({one}) => ({ 266 custom_domain: one(custom_domains, { 267 fields: [publication_domains.domain], ··· 277 }), 278 })); 279 280 export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ 281 document: one(documents, { 282 fields: [leaflets_in_publications.doc], ··· 288 }), 289 publication: one(publications, { 290 fields: [leaflets_in_publications.publication], 291 + references: [publications.uri] 292 + }), 293 + })); 294 + 295 + export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ 296 + identity: one(identities, { 297 + fields: [publication_subscriptions.identity], 298 + references: [identities.atp_did] 299 + }), 300 + publication: one(publications, { 301 + fields: [publication_subscriptions.publication], 302 references: [publications.uri] 303 }), 304 }));
+154 -81
drizzle/schema.ts
··· 1 - import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, uuid, bigint, boolean, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 2 import { sql } from "drizzle-orm" 3 4 export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) 5 export const code_challenge_method = pgEnum("code_challenge_method", ['s256', 'plain']) 6 export const factor_status = pgEnum("factor_status", ['unverified', 'verified']) 7 - export const factor_type = pgEnum("factor_type", ['totp', 'webauthn']) 8 export const one_time_token_type = pgEnum("one_time_token_type", ['confirmation_token', 'reauthentication_token', 'recovery_token', 'email_change_token_new', 'email_change_token_current', 'phone_change_token']) 9 - export const request_status = pgEnum("request_status", ['PENDING', 'SUCCESS', 'ERROR']) 10 export const key_status = pgEnum("key_status", ['default', 'valid', 'invalid', 'expired']) 11 export const key_type = pgEnum("key_type", ['aead-ietf', 'aead-det', 'hmacsha512', 'hmacsha256', 'auth', 'shorthash', 'generichash', 'kdf', 'secretbox', 'secretstream', 'stream_xchacha20']) 12 export const rsvp_status = pgEnum("rsvp_status", ['GOING', 'NOT_GOING', 'MAYBE']) 13 export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'ERROR']) 14 export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) 15 16 17 export const oauth_state_store = pgTable("oauth_state_store", { ··· 19 state: jsonb("state").notNull(), 20 }); 21 22 - export const oauth_session_store = pgTable("oauth_session_store", { 23 - key: text("key").primaryKey().notNull(), 24 - session: jsonb("session").notNull(), 25 - }); 26 - 27 - export const bsky_profiles = pgTable("bsky_profiles", { 28 - did: text("did").primaryKey().notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 29 - record: jsonb("record").notNull(), 30 - indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 31 - handle: text("handle"), 32 }); 33 34 export const publications = pgTable("publications", { ··· 37 name: text("name").notNull(), 38 identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 39 record: jsonb("record"), 40 - }); 41 - 42 - export const bsky_posts = pgTable("bsky_posts", { 43 - uri: text("uri").primaryKey().notNull(), 44 - indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 45 - post_view: jsonb("post_view").notNull(), 46 - cid: text("cid").notNull(), 47 }); 48 49 export const comments_on_documents = pgTable("comments_on_documents", { ··· 54 profile: text("profile").references(() => bsky_profiles.did, { onDelete: "set null", onUpdate: "cascade" } ), 55 }); 56 57 export const facts = pgTable("facts", { 58 id: uuid("id").primaryKey().notNull(), 59 entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), ··· 63 updated_at: timestamp("updated_at", { mode: 'string' }), 64 // You can use { mode: "bigint" } if numbers are exceeding js number limitations 65 version: bigint("version", { mode: "number" }).default(0).notNull(), 66 - }); 67 - 68 - export const documents = pgTable("documents", { 69 - uri: text("uri").primaryKey().notNull(), 70 - data: jsonb("data").notNull(), 71 - indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 72 }); 73 74 export const replicache_clients = pgTable("replicache_clients", { ··· 76 client_group: text("client_group").notNull(), 77 // You can use { mode: "bigint" } if numbers are exceeding js number limitations 78 last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), 79 }); 80 81 - export const entities = pgTable("entities", { 82 - id: uuid("id").primaryKey().notNull(), 83 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 84 - set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), 85 }); 86 87 export const entity_sets = pgTable("entity_sets", { ··· 89 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 90 }); 91 92 export const permission_tokens = pgTable("permission_tokens", { 93 id: uuid("id").defaultRandom().primaryKey().notNull(), 94 root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), ··· 110 } 111 }); 112 113 - export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_entity", { 114 - id: uuid("id").defaultRandom().primaryKey().notNull(), 115 - entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade" } ), 116 - email: text("email").notNull(), 117 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 118 - token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 119 - confirmed: boolean("confirmed").default(false).notNull(), 120 - confirmation_code: text("confirmation_code").notNull(), 121 - }); 122 - 123 - export const email_auth_tokens = pgTable("email_auth_tokens", { 124 - id: uuid("id").defaultRandom().primaryKey().notNull(), 125 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 126 - confirmed: boolean("confirmed").default(false).notNull(), 127 - email: text("email"), 128 - confirmation_code: text("confirmation_code").notNull(), 129 - identity: uuid("identity").references(() => identities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 130 - }); 131 - 132 export const phone_number_auth_tokens = pgTable("phone_number_auth_tokens", { 133 id: uuid("id").defaultRandom().primaryKey().notNull(), 134 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), ··· 138 country_code: text("country_code").notNull(), 139 }); 140 141 - export const custom_domains = pgTable("custom_domains", { 142 - domain: text("domain").primaryKey().notNull(), 143 - identity: text("identity").default('').references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), 144 - confirmed: boolean("confirmed").notNull(), 145 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 146 - identity_id: uuid("identity_id").references(() => identities.id, { onDelete: "cascade" } ), 147 - }); 148 - 149 export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { 150 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 151 phone_number: text("phone_number").notNull(), ··· 172 }, 173 (table) => { 174 return { 175 custom_domain_routes_domain_route_key: unique("custom_domain_routes_domain_route_key").on(table.domain, table.route), 176 } 177 }); 178 179 - export const poll_votes_on_entity = pgTable("poll_votes_on_entity", { 180 id: uuid("id").defaultRandom().primaryKey().notNull(), 181 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 182 - poll_entity: uuid("poll_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 183 - option_entity: uuid("option_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 184 - voter_token: uuid("voter_token").notNull(), 185 }); 186 187 - export const subscribers_to_publications = pgTable("subscribers_to_publications", { 188 - identity: text("identity").notNull().references(() => identities.email, { onUpdate: "cascade" } ), 189 - publication: text("publication").notNull().references(() => publications.uri), 190 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 191 }, 192 (table) => { 193 return { 194 - subscribers_to_publications_pkey: primaryKey({ columns: [table.identity, table.publication], name: "subscribers_to_publications_pkey"}), 195 } 196 }); 197 198 - export const document_mentions_in_bsky = pgTable("document_mentions_in_bsky", { 199 - uri: text("uri").notNull().references(() => bsky_posts.uri, { onDelete: "cascade" } ), 200 - link: text("link").notNull(), 201 - document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade" } ), 202 }, 203 (table) => { 204 return { 205 - document_mentions_in_bsky_pkey: primaryKey({ columns: [table.uri, table.document], name: "document_mentions_in_bsky_pkey"}), 206 } 207 }); 208 ··· 224 }, 225 (table) => { 226 return { 227 documents_in_publications_pkey: primaryKey({ columns: [table.publication, table.document], name: "documents_in_publications_pkey"}), 228 } 229 }); 230 231 - export const publication_domains = pgTable("publication_domains", { 232 - publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 233 - domain: text("domain").notNull().references(() => custom_domains.domain, { onDelete: "cascade" } ), 234 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 235 - identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade", onUpdate: "cascade" } ), 236 }, 237 (table) => { 238 return { 239 - publication_domains_pkey: primaryKey({ columns: [table.publication, table.domain], name: "publication_domains_pkey"}), 240 } 241 }); 242 243 - export const publication_subscriptions = pgTable("publication_subscriptions", { 244 publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 245 - identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 246 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 247 - record: jsonb("record").notNull(), 248 - uri: text("uri").notNull(), 249 }, 250 (table) => { 251 return { 252 - publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 253 - publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 254 } 255 }); 256 ··· 263 }, 264 (table) => { 265 return { 266 leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), 267 } 268 }); 269 270 export const permission_token_rights = pgTable("permission_token_rights", { 271 token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 272 entity_set: uuid("entity_set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), ··· 278 }, 279 (table) => { 280 return { 281 permission_token_rights_pkey: primaryKey({ columns: [table.token, table.entity_set], name: "permission_token_rights_pkey"}), 282 } 283 });
··· 1 + import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 2 import { sql } from "drizzle-orm" 3 4 export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) 5 export const code_challenge_method = pgEnum("code_challenge_method", ['s256', 'plain']) 6 export const factor_status = pgEnum("factor_status", ['unverified', 'verified']) 7 + export const factor_type = pgEnum("factor_type", ['totp', 'webauthn', 'phone']) 8 + export const oauth_authorization_status = pgEnum("oauth_authorization_status", ['pending', 'approved', 'denied', 'expired']) 9 + export const oauth_client_type = pgEnum("oauth_client_type", ['public', 'confidential']) 10 + export const oauth_registration_type = pgEnum("oauth_registration_type", ['dynamic', 'manual']) 11 + export const oauth_response_type = pgEnum("oauth_response_type", ['code']) 12 export const one_time_token_type = pgEnum("one_time_token_type", ['confirmation_token', 'reauthentication_token', 'recovery_token', 'email_change_token_new', 'email_change_token_current', 'phone_change_token']) 13 export const key_status = pgEnum("key_status", ['default', 'valid', 'invalid', 'expired']) 14 export const key_type = pgEnum("key_type", ['aead-ietf', 'aead-det', 'hmacsha512', 'hmacsha256', 'auth', 'shorthash', 'generichash', 'kdf', 'secretbox', 'secretstream', 'stream_xchacha20']) 15 export const rsvp_status = pgEnum("rsvp_status", ['GOING', 'NOT_GOING', 'MAYBE']) 16 export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'ERROR']) 17 export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) 18 + export const buckettype = pgEnum("buckettype", ['STANDARD', 'ANALYTICS', 'VECTOR']) 19 20 21 export const oauth_state_store = pgTable("oauth_state_store", { ··· 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(), 32 }); 33 34 export const publications = pgTable("publications", { ··· 37 name: text("name").notNull(), 38 identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 39 record: jsonb("record"), 40 + }, 41 + (table) => { 42 + return { 43 + identity_did_idx: index("publications_identity_did_idx").on(table.identity_did), 44 + } 45 }); 46 47 export const comments_on_documents = pgTable("comments_on_documents", { ··· 52 profile: text("profile").references(() => bsky_profiles.did, { onDelete: "set null", onUpdate: "cascade" } ), 53 }); 54 55 + export const entities = pgTable("entities", { 56 + id: uuid("id").primaryKey().notNull(), 57 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 58 + set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), 59 + }, 60 + (table) => { 61 + return { 62 + set_idx: index("entities_set_idx").on(table.set), 63 + } 64 + }); 65 + 66 export const facts = pgTable("facts", { 67 id: uuid("id").primaryKey().notNull(), 68 entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), ··· 72 updated_at: timestamp("updated_at", { mode: 'string' }), 73 // You can use { mode: "bigint" } if numbers are exceeding js number limitations 74 version: bigint("version", { mode: "number" }).default(0).notNull(), 75 + }, 76 + (table) => { 77 + return { 78 + entity_idx: index("facts_entity_idx").on(table.entity), 79 + } 80 }); 81 82 export const replicache_clients = pgTable("replicache_clients", { ··· 84 client_group: text("client_group").notNull(), 85 // You can use { mode: "bigint" } if numbers are exceeding js number limitations 86 last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), 87 + }, 88 + (table) => { 89 + return { 90 + client_group_idx: index("replicache_clients_client_group_idx").on(table.client_group), 91 + } 92 }); 93 94 + export const email_auth_tokens = pgTable("email_auth_tokens", { 95 + id: uuid("id").defaultRandom().primaryKey().notNull(), 96 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 97 + confirmed: boolean("confirmed").default(false).notNull(), 98 + email: text("email"), 99 + confirmation_code: text("confirmation_code").notNull(), 100 + identity: uuid("identity").references(() => identities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 101 + }); 102 + 103 + export const bsky_posts = pgTable("bsky_posts", { 104 + uri: text("uri").primaryKey().notNull(), 105 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 106 + post_view: jsonb("post_view").notNull(), 107 + cid: text("cid").notNull(), 108 + }); 109 + 110 + export const bsky_profiles = pgTable("bsky_profiles", { 111 + did: text("did").primaryKey().notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 112 + record: jsonb("record").notNull(), 113 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 114 + handle: text("handle"), 115 }); 116 117 export const entity_sets = pgTable("entity_sets", { ··· 119 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 120 }); 121 122 + export const poll_votes_on_entity = pgTable("poll_votes_on_entity", { 123 + id: uuid("id").defaultRandom().primaryKey().notNull(), 124 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 125 + poll_entity: uuid("poll_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 126 + option_entity: uuid("option_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 127 + voter_token: uuid("voter_token").notNull(), 128 + }); 129 + 130 export const permission_tokens = pgTable("permission_tokens", { 131 id: uuid("id").defaultRandom().primaryKey().notNull(), 132 root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), ··· 148 } 149 }); 150 151 export const phone_number_auth_tokens = pgTable("phone_number_auth_tokens", { 152 id: uuid("id").defaultRandom().primaryKey().notNull(), 153 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), ··· 157 country_code: text("country_code").notNull(), 158 }); 159 160 export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { 161 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 162 phone_number: text("phone_number").notNull(), ··· 183 }, 184 (table) => { 185 return { 186 + edit_permission_token_idx: index("custom_domain_routes_edit_permission_token_idx").on(table.edit_permission_token), 187 custom_domain_routes_domain_route_key: unique("custom_domain_routes_domain_route_key").on(table.domain, table.route), 188 } 189 }); 190 191 + export const custom_domains = pgTable("custom_domains", { 192 + domain: text("domain").primaryKey().notNull(), 193 + identity: text("identity").default('').references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), 194 + confirmed: boolean("confirmed").notNull(), 195 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 196 + identity_id: uuid("identity_id").references(() => identities.id, { onDelete: "cascade" } ), 197 + }); 198 + 199 + export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_entity", { 200 id: uuid("id").defaultRandom().primaryKey().notNull(), 201 + entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade" } ), 202 + email: text("email").notNull(), 203 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 204 + token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 205 + confirmed: boolean("confirmed").default(false).notNull(), 206 + confirmation_code: text("confirmation_code").notNull(), 207 + }); 208 + 209 + export const documents = pgTable("documents", { 210 + uri: text("uri").primaryKey().notNull(), 211 + data: jsonb("data").notNull(), 212 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 213 + }); 214 + 215 + export const atp_poll_votes = pgTable("atp_poll_votes", { 216 + uri: text("uri").primaryKey().notNull(), 217 + record: jsonb("record").notNull(), 218 + voter_did: text("voter_did").notNull(), 219 + poll_uri: text("poll_uri").notNull().references(() => atp_poll_records.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 220 + poll_cid: text("poll_cid").notNull(), 221 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 222 + }, 223 + (table) => { 224 + return { 225 + poll_uri_idx: index("atp_poll_votes_poll_uri_idx").on(table.poll_uri), 226 + voter_did_idx: index("atp_poll_votes_voter_did_idx").on(table.voter_did), 227 + } 228 }); 229 230 + export const atp_poll_records = pgTable("atp_poll_records", { 231 + uri: text("uri").primaryKey().notNull(), 232 + cid: text("cid").notNull(), 233 + record: jsonb("record").notNull(), 234 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 235 + }); 236 + 237 + export const oauth_session_store = pgTable("oauth_session_store", { 238 + key: text("key").primaryKey().notNull(), 239 + session: jsonb("session").notNull(), 240 + }); 241 + 242 + export const bsky_follows = pgTable("bsky_follows", { 243 + identity: text("identity").default('').notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 244 + follows: text("follows").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 245 }, 246 (table) => { 247 return { 248 + bsky_follows_pkey: primaryKey({ columns: [table.identity, table.follows], name: "bsky_follows_pkey"}), 249 } 250 }); 251 252 + export const subscribers_to_publications = pgTable("subscribers_to_publications", { 253 + identity: text("identity").notNull().references(() => identities.email, { onUpdate: "cascade" } ), 254 + publication: text("publication").notNull().references(() => publications.uri), 255 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 256 }, 257 (table) => { 258 return { 259 + subscribers_to_publications_pkey: primaryKey({ columns: [table.identity, table.publication], name: "subscribers_to_publications_pkey"}), 260 } 261 }); 262 ··· 278 }, 279 (table) => { 280 return { 281 + publication_idx: index("documents_in_publications_publication_idx").on(table.publication), 282 documents_in_publications_pkey: primaryKey({ columns: [table.publication, table.document], name: "documents_in_publications_pkey"}), 283 } 284 }); 285 286 + export const document_mentions_in_bsky = pgTable("document_mentions_in_bsky", { 287 + uri: text("uri").notNull().references(() => bsky_posts.uri, { onDelete: "cascade" } ), 288 + link: text("link").notNull(), 289 + document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade" } ), 290 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 291 }, 292 (table) => { 293 return { 294 + document_mentions_in_bsky_pkey: primaryKey({ columns: [table.uri, table.document], name: "document_mentions_in_bsky_pkey"}), 295 } 296 }); 297 298 + export const publication_domains = pgTable("publication_domains", { 299 publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 300 + domain: text("domain").notNull().references(() => custom_domains.domain, { onDelete: "cascade" } ), 301 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 302 + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade", onUpdate: "cascade" } ), 303 }, 304 (table) => { 305 return { 306 + publication_idx: index("publication_domains_publication_idx").on(table.publication), 307 + publication_domains_pkey: primaryKey({ columns: [table.publication, table.domain], name: "publication_domains_pkey"}), 308 } 309 }); 310 ··· 317 }, 318 (table) => { 319 return { 320 + leaflet_idx: index("leaflets_in_publications_leaflet_idx").on(table.leaflet), 321 + publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), 322 leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), 323 } 324 }); 325 326 + export const publication_subscriptions = pgTable("publication_subscriptions", { 327 + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 328 + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 329 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 330 + record: jsonb("record").notNull(), 331 + uri: text("uri").notNull(), 332 + }, 333 + (table) => { 334 + return { 335 + publication_idx: index("publication_subscriptions_publication_idx").on(table.publication), 336 + publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 337 + publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 338 + } 339 + }); 340 + 341 export const permission_token_rights = pgTable("permission_token_rights", { 342 token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 343 entity_set: uuid("entity_set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), ··· 349 }, 350 (table) => { 351 return { 352 + token_idx: index("permission_token_rights_token_idx").on(table.token), 353 + entity_set_idx: index("permission_token_rights_entity_set_idx").on(table.entity_set), 354 permission_token_rights_pkey: primaryKey({ columns: [table.token, table.entity_set], name: "permission_token_rights_pkey"}), 355 } 356 });
+95 -39
feeds/index.ts
··· 4 import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; 5 import { supabaseServerClient } from "supabase/serverClient"; 6 import { PubLeafletDocument } from "lexicons/api"; 7 8 const app = new Hono(); 9 ··· 27 28 app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => { 29 let auth = await validateAuth(c.req, serviceDid); 30 - if (!auth) return c.json({ feed: [] }); 31 let cursor = c.req.query("cursor"); 32 let limit = parseInt(c.req.query("limit") || "10"); 33 34 - let { data: publications } = await supabaseServerClient 35 - .from("publication_subscriptions") 36 - .select(`publications(*, documents_in_publications(documents(*)))`) 37 - .eq("identity", auth); 38 39 - const allPosts = (publications || []) 40 - .flatMap((pub) => { 41 - let posts = pub.publications?.documents_in_publications || []; 42 - return posts; 43 - }) 44 - .sort((a, b) => { 45 - let aRecord = a.documents?.data! as PubLeafletDocument.Record; 46 - let bRecord = b.documents?.data! as PubLeafletDocument.Record; 47 - const aDate = aRecord.publishedAt 48 - ? new Date(aRecord.publishedAt) 49 - : new Date(0); 50 - const bDate = bRecord.publishedAt 51 - ? new Date(bRecord.publishedAt) 52 - : new Date(0); 53 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 54 }); 55 - let posts; 56 - if (!cursor) { 57 - posts = allPosts.slice(0, 25); 58 } else { 59 - let date = cursor.split("::")[0]; 60 - let uri = cursor.split("::")[1]; 61 - posts = allPosts 62 - .filter((p) => { 63 - if (!p.documents?.data) return false; 64 - let record = p.documents.data as PubLeafletDocument.Record; 65 - if (!record.publishedAt) return false; 66 - return record.publishedAt <= date && uri !== p.documents?.uri; 67 - }) 68 - .slice(0, 25); 69 } 70 71 let lastPost = posts[posts.length - 1]; 72 - let lastRecord = lastPost?.documents?.data! as PubLeafletDocument.Record; 73 - let newCursor = lastRecord 74 - ? `${lastRecord.publishedAt}::${lastPost.documents?.uri}` 75 - : null; 76 return c.json({ 77 cursor: newCursor || cursor, 78 feed: posts.flatMap((p) => { 79 - if (!p.documents?.data) return []; 80 - let record = p.documents.data as PubLeafletDocument.Record; 81 if (!record.postRef) return []; 82 return { post: record.postRef.uri }; 83 }),
··· 4 import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; 5 import { supabaseServerClient } from "supabase/serverClient"; 6 import { PubLeafletDocument } from "lexicons/api"; 7 + import { inngest } from "app/api/inngest/client"; 8 + import { AtUri } from "@atproto/api"; 9 10 const app = new Hono(); 11 ··· 29 30 app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => { 31 let auth = await validateAuth(c.req, serviceDid); 32 + let feed = c.req.query("feed"); 33 + if (!auth || !feed) return c.json({ feed: [] }); 34 let cursor = c.req.query("cursor"); 35 + let parsedCursor; 36 + if (cursor) { 37 + let date = cursor.split("::")[0]; 38 + let uri = cursor.split("::")[1]; 39 + parsedCursor = { date, uri }; 40 + } 41 let limit = parseInt(c.req.query("limit") || "10"); 42 + let feedAtURI = new AtUri(feed); 43 + let posts; 44 + let query; 45 + if (feedAtURI.rkey == "bsky-leaflet-quotes") { 46 + let query = supabaseServerClient 47 + .from("document_mentions_in_bsky") 48 + .select("*") 49 + .order("indexed_at", { ascending: false }) 50 + .order("uri", { ascending: false }) 51 + .limit(25); 52 + if (parsedCursor) 53 + query = query.or( 54 + `indexed_at.lt.${parsedCursor.date},and(indexed_at.eq.${parsedCursor.date},uri.lt.${parsedCursor.uri})`, 55 + ); 56 57 + let { data, error } = await query; 58 + let posts = data || []; 59 60 + let lastPost = posts[posts.length - 1]; 61 + let newCursor = lastPost ? `${lastPost.indexed_at}::${lastPost.uri}` : null; 62 + return c.json({ 63 + cursor: newCursor || cursor, 64 + feed: posts.flatMap((p) => { 65 + return { post: p.uri }; 66 + }), 67 }); 68 + } 69 + if (feedAtURI.rkey === "bsky-follows-leaflets") { 70 + if (!cursor) { 71 + console.log("Sending event"); 72 + await inngest.send({ name: "feeds/index-follows", data: { did: auth } }); 73 + } 74 + query = supabaseServerClient 75 + .from("documents") 76 + .select( 77 + `*, 78 + documents_in_publications!inner( 79 + publications!inner(*, 80 + identities!publications_identity_did_fkey!inner( 81 + bsky_follows!bsky_follows_follows_fkey!inner(*) 82 + ) 83 + ) 84 + )`, 85 + ) 86 + .eq( 87 + "documents_in_publications.publications.identities.bsky_follows.identity", 88 + auth, 89 + ); 90 + } else if (feedAtURI.rkey === "all-leaflets") { 91 + query = supabaseServerClient 92 + .from("documents") 93 + .select( 94 + `*, 95 + documents_in_publications(publications(*))`, 96 + ) 97 + .or( 98 + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 99 + { referencedTable: "documents_in_publications.publications" }, 100 + ); 101 } else { 102 + //the default subscription feed 103 + query = supabaseServerClient 104 + .from("documents") 105 + .select( 106 + `*, 107 + documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 108 + ) 109 + .eq( 110 + "documents_in_publications.publications.publication_subscriptions.identity", 111 + auth, 112 + ); 113 } 114 + query = query 115 + .not("data -> postRef", "is", null) 116 + .order("indexed_at", { ascending: false }) 117 + .order("uri", { ascending: false }) 118 + .limit(25); 119 + if (parsedCursor) 120 + query = query.or( 121 + `indexed_at.lt.${parsedCursor.date},and(indexed_at.eq.${parsedCursor.date},uri.lt.${parsedCursor.uri})`, 122 + ); 123 + 124 + let { data, error } = await query; 125 + console.log(error); 126 + posts = data; 127 + 128 + posts = posts || []; 129 130 let lastPost = posts[posts.length - 1]; 131 + let newCursor = lastPost ? `${lastPost.indexed_at}::${lastPost.uri}` : null; 132 return c.json({ 133 cursor: newCursor || cursor, 134 feed: posts.flatMap((p) => { 135 + if (!p.data) return []; 136 + let record = p.data as PubLeafletDocument.Record; 137 if (!record.postRef) return []; 138 return { post: record.postRef.uri }; 139 }),
+193
lexicons/api/index.ts
··· 25 import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' 26 import * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote' 27 import * as PubLeafletBlocksBskyPost from './types/pub/leaflet/blocks/bskyPost' 28 import * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code' 29 import * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 30 import * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/horizontalRule' 31 import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 32 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 33 import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 34 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 35 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 36 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' 37 import * as PubLeafletComment from './types/pub/leaflet/comment' 38 import * as PubLeafletDocument from './types/pub/leaflet/document' 39 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 40 import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 41 import * as PubLeafletPublication from './types/pub/leaflet/publication' 42 import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 43 import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' ··· 59 export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' 60 export * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote' 61 export * as PubLeafletBlocksBskyPost from './types/pub/leaflet/blocks/bskyPost' 62 export * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code' 63 export * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 64 export * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/horizontalRule' 65 export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 66 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 67 export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 68 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 69 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 70 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' 71 export * as PubLeafletComment from './types/pub/leaflet/comment' 72 export * as PubLeafletDocument from './types/pub/leaflet/document' 73 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 74 export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 75 export * as PubLeafletPublication from './types/pub/leaflet/publication' 76 export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 77 export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 78 export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 79 80 export const PUB_LEAFLET_PAGES = { 81 LinearDocumentTextAlignLeft: 'pub.leaflet.pages.linearDocument#textAlignLeft', 82 LinearDocumentTextAlignCenter: 83 'pub.leaflet.pages.linearDocument#textAlignCenter', 84 LinearDocumentTextAlignRight: 85 'pub.leaflet.pages.linearDocument#textAlignRight', 86 } 87 88 export class AtpBaseClient extends XrpcClient { ··· 378 blocks: PubLeafletBlocksNS 379 graph: PubLeafletGraphNS 380 pages: PubLeafletPagesNS 381 richtext: PubLeafletRichtextNS 382 theme: PubLeafletThemeNS 383 ··· 386 this.blocks = new PubLeafletBlocksNS(client) 387 this.graph = new PubLeafletGraphNS(client) 388 this.pages = new PubLeafletPagesNS(client) 389 this.richtext = new PubLeafletRichtextNS(client) 390 this.theme = new PubLeafletThemeNS(client) 391 this.comment = new PubLeafletCommentRecord(client) ··· 500 501 constructor(client: XrpcClient) { 502 this._client = client 503 } 504 } 505
··· 25 import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' 26 import * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote' 27 import * as PubLeafletBlocksBskyPost from './types/pub/leaflet/blocks/bskyPost' 28 + import * as PubLeafletBlocksButton from './types/pub/leaflet/blocks/button' 29 import * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code' 30 import * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 31 import * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/horizontalRule' 32 import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 33 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 34 import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 35 + import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 36 + import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 37 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 38 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 39 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' 40 import * as PubLeafletComment from './types/pub/leaflet/comment' 41 import * as PubLeafletDocument from './types/pub/leaflet/document' 42 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 43 + import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 44 import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 45 + import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 46 + import * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' 47 import * as PubLeafletPublication from './types/pub/leaflet/publication' 48 import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 49 import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' ··· 65 export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' 66 export * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote' 67 export * as PubLeafletBlocksBskyPost from './types/pub/leaflet/blocks/bskyPost' 68 + export * as PubLeafletBlocksButton from './types/pub/leaflet/blocks/button' 69 export * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code' 70 export * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 71 export * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/horizontalRule' 72 export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 73 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 74 export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 75 + export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 76 + export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 77 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 78 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 79 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' 80 export * as PubLeafletComment from './types/pub/leaflet/comment' 81 export * as PubLeafletDocument from './types/pub/leaflet/document' 82 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 83 + export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' 84 export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 85 + export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 86 + export * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' 87 export * as PubLeafletPublication from './types/pub/leaflet/publication' 88 export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 89 export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 90 export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 91 92 export const PUB_LEAFLET_PAGES = { 93 + CanvasTextAlignLeft: 'pub.leaflet.pages.canvas#textAlignLeft', 94 + CanvasTextAlignCenter: 'pub.leaflet.pages.canvas#textAlignCenter', 95 + CanvasTextAlignRight: 'pub.leaflet.pages.canvas#textAlignRight', 96 LinearDocumentTextAlignLeft: 'pub.leaflet.pages.linearDocument#textAlignLeft', 97 LinearDocumentTextAlignCenter: 98 'pub.leaflet.pages.linearDocument#textAlignCenter', 99 LinearDocumentTextAlignRight: 100 'pub.leaflet.pages.linearDocument#textAlignRight', 101 + LinearDocumentTextAlignJustify: 102 + 'pub.leaflet.pages.linearDocument#textAlignJustify', 103 } 104 105 export class AtpBaseClient extends XrpcClient { ··· 395 blocks: PubLeafletBlocksNS 396 graph: PubLeafletGraphNS 397 pages: PubLeafletPagesNS 398 + poll: PubLeafletPollNS 399 richtext: PubLeafletRichtextNS 400 theme: PubLeafletThemeNS 401 ··· 404 this.blocks = new PubLeafletBlocksNS(client) 405 this.graph = new PubLeafletGraphNS(client) 406 this.pages = new PubLeafletPagesNS(client) 407 + this.poll = new PubLeafletPollNS(client) 408 this.richtext = new PubLeafletRichtextNS(client) 409 this.theme = new PubLeafletThemeNS(client) 410 this.comment = new PubLeafletCommentRecord(client) ··· 519 520 constructor(client: XrpcClient) { 521 this._client = client 522 + } 523 + } 524 + 525 + export class PubLeafletPollNS { 526 + _client: XrpcClient 527 + definition: PubLeafletPollDefinitionRecord 528 + vote: PubLeafletPollVoteRecord 529 + 530 + constructor(client: XrpcClient) { 531 + this._client = client 532 + this.definition = new PubLeafletPollDefinitionRecord(client) 533 + this.vote = new PubLeafletPollVoteRecord(client) 534 + } 535 + } 536 + 537 + export class PubLeafletPollDefinitionRecord { 538 + _client: XrpcClient 539 + 540 + constructor(client: XrpcClient) { 541 + this._client = client 542 + } 543 + 544 + async list( 545 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 546 + ): Promise<{ 547 + cursor?: string 548 + records: { uri: string; value: PubLeafletPollDefinition.Record }[] 549 + }> { 550 + const res = await this._client.call('com.atproto.repo.listRecords', { 551 + collection: 'pub.leaflet.poll.definition', 552 + ...params, 553 + }) 554 + return res.data 555 + } 556 + 557 + async get( 558 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 559 + ): Promise<{ 560 + uri: string 561 + cid: string 562 + value: PubLeafletPollDefinition.Record 563 + }> { 564 + const res = await this._client.call('com.atproto.repo.getRecord', { 565 + collection: 'pub.leaflet.poll.definition', 566 + ...params, 567 + }) 568 + return res.data 569 + } 570 + 571 + async create( 572 + params: OmitKey< 573 + ComAtprotoRepoCreateRecord.InputSchema, 574 + 'collection' | 'record' 575 + >, 576 + record: Un$Typed<PubLeafletPollDefinition.Record>, 577 + headers?: Record<string, string>, 578 + ): Promise<{ uri: string; cid: string }> { 579 + const collection = 'pub.leaflet.poll.definition' 580 + const res = await this._client.call( 581 + 'com.atproto.repo.createRecord', 582 + undefined, 583 + { collection, ...params, record: { ...record, $type: collection } }, 584 + { encoding: 'application/json', headers }, 585 + ) 586 + return res.data 587 + } 588 + 589 + async put( 590 + params: OmitKey< 591 + ComAtprotoRepoPutRecord.InputSchema, 592 + 'collection' | 'record' 593 + >, 594 + record: Un$Typed<PubLeafletPollDefinition.Record>, 595 + headers?: Record<string, string>, 596 + ): Promise<{ uri: string; cid: string }> { 597 + const collection = 'pub.leaflet.poll.definition' 598 + const res = await this._client.call( 599 + 'com.atproto.repo.putRecord', 600 + undefined, 601 + { collection, ...params, record: { ...record, $type: collection } }, 602 + { encoding: 'application/json', headers }, 603 + ) 604 + return res.data 605 + } 606 + 607 + async delete( 608 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 609 + headers?: Record<string, string>, 610 + ): Promise<void> { 611 + await this._client.call( 612 + 'com.atproto.repo.deleteRecord', 613 + undefined, 614 + { collection: 'pub.leaflet.poll.definition', ...params }, 615 + { headers }, 616 + ) 617 + } 618 + } 619 + 620 + export class PubLeafletPollVoteRecord { 621 + _client: XrpcClient 622 + 623 + constructor(client: XrpcClient) { 624 + this._client = client 625 + } 626 + 627 + async list( 628 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 629 + ): Promise<{ 630 + cursor?: string 631 + records: { uri: string; value: PubLeafletPollVote.Record }[] 632 + }> { 633 + const res = await this._client.call('com.atproto.repo.listRecords', { 634 + collection: 'pub.leaflet.poll.vote', 635 + ...params, 636 + }) 637 + return res.data 638 + } 639 + 640 + async get( 641 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 642 + ): Promise<{ uri: string; cid: string; value: PubLeafletPollVote.Record }> { 643 + const res = await this._client.call('com.atproto.repo.getRecord', { 644 + collection: 'pub.leaflet.poll.vote', 645 + ...params, 646 + }) 647 + return res.data 648 + } 649 + 650 + async create( 651 + params: OmitKey< 652 + ComAtprotoRepoCreateRecord.InputSchema, 653 + 'collection' | 'record' 654 + >, 655 + record: Un$Typed<PubLeafletPollVote.Record>, 656 + headers?: Record<string, string>, 657 + ): Promise<{ uri: string; cid: string }> { 658 + const collection = 'pub.leaflet.poll.vote' 659 + const res = await this._client.call( 660 + 'com.atproto.repo.createRecord', 661 + undefined, 662 + { collection, ...params, record: { ...record, $type: collection } }, 663 + { encoding: 'application/json', headers }, 664 + ) 665 + return res.data 666 + } 667 + 668 + async put( 669 + params: OmitKey< 670 + ComAtprotoRepoPutRecord.InputSchema, 671 + 'collection' | 'record' 672 + >, 673 + record: Un$Typed<PubLeafletPollVote.Record>, 674 + headers?: Record<string, string>, 675 + ): Promise<{ uri: string; cid: string }> { 676 + const collection = 'pub.leaflet.poll.vote' 677 + const res = await this._client.call( 678 + 'com.atproto.repo.putRecord', 679 + undefined, 680 + { collection, ...params, record: { ...record, $type: collection } }, 681 + { encoding: 'application/json', headers }, 682 + ) 683 + return res.data 684 + } 685 + 686 + async delete( 687 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 688 + headers?: Record<string, string>, 689 + ): Promise<void> { 690 + await this._client.call( 691 + 'com.atproto.repo.deleteRecord', 692 + undefined, 693 + { collection: 'pub.leaflet.poll.vote', ...params }, 694 + { headers }, 695 + ) 696 } 697 } 698
+279 -3
lexicons/api/lexicons.ts
··· 1052 }, 1053 }, 1054 }, 1055 PubLeafletBlocksCode: { 1056 lexicon: 1, 1057 id: 'pub.leaflet.blocks.code', ··· 1185 }, 1186 }, 1187 }, 1188 PubLeafletBlocksText: { 1189 lexicon: 1, 1190 id: 'pub.leaflet.blocks.text', ··· 1310 ref: 'lex:pub.leaflet.richtext.facet', 1311 }, 1312 }, 1313 attachment: { 1314 type: 'union', 1315 refs: ['lex:pub.leaflet.comment#linearDocumentQuote'], ··· 1355 description: 'Record containing a document', 1356 record: { 1357 type: 'object', 1358 - required: ['pages', 'author', 'title', 'publication'], 1359 properties: { 1360 title: { 1361 type: 'string', ··· 1383 type: 'string', 1384 format: 'at-identifier', 1385 }, 1386 pages: { 1387 type: 'array', 1388 items: { 1389 type: 'union', 1390 - refs: ['lex:pub.leaflet.pages.linearDocument'], 1391 }, 1392 }, 1393 }, ··· 1416 }, 1417 }, 1418 }, 1419 PubLeafletPagesLinearDocument: { 1420 lexicon: 1, 1421 id: 'pub.leaflet.pages.linearDocument', 1422 defs: { 1423 main: { 1424 type: 'object', 1425 properties: { 1426 blocks: { 1427 type: 'array', 1428 items: { ··· 1450 'lex:pub.leaflet.blocks.code', 1451 'lex:pub.leaflet.blocks.horizontalRule', 1452 'lex:pub.leaflet.blocks.bskyPost', 1453 ], 1454 }, 1455 alignment: { ··· 1470 type: 'token', 1471 }, 1472 textAlignRight: { 1473 type: 'token', 1474 }, 1475 quote: { ··· 1503 }, 1504 }, 1505 }, 1506 PubLeafletPublication: { 1507 lexicon: 1, 1508 id: 'pub.leaflet.publication', ··· 1521 }, 1522 base_path: { 1523 type: 'string', 1524 - format: 'uri', 1525 }, 1526 description: { 1527 type: 'string', ··· 1625 type: 'union', 1626 refs: [ 1627 'lex:pub.leaflet.richtext.facet#link', 1628 'lex:pub.leaflet.richtext.facet#code', 1629 'lex:pub.leaflet.richtext.facet#highlight', 1630 'lex:pub.leaflet.richtext.facet#underline', ··· 1661 properties: { 1662 uri: { 1663 type: 'string', 1664 format: 'uri', 1665 }, 1666 }, ··· 1839 ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', 1840 PubLeafletBlocksBlockquote: 'pub.leaflet.blocks.blockquote', 1841 PubLeafletBlocksBskyPost: 'pub.leaflet.blocks.bskyPost', 1842 PubLeafletBlocksCode: 'pub.leaflet.blocks.code', 1843 PubLeafletBlocksHeader: 'pub.leaflet.blocks.header', 1844 PubLeafletBlocksHorizontalRule: 'pub.leaflet.blocks.horizontalRule', 1845 PubLeafletBlocksIframe: 'pub.leaflet.blocks.iframe', 1846 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 1847 PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 1848 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 1849 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 1850 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', 1851 PubLeafletComment: 'pub.leaflet.comment', 1852 PubLeafletDocument: 'pub.leaflet.document', 1853 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 1854 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 1855 PubLeafletPublication: 'pub.leaflet.publication', 1856 PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 1857 PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage',
··· 1052 }, 1053 }, 1054 }, 1055 + PubLeafletBlocksButton: { 1056 + lexicon: 1, 1057 + id: 'pub.leaflet.blocks.button', 1058 + defs: { 1059 + main: { 1060 + type: 'object', 1061 + required: ['text', 'url'], 1062 + properties: { 1063 + text: { 1064 + type: 'string', 1065 + }, 1066 + url: { 1067 + type: 'string', 1068 + format: 'uri', 1069 + }, 1070 + }, 1071 + }, 1072 + }, 1073 + }, 1074 PubLeafletBlocksCode: { 1075 lexicon: 1, 1076 id: 'pub.leaflet.blocks.code', ··· 1204 }, 1205 }, 1206 }, 1207 + PubLeafletBlocksPage: { 1208 + lexicon: 1, 1209 + id: 'pub.leaflet.blocks.page', 1210 + defs: { 1211 + main: { 1212 + type: 'object', 1213 + required: ['id'], 1214 + properties: { 1215 + id: { 1216 + type: 'string', 1217 + }, 1218 + }, 1219 + }, 1220 + }, 1221 + }, 1222 + PubLeafletBlocksPoll: { 1223 + lexicon: 1, 1224 + id: 'pub.leaflet.blocks.poll', 1225 + defs: { 1226 + main: { 1227 + type: 'object', 1228 + required: ['pollRef'], 1229 + properties: { 1230 + pollRef: { 1231 + type: 'ref', 1232 + ref: 'lex:com.atproto.repo.strongRef', 1233 + }, 1234 + }, 1235 + }, 1236 + }, 1237 + }, 1238 PubLeafletBlocksText: { 1239 lexicon: 1, 1240 id: 'pub.leaflet.blocks.text', ··· 1360 ref: 'lex:pub.leaflet.richtext.facet', 1361 }, 1362 }, 1363 + onPage: { 1364 + type: 'string', 1365 + }, 1366 attachment: { 1367 type: 'union', 1368 refs: ['lex:pub.leaflet.comment#linearDocumentQuote'], ··· 1408 description: 'Record containing a document', 1409 record: { 1410 type: 'object', 1411 + required: ['pages', 'author', 'title'], 1412 properties: { 1413 title: { 1414 type: 'string', ··· 1436 type: 'string', 1437 format: 'at-identifier', 1438 }, 1439 + theme: { 1440 + type: 'ref', 1441 + ref: 'lex:pub.leaflet.publication#theme', 1442 + }, 1443 + tags: { 1444 + type: 'array', 1445 + items: { 1446 + type: 'string', 1447 + maxLength: 50, 1448 + }, 1449 + }, 1450 pages: { 1451 type: 'array', 1452 items: { 1453 type: 'union', 1454 + refs: [ 1455 + 'lex:pub.leaflet.pages.linearDocument', 1456 + 'lex:pub.leaflet.pages.canvas', 1457 + ], 1458 }, 1459 }, 1460 }, ··· 1483 }, 1484 }, 1485 }, 1486 + PubLeafletPagesCanvas: { 1487 + lexicon: 1, 1488 + id: 'pub.leaflet.pages.canvas', 1489 + defs: { 1490 + main: { 1491 + type: 'object', 1492 + required: ['blocks'], 1493 + properties: { 1494 + id: { 1495 + type: 'string', 1496 + }, 1497 + blocks: { 1498 + type: 'array', 1499 + items: { 1500 + type: 'ref', 1501 + ref: 'lex:pub.leaflet.pages.canvas#block', 1502 + }, 1503 + }, 1504 + }, 1505 + }, 1506 + block: { 1507 + type: 'object', 1508 + required: ['block', 'x', 'y', 'width'], 1509 + properties: { 1510 + block: { 1511 + type: 'union', 1512 + refs: [ 1513 + 'lex:pub.leaflet.blocks.iframe', 1514 + 'lex:pub.leaflet.blocks.text', 1515 + 'lex:pub.leaflet.blocks.blockquote', 1516 + 'lex:pub.leaflet.blocks.header', 1517 + 'lex:pub.leaflet.blocks.image', 1518 + 'lex:pub.leaflet.blocks.unorderedList', 1519 + 'lex:pub.leaflet.blocks.website', 1520 + 'lex:pub.leaflet.blocks.math', 1521 + 'lex:pub.leaflet.blocks.code', 1522 + 'lex:pub.leaflet.blocks.horizontalRule', 1523 + 'lex:pub.leaflet.blocks.bskyPost', 1524 + 'lex:pub.leaflet.blocks.page', 1525 + 'lex:pub.leaflet.blocks.poll', 1526 + 'lex:pub.leaflet.blocks.button', 1527 + ], 1528 + }, 1529 + x: { 1530 + type: 'integer', 1531 + }, 1532 + y: { 1533 + type: 'integer', 1534 + }, 1535 + width: { 1536 + type: 'integer', 1537 + }, 1538 + height: { 1539 + type: 'integer', 1540 + }, 1541 + rotation: { 1542 + type: 'integer', 1543 + description: 'The rotation of the block in degrees', 1544 + }, 1545 + }, 1546 + }, 1547 + textAlignLeft: { 1548 + type: 'token', 1549 + }, 1550 + textAlignCenter: { 1551 + type: 'token', 1552 + }, 1553 + textAlignRight: { 1554 + type: 'token', 1555 + }, 1556 + quote: { 1557 + type: 'object', 1558 + required: ['start', 'end'], 1559 + properties: { 1560 + start: { 1561 + type: 'ref', 1562 + ref: 'lex:pub.leaflet.pages.canvas#position', 1563 + }, 1564 + end: { 1565 + type: 'ref', 1566 + ref: 'lex:pub.leaflet.pages.canvas#position', 1567 + }, 1568 + }, 1569 + }, 1570 + position: { 1571 + type: 'object', 1572 + required: ['block', 'offset'], 1573 + properties: { 1574 + block: { 1575 + type: 'array', 1576 + items: { 1577 + type: 'integer', 1578 + }, 1579 + }, 1580 + offset: { 1581 + type: 'integer', 1582 + }, 1583 + }, 1584 + }, 1585 + }, 1586 + }, 1587 PubLeafletPagesLinearDocument: { 1588 lexicon: 1, 1589 id: 'pub.leaflet.pages.linearDocument', 1590 defs: { 1591 main: { 1592 type: 'object', 1593 + required: ['blocks'], 1594 properties: { 1595 + id: { 1596 + type: 'string', 1597 + }, 1598 blocks: { 1599 type: 'array', 1600 items: { ··· 1622 'lex:pub.leaflet.blocks.code', 1623 'lex:pub.leaflet.blocks.horizontalRule', 1624 'lex:pub.leaflet.blocks.bskyPost', 1625 + 'lex:pub.leaflet.blocks.page', 1626 + 'lex:pub.leaflet.blocks.poll', 1627 + 'lex:pub.leaflet.blocks.button', 1628 ], 1629 }, 1630 alignment: { ··· 1645 type: 'token', 1646 }, 1647 textAlignRight: { 1648 + type: 'token', 1649 + }, 1650 + textAlignJustify: { 1651 type: 'token', 1652 }, 1653 quote: { ··· 1681 }, 1682 }, 1683 }, 1684 + PubLeafletPollDefinition: { 1685 + lexicon: 1, 1686 + id: 'pub.leaflet.poll.definition', 1687 + defs: { 1688 + main: { 1689 + type: 'record', 1690 + key: 'tid', 1691 + description: 'Record declaring a poll', 1692 + record: { 1693 + type: 'object', 1694 + required: ['name', 'options'], 1695 + properties: { 1696 + name: { 1697 + type: 'string', 1698 + maxLength: 500, 1699 + maxGraphemes: 100, 1700 + }, 1701 + options: { 1702 + type: 'array', 1703 + items: { 1704 + type: 'ref', 1705 + ref: 'lex:pub.leaflet.poll.definition#option', 1706 + }, 1707 + }, 1708 + endDate: { 1709 + type: 'string', 1710 + format: 'datetime', 1711 + }, 1712 + }, 1713 + }, 1714 + }, 1715 + option: { 1716 + type: 'object', 1717 + properties: { 1718 + text: { 1719 + type: 'string', 1720 + maxLength: 500, 1721 + maxGraphemes: 50, 1722 + }, 1723 + }, 1724 + }, 1725 + }, 1726 + }, 1727 + PubLeafletPollVote: { 1728 + lexicon: 1, 1729 + id: 'pub.leaflet.poll.vote', 1730 + defs: { 1731 + main: { 1732 + type: 'record', 1733 + key: 'tid', 1734 + description: 'Record declaring a vote on a poll', 1735 + record: { 1736 + type: 'object', 1737 + required: ['poll', 'option'], 1738 + properties: { 1739 + poll: { 1740 + type: 'ref', 1741 + ref: 'lex:com.atproto.repo.strongRef', 1742 + }, 1743 + option: { 1744 + type: 'array', 1745 + items: { 1746 + type: 'string', 1747 + }, 1748 + }, 1749 + }, 1750 + }, 1751 + }, 1752 + }, 1753 + }, 1754 PubLeafletPublication: { 1755 lexicon: 1, 1756 id: 'pub.leaflet.publication', ··· 1769 }, 1770 base_path: { 1771 type: 'string', 1772 }, 1773 description: { 1774 type: 'string', ··· 1872 type: 'union', 1873 refs: [ 1874 'lex:pub.leaflet.richtext.facet#link', 1875 + 'lex:pub.leaflet.richtext.facet#didMention', 1876 + 'lex:pub.leaflet.richtext.facet#atMention', 1877 'lex:pub.leaflet.richtext.facet#code', 1878 'lex:pub.leaflet.richtext.facet#highlight', 1879 'lex:pub.leaflet.richtext.facet#underline', ··· 1910 properties: { 1911 uri: { 1912 type: 'string', 1913 + }, 1914 + }, 1915 + }, 1916 + didMention: { 1917 + type: 'object', 1918 + description: 'Facet feature for mentioning a did.', 1919 + required: ['did'], 1920 + properties: { 1921 + did: { 1922 + type: 'string', 1923 + format: 'did', 1924 + }, 1925 + }, 1926 + }, 1927 + atMention: { 1928 + type: 'object', 1929 + description: 'Facet feature for mentioning an AT URI.', 1930 + required: ['atURI'], 1931 + properties: { 1932 + atURI: { 1933 + type: 'string', 1934 format: 'uri', 1935 }, 1936 }, ··· 2109 ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', 2110 PubLeafletBlocksBlockquote: 'pub.leaflet.blocks.blockquote', 2111 PubLeafletBlocksBskyPost: 'pub.leaflet.blocks.bskyPost', 2112 + PubLeafletBlocksButton: 'pub.leaflet.blocks.button', 2113 PubLeafletBlocksCode: 'pub.leaflet.blocks.code', 2114 PubLeafletBlocksHeader: 'pub.leaflet.blocks.header', 2115 PubLeafletBlocksHorizontalRule: 'pub.leaflet.blocks.horizontalRule', 2116 PubLeafletBlocksIframe: 'pub.leaflet.blocks.iframe', 2117 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 2118 PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 2119 + PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 2120 + PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', 2121 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 2122 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 2123 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', 2124 PubLeafletComment: 'pub.leaflet.comment', 2125 PubLeafletDocument: 'pub.leaflet.document', 2126 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 2127 + PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', 2128 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 2129 + PubLeafletPollDefinition: 'pub.leaflet.poll.definition', 2130 + PubLeafletPollVote: 'pub.leaflet.poll.vote', 2131 PubLeafletPublication: 'pub.leaflet.publication', 2132 PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 2133 PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage',
+31
lexicons/api/types/pub/leaflet/blocks/button.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'pub.leaflet.blocks.button' 16 + 17 + export interface Main { 18 + $type?: 'pub.leaflet.blocks.button' 19 + text: string 20 + url: string 21 + } 22 + 23 + const hashMain = 'main' 24 + 25 + export function isMain<V>(v: V) { 26 + return is$typed(v, id, hashMain) 27 + } 28 + 29 + export function validateMain<V>(v: V) { 30 + return validate<Main & V>(v, id, hashMain) 31 + }
+30
lexicons/api/types/pub/leaflet/blocks/page.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'pub.leaflet.blocks.page' 16 + 17 + export interface Main { 18 + $type?: 'pub.leaflet.blocks.page' 19 + id: string 20 + } 21 + 22 + const hashMain = 'main' 23 + 24 + export function isMain<V>(v: V) { 25 + return is$typed(v, id, hashMain) 26 + } 27 + 28 + export function validateMain<V>(v: V) { 29 + return validate<Main & V>(v, id, hashMain) 30 + }
+31
lexicons/api/types/pub/leaflet/blocks/poll.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'pub.leaflet.blocks.poll' 17 + 18 + export interface Main { 19 + $type?: 'pub.leaflet.blocks.poll' 20 + pollRef: ComAtprotoRepoStrongRef.Main 21 + } 22 + 23 + const hashMain = 'main' 24 + 25 + export function isMain<V>(v: V) { 26 + return is$typed(v, id, hashMain) 27 + } 28 + 29 + export function validateMain<V>(v: V) { 30 + return validate<Main & V>(v, id, hashMain) 31 + }
+1
lexicons/api/types/pub/leaflet/comment.ts
··· 19 reply?: ReplyRef 20 plaintext: string 21 facets?: PubLeafletRichtextFacet.Main[] 22 attachment?: $Typed<LinearDocumentQuote> | { $type: string } 23 [k: string]: unknown 24 }
··· 19 reply?: ReplyRef 20 plaintext: string 21 facets?: PubLeafletRichtextFacet.Main[] 22 + onPage?: string 23 attachment?: $Typed<LinearDocumentQuote> | { $type: string } 24 [k: string]: unknown 25 }
+10 -2
lexicons/api/types/pub/leaflet/document.ts
··· 6 import { validate as _validate } from '../../../lexicons' 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 10 11 const is$typed = _is$typed, 12 validate = _validate ··· 18 postRef?: ComAtprotoRepoStrongRef.Main 19 description?: string 20 publishedAt?: string 21 - publication: string 22 author: string 23 - pages: ($Typed<PubLeafletPagesLinearDocument.Main> | { $type: string })[] 24 [k: string]: unknown 25 } 26
··· 6 import { validate as _validate } from '../../../lexicons' 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 + import type * as PubLeafletPublication from './publication' 10 import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 11 + import type * as PubLeafletPagesCanvas from './pages/canvas' 12 13 const is$typed = _is$typed, 14 validate = _validate ··· 20 postRef?: ComAtprotoRepoStrongRef.Main 21 description?: string 22 publishedAt?: string 23 + publication?: string 24 author: string 25 + theme?: PubLeafletPublication.Theme 26 + tags?: string[] 27 + pages: ( 28 + | $Typed<PubLeafletPagesLinearDocument.Main> 29 + | $Typed<PubLeafletPagesCanvas.Main> 30 + | { $type: string } 31 + )[] 32 [k: string]: unknown 33 } 34
+117
lexicons/api/types/pub/leaflet/pages/canvas.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as PubLeafletBlocksIframe from '../blocks/iframe' 13 + import type * as PubLeafletBlocksText from '../blocks/text' 14 + import type * as PubLeafletBlocksBlockquote from '../blocks/blockquote' 15 + import type * as PubLeafletBlocksHeader from '../blocks/header' 16 + import type * as PubLeafletBlocksImage from '../blocks/image' 17 + import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 18 + import type * as PubLeafletBlocksWebsite from '../blocks/website' 19 + import type * as PubLeafletBlocksMath from '../blocks/math' 20 + import type * as PubLeafletBlocksCode from '../blocks/code' 21 + import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' 22 + import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' 23 + import type * as PubLeafletBlocksPage from '../blocks/page' 24 + import type * as PubLeafletBlocksPoll from '../blocks/poll' 25 + import type * as PubLeafletBlocksButton from '../blocks/button' 26 + 27 + const is$typed = _is$typed, 28 + validate = _validate 29 + const id = 'pub.leaflet.pages.canvas' 30 + 31 + export interface Main { 32 + $type?: 'pub.leaflet.pages.canvas' 33 + id?: string 34 + blocks: Block[] 35 + } 36 + 37 + const hashMain = 'main' 38 + 39 + export function isMain<V>(v: V) { 40 + return is$typed(v, id, hashMain) 41 + } 42 + 43 + export function validateMain<V>(v: V) { 44 + return validate<Main & V>(v, id, hashMain) 45 + } 46 + 47 + export interface Block { 48 + $type?: 'pub.leaflet.pages.canvas#block' 49 + block: 50 + | $Typed<PubLeafletBlocksIframe.Main> 51 + | $Typed<PubLeafletBlocksText.Main> 52 + | $Typed<PubLeafletBlocksBlockquote.Main> 53 + | $Typed<PubLeafletBlocksHeader.Main> 54 + | $Typed<PubLeafletBlocksImage.Main> 55 + | $Typed<PubLeafletBlocksUnorderedList.Main> 56 + | $Typed<PubLeafletBlocksWebsite.Main> 57 + | $Typed<PubLeafletBlocksMath.Main> 58 + | $Typed<PubLeafletBlocksCode.Main> 59 + | $Typed<PubLeafletBlocksHorizontalRule.Main> 60 + | $Typed<PubLeafletBlocksBskyPost.Main> 61 + | $Typed<PubLeafletBlocksPage.Main> 62 + | $Typed<PubLeafletBlocksPoll.Main> 63 + | $Typed<PubLeafletBlocksButton.Main> 64 + | { $type: string } 65 + x: number 66 + y: number 67 + width: number 68 + height?: number 69 + /** The rotation of the block in degrees */ 70 + rotation?: number 71 + } 72 + 73 + const hashBlock = 'block' 74 + 75 + export function isBlock<V>(v: V) { 76 + return is$typed(v, id, hashBlock) 77 + } 78 + 79 + export function validateBlock<V>(v: V) { 80 + return validate<Block & V>(v, id, hashBlock) 81 + } 82 + 83 + export const TEXTALIGNLEFT = `${id}#textAlignLeft` 84 + export const TEXTALIGNCENTER = `${id}#textAlignCenter` 85 + export const TEXTALIGNRIGHT = `${id}#textAlignRight` 86 + 87 + export interface Quote { 88 + $type?: 'pub.leaflet.pages.canvas#quote' 89 + start: Position 90 + end: Position 91 + } 92 + 93 + const hashQuote = 'quote' 94 + 95 + export function isQuote<V>(v: V) { 96 + return is$typed(v, id, hashQuote) 97 + } 98 + 99 + export function validateQuote<V>(v: V) { 100 + return validate<Quote & V>(v, id, hashQuote) 101 + } 102 + 103 + export interface Position { 104 + $type?: 'pub.leaflet.pages.canvas#position' 105 + block: number[] 106 + offset: number 107 + } 108 + 109 + const hashPosition = 'position' 110 + 111 + export function isPosition<V>(v: V) { 112 + return is$typed(v, id, hashPosition) 113 + } 114 + 115 + export function validatePosition<V>(v: V) { 116 + return validate<Position & V>(v, id, hashPosition) 117 + }
+9 -1
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 20 import type * as PubLeafletBlocksCode from '../blocks/code' 21 import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' 22 import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' 23 24 const is$typed = _is$typed, 25 validate = _validate ··· 27 28 export interface Main { 29 $type?: 'pub.leaflet.pages.linearDocument' 30 - blocks?: Block[] 31 } 32 33 const hashMain = 'main' ··· 54 | $Typed<PubLeafletBlocksCode.Main> 55 | $Typed<PubLeafletBlocksHorizontalRule.Main> 56 | $Typed<PubLeafletBlocksBskyPost.Main> 57 | { $type: string } 58 alignment?: 59 | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft' ··· 76 export const TEXTALIGNLEFT = `${id}#textAlignLeft` 77 export const TEXTALIGNCENTER = `${id}#textAlignCenter` 78 export const TEXTALIGNRIGHT = `${id}#textAlignRight` 79 80 export interface Quote { 81 $type?: 'pub.leaflet.pages.linearDocument#quote'
··· 20 import type * as PubLeafletBlocksCode from '../blocks/code' 21 import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' 22 import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' 23 + import type * as PubLeafletBlocksPage from '../blocks/page' 24 + import type * as PubLeafletBlocksPoll from '../blocks/poll' 25 + import type * as PubLeafletBlocksButton from '../blocks/button' 26 27 const is$typed = _is$typed, 28 validate = _validate ··· 30 31 export interface Main { 32 $type?: 'pub.leaflet.pages.linearDocument' 33 + id?: string 34 + blocks: Block[] 35 } 36 37 const hashMain = 'main' ··· 58 | $Typed<PubLeafletBlocksCode.Main> 59 | $Typed<PubLeafletBlocksHorizontalRule.Main> 60 | $Typed<PubLeafletBlocksBskyPost.Main> 61 + | $Typed<PubLeafletBlocksPage.Main> 62 + | $Typed<PubLeafletBlocksPoll.Main> 63 + | $Typed<PubLeafletBlocksButton.Main> 64 | { $type: string } 65 alignment?: 66 | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft' ··· 83 export const TEXTALIGNLEFT = `${id}#textAlignLeft` 84 export const TEXTALIGNCENTER = `${id}#textAlignCenter` 85 export const TEXTALIGNRIGHT = `${id}#textAlignRight` 86 + export const TEXTALIGNJUSTIFY = `${id}#textAlignJustify` 87 88 export interface Quote { 89 $type?: 'pub.leaflet.pages.linearDocument#quote'
+48
lexicons/api/types/pub/leaflet/poll/definition.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'pub.leaflet.poll.definition' 16 + 17 + export interface Record { 18 + $type: 'pub.leaflet.poll.definition' 19 + name: string 20 + options: Option[] 21 + endDate?: string 22 + [k: string]: unknown 23 + } 24 + 25 + const hashRecord = 'main' 26 + 27 + export function isRecord<V>(v: V) { 28 + return is$typed(v, id, hashRecord) 29 + } 30 + 31 + export function validateRecord<V>(v: V) { 32 + return validate<Record & V>(v, id, hashRecord, true) 33 + } 34 + 35 + export interface Option { 36 + $type?: 'pub.leaflet.poll.definition#option' 37 + text?: string 38 + } 39 + 40 + const hashOption = 'option' 41 + 42 + export function isOption<V>(v: V) { 43 + return is$typed(v, id, hashOption) 44 + } 45 + 46 + export function validateOption<V>(v: V) { 47 + return validate<Option & V>(v, id, hashOption) 48 + }
+33
lexicons/api/types/pub/leaflet/poll/vote.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'pub.leaflet.poll.vote' 17 + 18 + export interface Record { 19 + $type: 'pub.leaflet.poll.vote' 20 + poll: ComAtprotoRepoStrongRef.Main 21 + option: string[] 22 + [k: string]: unknown 23 + } 24 + 25 + const hashRecord = 'main' 26 + 27 + export function isRecord<V>(v: V) { 28 + return is$typed(v, id, hashRecord) 29 + } 30 + 31 + export function validateRecord<V>(v: V) { 32 + return validate<Record & V>(v, id, hashRecord, true) 33 + }
+34
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 20 index: ByteSlice 21 features: ( 22 | $Typed<Link> 23 | $Typed<Code> 24 | $Typed<Highlight> 25 | $Typed<Underline> ··· 72 73 export function validateLink<V>(v: V) { 74 return validate<Link & V>(v, id, hashLink) 75 } 76 77 /** Facet feature for inline code. */
··· 20 index: ByteSlice 21 features: ( 22 | $Typed<Link> 23 + | $Typed<DidMention> 24 + | $Typed<AtMention> 25 | $Typed<Code> 26 | $Typed<Highlight> 27 | $Typed<Underline> ··· 74 75 export function validateLink<V>(v: V) { 76 return validate<Link & V>(v, id, hashLink) 77 + } 78 + 79 + /** Facet feature for mentioning a did. */ 80 + export interface DidMention { 81 + $type?: 'pub.leaflet.richtext.facet#didMention' 82 + did: string 83 + } 84 + 85 + const hashDidMention = 'didMention' 86 + 87 + export function isDidMention<V>(v: V) { 88 + return is$typed(v, id, hashDidMention) 89 + } 90 + 91 + export function validateDidMention<V>(v: V) { 92 + return validate<DidMention & V>(v, id, hashDidMention) 93 + } 94 + 95 + /** Facet feature for mentioning an AT URI. */ 96 + export interface AtMention { 97 + $type?: 'pub.leaflet.richtext.facet#atMention' 98 + atURI: string 99 + } 100 + 101 + const hashAtMention = 'atMention' 102 + 103 + export function isAtMention<V>(v: V) { 104 + return is$typed(v, id, hashAtMention) 105 + } 106 + 107 + export function validateAtMention<V>(v: V) { 108 + return validate<AtMention & V>(v, id, hashAtMention) 109 } 110 111 /** Facet feature for inline code. */
+3
lexicons/build.ts
··· 2 import { BlockLexicons } from "./src/blocks"; 3 import { PubLeafletDocument } from "./src/document"; 4 import * as PublicationLexicons from "./src/publication"; 5 import { ThemeLexicons } from "./src/theme"; 6 7 import * as fs from "fs"; ··· 21 PubLeafletComment, 22 PubLeafletRichTextFacet, 23 PageLexicons.PubLeafletPagesLinearDocument, 24 ...ThemeLexicons, 25 ...BlockLexicons, 26 ...Object.values(PublicationLexicons), 27 ]; 28 29 // Write each lexicon to a file
··· 2 import { BlockLexicons } from "./src/blocks"; 3 import { PubLeafletDocument } from "./src/document"; 4 import * as PublicationLexicons from "./src/publication"; 5 + import * as PollLexicons from "./src/polls"; 6 import { ThemeLexicons } from "./src/theme"; 7 8 import * as fs from "fs"; ··· 22 PubLeafletComment, 23 PubLeafletRichTextFacet, 24 PageLexicons.PubLeafletPagesLinearDocument, 25 + PageLexicons.PubLeafletPagesCanvasDocument, 26 ...ThemeLexicons, 27 ...BlockLexicons, 28 ...Object.values(PublicationLexicons), 29 + ...Object.values(PollLexicons), 30 ]; 31 32 // Write each lexicon to a file
+22
lexicons/pub/leaflet/blocks/button.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.button", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "text", 9 + "url" 10 + ], 11 + "properties": { 12 + "text": { 13 + "type": "string" 14 + }, 15 + "url": { 16 + "type": "string", 17 + "format": "uri" 18 + } 19 + } 20 + } 21 + } 22 + }
+17
lexicons/pub/leaflet/blocks/page.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.page", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "id" 9 + ], 10 + "properties": { 11 + "id": { 12 + "type": "string" 13 + } 14 + } 15 + } 16 + } 17 + }
+18
lexicons/pub/leaflet/blocks/poll.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.poll", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "pollRef" 9 + ], 10 + "properties": { 11 + "pollRef": { 12 + "type": "ref", 13 + "ref": "com.atproto.repo.strongRef" 14 + } 15 + } 16 + } 17 + } 18 + }
+3
lexicons/pub/leaflet/comment.json
··· 38 "ref": "pub.leaflet.richtext.facet" 39 } 40 }, 41 "attachment": { 42 "type": "union", 43 "refs": [
··· 38 "ref": "pub.leaflet.richtext.facet" 39 } 40 }, 41 + "onPage": { 42 + "type": "string" 43 + }, 44 "attachment": { 45 "type": "union", 46 "refs": [
+14 -3
lexicons/pub/leaflet/document.json
··· 13 "required": [ 14 "pages", 15 "author", 16 - "title", 17 - "publication" 18 ], 19 "properties": { 20 "title": { ··· 43 "type": "string", 44 "format": "at-identifier" 45 }, 46 "pages": { 47 "type": "array", 48 "items": { 49 "type": "union", 50 "refs": [ 51 - "pub.leaflet.pages.linearDocument" 52 ] 53 } 54 }
··· 13 "required": [ 14 "pages", 15 "author", 16 + "title" 17 ], 18 "properties": { 19 "title": { ··· 42 "type": "string", 43 "format": "at-identifier" 44 }, 45 + "theme": { 46 + "type": "ref", 47 + "ref": "pub.leaflet.publication#theme" 48 + }, 49 + "tags": { 50 + "type": "array", 51 + "items": { 52 + "type": "string", 53 + "maxLength": 50 54 + } 55 + }, 56 "pages": { 57 "type": "array", 58 "items": { 59 "type": "union", 60 "refs": [ 61 + "pub.leaflet.pages.linearDocument", 62 + "pub.leaflet.pages.canvas" 63 ] 64 } 65 }
+114
lexicons/pub/leaflet/pages/canvas.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.pages.canvas", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "blocks" 9 + ], 10 + "properties": { 11 + "id": { 12 + "type": "string" 13 + }, 14 + "blocks": { 15 + "type": "array", 16 + "items": { 17 + "type": "ref", 18 + "ref": "#block" 19 + } 20 + } 21 + } 22 + }, 23 + "block": { 24 + "type": "object", 25 + "required": [ 26 + "block", 27 + "x", 28 + "y", 29 + "width" 30 + ], 31 + "properties": { 32 + "block": { 33 + "type": "union", 34 + "refs": [ 35 + "pub.leaflet.blocks.iframe", 36 + "pub.leaflet.blocks.text", 37 + "pub.leaflet.blocks.blockquote", 38 + "pub.leaflet.blocks.header", 39 + "pub.leaflet.blocks.image", 40 + "pub.leaflet.blocks.unorderedList", 41 + "pub.leaflet.blocks.website", 42 + "pub.leaflet.blocks.math", 43 + "pub.leaflet.blocks.code", 44 + "pub.leaflet.blocks.horizontalRule", 45 + "pub.leaflet.blocks.bskyPost", 46 + "pub.leaflet.blocks.page", 47 + "pub.leaflet.blocks.poll", 48 + "pub.leaflet.blocks.button" 49 + ] 50 + }, 51 + "x": { 52 + "type": "integer" 53 + }, 54 + "y": { 55 + "type": "integer" 56 + }, 57 + "width": { 58 + "type": "integer" 59 + }, 60 + "height": { 61 + "type": "integer" 62 + }, 63 + "rotation": { 64 + "type": "integer", 65 + "description": "The rotation of the block in degrees" 66 + } 67 + } 68 + }, 69 + "textAlignLeft": { 70 + "type": "token" 71 + }, 72 + "textAlignCenter": { 73 + "type": "token" 74 + }, 75 + "textAlignRight": { 76 + "type": "token" 77 + }, 78 + "quote": { 79 + "type": "object", 80 + "required": [ 81 + "start", 82 + "end" 83 + ], 84 + "properties": { 85 + "start": { 86 + "type": "ref", 87 + "ref": "#position" 88 + }, 89 + "end": { 90 + "type": "ref", 91 + "ref": "#position" 92 + } 93 + } 94 + }, 95 + "position": { 96 + "type": "object", 97 + "required": [ 98 + "block", 99 + "offset" 100 + ], 101 + "properties": { 102 + "block": { 103 + "type": "array", 104 + "items": { 105 + "type": "integer" 106 + } 107 + }, 108 + "offset": { 109 + "type": "integer" 110 + } 111 + } 112 + } 113 + } 114 + }
+13 -1
lexicons/pub/leaflet/pages/linearDocument.json
··· 4 "defs": { 5 "main": { 6 "type": "object", 7 "properties": { 8 "blocks": { 9 "type": "array", 10 "items": { ··· 33 "pub.leaflet.blocks.math", 34 "pub.leaflet.blocks.code", 35 "pub.leaflet.blocks.horizontalRule", 36 - "pub.leaflet.blocks.bskyPost" 37 ] 38 }, 39 "alignment": { ··· 54 "type": "token" 55 }, 56 "textAlignRight": { 57 "type": "token" 58 }, 59 "quote": {
··· 4 "defs": { 5 "main": { 6 "type": "object", 7 + "required": [ 8 + "blocks" 9 + ], 10 "properties": { 11 + "id": { 12 + "type": "string" 13 + }, 14 "blocks": { 15 "type": "array", 16 "items": { ··· 39 "pub.leaflet.blocks.math", 40 "pub.leaflet.blocks.code", 41 "pub.leaflet.blocks.horizontalRule", 42 + "pub.leaflet.blocks.bskyPost", 43 + "pub.leaflet.blocks.page", 44 + "pub.leaflet.blocks.poll", 45 + "pub.leaflet.blocks.button" 46 ] 47 }, 48 "alignment": { ··· 63 "type": "token" 64 }, 65 "textAlignRight": { 66 + "type": "token" 67 + }, 68 + "textAlignJustify": { 69 "type": "token" 70 }, 71 "quote": {
+46
lexicons/pub/leaflet/poll/definition.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.poll.definition", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record declaring a poll", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "name", 13 + "options" 14 + ], 15 + "properties": { 16 + "name": { 17 + "type": "string", 18 + "maxLength": 500, 19 + "maxGraphemes": 100 20 + }, 21 + "options": { 22 + "type": "array", 23 + "items": { 24 + "type": "ref", 25 + "ref": "#option" 26 + } 27 + }, 28 + "endDate": { 29 + "type": "string", 30 + "format": "datetime" 31 + } 32 + } 33 + } 34 + }, 35 + "option": { 36 + "type": "object", 37 + "properties": { 38 + "text": { 39 + "type": "string", 40 + "maxLength": 500, 41 + "maxGraphemes": 50 42 + } 43 + } 44 + } 45 + } 46 + }
+30
lexicons/pub/leaflet/poll/vote.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.poll.vote", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record declaring a vote on a poll", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "poll", 13 + "option" 14 + ], 15 + "properties": { 16 + "poll": { 17 + "type": "ref", 18 + "ref": "com.atproto.repo.strongRef" 19 + }, 20 + "option": { 21 + "type": "array", 22 + "items": { 23 + "type": "string" 24 + } 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+1 -2
lexicons/pub/leaflet/publication.json
··· 17 "maxLength": 2000 18 }, 19 "base_path": { 20 - "type": "string", 21 - "format": "uri" 22 }, 23 "description": { 24 "type": "string",
··· 17 "maxLength": 2000 18 }, 19 "base_path": { 20 + "type": "string" 21 }, 22 "description": { 23 "type": "string",
+27
lexicons/pub/leaflet/richtext/facet.json
··· 20 "type": "union", 21 "refs": [ 22 "#link", 23 "#code", 24 "#highlight", 25 "#underline", ··· 58 ], 59 "properties": { 60 "uri": { 61 "type": "string", 62 "format": "uri" 63 }
··· 20 "type": "union", 21 "refs": [ 22 "#link", 23 + "#didMention", 24 + "#atMention", 25 "#code", 26 "#highlight", 27 "#underline", ··· 60 ], 61 "properties": { 62 "uri": { 63 + "type": "string" 64 + } 65 + } 66 + }, 67 + "didMention": { 68 + "type": "object", 69 + "description": "Facet feature for mentioning a did.", 70 + "required": [ 71 + "did" 72 + ], 73 + "properties": { 74 + "did": { 75 + "type": "string", 76 + "format": "did" 77 + } 78 + } 79 + }, 80 + "atMention": { 81 + "type": "object", 82 + "description": "Facet feature for mentioning an AT URI.", 83 + "required": [ 84 + "atURI" 85 + ], 86 + "properties": { 87 + "atURI": { 88 "type": "string", 89 "format": "uri" 90 }
+47
lexicons/src/blocks.ts
··· 19 }, 20 }; 21 22 export const PubLeafletBlocksBskyPost: LexiconDoc = { 23 lexicon: 1, 24 id: "pub.leaflet.blocks.bskyPost", ··· 250 }, 251 }, 252 }; 253 export const BlockLexicons = [ 254 PubLeafletBlocksIFrame, 255 PubLeafletBlocksText, ··· 262 PubLeafletBlocksCode, 263 PubLeafletBlocksHorizontalRule, 264 PubLeafletBlocksBskyPost, 265 ]; 266 export const BlockUnion: LexRefUnion = { 267 type: "union",
··· 19 }, 20 }; 21 22 + export const PubLeafletBlocksPage: LexiconDoc = { 23 + lexicon: 1, 24 + id: "pub.leaflet.blocks.page", 25 + defs: { 26 + main: { 27 + type: "object", 28 + required: ["id"], 29 + properties: { 30 + id: { type: "string" }, 31 + }, 32 + }, 33 + }, 34 + }; 35 + 36 export const PubLeafletBlocksBskyPost: LexiconDoc = { 37 lexicon: 1, 38 id: "pub.leaflet.blocks.bskyPost", ··· 264 }, 265 }, 266 }; 267 + 268 + export const PubLeafletBlocksPoll: LexiconDoc = { 269 + lexicon: 1, 270 + id: "pub.leaflet.blocks.poll", 271 + defs: { 272 + main: { 273 + type: "object", 274 + required: ["pollRef"], 275 + properties: { 276 + pollRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, 277 + }, 278 + }, 279 + }, 280 + }; 281 + 282 + export const PubLeafletBlocksButton: LexiconDoc = { 283 + lexicon: 1, 284 + id: "pub.leaflet.blocks.button", 285 + defs: { 286 + main: { 287 + type: "object", 288 + required: ["text", "url"], 289 + properties: { 290 + text: { type: "string" }, 291 + url: { type: "string", format: "uri" }, 292 + }, 293 + }, 294 + }, 295 + }; 296 + 297 export const BlockLexicons = [ 298 PubLeafletBlocksIFrame, 299 PubLeafletBlocksText, ··· 306 PubLeafletBlocksCode, 307 PubLeafletBlocksHorizontalRule, 308 PubLeafletBlocksBskyPost, 309 + PubLeafletBlocksPage, 310 + PubLeafletBlocksPoll, 311 + PubLeafletBlocksButton, 312 ]; 313 export const BlockUnion: LexRefUnion = { 314 type: "union",
+1
lexicons/src/comment.ts
··· 23 type: "array", 24 items: { type: "ref", ref: PubLeafletRichTextFacet.id }, 25 }, 26 attachment: { type: "union", refs: ["#linearDocumentQuote"] }, 27 }, 28 },
··· 23 type: "array", 24 items: { type: "ref", ref: PubLeafletRichTextFacet.id }, 25 }, 26 + onPage: { type: "string" }, 27 attachment: { type: "union", refs: ["#linearDocumentQuote"] }, 28 }, 29 },
+8 -2
lexicons/src/document.ts
··· 1 import { LexiconDoc } from "@atproto/lexicon"; 2 import { PubLeafletPagesLinearDocument } from "./pages/LinearDocument"; 3 4 export const PubLeafletDocument: LexiconDoc = { 5 lexicon: 1, ··· 13 description: "Record containing a document", 14 record: { 15 type: "object", 16 - required: ["pages", "author", "title", "publication"], 17 properties: { 18 title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, 19 postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, ··· 21 publishedAt: { type: "string", format: "datetime" }, 22 publication: { type: "string", format: "at-uri" }, 23 author: { type: "string", format: "at-identifier" }, 24 pages: { 25 type: "array", 26 items: { 27 type: "union", 28 - refs: [PubLeafletPagesLinearDocument.id], 29 }, 30 }, 31 },
··· 1 import { LexiconDoc } from "@atproto/lexicon"; 2 import { PubLeafletPagesLinearDocument } from "./pages/LinearDocument"; 3 + import { PubLeafletPagesCanvasDocument } from "./pages"; 4 5 export const PubLeafletDocument: LexiconDoc = { 6 lexicon: 1, ··· 14 description: "Record containing a document", 15 record: { 16 type: "object", 17 + required: ["pages", "author", "title"], 18 properties: { 19 title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, 20 postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, ··· 22 publishedAt: { type: "string", format: "datetime" }, 23 publication: { type: "string", format: "at-uri" }, 24 author: { type: "string", format: "at-identifier" }, 25 + theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, 26 + tags: { type: "array", items: { type: "string", maxLength: 50 } }, 27 pages: { 28 type: "array", 29 items: { 30 type: "union", 31 + refs: [ 32 + PubLeafletPagesLinearDocument.id, 33 + PubLeafletPagesCanvasDocument.id, 34 + ], 35 }, 36 }, 37 },
+12
lexicons/src/facet.ts
··· 9 uri: { type: "string" }, 10 }, 11 }, 12 code: { 13 type: "object", 14 description: "Facet feature for inline code.",
··· 9 uri: { type: "string" }, 10 }, 11 }, 12 + didMention: { 13 + type: "object", 14 + description: "Facet feature for mentioning a did.", 15 + required: ["did"], 16 + properties: { did: { type: "string", format: "did" } }, 17 + }, 18 + atMention: { 19 + type: "object", 20 + description: "Facet feature for mentioning an AT URI.", 21 + required: ["atURI"], 22 + properties: { atURI: { type: "string", format: "uri" } }, 23 + }, 24 code: { 25 type: "object", 26 description: "Facet feature for inline code.",
+51
lexicons/src/pages/Canvas.ts
···
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + import { BlockUnion } from "../blocks"; 3 + 4 + export const PubLeafletPagesCanvasDocument: LexiconDoc = { 5 + lexicon: 1, 6 + id: "pub.leaflet.pages.canvas", 7 + defs: { 8 + main: { 9 + type: "object", 10 + required: ["blocks"], 11 + properties: { 12 + id: { type: "string" }, 13 + blocks: { type: "array", items: { type: "ref", ref: "#block" } }, 14 + }, 15 + }, 16 + block: { 17 + type: "object", 18 + required: ["block", "x", "y", "width"], 19 + properties: { 20 + block: BlockUnion, 21 + x: { type: "integer" }, 22 + y: { type: "integer" }, 23 + width: { type: "integer" }, 24 + height: { type: "integer" }, 25 + rotation: { 26 + type: "integer", 27 + description: "The rotation of the block in degrees", 28 + }, 29 + }, 30 + }, 31 + textAlignLeft: { type: "token" }, 32 + textAlignCenter: { type: "token" }, 33 + textAlignRight: { type: "token" }, 34 + quote: { 35 + type: "object", 36 + required: ["start", "end"], 37 + properties: { 38 + start: { type: "ref", ref: "#position" }, 39 + end: { type: "ref", ref: "#position" }, 40 + }, 41 + }, 42 + position: { 43 + type: "object", 44 + required: ["block", "offset"], 45 + properties: { 46 + block: { type: "array", items: { type: "integer" } }, 47 + offset: { type: "integer" }, 48 + }, 49 + }, 50 + }, 51 + };
+3
lexicons/src/pages/LinearDocument.ts
··· 7 defs: { 8 main: { 9 type: "object", 10 properties: { 11 blocks: { type: "array", items: { type: "ref", ref: "#block" } }, 12 }, 13 }, ··· 30 textAlignLeft: { type: "token" }, 31 textAlignCenter: { type: "token" }, 32 textAlignRight: { type: "token" }, 33 quote: { 34 type: "object", 35 required: ["start", "end"],
··· 7 defs: { 8 main: { 9 type: "object", 10 + required: ["blocks"], 11 properties: { 12 + id: { type: "string" }, 13 blocks: { type: "array", items: { type: "ref", ref: "#block" } }, 14 }, 15 }, ··· 32 textAlignLeft: { type: "token" }, 33 textAlignCenter: { type: "token" }, 34 textAlignRight: { type: "token" }, 35 + textAlignJustify: { type: "token" }, 36 quote: { 37 type: "object", 38 required: ["start", "end"],
+1
lexicons/src/pages/index.ts
··· 1 export { PubLeafletPagesLinearDocument } from "./LinearDocument";
··· 1 export { PubLeafletPagesLinearDocument } from "./LinearDocument"; 2 + export { PubLeafletPagesCanvasDocument } from "./Canvas";
+48
lexicons/src/polls/index.ts
···
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + 3 + export const PubLeafletPollDefinition: LexiconDoc = { 4 + lexicon: 1, 5 + id: "pub.leaflet.poll.definition", 6 + defs: { 7 + main: { 8 + type: "record", 9 + key: "tid", 10 + description: "Record declaring a poll", 11 + record: { 12 + type: "object", 13 + required: ["name", "options"], 14 + properties: { 15 + name: { type: "string", maxLength: 500, maxGraphemes: 100 }, 16 + options: { type: "array", items: { type: "ref", ref: "#option" } }, 17 + endDate: { type: "string", format: "datetime" }, 18 + }, 19 + }, 20 + }, 21 + option: { 22 + type: "object", 23 + properties: { 24 + text: { type: "string", maxLength: 500, maxGraphemes: 50 }, 25 + }, 26 + }, 27 + }, 28 + }; 29 + 30 + export const PubLeafletPollVote: LexiconDoc = { 31 + lexicon: 1, 32 + id: "pub.leaflet.poll.vote", 33 + defs: { 34 + main: { 35 + type: "record", 36 + key: "tid", 37 + description: "Record declaring a vote on a poll", 38 + record: { 39 + type: "object", 40 + required: ["poll", "option"], 41 + properties: { 42 + poll: { type: "ref", ref: "com.atproto.repo.strongRef" }, 43 + option: { type: "array", items: { type: "string" } }, 44 + }, 45 + }, 46 + }, 47 + }, 48 + };
+1 -1
lexicons/src/publication.ts
··· 14 required: ["name"], 15 properties: { 16 name: { type: "string", maxLength: 2000 }, 17 - base_path: { type: "string", format: "uri" }, 18 description: { type: "string", maxLength: 2000 }, 19 icon: { type: "blob", accept: ["image/*"], maxSize: 1000000 }, 20 theme: { type: "ref", ref: "#theme" },
··· 14 required: ["name"], 15 properties: { 16 name: { type: "string", maxLength: 2000 }, 17 + base_path: { type: "string" }, 18 description: { type: "string", maxLength: 2000 }, 19 icon: { type: "blob", accept: ["image/*"], maxSize: 1000000 }, 20 theme: { type: "ref", ref: "#theme" },
+1 -1
middleware.ts
··· 83 let aturi = new AtUri(pub?.uri); 84 return NextResponse.rewrite( 85 new URL( 86 - `/lish/${aturi.host}/${encodeURIComponent(pub.name)}${req.nextUrl.pathname}`, 87 req.url, 88 ), 89 );
··· 83 let aturi = new AtUri(pub?.uri); 84 return NextResponse.rewrite( 85 new URL( 86 + `/lish/${aturi.host}/${aturi.rkey}${req.nextUrl.pathname}`, 87 req.url, 88 ), 89 );
+1 -1
next-env.d.ts
··· 1 /// <reference types="next" /> 2 /// <reference types="next/image-types/global" /> 3 - /// <reference path="./.next/types/routes.d.ts" /> 4 5 // NOTE: This file should not be edited 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
··· 1 /// <reference types="next" /> 2 /// <reference types="next/image-types/global" /> 3 + import "./.next/dev/types/routes.d.ts"; 4 5 // NOTE: This file should not be edited 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -1
next.config.js
··· 21 }, 22 ]; 23 }, 24 pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"], 25 images: { 26 loader: "custom", ··· 30 { protocol: "https", hostname: "bdefzwcumgzjwllsnaej.supabase.co" }, 31 ], 32 }, 33 experimental: { 34 - reactCompiler: true, 35 serverActions: { 36 bodySizeLimit: "5mb", 37 },
··· 21 }, 22 ]; 23 }, 24 + serverExternalPackages: ["yjs", "pino"], 25 pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"], 26 images: { 27 loader: "custom", ··· 31 { protocol: "https", hostname: "bdefzwcumgzjwllsnaej.supabase.co" }, 32 ], 33 }, 34 + reactCompiler: true, 35 experimental: { 36 serverActions: { 37 bodySizeLimit: "5mb", 38 },
+2775 -538
package-lock.json
··· 21 "@hono/node-server": "^1.14.3", 22 "@mdx-js/loader": "^3.1.0", 23 "@mdx-js/react": "^3.1.0", 24 - "@next/bundle-analyzer": "^15.3.2", 25 - "@next/mdx": "15.3.2", 26 "@radix-ui/react-dialog": "^1.1.15", 27 "@radix-ui/react-dropdown-menu": "^2.1.16", 28 "@radix-ui/react-popover": "^1.1.15", ··· 44 "feed": "^5.1.0", 45 "fractional-indexing": "^3.2.0", 46 "hono": "^4.7.11", 47 "inngest": "^3.40.1", 48 "ioredis": "^5.6.1", 49 "katex": "^0.16.22", 50 "linkifyjs": "^4.2.0", 51 "multiformats": "^13.3.2", 52 - "next": "^15.5.3", 53 "pg": "^8.16.3", 54 "prosemirror-commands": "^1.5.2", 55 "prosemirror-inputrules": "^1.4.0", ··· 57 "prosemirror-model": "^1.21.0", 58 "prosemirror-schema-basic": "^1.2.2", 59 "prosemirror-state": "^1.4.3", 60 - "react": "^19.1.1", 61 "react-aria-components": "^1.8.0", 62 "react-day-picker": "^9.3.0", 63 - "react-dom": "^19.1.1", 64 "react-use-measure": "^2.1.1", 65 "redlock": "^5.0.0-beta.2", 66 "rehype-parse": "^9.0.0", ··· 71 "remark-rehype": "^11.1.0", 72 "remark-stringify": "^11.0.0", 73 "replicache": "^15.3.0", 74 - "sharp": "^0.34.2", 75 "shiki": "^3.8.1", 76 "swr": "^2.3.3", 77 "thumbhash": "^0.1.1", ··· 88 "@cloudflare/workers-types": "^4.20240512.0", 89 "@tailwindcss/postcss": "^4.1.13", 90 "@types/katex": "^0.16.7", 91 "@types/node": "^22.15.17", 92 - "@types/react": "19.1.3", 93 - "@types/react-dom": "19.1.3", 94 "@types/uuid": "^10.0.0", 95 "drizzle-kit": "^0.21.2", 96 "esbuild": "^0.25.4", 97 - "eslint": "8.57.0", 98 - "eslint-config-next": "^15.5.3", 99 "postcss": "^8.4.38", 100 "prettier": "3.2.5", 101 "supabase": "^1.187.3", ··· 564 "node": ">=18.7.0" 565 } 566 }, 567 "node_modules/@babel/helper-string-parser": { 568 "version": "7.27.1", 569 "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", ··· 574 } 575 }, 576 "node_modules/@babel/helper-validator-identifier": { 577 "version": "7.27.1", 578 - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", 579 - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", 580 - "license": "MIT", 581 "engines": { 582 "node": ">=6.9.0" 583 } 584 }, 585 "node_modules/@babel/types": { 586 - "version": "7.27.1", 587 - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", 588 - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", 589 - "license": "MIT", 590 "dependencies": { 591 "@babel/helper-string-parser": "^7.27.1", 592 - "@babel/helper-validator-identifier": "^7.27.1" 593 }, 594 "engines": { 595 "node": ">=6.9.0" ··· 692 "node": ">=10.0.0" 693 } 694 }, 695 "node_modules/@esbuild-kit/core-utils": { 696 "version": "3.3.2", 697 "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", ··· 702 "source-map-support": "^0.5.21" 703 } 704 }, 705 "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { 706 "version": "0.18.20", 707 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", ··· 718 "node": ">=12" 719 } 720 }, 721 "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { 722 "version": "0.18.20", 723 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", ··· 787 "esbuild": "*" 788 } 789 }, 790 "node_modules/@esbuild/linux-x64": { 791 "version": "0.25.4", 792 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", ··· 804 "node": ">=18" 805 } 806 }, 807 "node_modules/@eslint-community/eslint-utils": { 808 - "version": "4.7.0", 809 - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", 810 - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", 811 "dev": true, 812 - "license": "MIT", 813 "dependencies": { 814 "eslint-visitor-keys": "^3.4.3" 815 }, ··· 823 "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" 824 } 825 }, 826 "node_modules/@eslint-community/regexpp": { 827 - "version": "4.10.0", 828 - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", 829 - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", 830 "dev": true, 831 "engines": { 832 "node": "^12.0.0 || ^14.0.0 || >=16.0.0" 833 } 834 }, 835 "node_modules/@eslint/eslintrc": { 836 - "version": "2.1.4", 837 - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", 838 - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", 839 "dev": true, 840 "dependencies": { 841 "ajv": "^6.12.4", 842 "debug": "^4.3.2", 843 - "espree": "^9.6.0", 844 - "globals": "^13.19.0", 845 "ignore": "^5.2.0", 846 "import-fresh": "^3.2.1", 847 "js-yaml": "^4.1.0", ··· 849 "strip-json-comments": "^3.1.1" 850 }, 851 "engines": { 852 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 853 }, 854 "funding": { 855 "url": "https://opencollective.com/eslint" 856 } 857 }, 858 "node_modules/@eslint/js": { 859 - "version": "8.57.0", 860 - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", 861 - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", 862 "dev": true, 863 "engines": { 864 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 865 } 866 }, 867 "node_modules/@fastify/busboy": { ··· 1005 "hono": "^4" 1006 } 1007 }, 1008 - "node_modules/@humanwhocodes/config-array": { 1009 - "version": "0.11.14", 1010 - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", 1011 - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", 1012 "dev": true, 1013 "dependencies": { 1014 - "@humanwhocodes/object-schema": "^2.0.2", 1015 - "debug": "^4.3.1", 1016 - "minimatch": "^3.0.5" 1017 }, 1018 "engines": { 1019 - "node": ">=10.10.0" 1020 } 1021 }, 1022 "node_modules/@humanwhocodes/module-importer": { ··· 1032 "url": "https://github.com/sponsors/nzakas" 1033 } 1034 }, 1035 - "node_modules/@humanwhocodes/object-schema": { 1036 - "version": "2.0.3", 1037 - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", 1038 - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", 1039 - "dev": true 1040 }, 1041 "node_modules/@img/sharp-libvips-linux-x64": { 1042 - "version": "1.2.0", 1043 - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", 1044 - "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", 1045 "cpu": [ 1046 "x64" 1047 ], 1048 - "license": "LGPL-3.0-or-later", 1049 "optional": true, 1050 "os": [ 1051 "linux" ··· 1054 "url": "https://opencollective.com/libvips" 1055 } 1056 }, 1057 "node_modules/@img/sharp-linux-x64": { 1058 - "version": "0.34.3", 1059 - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", 1060 - "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", 1061 "cpu": [ 1062 "x64" 1063 ], 1064 - "license": "Apache-2.0", 1065 "optional": true, 1066 "os": [ 1067 "linux" ··· 1073 "url": "https://opencollective.com/libvips" 1074 }, 1075 "optionalDependencies": { 1076 - "@img/sharp-libvips-linux-x64": "1.2.0" 1077 } 1078 }, 1079 "node_modules/@inngest/ai": { ··· 1319 } 1320 }, 1321 "node_modules/@next/bundle-analyzer": { 1322 - "version": "15.3.2", 1323 - "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-15.3.2.tgz", 1324 - "integrity": "sha512-zY5O1PNKNxWEjaFX8gKzm77z2oL0cnj+m5aiqNBgay9LPLCDO13Cf+FJONeNq/nJjeXptwHFT9EMmTecF9U4Iw==", 1325 - "license": "MIT", 1326 "dependencies": { 1327 "webpack-bundle-analyzer": "4.10.1" 1328 } 1329 }, 1330 "node_modules/@next/env": { 1331 - "version": "15.5.3", 1332 - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz", 1333 - "integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==", 1334 "license": "MIT" 1335 }, 1336 "node_modules/@next/eslint-plugin-next": { 1337 - "version": "15.5.3", 1338 - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.3.tgz", 1339 - "integrity": "sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==", 1340 "dev": true, 1341 - "license": "MIT", 1342 "dependencies": { 1343 "fast-glob": "3.3.1" 1344 } ··· 1348 "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", 1349 "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", 1350 "dev": true, 1351 - "license": "MIT", 1352 "dependencies": { 1353 "@nodelib/fs.stat": "^2.0.2", 1354 "@nodelib/fs.walk": "^1.2.3", ··· 1365 "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", 1366 "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", 1367 "dev": true, 1368 - "license": "ISC", 1369 "dependencies": { 1370 "is-glob": "^4.0.1" 1371 }, ··· 1374 } 1375 }, 1376 "node_modules/@next/mdx": { 1377 - "version": "15.3.2", 1378 - "resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-15.3.2.tgz", 1379 - "integrity": "sha512-D6lSSbVzn1EiPwrBKG5QzXClcgdqiNCL8a3/6oROinzgZnYSxbVmnfs0UrqygtGSOmgW7sdJJSEOy555DoAwvw==", 1380 - "license": "MIT", 1381 "dependencies": { 1382 "source-map": "^0.7.0" 1383 }, ··· 1403 } 1404 }, 1405 "node_modules/@next/swc-darwin-arm64": { 1406 - "version": "15.5.3", 1407 - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz", 1408 - "integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==", 1409 "cpu": [ 1410 "arm64" 1411 ], ··· 1419 } 1420 }, 1421 "node_modules/@next/swc-darwin-x64": { 1422 - "version": "15.5.3", 1423 - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz", 1424 - "integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==", 1425 "cpu": [ 1426 "x64" 1427 ], ··· 1435 } 1436 }, 1437 "node_modules/@next/swc-linux-arm64-gnu": { 1438 - "version": "15.5.3", 1439 - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz", 1440 - "integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==", 1441 "cpu": [ 1442 "arm64" 1443 ], ··· 1451 } 1452 }, 1453 "node_modules/@next/swc-linux-arm64-musl": { 1454 - "version": "15.5.3", 1455 - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz", 1456 - "integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==", 1457 "cpu": [ 1458 "arm64" 1459 ], ··· 1467 } 1468 }, 1469 "node_modules/@next/swc-linux-x64-gnu": { 1470 - "version": "15.5.3", 1471 - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz", 1472 - "integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==", 1473 "cpu": [ 1474 "x64" 1475 ], ··· 1483 } 1484 }, 1485 "node_modules/@next/swc-linux-x64-musl": { 1486 - "version": "15.5.3", 1487 - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz", 1488 - "integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==", 1489 "cpu": [ 1490 "x64" 1491 ], ··· 1499 } 1500 }, 1501 "node_modules/@next/swc-win32-arm64-msvc": { 1502 - "version": "15.5.3", 1503 - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz", 1504 - "integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==", 1505 "cpu": [ 1506 "arm64" 1507 ], ··· 1515 } 1516 }, 1517 "node_modules/@next/swc-win32-x64-msvc": { 1518 - "version": "15.5.3", 1519 - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz", 1520 - "integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==", 1521 "cpu": [ 1522 "x64" 1523 ], ··· 5619 "dev": true, 5620 "license": "MIT" 5621 }, 5622 - "node_modules/@rushstack/eslint-patch": { 5623 - "version": "1.10.3", 5624 - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz", 5625 - "integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==", 5626 - "dev": true 5627 - }, 5628 "node_modules/@shikijs/core": { 5629 "version": "3.8.1", 5630 "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.8.1.tgz", ··· 6219 "@types/unist": "*" 6220 } 6221 }, 6222 "node_modules/@types/json5": { 6223 "version": "0.0.29", 6224 "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", ··· 6238 "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", 6239 "license": "MIT", 6240 "peer": true 6241 }, 6242 "node_modules/@types/markdown-it": { 6243 "version": "14.1.2", ··· 6338 "integrity": "sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==" 6339 }, 6340 "node_modules/@types/react": { 6341 - "version": "19.1.3", 6342 - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.3.tgz", 6343 - "integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==", 6344 - "license": "MIT", 6345 "dependencies": { 6346 - "csstype": "^3.0.2" 6347 } 6348 }, 6349 "node_modules/@types/react-dom": { 6350 - "version": "19.1.3", 6351 - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz", 6352 - "integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==", 6353 "devOptional": true, 6354 - "license": "MIT", 6355 "peerDependencies": { 6356 - "@types/react": "^19.0.0" 6357 } 6358 }, 6359 "node_modules/@types/shimmer": { ··· 6391 } 6392 }, 6393 "node_modules/@typescript-eslint/eslint-plugin": { 6394 - "version": "8.32.0", 6395 - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz", 6396 - "integrity": "sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==", 6397 "dev": true, 6398 - "license": "MIT", 6399 "dependencies": { 6400 "@eslint-community/regexpp": "^4.10.0", 6401 - "@typescript-eslint/scope-manager": "8.32.0", 6402 - "@typescript-eslint/type-utils": "8.32.0", 6403 - "@typescript-eslint/utils": "8.32.0", 6404 - "@typescript-eslint/visitor-keys": "8.32.0", 6405 "graphemer": "^1.4.0", 6406 - "ignore": "^5.3.1", 6407 "natural-compare": "^1.4.0", 6408 "ts-api-utils": "^2.1.0" 6409 }, ··· 6415 "url": "https://opencollective.com/typescript-eslint" 6416 }, 6417 "peerDependencies": { 6418 - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", 6419 "eslint": "^8.57.0 || ^9.0.0", 6420 - "typescript": ">=4.8.4 <5.9.0" 6421 } 6422 }, 6423 "node_modules/@typescript-eslint/parser": { 6424 - "version": "8.32.0", 6425 - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.0.tgz", 6426 - "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", 6427 "dev": true, 6428 - "license": "MIT", 6429 "dependencies": { 6430 - "@typescript-eslint/scope-manager": "8.32.0", 6431 - "@typescript-eslint/types": "8.32.0", 6432 - "@typescript-eslint/typescript-estree": "8.32.0", 6433 - "@typescript-eslint/visitor-keys": "8.32.0", 6434 "debug": "^4.3.4" 6435 }, 6436 "engines": { ··· 6442 }, 6443 "peerDependencies": { 6444 "eslint": "^8.57.0 || ^9.0.0", 6445 - "typescript": ">=4.8.4 <5.9.0" 6446 } 6447 }, 6448 "node_modules/@typescript-eslint/scope-manager": { 6449 - "version": "8.32.0", 6450 - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz", 6451 - "integrity": "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==", 6452 "dev": true, 6453 - "license": "MIT", 6454 "dependencies": { 6455 - "@typescript-eslint/types": "8.32.0", 6456 - "@typescript-eslint/visitor-keys": "8.32.0" 6457 }, 6458 "engines": { 6459 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" ··· 6463 "url": "https://opencollective.com/typescript-eslint" 6464 } 6465 }, 6466 "node_modules/@typescript-eslint/type-utils": { 6467 - "version": "8.32.0", 6468 - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz", 6469 - "integrity": "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==", 6470 "dev": true, 6471 - "license": "MIT", 6472 "dependencies": { 6473 - "@typescript-eslint/typescript-estree": "8.32.0", 6474 - "@typescript-eslint/utils": "8.32.0", 6475 "debug": "^4.3.4", 6476 "ts-api-utils": "^2.1.0" 6477 }, ··· 6484 }, 6485 "peerDependencies": { 6486 "eslint": "^8.57.0 || ^9.0.0", 6487 - "typescript": ">=4.8.4 <5.9.0" 6488 } 6489 }, 6490 "node_modules/@typescript-eslint/types": { 6491 - "version": "8.32.0", 6492 - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.0.tgz", 6493 - "integrity": "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==", 6494 "dev": true, 6495 - "license": "MIT", 6496 "engines": { 6497 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 6498 }, ··· 6502 } 6503 }, 6504 "node_modules/@typescript-eslint/typescript-estree": { 6505 - "version": "8.32.0", 6506 - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz", 6507 - "integrity": "sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==", 6508 "dev": true, 6509 - "license": "MIT", 6510 "dependencies": { 6511 - "@typescript-eslint/types": "8.32.0", 6512 - "@typescript-eslint/visitor-keys": "8.32.0", 6513 "debug": "^4.3.4", 6514 "fast-glob": "^3.3.2", 6515 "is-glob": "^4.0.3", ··· 6525 "url": "https://opencollective.com/typescript-eslint" 6526 }, 6527 "peerDependencies": { 6528 - "typescript": ">=4.8.4 <5.9.0" 6529 } 6530 }, 6531 "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { 6532 - "version": "2.0.1", 6533 - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", 6534 - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", 6535 "dev": true, 6536 - "license": "MIT", 6537 "dependencies": { 6538 "balanced-match": "^1.0.0" 6539 } ··· 6543 "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", 6544 "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 6545 "dev": true, 6546 - "license": "ISC", 6547 "dependencies": { 6548 "brace-expansion": "^2.0.1" 6549 }, ··· 6555 } 6556 }, 6557 "node_modules/@typescript-eslint/utils": { 6558 - "version": "8.32.0", 6559 - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.0.tgz", 6560 - "integrity": "sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==", 6561 "dev": true, 6562 - "license": "MIT", 6563 "dependencies": { 6564 "@eslint-community/eslint-utils": "^4.7.0", 6565 - "@typescript-eslint/scope-manager": "8.32.0", 6566 - "@typescript-eslint/types": "8.32.0", 6567 - "@typescript-eslint/typescript-estree": "8.32.0" 6568 }, 6569 "engines": { 6570 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" ··· 6575 }, 6576 "peerDependencies": { 6577 "eslint": "^8.57.0 || ^9.0.0", 6578 - "typescript": ">=4.8.4 <5.9.0" 6579 } 6580 }, 6581 "node_modules/@typescript-eslint/visitor-keys": { 6582 - "version": "8.32.0", 6583 - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz", 6584 - "integrity": "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==", 6585 "dev": true, 6586 - "license": "MIT", 6587 "dependencies": { 6588 - "@typescript-eslint/types": "8.32.0", 6589 - "eslint-visitor-keys": "^4.2.0" 6590 }, 6591 "engines": { 6592 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" ··· 6596 "url": "https://opencollective.com/typescript-eslint" 6597 } 6598 }, 6599 - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { 6600 - "version": "4.2.0", 6601 - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", 6602 - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", 6603 - "dev": true, 6604 - "license": "Apache-2.0", 6605 - "engines": { 6606 - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 6607 - }, 6608 - "funding": { 6609 - "url": "https://opencollective.com/eslint" 6610 - } 6611 - }, 6612 "node_modules/@ungap/structured-clone": { 6613 "version": "1.2.0", 6614 "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", ··· 6782 "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", 6783 "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", 6784 "dev": true, 6785 - "license": "MIT", 6786 "dependencies": { 6787 "fast-deep-equal": "^3.1.3", 6788 "fast-uri": "^3.0.1", ··· 6879 "license": "MIT" 6880 }, 6881 "node_modules/array-includes": { 6882 - "version": "3.1.8", 6883 - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", 6884 - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", 6885 "dev": true, 6886 "dependencies": { 6887 - "call-bind": "^1.0.7", 6888 "define-properties": "^1.2.1", 6889 - "es-abstract": "^1.23.2", 6890 - "es-object-atoms": "^1.0.0", 6891 - "get-intrinsic": "^1.2.4", 6892 - "is-string": "^1.0.7" 6893 }, 6894 "engines": { 6895 "node": ">= 0.4" ··· 6920 } 6921 }, 6922 "node_modules/array.prototype.findlastindex": { 6923 - "version": "1.2.5", 6924 - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", 6925 - "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", 6926 "dev": true, 6927 "dependencies": { 6928 - "call-bind": "^1.0.7", 6929 "define-properties": "^1.2.1", 6930 - "es-abstract": "^1.23.2", 6931 "es-errors": "^1.3.0", 6932 - "es-object-atoms": "^1.0.0", 6933 - "es-shim-unscopables": "^1.0.2" 6934 }, 6935 "engines": { 6936 "node": ">= 0.4" ··· 6940 } 6941 }, 6942 "node_modules/array.prototype.flat": { 6943 - "version": "1.3.2", 6944 - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", 6945 - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", 6946 "dev": true, 6947 "dependencies": { 6948 - "call-bind": "^1.0.2", 6949 - "define-properties": "^1.2.0", 6950 - "es-abstract": "^1.22.1", 6951 - "es-shim-unscopables": "^1.0.0" 6952 }, 6953 "engines": { 6954 "node": ">= 0.4" ··· 7159 } 7160 ] 7161 }, 7162 "node_modules/bignumber.js": { 7163 "version": "9.3.1", 7164 "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", ··· 7277 "node": ">=8" 7278 } 7279 }, 7280 "node_modules/buffer": { 7281 "version": "6.0.3", 7282 "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", ··· 7380 } 7381 }, 7382 "node_modules/caniuse-lite": { 7383 - "version": "1.0.30001717", 7384 - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz", 7385 - "integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==", 7386 "funding": [ 7387 { 7388 "type": "opencollective", ··· 7396 "type": "github", 7397 "url": "https://github.com/sponsors/ai" 7398 } 7399 - ], 7400 - "license": "CC-BY-4.0" 7401 }, 7402 "node_modules/canonicalize": { 7403 "version": "1.0.8", ··· 7643 "url": "https://github.com/sponsors/wooorm" 7644 } 7645 }, 7646 - "node_modules/color": { 7647 - "version": "4.2.3", 7648 - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", 7649 - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", 7650 - "license": "MIT", 7651 - "dependencies": { 7652 - "color-convert": "^2.0.1", 7653 - "color-string": "^1.9.0" 7654 - }, 7655 - "engines": { 7656 - "node": ">=12.5.0" 7657 - } 7658 - }, 7659 "node_modules/color-convert": { 7660 "version": "2.0.1", 7661 "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", ··· 7672 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 7673 "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 7674 }, 7675 - "node_modules/color-string": { 7676 - "version": "1.9.1", 7677 - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", 7678 - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", 7679 - "license": "MIT", 7680 - "dependencies": { 7681 - "color-name": "^1.0.0", 7682 - "simple-swizzle": "^0.2.2" 7683 - } 7684 - }, 7685 "node_modules/colorjs.io": { 7686 "version": "0.5.2", 7687 "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", ··· 7743 "node": ">= 0.6" 7744 } 7745 }, 7746 "node_modules/cookie": { 7747 "version": "0.5.0", 7748 "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", ··· 7794 } 7795 }, 7796 "node_modules/cross-spawn": { 7797 - "version": "7.0.3", 7798 - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", 7799 - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", 7800 "dev": true, 7801 "dependencies": { 7802 "path-key": "^3.1.0", ··· 7808 } 7809 }, 7810 "node_modules/csstype": { 7811 - "version": "3.1.3", 7812 - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", 7813 - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" 7814 }, 7815 "node_modules/d": { 7816 "version": "1.0.2", ··· 8035 } 8036 }, 8037 "node_modules/detect-libc": { 8038 - "version": "2.0.4", 8039 - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", 8040 - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", 8041 - "license": "Apache-2.0", 8042 "engines": { 8043 "node": ">=8" 8044 } ··· 8073 "node": "*" 8074 } 8075 }, 8076 - "node_modules/doctrine": { 8077 - "version": "3.0.0", 8078 - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", 8079 - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", 8080 - "dev": true, 8081 - "dependencies": { 8082 - "esutils": "^2.0.2" 8083 - }, 8084 - "engines": { 8085 - "node": ">=6.0.0" 8086 - } 8087 - }, 8088 "node_modules/dreamopt": { 8089 "version": "0.8.0", 8090 "resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz", ··· 8117 "drizzle-kit": "bin.cjs" 8118 } 8119 }, 8120 "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { 8121 "version": "0.19.12", 8122 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", ··· 8134 "node": ">=12" 8135 } 8136 }, 8137 "node_modules/drizzle-kit/node_modules/esbuild": { 8138 "version": "0.19.12", 8139 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", ··· 8317 "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", 8318 "license": "MIT" 8319 }, 8320 "node_modules/emoji-regex": { 8321 "version": "9.2.2", 8322 "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", ··· 8370 } 8371 }, 8372 "node_modules/es-abstract": { 8373 - "version": "1.23.9", 8374 - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", 8375 - "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", 8376 "dev": true, 8377 - "license": "MIT", 8378 "dependencies": { 8379 "array-buffer-byte-length": "^1.0.2", 8380 "arraybuffer.prototype.slice": "^1.0.4", 8381 "available-typed-arrays": "^1.0.7", 8382 "call-bind": "^1.0.8", 8383 - "call-bound": "^1.0.3", 8384 "data-view-buffer": "^1.0.2", 8385 "data-view-byte-length": "^1.0.2", 8386 "data-view-byte-offset": "^1.0.1", 8387 "es-define-property": "^1.0.1", 8388 "es-errors": "^1.3.0", 8389 - "es-object-atoms": "^1.0.0", 8390 "es-set-tostringtag": "^2.1.0", 8391 "es-to-primitive": "^1.3.0", 8392 "function.prototype.name": "^1.1.8", 8393 - "get-intrinsic": "^1.2.7", 8394 - "get-proto": "^1.0.0", 8395 "get-symbol-description": "^1.1.0", 8396 "globalthis": "^1.0.4", 8397 "gopd": "^1.2.0", ··· 8403 "is-array-buffer": "^3.0.5", 8404 "is-callable": "^1.2.7", 8405 "is-data-view": "^1.0.2", 8406 "is-regex": "^1.2.1", 8407 "is-shared-array-buffer": "^1.0.4", 8408 "is-string": "^1.1.1", 8409 "is-typed-array": "^1.1.15", 8410 - "is-weakref": "^1.1.0", 8411 "math-intrinsics": "^1.1.0", 8412 - "object-inspect": "^1.13.3", 8413 "object-keys": "^1.1.1", 8414 "object.assign": "^4.1.7", 8415 "own-keys": "^1.0.1", 8416 - "regexp.prototype.flags": "^1.5.3", 8417 "safe-array-concat": "^1.1.3", 8418 "safe-push-apply": "^1.0.0", 8419 "safe-regex-test": "^1.1.0", 8420 "set-proto": "^1.0.0", 8421 "string.prototype.trim": "^1.2.10", 8422 "string.prototype.trimend": "^1.0.9", 8423 "string.prototype.trimstart": "^1.0.8", ··· 8426 "typed-array-byte-offset": "^1.0.4", 8427 "typed-array-length": "^1.0.7", 8428 "unbox-primitive": "^1.1.0", 8429 - "which-typed-array": "^1.1.18" 8430 }, 8431 "engines": { 8432 "node": ">= 0.4" ··· 8509 } 8510 }, 8511 "node_modules/es-shim-unscopables": { 8512 - "version": "1.0.2", 8513 - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", 8514 - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", 8515 "dev": true, 8516 "dependencies": { 8517 - "hasown": "^2.0.0" 8518 } 8519 }, 8520 "node_modules/es-to-primitive": { ··· 8670 "esbuild": ">=0.12 <1" 8671 } 8672 }, 8673 "node_modules/escalade": { 8674 - "version": "3.1.2", 8675 - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", 8676 - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", 8677 "engines": { 8678 "node": ">=6" 8679 } ··· 8696 } 8697 }, 8698 "node_modules/eslint": { 8699 - "version": "8.57.0", 8700 - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", 8701 - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", 8702 "dev": true, 8703 "dependencies": { 8704 - "@eslint-community/eslint-utils": "^4.2.0", 8705 - "@eslint-community/regexpp": "^4.6.1", 8706 - "@eslint/eslintrc": "^2.1.4", 8707 - "@eslint/js": "8.57.0", 8708 - "@humanwhocodes/config-array": "^0.11.14", 8709 "@humanwhocodes/module-importer": "^1.0.1", 8710 - "@nodelib/fs.walk": "^1.2.8", 8711 - "@ungap/structured-clone": "^1.2.0", 8712 "ajv": "^6.12.4", 8713 "chalk": "^4.0.0", 8714 - "cross-spawn": "^7.0.2", 8715 "debug": "^4.3.2", 8716 - "doctrine": "^3.0.0", 8717 "escape-string-regexp": "^4.0.0", 8718 - "eslint-scope": "^7.2.2", 8719 - "eslint-visitor-keys": "^3.4.3", 8720 - "espree": "^9.6.1", 8721 - "esquery": "^1.4.2", 8722 "esutils": "^2.0.2", 8723 "fast-deep-equal": "^3.1.3", 8724 - "file-entry-cache": "^6.0.1", 8725 "find-up": "^5.0.0", 8726 "glob-parent": "^6.0.2", 8727 - "globals": "^13.19.0", 8728 - "graphemer": "^1.4.0", 8729 "ignore": "^5.2.0", 8730 "imurmurhash": "^0.1.4", 8731 "is-glob": "^4.0.0", 8732 - "is-path-inside": "^3.0.3", 8733 - "js-yaml": "^4.1.0", 8734 "json-stable-stringify-without-jsonify": "^1.0.1", 8735 - "levn": "^0.4.1", 8736 "lodash.merge": "^4.6.2", 8737 "minimatch": "^3.1.2", 8738 "natural-compare": "^1.4.0", 8739 - "optionator": "^0.9.3", 8740 - "strip-ansi": "^6.0.1", 8741 - "text-table": "^0.2.0" 8742 }, 8743 "bin": { 8744 "eslint": "bin/eslint.js" 8745 }, 8746 "engines": { 8747 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 8748 }, 8749 "funding": { 8750 - "url": "https://opencollective.com/eslint" 8751 } 8752 }, 8753 "node_modules/eslint-config-next": { 8754 - "version": "15.5.3", 8755 - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.3.tgz", 8756 - "integrity": "sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==", 8757 "dev": true, 8758 - "license": "MIT", 8759 "dependencies": { 8760 - "@next/eslint-plugin-next": "15.5.3", 8761 - "@rushstack/eslint-patch": "^1.10.3", 8762 - "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", 8763 - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", 8764 "eslint-import-resolver-node": "^0.3.6", 8765 "eslint-import-resolver-typescript": "^3.5.2", 8766 - "eslint-plugin-import": "^2.31.0", 8767 "eslint-plugin-jsx-a11y": "^6.10.0", 8768 "eslint-plugin-react": "^7.37.0", 8769 - "eslint-plugin-react-hooks": "^5.0.0" 8770 }, 8771 "peerDependencies": { 8772 - "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", 8773 "typescript": ">=3.3.1" 8774 }, 8775 "peerDependenciesMeta": { 8776 "typescript": { 8777 "optional": true 8778 } 8779 } 8780 }, 8781 "node_modules/eslint-import-resolver-node": { ··· 8824 } 8825 }, 8826 "node_modules/eslint-module-utils": { 8827 - "version": "2.12.0", 8828 - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", 8829 - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", 8830 "dev": true, 8831 - "license": "MIT", 8832 "dependencies": { 8833 "debug": "^3.2.7" 8834 }, ··· 8851 } 8852 }, 8853 "node_modules/eslint-plugin-import": { 8854 - "version": "2.31.0", 8855 - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", 8856 - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", 8857 "dev": true, 8858 - "license": "MIT", 8859 "dependencies": { 8860 "@rtsao/scc": "^1.1.0", 8861 - "array-includes": "^3.1.8", 8862 - "array.prototype.findlastindex": "^1.2.5", 8863 - "array.prototype.flat": "^1.3.2", 8864 - "array.prototype.flatmap": "^1.3.2", 8865 "debug": "^3.2.7", 8866 "doctrine": "^2.1.0", 8867 "eslint-import-resolver-node": "^0.3.9", 8868 - "eslint-module-utils": "^2.12.0", 8869 "hasown": "^2.0.2", 8870 - "is-core-module": "^2.15.1", 8871 "is-glob": "^4.0.3", 8872 "minimatch": "^3.1.2", 8873 "object.fromentries": "^2.0.8", 8874 "object.groupby": "^1.0.3", 8875 - "object.values": "^1.2.0", 8876 "semver": "^6.3.1", 8877 - "string.prototype.trimend": "^1.0.8", 8878 "tsconfig-paths": "^3.15.0" 8879 }, 8880 "engines": { ··· 8978 } 8979 }, 8980 "node_modules/eslint-plugin-react-hooks": { 8981 - "version": "5.2.0", 8982 - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", 8983 - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", 8984 "dev": true, 8985 - "license": "MIT", 8986 "engines": { 8987 - "node": ">=10" 8988 }, 8989 "peerDependencies": { 8990 "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" 8991 } 8992 }, 8993 "node_modules/eslint-plugin-react/node_modules/doctrine": { 8994 "version": "2.1.0", 8995 "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", ··· 9032 } 9033 }, 9034 "node_modules/eslint-scope": { 9035 - "version": "7.2.2", 9036 - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", 9037 - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", 9038 "dev": true, 9039 "dependencies": { 9040 "esrecurse": "^4.3.0", 9041 "estraverse": "^5.2.0" 9042 }, 9043 "engines": { 9044 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 9045 }, 9046 "funding": { 9047 "url": "https://opencollective.com/eslint" 9048 } 9049 }, 9050 "node_modules/eslint-visitor-keys": { 9051 - "version": "3.4.3", 9052 - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", 9053 - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", 9054 "dev": true, 9055 "engines": { 9056 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 9057 }, 9058 "funding": { 9059 "url": "https://opencollective.com/eslint" ··· 9075 } 9076 }, 9077 "node_modules/espree": { 9078 - "version": "9.6.1", 9079 - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", 9080 - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", 9081 "dev": true, 9082 "dependencies": { 9083 - "acorn": "^8.9.0", 9084 "acorn-jsx": "^5.3.2", 9085 - "eslint-visitor-keys": "^3.4.1" 9086 }, 9087 "engines": { 9088 - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 9089 }, 9090 "funding": { 9091 "url": "https://opencollective.com/eslint" ··· 9441 } 9442 }, 9443 "node_modules/fast-uri": { 9444 - "version": "3.0.5", 9445 - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz", 9446 - "integrity": "sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==", 9447 "dev": true, 9448 "funding": [ 9449 { ··· 9454 "type": "opencollective", 9455 "url": "https://opencollective.com/fastify" 9456 } 9457 - ], 9458 - "license": "BSD-3-Clause" 9459 }, 9460 "node_modules/fastq": { 9461 "version": "1.17.1", ··· 9503 } 9504 }, 9505 "node_modules/file-entry-cache": { 9506 - "version": "6.0.1", 9507 - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", 9508 - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", 9509 "dev": true, 9510 "dependencies": { 9511 - "flat-cache": "^3.0.4" 9512 }, 9513 "engines": { 9514 - "node": "^10.12.0 || >=12.0.0" 9515 } 9516 }, 9517 "node_modules/fill-range": { ··· 9576 } 9577 }, 9578 "node_modules/flat-cache": { 9579 - "version": "3.2.0", 9580 - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", 9581 - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", 9582 "dev": true, 9583 "dependencies": { 9584 "flatted": "^3.2.9", 9585 - "keyv": "^4.5.3", 9586 - "rimraf": "^3.0.2" 9587 }, 9588 "engines": { 9589 - "node": "^10.12.0 || >=12.0.0" 9590 } 9591 }, 9592 "node_modules/flatted": { 9593 - "version": "3.3.1", 9594 - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", 9595 - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", 9596 "dev": true 9597 }, 9598 "node_modules/follow-redirects": { ··· 9794 "node": ">=14" 9795 } 9796 }, 9797 "node_modules/get-caller-file": { 9798 "version": "2.0.5", 9799 "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", ··· 9955 } 9956 }, 9957 "node_modules/globals": { 9958 - "version": "13.24.0", 9959 - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", 9960 - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", 9961 "dev": true, 9962 - "dependencies": { 9963 - "type-fest": "^0.20.2" 9964 - }, 9965 "engines": { 9966 - "node": ">=8" 9967 }, 9968 "funding": { 9969 "url": "https://github.com/sponsors/sindresorhus" ··· 10423 "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", 10424 "dev": true 10425 }, 10426 "node_modules/hono": { 10427 "version": "4.7.11", 10428 "resolved": "https://registry.npmjs.org/hono/-/hono-4.7.11.tgz", ··· 10517 } 10518 }, 10519 "node_modules/immer": { 10520 - "version": "10.1.1", 10521 - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", 10522 - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", 10523 - "optional": true, 10524 - "peer": true, 10525 "funding": { 10526 "type": "opencollective", 10527 "url": "https://opencollective.com/immer" 10528 } 10529 }, 10530 "node_modules/import-fresh": { 10531 - "version": "3.3.0", 10532 - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", 10533 - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", 10534 "dev": true, 10535 "dependencies": { 10536 "parent-module": "^1.0.0", ··· 10791 "funding": { 10792 "url": "https://github.com/sponsors/ljharb" 10793 } 10794 - }, 10795 - "node_modules/is-arrayish": { 10796 - "version": "0.3.2", 10797 - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", 10798 - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", 10799 - "license": "MIT" 10800 }, 10801 "node_modules/is-async-function": { 10802 "version": "2.1.1", ··· 11021 "url": "https://github.com/sponsors/ljharb" 11022 } 11023 }, 11024 "node_modules/is-number": { 11025 "version": "7.0.0", 11026 "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", ··· 11045 }, 11046 "funding": { 11047 "url": "https://github.com/sponsors/ljharb" 11048 - } 11049 - }, 11050 - "node_modules/is-path-inside": { 11051 - "version": "3.0.3", 11052 - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", 11053 - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", 11054 - "dev": true, 11055 - "engines": { 11056 - "node": ">=8" 11057 } 11058 }, 11059 "node_modules/is-plain-obj": { ··· 11312 "license": "MIT" 11313 }, 11314 "node_modules/js-yaml": { 11315 - "version": "4.1.0", 11316 - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", 11317 - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", 11318 "dev": true, 11319 "dependencies": { 11320 "argparse": "^2.0.1" 11321 }, 11322 "bin": { 11323 "js-yaml": "bin/js-yaml.js" 11324 } 11325 }, 11326 "node_modules/json-bigint": { ··· 11359 "version": "1.0.0", 11360 "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", 11361 "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", 11362 - "dev": true, 11363 - "license": "MIT" 11364 }, 11365 "node_modules/json-stable-stringify-without-jsonify": { 11366 "version": "1.0.1", ··· 11478 "dependencies": { 11479 "json-buffer": "3.0.1" 11480 } 11481 }, 11482 "node_modules/language-subtag-registry": { 11483 "version": "0.3.23", ··· 11917 "dev": true, 11918 "dependencies": { 11919 "es5-ext": "~0.10.2" 11920 } 11921 }, 11922 "node_modules/magic-string": { ··· 13218 } 13219 }, 13220 "node_modules/next": { 13221 - "version": "15.5.3", 13222 - "resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz", 13223 - "integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==", 13224 "license": "MIT", 13225 "dependencies": { 13226 - "@next/env": "15.5.3", 13227 "@swc/helpers": "0.5.15", 13228 "caniuse-lite": "^1.0.30001579", 13229 "postcss": "8.4.31", ··· 13233 "next": "dist/bin/next" 13234 }, 13235 "engines": { 13236 - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" 13237 }, 13238 "optionalDependencies": { 13239 - "@next/swc-darwin-arm64": "15.5.3", 13240 - "@next/swc-darwin-x64": "15.5.3", 13241 - "@next/swc-linux-arm64-gnu": "15.5.3", 13242 - "@next/swc-linux-arm64-musl": "15.5.3", 13243 - "@next/swc-linux-x64-gnu": "15.5.3", 13244 - "@next/swc-linux-x64-musl": "15.5.3", 13245 - "@next/swc-win32-arm64-msvc": "15.5.3", 13246 - "@next/swc-win32-x64-msvc": "15.5.3", 13247 - "sharp": "^0.34.3" 13248 }, 13249 "peerDependencies": { 13250 "@opentelemetry/api": "^1.1.0", ··· 13368 "node-gyp-build-optional-packages-optional": "optional.js", 13369 "node-gyp-build-optional-packages-test": "build-test.js" 13370 } 13371 }, 13372 "node_modules/normalize-path": { 13373 "version": "3.0.0", ··· 13736 "dev": true, 13737 "engines": { 13738 "node": ">=8" 13739 - } 13740 - }, 13741 - "node_modules/path-is-absolute": { 13742 - "version": "1.0.1", 13743 - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 13744 - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", 13745 - "dev": true, 13746 - "engines": { 13747 - "node": ">=0.10.0" 13748 } 13749 }, 13750 "node_modules/path-key": { ··· 14435 } 14436 }, 14437 "node_modules/react": { 14438 - "version": "19.1.1", 14439 - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", 14440 - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", 14441 "license": "MIT", 14442 "engines": { 14443 "node": ">=0.10.0" ··· 14557 } 14558 }, 14559 "node_modules/react-dom": { 14560 - "version": "19.1.1", 14561 - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", 14562 - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", 14563 "license": "MIT", 14564 "dependencies": { 14565 - "scheduler": "^0.26.0" 14566 }, 14567 "peerDependencies": { 14568 - "react": "^19.1.1" 14569 } 14570 }, 14571 "node_modules/react-is": { ··· 15078 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", 15079 "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", 15080 "dev": true, 15081 - "license": "MIT", 15082 "engines": { 15083 "node": ">=0.10.0" 15084 } ··· 15150 "node": ">=0.10.0" 15151 } 15152 }, 15153 - "node_modules/rimraf": { 15154 - "version": "3.0.2", 15155 - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", 15156 - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", 15157 - "deprecated": "Rimraf versions prior to v4 are no longer supported", 15158 - "dev": true, 15159 - "dependencies": { 15160 - "glob": "^7.1.3" 15161 - }, 15162 - "bin": { 15163 - "rimraf": "bin.js" 15164 - }, 15165 - "funding": { 15166 - "url": "https://github.com/sponsors/isaacs" 15167 - } 15168 - }, 15169 - "node_modules/rimraf/node_modules/glob": { 15170 - "version": "7.2.3", 15171 - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", 15172 - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", 15173 - "deprecated": "Glob versions prior to v9 are no longer supported", 15174 - "dev": true, 15175 - "dependencies": { 15176 - "fs.realpath": "^1.0.0", 15177 - "inflight": "^1.0.4", 15178 - "inherits": "2", 15179 - "minimatch": "^3.1.1", 15180 - "once": "^1.3.0", 15181 - "path-is-absolute": "^1.0.0" 15182 - }, 15183 - "engines": { 15184 - "node": "*" 15185 - }, 15186 - "funding": { 15187 - "url": "https://github.com/sponsors/isaacs" 15188 - } 15189 - }, 15190 "node_modules/rollup-plugin-inject": { 15191 "version": "3.0.2", 15192 "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", ··· 15344 "license": "ISC" 15345 }, 15346 "node_modules/scheduler": { 15347 - "version": "0.26.0", 15348 - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", 15349 - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", 15350 - "license": "MIT" 15351 }, 15352 "node_modules/scmp": { 15353 "version": "2.1.0", ··· 15519 "license": "ISC" 15520 }, 15521 "node_modules/sharp": { 15522 - "version": "0.34.3", 15523 - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", 15524 - "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", 15525 "hasInstallScript": true, 15526 - "license": "Apache-2.0", 15527 "dependencies": { 15528 - "color": "^4.2.3", 15529 - "detect-libc": "^2.0.4", 15530 "semver": "^7.7.2" 15531 }, 15532 "engines": { ··· 15536 "url": "https://opencollective.com/libvips" 15537 }, 15538 "optionalDependencies": { 15539 - "@img/sharp-darwin-arm64": "0.34.3", 15540 - "@img/sharp-darwin-x64": "0.34.3", 15541 - "@img/sharp-libvips-darwin-arm64": "1.2.0", 15542 - "@img/sharp-libvips-darwin-x64": "1.2.0", 15543 - "@img/sharp-libvips-linux-arm": "1.2.0", 15544 - "@img/sharp-libvips-linux-arm64": "1.2.0", 15545 - "@img/sharp-libvips-linux-ppc64": "1.2.0", 15546 - "@img/sharp-libvips-linux-s390x": "1.2.0", 15547 - "@img/sharp-libvips-linux-x64": "1.2.0", 15548 - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", 15549 - "@img/sharp-libvips-linuxmusl-x64": "1.2.0", 15550 - "@img/sharp-linux-arm": "0.34.3", 15551 - "@img/sharp-linux-arm64": "0.34.3", 15552 - "@img/sharp-linux-ppc64": "0.34.3", 15553 - "@img/sharp-linux-s390x": "0.34.3", 15554 - "@img/sharp-linux-x64": "0.34.3", 15555 - "@img/sharp-linuxmusl-arm64": "0.34.3", 15556 - "@img/sharp-linuxmusl-x64": "0.34.3", 15557 - "@img/sharp-wasm32": "0.34.3", 15558 - "@img/sharp-win32-arm64": "0.34.3", 15559 - "@img/sharp-win32-ia32": "0.34.3", 15560 - "@img/sharp-win32-x64": "0.34.3" 15561 } 15562 }, 15563 "node_modules/shebang-command": { ··· 15686 "url": "https://github.com/sponsors/isaacs" 15687 } 15688 }, 15689 - "node_modules/simple-swizzle": { 15690 - "version": "0.2.2", 15691 - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", 15692 - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", 15693 - "license": "MIT", 15694 - "dependencies": { 15695 - "is-arrayish": "^0.3.1" 15696 - } 15697 - }, 15698 "node_modules/sirv": { 15699 "version": "2.0.4", 15700 "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", ··· 15802 "node": ">= 0.8" 15803 } 15804 }, 15805 "node_modules/stoppable": { 15806 "version": "1.1.0", 15807 "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", ··· 16129 "integrity": "sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==", 16130 "license": "ISC" 16131 }, 16132 - "node_modules/text-table": { 16133 - "version": "0.2.0", 16134 - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", 16135 - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", 16136 - "dev": true 16137 - }, 16138 "node_modules/thread-stream": { 16139 "version": "2.7.0", 16140 "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz", ··· 16290 "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", 16291 "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", 16292 "dev": true, 16293 - "license": "MIT", 16294 "engines": { 16295 "node": ">=18.12" 16296 }, ··· 16408 "node": ">= 0.8.0" 16409 } 16410 }, 16411 - "node_modules/type-fest": { 16412 - "version": "0.20.2", 16413 - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", 16414 - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", 16415 - "dev": true, 16416 - "engines": { 16417 - "node": ">=10" 16418 - }, 16419 - "funding": { 16420 - "url": "https://github.com/sponsors/sindresorhus" 16421 - } 16422 - }, 16423 "node_modules/type-is": { 16424 "version": "1.6.18", 16425 "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", ··· 16524 "node": ">=14.17" 16525 } 16526 }, 16527 "node_modules/uc.micro": { 16528 "version": "2.1.0", 16529 "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", ··· 16695 "node": ">= 0.8" 16696 } 16697 }, 16698 "node_modules/use-callback-ref": { 16699 "version": "1.3.3", 16700 "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", ··· 17104 } 17105 } 17106 }, 17107 "node_modules/wrangler/node_modules/@esbuild/linux-x64": { 17108 "version": "0.17.19", 17109 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", ··· 17115 "optional": true, 17116 "os": [ 17117 "linux" 17118 ], 17119 "engines": { 17120 "node": ">=12"
··· 21 "@hono/node-server": "^1.14.3", 22 "@mdx-js/loader": "^3.1.0", 23 "@mdx-js/react": "^3.1.0", 24 + "@next/bundle-analyzer": "16.0.3", 25 + "@next/mdx": "16.0.3", 26 "@radix-ui/react-dialog": "^1.1.15", 27 "@radix-ui/react-dropdown-menu": "^2.1.16", 28 "@radix-ui/react-popover": "^1.1.15", ··· 44 "feed": "^5.1.0", 45 "fractional-indexing": "^3.2.0", 46 "hono": "^4.7.11", 47 + "immer": "^10.2.0", 48 "inngest": "^3.40.1", 49 "ioredis": "^5.6.1", 50 "katex": "^0.16.22", 51 + "l": "^0.6.0", 52 "linkifyjs": "^4.2.0", 53 + "luxon": "^3.7.2", 54 "multiformats": "^13.3.2", 55 + "next": "^16.0.7", 56 "pg": "^8.16.3", 57 "prosemirror-commands": "^1.5.2", 58 "prosemirror-inputrules": "^1.4.0", ··· 60 "prosemirror-model": "^1.21.0", 61 "prosemirror-schema-basic": "^1.2.2", 62 "prosemirror-state": "^1.4.3", 63 + "react": "19.2.1", 64 "react-aria-components": "^1.8.0", 65 "react-day-picker": "^9.3.0", 66 + "react-dom": "19.2.1", 67 "react-use-measure": "^2.1.1", 68 "redlock": "^5.0.0-beta.2", 69 "rehype-parse": "^9.0.0", ··· 74 "remark-rehype": "^11.1.0", 75 "remark-stringify": "^11.0.0", 76 "replicache": "^15.3.0", 77 + "sharp": "^0.34.4", 78 "shiki": "^3.8.1", 79 "swr": "^2.3.3", 80 "thumbhash": "^0.1.1", ··· 91 "@cloudflare/workers-types": "^4.20240512.0", 92 "@tailwindcss/postcss": "^4.1.13", 93 "@types/katex": "^0.16.7", 94 + "@types/luxon": "^3.7.1", 95 "@types/node": "^22.15.17", 96 + "@types/react": "19.2.6", 97 + "@types/react-dom": "19.2.3", 98 "@types/uuid": "^10.0.0", 99 "drizzle-kit": "^0.21.2", 100 "esbuild": "^0.25.4", 101 + "eslint": "^9.39.1", 102 + "eslint-config-next": "16.0.3", 103 "postcss": "^8.4.38", 104 "prettier": "3.2.5", 105 "supabase": "^1.187.3", ··· 568 "node": ">=18.7.0" 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 + }, 740 "node_modules/@babel/helper-string-parser": { 741 "version": "7.27.1", 742 "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", ··· 747 } 748 }, 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": { 758 "version": "7.27.1", 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 + }, 822 "engines": { 823 "node": ">=6.9.0" 824 } 825 }, 826 "node_modules/@babel/types": { 827 + "version": "7.28.5", 828 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", 829 + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", 830 "dependencies": { 831 "@babel/helper-string-parser": "^7.27.1", 832 + "@babel/helper-validator-identifier": "^7.28.5" 833 }, 834 "engines": { 835 "node": ">=6.9.0" ··· 932 "node": ">=10.0.0" 933 } 934 }, 935 + "node_modules/@emnapi/runtime": { 936 + "version": "1.5.0", 937 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", 938 + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", 939 + "optional": true, 940 + "dependencies": { 941 + "tslib": "^2.4.0" 942 + } 943 + }, 944 "node_modules/@esbuild-kit/core-utils": { 945 "version": "3.3.2", 946 "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", ··· 951 "source-map-support": "^0.5.21" 952 } 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 + }, 1194 "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { 1195 "version": "0.18.20", 1196 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", ··· 1207 "node": ">=12" 1208 } 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 + }, 1306 "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { 1307 "version": "0.18.20", 1308 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", ··· 1372 "esbuild": "*" 1373 } 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 + }, 1615 "node_modules/@esbuild/linux-x64": { 1616 "version": "0.25.4", 1617 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", ··· 1629 "node": ">=18" 1630 } 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 + }, 1760 "node_modules/@eslint-community/eslint-utils": { 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==", 1764 "dev": true, 1765 "dependencies": { 1766 "eslint-visitor-keys": "^3.4.3" 1767 }, ··· 1775 "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" 1776 } 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 + }, 1790 "node_modules/@eslint-community/regexpp": { 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==", 1794 "dev": true, 1795 "engines": { 1796 "node": "^12.0.0 || ^14.0.0 || >=16.0.0" 1797 } 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 + }, 1837 "node_modules/@eslint/eslintrc": { 1838 + "version": "3.3.1", 1839 + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", 1840 + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", 1841 "dev": true, 1842 "dependencies": { 1843 "ajv": "^6.12.4", 1844 "debug": "^4.3.2", 1845 + "espree": "^10.0.1", 1846 + "globals": "^14.0.0", 1847 "ignore": "^5.2.0", 1848 "import-fresh": "^3.2.1", 1849 "js-yaml": "^4.1.0", ··· 1851 "strip-json-comments": "^3.1.1" 1852 }, 1853 "engines": { 1854 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1855 }, 1856 "funding": { 1857 "url": "https://opencollective.com/eslint" 1858 } 1859 }, 1860 "node_modules/@eslint/js": { 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==", 1885 + "dev": true, 1886 + "dependencies": { 1887 + "@eslint/core": "^0.17.0", 1888 + "levn": "^0.4.1" 1889 + }, 1890 + "engines": { 1891 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1892 } 1893 }, 1894 "node_modules/@fastify/busboy": { ··· 2032 "hono": "^4" 2033 } 2034 }, 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==", 2048 "dev": true, 2049 "dependencies": { 2050 + "@humanfs/core": "^0.19.1", 2051 + "@humanwhocodes/retry": "^0.4.0" 2052 }, 2053 "engines": { 2054 + "node": ">=18.18.0" 2055 } 2056 }, 2057 "node_modules/@humanwhocodes/module-importer": { ··· 2067 "url": "https://github.com/sponsors/nzakas" 2068 } 2069 }, 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 + } 2082 + }, 2083 + "node_modules/@img/colour": { 2084 + "version": "1.0.0", 2085 + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", 2086 + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", 2087 + "engines": { 2088 + "node": ">=18" 2089 + } 2090 + }, 2091 + "node_modules/@img/sharp-darwin-arm64": { 2092 + "version": "0.34.4", 2093 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", 2094 + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", 2095 + "cpu": [ 2096 + "arm64" 2097 + ], 2098 + "optional": true, 2099 + "os": [ 2100 + "darwin" 2101 + ], 2102 + "engines": { 2103 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2104 + }, 2105 + "funding": { 2106 + "url": "https://opencollective.com/libvips" 2107 + }, 2108 + "optionalDependencies": { 2109 + "@img/sharp-libvips-darwin-arm64": "1.2.3" 2110 + } 2111 + }, 2112 + "node_modules/@img/sharp-darwin-x64": { 2113 + "version": "0.34.4", 2114 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", 2115 + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", 2116 + "cpu": [ 2117 + "x64" 2118 + ], 2119 + "optional": true, 2120 + "os": [ 2121 + "darwin" 2122 + ], 2123 + "engines": { 2124 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2125 + }, 2126 + "funding": { 2127 + "url": "https://opencollective.com/libvips" 2128 + }, 2129 + "optionalDependencies": { 2130 + "@img/sharp-libvips-darwin-x64": "1.2.3" 2131 + } 2132 + }, 2133 + "node_modules/@img/sharp-libvips-darwin-arm64": { 2134 + "version": "1.2.3", 2135 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", 2136 + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", 2137 + "cpu": [ 2138 + "arm64" 2139 + ], 2140 + "optional": true, 2141 + "os": [ 2142 + "darwin" 2143 + ], 2144 + "funding": { 2145 + "url": "https://opencollective.com/libvips" 2146 + } 2147 + }, 2148 + "node_modules/@img/sharp-libvips-darwin-x64": { 2149 + "version": "1.2.3", 2150 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", 2151 + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", 2152 + "cpu": [ 2153 + "x64" 2154 + ], 2155 + "optional": true, 2156 + "os": [ 2157 + "darwin" 2158 + ], 2159 + "funding": { 2160 + "url": "https://opencollective.com/libvips" 2161 + } 2162 + }, 2163 + "node_modules/@img/sharp-libvips-linux-arm": { 2164 + "version": "1.2.3", 2165 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", 2166 + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", 2167 + "cpu": [ 2168 + "arm" 2169 + ], 2170 + "optional": true, 2171 + "os": [ 2172 + "linux" 2173 + ], 2174 + "funding": { 2175 + "url": "https://opencollective.com/libvips" 2176 + } 2177 + }, 2178 + "node_modules/@img/sharp-libvips-linux-arm64": { 2179 + "version": "1.2.3", 2180 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", 2181 + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", 2182 + "cpu": [ 2183 + "arm64" 2184 + ], 2185 + "optional": true, 2186 + "os": [ 2187 + "linux" 2188 + ], 2189 + "funding": { 2190 + "url": "https://opencollective.com/libvips" 2191 + } 2192 + }, 2193 + "node_modules/@img/sharp-libvips-linux-ppc64": { 2194 + "version": "1.2.3", 2195 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", 2196 + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", 2197 + "cpu": [ 2198 + "ppc64" 2199 + ], 2200 + "optional": true, 2201 + "os": [ 2202 + "linux" 2203 + ], 2204 + "funding": { 2205 + "url": "https://opencollective.com/libvips" 2206 + } 2207 + }, 2208 + "node_modules/@img/sharp-libvips-linux-s390x": { 2209 + "version": "1.2.3", 2210 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", 2211 + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", 2212 + "cpu": [ 2213 + "s390x" 2214 + ], 2215 + "optional": true, 2216 + "os": [ 2217 + "linux" 2218 + ], 2219 + "funding": { 2220 + "url": "https://opencollective.com/libvips" 2221 + } 2222 }, 2223 "node_modules/@img/sharp-libvips-linux-x64": { 2224 + "version": "1.2.3", 2225 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", 2226 + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", 2227 "cpu": [ 2228 "x64" 2229 ], 2230 + "optional": true, 2231 + "os": [ 2232 + "linux" 2233 + ], 2234 + "funding": { 2235 + "url": "https://opencollective.com/libvips" 2236 + } 2237 + }, 2238 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { 2239 + "version": "1.2.3", 2240 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", 2241 + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", 2242 + "cpu": [ 2243 + "arm64" 2244 + ], 2245 "optional": true, 2246 "os": [ 2247 "linux" ··· 2250 "url": "https://opencollective.com/libvips" 2251 } 2252 }, 2253 + "node_modules/@img/sharp-libvips-linuxmusl-x64": { 2254 + "version": "1.2.3", 2255 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", 2256 + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", 2257 + "cpu": [ 2258 + "x64" 2259 + ], 2260 + "optional": true, 2261 + "os": [ 2262 + "linux" 2263 + ], 2264 + "funding": { 2265 + "url": "https://opencollective.com/libvips" 2266 + } 2267 + }, 2268 + "node_modules/@img/sharp-linux-arm": { 2269 + "version": "0.34.4", 2270 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", 2271 + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", 2272 + "cpu": [ 2273 + "arm" 2274 + ], 2275 + "optional": true, 2276 + "os": [ 2277 + "linux" 2278 + ], 2279 + "engines": { 2280 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2281 + }, 2282 + "funding": { 2283 + "url": "https://opencollective.com/libvips" 2284 + }, 2285 + "optionalDependencies": { 2286 + "@img/sharp-libvips-linux-arm": "1.2.3" 2287 + } 2288 + }, 2289 + "node_modules/@img/sharp-linux-arm64": { 2290 + "version": "0.34.4", 2291 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", 2292 + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", 2293 + "cpu": [ 2294 + "arm64" 2295 + ], 2296 + "optional": true, 2297 + "os": [ 2298 + "linux" 2299 + ], 2300 + "engines": { 2301 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2302 + }, 2303 + "funding": { 2304 + "url": "https://opencollective.com/libvips" 2305 + }, 2306 + "optionalDependencies": { 2307 + "@img/sharp-libvips-linux-arm64": "1.2.3" 2308 + } 2309 + }, 2310 + "node_modules/@img/sharp-linux-ppc64": { 2311 + "version": "0.34.4", 2312 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", 2313 + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", 2314 + "cpu": [ 2315 + "ppc64" 2316 + ], 2317 + "optional": true, 2318 + "os": [ 2319 + "linux" 2320 + ], 2321 + "engines": { 2322 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2323 + }, 2324 + "funding": { 2325 + "url": "https://opencollective.com/libvips" 2326 + }, 2327 + "optionalDependencies": { 2328 + "@img/sharp-libvips-linux-ppc64": "1.2.3" 2329 + } 2330 + }, 2331 + "node_modules/@img/sharp-linux-s390x": { 2332 + "version": "0.34.4", 2333 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", 2334 + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", 2335 + "cpu": [ 2336 + "s390x" 2337 + ], 2338 + "optional": true, 2339 + "os": [ 2340 + "linux" 2341 + ], 2342 + "engines": { 2343 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2344 + }, 2345 + "funding": { 2346 + "url": "https://opencollective.com/libvips" 2347 + }, 2348 + "optionalDependencies": { 2349 + "@img/sharp-libvips-linux-s390x": "1.2.3" 2350 + } 2351 + }, 2352 "node_modules/@img/sharp-linux-x64": { 2353 + "version": "0.34.4", 2354 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", 2355 + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", 2356 "cpu": [ 2357 "x64" 2358 ], 2359 "optional": true, 2360 "os": [ 2361 "linux" ··· 2367 "url": "https://opencollective.com/libvips" 2368 }, 2369 "optionalDependencies": { 2370 + "@img/sharp-libvips-linux-x64": "1.2.3" 2371 + } 2372 + }, 2373 + "node_modules/@img/sharp-linuxmusl-arm64": { 2374 + "version": "0.34.4", 2375 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", 2376 + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", 2377 + "cpu": [ 2378 + "arm64" 2379 + ], 2380 + "optional": true, 2381 + "os": [ 2382 + "linux" 2383 + ], 2384 + "engines": { 2385 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2386 + }, 2387 + "funding": { 2388 + "url": "https://opencollective.com/libvips" 2389 + }, 2390 + "optionalDependencies": { 2391 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" 2392 + } 2393 + }, 2394 + "node_modules/@img/sharp-linuxmusl-x64": { 2395 + "version": "0.34.4", 2396 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", 2397 + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", 2398 + "cpu": [ 2399 + "x64" 2400 + ], 2401 + "optional": true, 2402 + "os": [ 2403 + "linux" 2404 + ], 2405 + "engines": { 2406 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2407 + }, 2408 + "funding": { 2409 + "url": "https://opencollective.com/libvips" 2410 + }, 2411 + "optionalDependencies": { 2412 + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" 2413 + } 2414 + }, 2415 + "node_modules/@img/sharp-wasm32": { 2416 + "version": "0.34.4", 2417 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", 2418 + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", 2419 + "cpu": [ 2420 + "wasm32" 2421 + ], 2422 + "optional": true, 2423 + "dependencies": { 2424 + "@emnapi/runtime": "^1.5.0" 2425 + }, 2426 + "engines": { 2427 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2428 + }, 2429 + "funding": { 2430 + "url": "https://opencollective.com/libvips" 2431 + } 2432 + }, 2433 + "node_modules/@img/sharp-win32-arm64": { 2434 + "version": "0.34.4", 2435 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", 2436 + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", 2437 + "cpu": [ 2438 + "arm64" 2439 + ], 2440 + "optional": true, 2441 + "os": [ 2442 + "win32" 2443 + ], 2444 + "engines": { 2445 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2446 + }, 2447 + "funding": { 2448 + "url": "https://opencollective.com/libvips" 2449 + } 2450 + }, 2451 + "node_modules/@img/sharp-win32-ia32": { 2452 + "version": "0.34.4", 2453 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", 2454 + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", 2455 + "cpu": [ 2456 + "ia32" 2457 + ], 2458 + "optional": true, 2459 + "os": [ 2460 + "win32" 2461 + ], 2462 + "engines": { 2463 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2464 + }, 2465 + "funding": { 2466 + "url": "https://opencollective.com/libvips" 2467 + } 2468 + }, 2469 + "node_modules/@img/sharp-win32-x64": { 2470 + "version": "0.34.4", 2471 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", 2472 + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", 2473 + "cpu": [ 2474 + "x64" 2475 + ], 2476 + "optional": true, 2477 + "os": [ 2478 + "win32" 2479 + ], 2480 + "engines": { 2481 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2482 + }, 2483 + "funding": { 2484 + "url": "https://opencollective.com/libvips" 2485 } 2486 }, 2487 "node_modules/@inngest/ai": { ··· 2727 } 2728 }, 2729 "node_modules/@next/bundle-analyzer": { 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==", 2733 "dependencies": { 2734 "webpack-bundle-analyzer": "4.10.1" 2735 } 2736 }, 2737 "node_modules/@next/env": { 2738 + "version": "16.0.7", 2739 + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz", 2740 + "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==", 2741 "license": "MIT" 2742 }, 2743 "node_modules/@next/eslint-plugin-next": { 2744 + "version": "16.0.3", 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==", 2747 "dev": true, 2748 "dependencies": { 2749 "fast-glob": "3.3.1" 2750 } ··· 2754 "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", 2755 "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", 2756 "dev": true, 2757 "dependencies": { 2758 "@nodelib/fs.stat": "^2.0.2", 2759 "@nodelib/fs.walk": "^1.2.3", ··· 2770 "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", 2771 "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", 2772 "dev": true, 2773 "dependencies": { 2774 "is-glob": "^4.0.1" 2775 }, ··· 2778 } 2779 }, 2780 "node_modules/@next/mdx": { 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==", 2784 "dependencies": { 2785 "source-map": "^0.7.0" 2786 }, ··· 2806 } 2807 }, 2808 "node_modules/@next/swc-darwin-arm64": { 2809 + "version": "16.0.7", 2810 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz", 2811 + "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==", 2812 "cpu": [ 2813 "arm64" 2814 ], ··· 2822 } 2823 }, 2824 "node_modules/@next/swc-darwin-x64": { 2825 + "version": "16.0.7", 2826 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz", 2827 + "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==", 2828 "cpu": [ 2829 "x64" 2830 ], ··· 2838 } 2839 }, 2840 "node_modules/@next/swc-linux-arm64-gnu": { 2841 + "version": "16.0.7", 2842 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz", 2843 + "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==", 2844 "cpu": [ 2845 "arm64" 2846 ], ··· 2854 } 2855 }, 2856 "node_modules/@next/swc-linux-arm64-musl": { 2857 + "version": "16.0.7", 2858 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz", 2859 + "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==", 2860 "cpu": [ 2861 "arm64" 2862 ], ··· 2870 } 2871 }, 2872 "node_modules/@next/swc-linux-x64-gnu": { 2873 + "version": "16.0.7", 2874 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz", 2875 + "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==", 2876 "cpu": [ 2877 "x64" 2878 ], ··· 2886 } 2887 }, 2888 "node_modules/@next/swc-linux-x64-musl": { 2889 + "version": "16.0.7", 2890 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz", 2891 + "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==", 2892 "cpu": [ 2893 "x64" 2894 ], ··· 2902 } 2903 }, 2904 "node_modules/@next/swc-win32-arm64-msvc": { 2905 + "version": "16.0.7", 2906 + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz", 2907 + "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==", 2908 "cpu": [ 2909 "arm64" 2910 ], ··· 2918 } 2919 }, 2920 "node_modules/@next/swc-win32-x64-msvc": { 2921 + "version": "16.0.7", 2922 + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz", 2923 + "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==", 2924 "cpu": [ 2925 "x64" 2926 ], ··· 7022 "dev": true, 7023 "license": "MIT" 7024 }, 7025 "node_modules/@shikijs/core": { 7026 "version": "3.8.1", 7027 "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.8.1.tgz", ··· 7616 "@types/unist": "*" 7617 } 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 + }, 7625 "node_modules/@types/json5": { 7626 "version": "0.0.29", 7627 "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", ··· 7641 "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", 7642 "license": "MIT", 7643 "peer": true 7644 + }, 7645 + "node_modules/@types/luxon": { 7646 + "version": "3.7.1", 7647 + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", 7648 + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", 7649 + "dev": true, 7650 + "license": "MIT" 7651 }, 7652 "node_modules/@types/markdown-it": { 7653 "version": "14.1.2", ··· 7748 "integrity": "sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==" 7749 }, 7750 "node_modules/@types/react": { 7751 + "version": "19.2.6", 7752 + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", 7753 + "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", 7754 "dependencies": { 7755 + "csstype": "^3.2.2" 7756 } 7757 }, 7758 "node_modules/@types/react-dom": { 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==", 7762 "devOptional": true, 7763 "peerDependencies": { 7764 + "@types/react": "^19.2.0" 7765 } 7766 }, 7767 "node_modules/@types/shimmer": { ··· 7799 } 7800 }, 7801 "node_modules/@typescript-eslint/eslint-plugin": { 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==", 7805 "dev": true, 7806 "dependencies": { 7807 "@eslint-community/regexpp": "^4.10.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", 7812 "graphemer": "^1.4.0", 7813 + "ignore": "^7.0.0", 7814 "natural-compare": "^1.4.0", 7815 "ts-api-utils": "^2.1.0" 7816 }, ··· 7822 "url": "https://opencollective.com/typescript-eslint" 7823 }, 7824 "peerDependencies": { 7825 + "@typescript-eslint/parser": "^8.47.0", 7826 "eslint": "^8.57.0 || ^9.0.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" 7837 } 7838 }, 7839 "node_modules/@typescript-eslint/parser": { 7840 + "version": "8.47.0", 7841 + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", 7842 + "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", 7843 "dev": true, 7844 "dependencies": { 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", 7849 "debug": "^4.3.4" 7850 }, 7851 "engines": { ··· 7857 }, 7858 "peerDependencies": { 7859 "eslint": "^8.57.0 || ^9.0.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" 7882 } 7883 }, 7884 "node_modules/@typescript-eslint/scope-manager": { 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==", 7888 "dev": true, 7889 "dependencies": { 7890 + "@typescript-eslint/types": "8.47.0", 7891 + "@typescript-eslint/visitor-keys": "8.47.0" 7892 }, 7893 "engines": { 7894 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" ··· 7898 "url": "https://opencollective.com/typescript-eslint" 7899 } 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 + }, 7917 "node_modules/@typescript-eslint/type-utils": { 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==", 7921 "dev": true, 7922 "dependencies": { 7923 + "@typescript-eslint/types": "8.47.0", 7924 + "@typescript-eslint/typescript-estree": "8.47.0", 7925 + "@typescript-eslint/utils": "8.47.0", 7926 "debug": "^4.3.4", 7927 "ts-api-utils": "^2.1.0" 7928 }, ··· 7935 }, 7936 "peerDependencies": { 7937 "eslint": "^8.57.0 || ^9.0.0", 7938 + "typescript": ">=4.8.4 <6.0.0" 7939 } 7940 }, 7941 "node_modules/@typescript-eslint/types": { 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==", 7945 "dev": true, 7946 "engines": { 7947 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 7948 }, ··· 7952 } 7953 }, 7954 "node_modules/@typescript-eslint/typescript-estree": { 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==", 7958 "dev": true, 7959 "dependencies": { 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", 7964 "debug": "^4.3.4", 7965 "fast-glob": "^3.3.2", 7966 "is-glob": "^4.0.3", ··· 7976 "url": "https://opencollective.com/typescript-eslint" 7977 }, 7978 "peerDependencies": { 7979 + "typescript": ">=4.8.4 <6.0.0" 7980 } 7981 }, 7982 "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { 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==", 7986 "dev": true, 7987 "dependencies": { 7988 "balanced-match": "^1.0.0" 7989 } ··· 7993 "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", 7994 "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 7995 "dev": true, 7996 "dependencies": { 7997 "brace-expansion": "^2.0.1" 7998 }, ··· 8004 } 8005 }, 8006 "node_modules/@typescript-eslint/utils": { 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==", 8010 "dev": true, 8011 "dependencies": { 8012 "@eslint-community/eslint-utils": "^4.7.0", 8013 + "@typescript-eslint/scope-manager": "8.47.0", 8014 + "@typescript-eslint/types": "8.47.0", 8015 + "@typescript-eslint/typescript-estree": "8.47.0" 8016 }, 8017 "engines": { 8018 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" ··· 8023 }, 8024 "peerDependencies": { 8025 "eslint": "^8.57.0 || ^9.0.0", 8026 + "typescript": ">=4.8.4 <6.0.0" 8027 } 8028 }, 8029 "node_modules/@typescript-eslint/visitor-keys": { 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==", 8033 "dev": true, 8034 "dependencies": { 8035 + "@typescript-eslint/types": "8.47.0", 8036 + "eslint-visitor-keys": "^4.2.1" 8037 }, 8038 "engines": { 8039 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" ··· 8043 "url": "https://opencollective.com/typescript-eslint" 8044 } 8045 }, 8046 "node_modules/@ungap/structured-clone": { 8047 "version": "1.2.0", 8048 "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", ··· 8216 "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", 8217 "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", 8218 "dev": true, 8219 "dependencies": { 8220 "fast-deep-equal": "^3.1.3", 8221 "fast-uri": "^3.0.1", ··· 8312 "license": "MIT" 8313 }, 8314 "node_modules/array-includes": { 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==", 8318 "dev": true, 8319 "dependencies": { 8320 + "call-bind": "^1.0.8", 8321 + "call-bound": "^1.0.4", 8322 "define-properties": "^1.2.1", 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" 8328 }, 8329 "engines": { 8330 "node": ">= 0.4" ··· 8355 } 8356 }, 8357 "node_modules/array.prototype.findlastindex": { 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==", 8361 "dev": true, 8362 "dependencies": { 8363 + "call-bind": "^1.0.8", 8364 + "call-bound": "^1.0.4", 8365 "define-properties": "^1.2.1", 8366 + "es-abstract": "^1.23.9", 8367 "es-errors": "^1.3.0", 8368 + "es-object-atoms": "^1.1.1", 8369 + "es-shim-unscopables": "^1.1.0" 8370 }, 8371 "engines": { 8372 "node": ">= 0.4" ··· 8376 } 8377 }, 8378 "node_modules/array.prototype.flat": { 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==", 8382 "dev": true, 8383 "dependencies": { 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" 8388 }, 8389 "engines": { 8390 "node": ">= 0.4" ··· 8595 } 8596 ] 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 + }, 8607 "node_modules/bignumber.js": { 8608 "version": "9.3.1", 8609 "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", ··· 8722 "node": ">=8" 8723 } 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 + }, 8758 "node_modules/buffer": { 8759 "version": "6.0.3", 8760 "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", ··· 8858 } 8859 }, 8860 "node_modules/caniuse-lite": { 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==", 8864 "funding": [ 8865 { 8866 "type": "opencollective", ··· 8874 "type": "github", 8875 "url": "https://github.com/sponsors/ai" 8876 } 8877 + ] 8878 }, 8879 "node_modules/canonicalize": { 8880 "version": "1.0.8", ··· 9120 "url": "https://github.com/sponsors/wooorm" 9121 } 9122 }, 9123 "node_modules/color-convert": { 9124 "version": "2.0.1", 9125 "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", ··· 9136 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 9137 "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 9138 }, 9139 "node_modules/colorjs.io": { 9140 "version": "0.5.2", 9141 "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", ··· 9197 "node": ">= 0.6" 9198 } 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 + }, 9206 "node_modules/cookie": { 9207 "version": "0.5.0", 9208 "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", ··· 9254 } 9255 }, 9256 "node_modules/cross-spawn": { 9257 + "version": "7.0.6", 9258 + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 9259 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 9260 "dev": true, 9261 "dependencies": { 9262 "path-key": "^3.1.0", ··· 9268 } 9269 }, 9270 "node_modules/csstype": { 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==" 9274 }, 9275 "node_modules/d": { 9276 "version": "1.0.2", ··· 9495 } 9496 }, 9497 "node_modules/detect-libc": { 9498 + "version": "2.1.2", 9499 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 9500 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", 9501 "engines": { 9502 "node": ">=8" 9503 } ··· 9532 "node": "*" 9533 } 9534 }, 9535 "node_modules/dreamopt": { 9536 "version": "0.8.0", 9537 "resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz", ··· 9564 "drizzle-kit": "bin.cjs" 9565 } 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 + }, 9823 "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { 9824 "version": "0.19.12", 9825 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", ··· 9837 "node": ">=12" 9838 } 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 + }, 9936 "node_modules/drizzle-kit/node_modules/esbuild": { 9937 "version": "0.19.12", 9938 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", ··· 10116 "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", 10117 "license": "MIT" 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 + }, 10125 "node_modules/emoji-regex": { 10126 "version": "9.2.2", 10127 "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", ··· 10175 } 10176 }, 10177 "node_modules/es-abstract": { 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==", 10181 "dev": true, 10182 "dependencies": { 10183 "array-buffer-byte-length": "^1.0.2", 10184 "arraybuffer.prototype.slice": "^1.0.4", 10185 "available-typed-arrays": "^1.0.7", 10186 "call-bind": "^1.0.8", 10187 + "call-bound": "^1.0.4", 10188 "data-view-buffer": "^1.0.2", 10189 "data-view-byte-length": "^1.0.2", 10190 "data-view-byte-offset": "^1.0.1", 10191 "es-define-property": "^1.0.1", 10192 "es-errors": "^1.3.0", 10193 + "es-object-atoms": "^1.1.1", 10194 "es-set-tostringtag": "^2.1.0", 10195 "es-to-primitive": "^1.3.0", 10196 "function.prototype.name": "^1.1.8", 10197 + "get-intrinsic": "^1.3.0", 10198 + "get-proto": "^1.0.1", 10199 "get-symbol-description": "^1.1.0", 10200 "globalthis": "^1.0.4", 10201 "gopd": "^1.2.0", ··· 10207 "is-array-buffer": "^3.0.5", 10208 "is-callable": "^1.2.7", 10209 "is-data-view": "^1.0.2", 10210 + "is-negative-zero": "^2.0.3", 10211 "is-regex": "^1.2.1", 10212 + "is-set": "^2.0.3", 10213 "is-shared-array-buffer": "^1.0.4", 10214 "is-string": "^1.1.1", 10215 "is-typed-array": "^1.1.15", 10216 + "is-weakref": "^1.1.1", 10217 "math-intrinsics": "^1.1.0", 10218 + "object-inspect": "^1.13.4", 10219 "object-keys": "^1.1.1", 10220 "object.assign": "^4.1.7", 10221 "own-keys": "^1.0.1", 10222 + "regexp.prototype.flags": "^1.5.4", 10223 "safe-array-concat": "^1.1.3", 10224 "safe-push-apply": "^1.0.0", 10225 "safe-regex-test": "^1.1.0", 10226 "set-proto": "^1.0.0", 10227 + "stop-iteration-iterator": "^1.1.0", 10228 "string.prototype.trim": "^1.2.10", 10229 "string.prototype.trimend": "^1.0.9", 10230 "string.prototype.trimstart": "^1.0.8", ··· 10233 "typed-array-byte-offset": "^1.0.4", 10234 "typed-array-length": "^1.0.7", 10235 "unbox-primitive": "^1.1.0", 10236 + "which-typed-array": "^1.1.19" 10237 }, 10238 "engines": { 10239 "node": ">= 0.4" ··· 10316 } 10317 }, 10318 "node_modules/es-shim-unscopables": { 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==", 10322 "dev": true, 10323 "dependencies": { 10324 + "hasown": "^2.0.2" 10325 + }, 10326 + "engines": { 10327 + "node": ">= 0.4" 10328 } 10329 }, 10330 "node_modules/es-to-primitive": { ··· 10480 "esbuild": ">=0.12 <1" 10481 } 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 + }, 10499 "node_modules/escalade": { 10500 + "version": "3.2.0", 10501 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", 10502 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", 10503 "engines": { 10504 "node": ">=6" 10505 } ··· 10522 } 10523 }, 10524 "node_modules/eslint": { 10525 + "version": "9.39.1", 10526 + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", 10527 + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", 10528 "dev": true, 10529 "dependencies": { 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", 10539 "@humanwhocodes/module-importer": "^1.0.1", 10540 + "@humanwhocodes/retry": "^0.4.2", 10541 + "@types/estree": "^1.0.6", 10542 "ajv": "^6.12.4", 10543 "chalk": "^4.0.0", 10544 + "cross-spawn": "^7.0.6", 10545 "debug": "^4.3.2", 10546 "escape-string-regexp": "^4.0.0", 10547 + "eslint-scope": "^8.4.0", 10548 + "eslint-visitor-keys": "^4.2.1", 10549 + "espree": "^10.4.0", 10550 + "esquery": "^1.5.0", 10551 "esutils": "^2.0.2", 10552 "fast-deep-equal": "^3.1.3", 10553 + "file-entry-cache": "^8.0.0", 10554 "find-up": "^5.0.0", 10555 "glob-parent": "^6.0.2", 10556 "ignore": "^5.2.0", 10557 "imurmurhash": "^0.1.4", 10558 "is-glob": "^4.0.0", 10559 "json-stable-stringify-without-jsonify": "^1.0.1", 10560 "lodash.merge": "^4.6.2", 10561 "minimatch": "^3.1.2", 10562 "natural-compare": "^1.4.0", 10563 + "optionator": "^0.9.3" 10564 }, 10565 "bin": { 10566 "eslint": "bin/eslint.js" 10567 }, 10568 "engines": { 10569 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 10570 }, 10571 "funding": { 10572 + "url": "https://eslint.org/donate" 10573 + }, 10574 + "peerDependencies": { 10575 + "jiti": "*" 10576 + }, 10577 + "peerDependenciesMeta": { 10578 + "jiti": { 10579 + "optional": true 10580 + } 10581 } 10582 }, 10583 "node_modules/eslint-config-next": { 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==", 10587 "dev": true, 10588 "dependencies": { 10589 + "@next/eslint-plugin-next": "16.0.3", 10590 "eslint-import-resolver-node": "^0.3.6", 10591 "eslint-import-resolver-typescript": "^3.5.2", 10592 + "eslint-plugin-import": "^2.32.0", 10593 "eslint-plugin-jsx-a11y": "^6.10.0", 10594 "eslint-plugin-react": "^7.37.0", 10595 + "eslint-plugin-react-hooks": "^7.0.0", 10596 + "globals": "16.4.0", 10597 + "typescript-eslint": "^8.46.0" 10598 }, 10599 "peerDependencies": { 10600 + "eslint": ">=9.0.0", 10601 "typescript": ">=3.3.1" 10602 }, 10603 "peerDependenciesMeta": { 10604 "typescript": { 10605 "optional": true 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" 10619 } 10620 }, 10621 "node_modules/eslint-import-resolver-node": { ··· 10664 } 10665 }, 10666 "node_modules/eslint-module-utils": { 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==", 10670 "dev": true, 10671 "dependencies": { 10672 "debug": "^3.2.7" 10673 }, ··· 10690 } 10691 }, 10692 "node_modules/eslint-plugin-import": { 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==", 10696 "dev": true, 10697 "dependencies": { 10698 "@rtsao/scc": "^1.1.0", 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", 10703 "debug": "^3.2.7", 10704 "doctrine": "^2.1.0", 10705 "eslint-import-resolver-node": "^0.3.9", 10706 + "eslint-module-utils": "^2.12.1", 10707 "hasown": "^2.0.2", 10708 + "is-core-module": "^2.16.1", 10709 "is-glob": "^4.0.3", 10710 "minimatch": "^3.1.2", 10711 "object.fromentries": "^2.0.8", 10712 "object.groupby": "^1.0.3", 10713 + "object.values": "^1.2.1", 10714 "semver": "^6.3.1", 10715 + "string.prototype.trimend": "^1.0.9", 10716 "tsconfig-paths": "^3.15.0" 10717 }, 10718 "engines": { ··· 10816 } 10817 }, 10818 "node_modules/eslint-plugin-react-hooks": { 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==", 10822 "dev": true, 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 + }, 10830 "engines": { 10831 + "node": ">=18" 10832 }, 10833 "peerDependencies": { 10834 "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" 10835 } 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 + }, 10858 "node_modules/eslint-plugin-react/node_modules/doctrine": { 10859 "version": "2.1.0", 10860 "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", ··· 10897 } 10898 }, 10899 "node_modules/eslint-scope": { 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==", 10903 "dev": true, 10904 "dependencies": { 10905 "esrecurse": "^4.3.0", 10906 "estraverse": "^5.2.0" 10907 }, 10908 "engines": { 10909 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 10910 }, 10911 "funding": { 10912 "url": "https://opencollective.com/eslint" 10913 } 10914 }, 10915 "node_modules/eslint-visitor-keys": { 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==", 10919 "dev": true, 10920 "engines": { 10921 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 10922 }, 10923 "funding": { 10924 "url": "https://opencollective.com/eslint" ··· 10940 } 10941 }, 10942 "node_modules/espree": { 10943 + "version": "10.4.0", 10944 + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", 10945 + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", 10946 "dev": true, 10947 "dependencies": { 10948 + "acorn": "^8.15.0", 10949 "acorn-jsx": "^5.3.2", 10950 + "eslint-visitor-keys": "^4.2.1" 10951 }, 10952 "engines": { 10953 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 10954 }, 10955 "funding": { 10956 "url": "https://opencollective.com/eslint" ··· 11306 } 11307 }, 11308 "node_modules/fast-uri": { 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==", 11312 "dev": true, 11313 "funding": [ 11314 { ··· 11319 "type": "opencollective", 11320 "url": "https://opencollective.com/fastify" 11321 } 11322 + ] 11323 }, 11324 "node_modules/fastq": { 11325 "version": "1.17.1", ··· 11367 } 11368 }, 11369 "node_modules/file-entry-cache": { 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==", 11373 "dev": true, 11374 "dependencies": { 11375 + "flat-cache": "^4.0.0" 11376 }, 11377 "engines": { 11378 + "node": ">=16.0.0" 11379 } 11380 }, 11381 "node_modules/fill-range": { ··· 11440 } 11441 }, 11442 "node_modules/flat-cache": { 11443 + "version": "4.0.1", 11444 + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", 11445 + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", 11446 "dev": true, 11447 "dependencies": { 11448 "flatted": "^3.2.9", 11449 + "keyv": "^4.5.4" 11450 }, 11451 "engines": { 11452 + "node": ">=16" 11453 } 11454 }, 11455 "node_modules/flatted": { 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==", 11459 "dev": true 11460 }, 11461 "node_modules/follow-redirects": { ··· 11657 "node": ">=14" 11658 } 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 + }, 11669 "node_modules/get-caller-file": { 11670 "version": "2.0.5", 11671 "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", ··· 11827 } 11828 }, 11829 "node_modules/globals": { 11830 + "version": "14.0.0", 11831 + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", 11832 + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", 11833 "dev": true, 11834 "engines": { 11835 + "node": ">=18" 11836 }, 11837 "funding": { 11838 "url": "https://github.com/sponsors/sindresorhus" ··· 12292 "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", 12293 "dev": true 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 + }, 12310 "node_modules/hono": { 12311 "version": "4.7.11", 12312 "resolved": "https://registry.npmjs.org/hono/-/hono-4.7.11.tgz", ··· 12401 } 12402 }, 12403 "node_modules/immer": { 12404 + "version": "10.2.0", 12405 + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", 12406 + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", 12407 + "license": "MIT", 12408 "funding": { 12409 "type": "opencollective", 12410 "url": "https://opencollective.com/immer" 12411 } 12412 }, 12413 "node_modules/import-fresh": { 12414 + "version": "3.3.1", 12415 + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", 12416 + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", 12417 "dev": true, 12418 "dependencies": { 12419 "parent-module": "^1.0.0", ··· 12674 "funding": { 12675 "url": "https://github.com/sponsors/ljharb" 12676 } 12677 }, 12678 "node_modules/is-async-function": { 12679 "version": "2.1.1", ··· 12898 "url": "https://github.com/sponsors/ljharb" 12899 } 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 + }, 12913 "node_modules/is-number": { 12914 "version": "7.0.0", 12915 "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", ··· 12934 }, 12935 "funding": { 12936 "url": "https://github.com/sponsors/ljharb" 12937 } 12938 }, 12939 "node_modules/is-plain-obj": { ··· 13192 "license": "MIT" 13193 }, 13194 "node_modules/js-yaml": { 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==", 13198 "dev": true, 13199 "dependencies": { 13200 "argparse": "^2.0.1" 13201 }, 13202 "bin": { 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" 13216 } 13217 }, 13218 "node_modules/json-bigint": { ··· 13251 "version": "1.0.0", 13252 "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", 13253 "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", 13254 + "dev": true 13255 }, 13256 "node_modules/json-stable-stringify-without-jsonify": { 13257 "version": "1.0.1", ··· 13369 "dependencies": { 13370 "json-buffer": "3.0.1" 13371 } 13372 + }, 13373 + "node_modules/l": { 13374 + "version": "0.6.0", 13375 + "resolved": "https://registry.npmjs.org/l/-/l-0.6.0.tgz", 13376 + "integrity": "sha512-rB5disIyfKRBQ1xcedByHCcAmPWy2NPnjWo5u4mVVIPtathROHyfHjkloqSBT49mLnSRnupkpoIUOFCL7irCVQ==", 13377 + "license": "MIT" 13378 }, 13379 "node_modules/language-subtag-registry": { 13380 "version": "0.3.23", ··· 13814 "dev": true, 13815 "dependencies": { 13816 "es5-ext": "~0.10.2" 13817 + } 13818 + }, 13819 + "node_modules/luxon": { 13820 + "version": "3.7.2", 13821 + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", 13822 + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", 13823 + "license": "MIT", 13824 + "engines": { 13825 + "node": ">=12" 13826 } 13827 }, 13828 "node_modules/magic-string": { ··· 15124 } 15125 }, 15126 "node_modules/next": { 15127 + "version": "16.0.7", 15128 + "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", 15129 + "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", 15130 "license": "MIT", 15131 "dependencies": { 15132 + "@next/env": "16.0.7", 15133 "@swc/helpers": "0.5.15", 15134 "caniuse-lite": "^1.0.30001579", 15135 "postcss": "8.4.31", ··· 15139 "next": "dist/bin/next" 15140 }, 15141 "engines": { 15142 + "node": ">=20.9.0" 15143 }, 15144 "optionalDependencies": { 15145 + "@next/swc-darwin-arm64": "16.0.7", 15146 + "@next/swc-darwin-x64": "16.0.7", 15147 + "@next/swc-linux-arm64-gnu": "16.0.7", 15148 + "@next/swc-linux-arm64-musl": "16.0.7", 15149 + "@next/swc-linux-x64-gnu": "16.0.7", 15150 + "@next/swc-linux-x64-musl": "16.0.7", 15151 + "@next/swc-win32-arm64-msvc": "16.0.7", 15152 + "@next/swc-win32-x64-msvc": "16.0.7", 15153 + "sharp": "^0.34.4" 15154 }, 15155 "peerDependencies": { 15156 "@opentelemetry/api": "^1.1.0", ··· 15274 "node-gyp-build-optional-packages-optional": "optional.js", 15275 "node-gyp-build-optional-packages-test": "build-test.js" 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 15283 }, 15284 "node_modules/normalize-path": { 15285 "version": "3.0.0", ··· 15648 "dev": true, 15649 "engines": { 15650 "node": ">=8" 15651 } 15652 }, 15653 "node_modules/path-key": { ··· 16338 } 16339 }, 16340 "node_modules/react": { 16341 + "version": "19.2.1", 16342 + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", 16343 + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", 16344 "license": "MIT", 16345 "engines": { 16346 "node": ">=0.10.0" ··· 16460 } 16461 }, 16462 "node_modules/react-dom": { 16463 + "version": "19.2.1", 16464 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", 16465 + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", 16466 "license": "MIT", 16467 "dependencies": { 16468 + "scheduler": "^0.27.0" 16469 }, 16470 "peerDependencies": { 16471 + "react": "^19.2.1" 16472 } 16473 }, 16474 "node_modules/react-is": { ··· 16981 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", 16982 "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", 16983 "dev": true, 16984 "engines": { 16985 "node": ">=0.10.0" 16986 } ··· 17052 "node": ">=0.10.0" 17053 } 17054 }, 17055 "node_modules/rollup-plugin-inject": { 17056 "version": "3.0.2", 17057 "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", ··· 17209 "license": "ISC" 17210 }, 17211 "node_modules/scheduler": { 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==" 17215 }, 17216 "node_modules/scmp": { 17217 "version": "2.1.0", ··· 17383 "license": "ISC" 17384 }, 17385 "node_modules/sharp": { 17386 + "version": "0.34.4", 17387 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", 17388 + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", 17389 "hasInstallScript": true, 17390 "dependencies": { 17391 + "@img/colour": "^1.0.0", 17392 + "detect-libc": "^2.1.0", 17393 "semver": "^7.7.2" 17394 }, 17395 "engines": { ··· 17399 "url": "https://opencollective.com/libvips" 17400 }, 17401 "optionalDependencies": { 17402 + "@img/sharp-darwin-arm64": "0.34.4", 17403 + "@img/sharp-darwin-x64": "0.34.4", 17404 + "@img/sharp-libvips-darwin-arm64": "1.2.3", 17405 + "@img/sharp-libvips-darwin-x64": "1.2.3", 17406 + "@img/sharp-libvips-linux-arm": "1.2.3", 17407 + "@img/sharp-libvips-linux-arm64": "1.2.3", 17408 + "@img/sharp-libvips-linux-ppc64": "1.2.3", 17409 + "@img/sharp-libvips-linux-s390x": "1.2.3", 17410 + "@img/sharp-libvips-linux-x64": "1.2.3", 17411 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", 17412 + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", 17413 + "@img/sharp-linux-arm": "0.34.4", 17414 + "@img/sharp-linux-arm64": "0.34.4", 17415 + "@img/sharp-linux-ppc64": "0.34.4", 17416 + "@img/sharp-linux-s390x": "0.34.4", 17417 + "@img/sharp-linux-x64": "0.34.4", 17418 + "@img/sharp-linuxmusl-arm64": "0.34.4", 17419 + "@img/sharp-linuxmusl-x64": "0.34.4", 17420 + "@img/sharp-wasm32": "0.34.4", 17421 + "@img/sharp-win32-arm64": "0.34.4", 17422 + "@img/sharp-win32-ia32": "0.34.4", 17423 + "@img/sharp-win32-x64": "0.34.4" 17424 } 17425 }, 17426 "node_modules/shebang-command": { ··· 17549 "url": "https://github.com/sponsors/isaacs" 17550 } 17551 }, 17552 "node_modules/sirv": { 17553 "version": "2.0.4", 17554 "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", ··· 17656 "node": ">= 0.8" 17657 } 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 + }, 17672 "node_modules/stoppable": { 17673 "version": "1.1.0", 17674 "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", ··· 17996 "integrity": "sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==", 17997 "license": "ISC" 17998 }, 17999 "node_modules/thread-stream": { 18000 "version": "2.7.0", 18001 "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz", ··· 18151 "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", 18152 "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", 18153 "dev": true, 18154 "engines": { 18155 "node": ">=18.12" 18156 }, ··· 18268 "node": ">= 0.8.0" 18269 } 18270 }, 18271 "node_modules/type-is": { 18272 "version": "1.6.18", 18273 "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", ··· 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" 18396 + } 18397 + }, 18398 "node_modules/uc.micro": { 18399 "version": "2.1.0", 18400 "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", ··· 18566 "node": ">= 0.8" 18567 } 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 + }, 18599 "node_modules/use-callback-ref": { 18600 "version": "1.3.3", 18601 "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", ··· 19005 } 19006 } 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 + }, 19248 "node_modules/wrangler/node_modules/@esbuild/linux-x64": { 19249 "version": "0.17.19", 19250 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", ··· 19256 "optional": true, 19257 "os": [ 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" 19355 ], 19356 "engines": { 19357 "node": ">=12"
+17 -13
package.json
··· 4 "description": "", 5 "main": "index.js", 6 "scripts": { 7 - "dev": "next dev --turbo", 8 "publish-lexicons": "tsx lexicons/publish.ts", 9 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 10 "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;", ··· 31 "@hono/node-server": "^1.14.3", 32 "@mdx-js/loader": "^3.1.0", 33 "@mdx-js/react": "^3.1.0", 34 - "@next/bundle-analyzer": "^15.3.2", 35 - "@next/mdx": "15.3.2", 36 "@radix-ui/react-dialog": "^1.1.15", 37 "@radix-ui/react-dropdown-menu": "^2.1.16", 38 "@radix-ui/react-popover": "^1.1.15", ··· 54 "feed": "^5.1.0", 55 "fractional-indexing": "^3.2.0", 56 "hono": "^4.7.11", 57 "inngest": "^3.40.1", 58 "ioredis": "^5.6.1", 59 "katex": "^0.16.22", 60 "linkifyjs": "^4.2.0", 61 "multiformats": "^13.3.2", 62 - "next": "^15.5.3", 63 "pg": "^8.16.3", 64 "prosemirror-commands": "^1.5.2", 65 "prosemirror-inputrules": "^1.4.0", ··· 67 "prosemirror-model": "^1.21.0", 68 "prosemirror-schema-basic": "^1.2.2", 69 "prosemirror-state": "^1.4.3", 70 - "react": "^19.1.1", 71 "react-aria-components": "^1.8.0", 72 "react-day-picker": "^9.3.0", 73 - "react-dom": "^19.1.1", 74 "react-use-measure": "^2.1.1", 75 "redlock": "^5.0.0-beta.2", 76 "rehype-parse": "^9.0.0", ··· 81 "remark-rehype": "^11.1.0", 82 "remark-stringify": "^11.0.0", 83 "replicache": "^15.3.0", 84 - "sharp": "^0.34.2", 85 "shiki": "^3.8.1", 86 "swr": "^2.3.3", 87 "thumbhash": "^0.1.1", ··· 98 "@cloudflare/workers-types": "^4.20240512.0", 99 "@tailwindcss/postcss": "^4.1.13", 100 "@types/katex": "^0.16.7", 101 "@types/node": "^22.15.17", 102 - "@types/react": "19.1.3", 103 - "@types/react-dom": "19.1.3", 104 "@types/uuid": "^10.0.0", 105 "drizzle-kit": "^0.21.2", 106 "esbuild": "^0.25.4", 107 - "eslint": "8.57.0", 108 - "eslint-config-next": "^15.5.3", 109 "postcss": "^8.4.38", 110 "prettier": "3.2.5", 111 "supabase": "^1.187.3", ··· 117 "overrides": { 118 "ajv": "^8.17.1", 119 "whatwg-url": "^14.0.0", 120 - "@types/react": "19.1.3", 121 - "@types/react-dom": "19.1.3" 122 } 123 }
··· 4 "description": "", 5 "main": "index.js", 6 "scripts": { 7 + "dev": "TZ=UTC next dev --turbo", 8 "publish-lexicons": "tsx lexicons/publish.ts", 9 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 10 "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;", ··· 31 "@hono/node-server": "^1.14.3", 32 "@mdx-js/loader": "^3.1.0", 33 "@mdx-js/react": "^3.1.0", 34 + "@next/bundle-analyzer": "16.0.3", 35 + "@next/mdx": "16.0.3", 36 "@radix-ui/react-dialog": "^1.1.15", 37 "@radix-ui/react-dropdown-menu": "^2.1.16", 38 "@radix-ui/react-popover": "^1.1.15", ··· 54 "feed": "^5.1.0", 55 "fractional-indexing": "^3.2.0", 56 "hono": "^4.7.11", 57 + "immer": "^10.2.0", 58 "inngest": "^3.40.1", 59 "ioredis": "^5.6.1", 60 "katex": "^0.16.22", 61 + "l": "^0.6.0", 62 "linkifyjs": "^4.2.0", 63 + "luxon": "^3.7.2", 64 "multiformats": "^13.3.2", 65 + "next": "^16.0.7", 66 "pg": "^8.16.3", 67 "prosemirror-commands": "^1.5.2", 68 "prosemirror-inputrules": "^1.4.0", ··· 70 "prosemirror-model": "^1.21.0", 71 "prosemirror-schema-basic": "^1.2.2", 72 "prosemirror-state": "^1.4.3", 73 + "react": "19.2.1", 74 "react-aria-components": "^1.8.0", 75 "react-day-picker": "^9.3.0", 76 + "react-dom": "19.2.1", 77 "react-use-measure": "^2.1.1", 78 "redlock": "^5.0.0-beta.2", 79 "rehype-parse": "^9.0.0", ··· 84 "remark-rehype": "^11.1.0", 85 "remark-stringify": "^11.0.0", 86 "replicache": "^15.3.0", 87 + "sharp": "^0.34.4", 88 "shiki": "^3.8.1", 89 "swr": "^2.3.3", 90 "thumbhash": "^0.1.1", ··· 101 "@cloudflare/workers-types": "^4.20240512.0", 102 "@tailwindcss/postcss": "^4.1.13", 103 "@types/katex": "^0.16.7", 104 + "@types/luxon": "^3.7.1", 105 "@types/node": "^22.15.17", 106 + "@types/react": "19.2.6", 107 + "@types/react-dom": "19.2.3", 108 "@types/uuid": "^10.0.0", 109 "drizzle-kit": "^0.21.2", 110 "esbuild": "^0.25.4", 111 + "eslint": "^9.39.1", 112 + "eslint-config-next": "16.0.3", 113 "postcss": "^8.4.38", 114 "prettier": "3.2.5", 115 "supabase": "^1.187.3", ··· 121 "overrides": { 122 "ajv": "^8.17.1", 123 "whatwg-url": "^14.0.0", 124 + "@types/react": "19.2.6", 125 + "@types/react-dom": "19.2.3" 126 } 127 }
+144
patterns/notifications.md
···
··· 1 + # Notification System 2 + 3 + ## Overview 4 + 5 + Notifications are stored in the database and hydrated with related data before being rendered. The system supports multiple notification types (comments, subscriptions, etc.) that are processed in parallel. 6 + 7 + ## Key Files 8 + 9 + - **`src/notifications.ts`** - Core notification types and hydration logic 10 + - **`app/(home-pages)/notifications/NotificationList.tsx`** - Renders all notification types 11 + - **`app/(home-pages)/notifications/Notification.tsx`** - Base notification component 12 + - Individual notification components (e.g., `CommentNotification.tsx`, `FollowNotification.tsx`) 13 + 14 + ## Adding a New Notification Type 15 + 16 + ### 1. Update Notification Data Types (`src/notifications.ts`) 17 + 18 + Add your type to the `NotificationData` union: 19 + 20 + ```typescript 21 + export type NotificationData = 22 + | { type: "comment"; comment_uri: string; parent_uri?: string } 23 + | { type: "subscribe"; subscription_uri: string } 24 + | { type: "your_type"; your_field: string }; // Add here 25 + ``` 26 + 27 + Add to the `HydratedNotification` union: 28 + 29 + ```typescript 30 + export type HydratedNotification = 31 + | HydratedCommentNotification 32 + | HydratedSubscribeNotification 33 + | HydratedYourNotification; // Add here 34 + ``` 35 + 36 + ### 2. Create Hydration Function (`src/notifications.ts`) 37 + 38 + ```typescript 39 + export type HydratedYourNotification = Awaited< 40 + ReturnType<typeof hydrateYourNotifications> 41 + >[0]; 42 + 43 + async function hydrateYourNotifications(notifications: NotificationRow[]) { 44 + const yourNotifications = notifications.filter( 45 + (n): n is NotificationRow & { data: ExtractNotificationType<"your_type"> } => 46 + (n.data as NotificationData)?.type === "your_type", 47 + ); 48 + 49 + if (yourNotifications.length === 0) return []; 50 + 51 + // Fetch related data with joins 52 + const { data } = await supabaseServerClient 53 + .from("your_table") 54 + .select("*, related_table(*)") 55 + .in("uri", yourNotifications.map((n) => n.data.your_field)); 56 + 57 + return yourNotifications.map((notification) => ({ 58 + id: notification.id, 59 + recipient: notification.recipient, 60 + created_at: notification.created_at, 61 + type: "your_type" as const, 62 + your_field: notification.data.your_field, 63 + yourData: data?.find((d) => d.uri === notification.data.your_field)!, 64 + })); 65 + } 66 + ``` 67 + 68 + Add to `hydrateNotifications` parallel array: 69 + 70 + ```typescript 71 + const [commentNotifications, subscribeNotifications, yourNotifications] = await Promise.all([ 72 + hydrateCommentNotifications(notifications), 73 + hydrateSubscribeNotifications(notifications), 74 + hydrateYourNotifications(notifications), // Add here 75 + ]); 76 + 77 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...yourNotifications]; 78 + ``` 79 + 80 + ### 3. Trigger the Notification (in your action file) 81 + 82 + ```typescript 83 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 84 + import { v7 } from "uuid"; 85 + 86 + // When the event occurs: 87 + const recipient = /* determine who should receive it */; 88 + if (recipient !== currentUser) { 89 + const notification: Notification = { 90 + id: v7(), 91 + recipient, 92 + data: { 93 + type: "your_type", 94 + your_field: "value", 95 + }, 96 + }; 97 + await supabaseServerClient.from("notifications").insert(notification); 98 + await pingIdentityToUpdateNotification(recipient); 99 + } 100 + ``` 101 + 102 + ### 4. Create Notification Component 103 + 104 + Create a new component (e.g., `YourNotification.tsx`): 105 + 106 + ```typescript 107 + import { HydratedYourNotification } from "src/notifications"; 108 + import { Notification } from "./Notification"; 109 + 110 + export const YourNotification = (props: HydratedYourNotification) => { 111 + // Extract data from props.yourData 112 + 113 + return ( 114 + <Notification 115 + timestamp={props.created_at} 116 + href={/* link to relevant page */} 117 + icon={/* icon or avatar */} 118 + actionText={<>Message to display</>} 119 + content={/* optional additional content */} 120 + /> 121 + ); 122 + }; 123 + ``` 124 + 125 + ### 5. Update NotificationList (`NotificationList.tsx`) 126 + 127 + Import and render your notification type: 128 + 129 + ```typescript 130 + import { YourNotification } from "./YourNotification"; 131 + 132 + // In the map function: 133 + if (n.type === "your_type") { 134 + return <YourNotification key={n.id} {...n} />; 135 + } 136 + ``` 137 + 138 + ## Example: Subscribe Notifications 139 + 140 + See the implementation in: 141 + - `src/notifications.ts:88-125` - Hydration logic 142 + - `app/lish/subscribeToPublication.ts:55-68` - Trigger 143 + - `app/(home-pages)/notifications/FollowNotification.tsx` - Component 144 + - `app/(home-pages)/notifications/NotificationList.tsx:40-42` - Rendering
+55
src/hooks/useLocalizedDate.ts
···
··· 1 + "use client"; 2 + import { useContext, useMemo } from "react"; 3 + import { DateTime } from "luxon"; 4 + import { RequestHeadersContext } from "components/Providers/RequestHeadersProvider"; 5 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 6 + 7 + /** 8 + * Hook that formats a date string using Luxon with timezone and locale from request headers. 9 + * On initial page load, uses the timezone from request headers. After hydration, uses the system timezone. 10 + * 11 + * @param dateString - ISO date string to format 12 + * @param options - Intl.DateTimeFormatOptions for formatting 13 + * @returns Formatted date string 14 + * 15 + * @example 16 + * const formatted = useLocalizedDate("2024-01-15T10:30:00Z", { dateStyle: 'full', timeStyle: 'short' }); 17 + */ 18 + export function useLocalizedDate( 19 + dateString: string, 20 + options?: Intl.DateTimeFormatOptions, 21 + ): string { 22 + const { timezone, language } = useContext(RequestHeadersContext); 23 + const hasPageLoaded = useHasPageLoaded(); 24 + 25 + return useMemo(() => { 26 + // Parse the date string to Luxon DateTime 27 + let dateTime = DateTime.fromISO(dateString); 28 + 29 + // On initial page load, use header timezone. After hydration, use system timezone 30 + const effectiveTimezone = !hasPageLoaded 31 + ? timezone || "UTC" 32 + : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 + 34 + console.log("tz", effectiveTimezone); 35 + 36 + // Apply timezone if available 37 + if (effectiveTimezone) { 38 + dateTime = dateTime.setZone(effectiveTimezone); 39 + } 40 + 41 + // On initial page load, use header locale. After hydration, use system locale 42 + // Parse locale from accept-language header (take first locale) 43 + // accept-language format: "en-US,en;q=0.9,es;q=0.8" 44 + const effectiveLocale = !hasPageLoaded 45 + ? language?.split(",")[0]?.split(";")[0]?.trim() || "en-US" 46 + : Intl.DateTimeFormat().resolvedOptions().locale; 47 + 48 + try { 49 + return dateTime.toLocaleString(options, { locale: effectiveLocale }); 50 + } catch (error) { 51 + // Fallback to en-US if locale is invalid 52 + return dateTime.toLocaleString(options, { locale: "en-US" }); 53 + } 54 + }, [dateString, options, timezone, language, hasPageLoaded]); 55 + }
+4 -3
src/hooks/usePreserveScroll.ts
··· 6 useEffect(() => { 7 if (!ref.current || !key) return; 8 9 - window.requestAnimationFrame(() => { 10 - ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 11 - }); 12 13 const listener = () => { 14 if (!ref.current?.scrollTop) return;
··· 6 useEffect(() => { 7 if (!ref.current || !key) return; 8 9 + if (scrollPositions[key] !== undefined) 10 + window.requestAnimationFrame(() => { 11 + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 12 + }); 13 14 const listener = () => { 15 if (!ref.current?.scrollTop) return;
+393
src/notifications.ts
···
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Tables, TablesInsert } from "supabase/database.types"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 + 8 + type NotificationRow = Tables<"notifications">; 9 + 10 + export type Notification = Omit<TablesInsert<"notifications">, "data"> & { 11 + data: NotificationData; 12 + }; 13 + 14 + export type NotificationData = 15 + | { type: "comment"; comment_uri: string; parent_uri?: string } 16 + | { type: "subscribe"; subscription_uri: string } 17 + | { type: "quote"; bsky_post_uri: string; document_uri: string } 18 + | { type: "mention"; document_uri: string; mention_type: "did" } 19 + | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 20 + | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } 21 + | { type: "comment_mention"; comment_uri: string; mention_type: "did" } 22 + | { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string } 23 + | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string }; 24 + 25 + export type HydratedNotification = 26 + | HydratedCommentNotification 27 + | HydratedSubscribeNotification 28 + | HydratedQuoteNotification 29 + | HydratedMentionNotification 30 + | HydratedCommentMentionNotification; 31 + export async function hydrateNotifications( 32 + notifications: NotificationRow[], 33 + ): Promise<Array<HydratedNotification>> { 34 + // Call all hydrators in parallel 35 + const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 36 + hydrateCommentNotifications(notifications), 37 + hydrateSubscribeNotifications(notifications), 38 + hydrateQuoteNotifications(notifications), 39 + hydrateMentionNotifications(notifications), 40 + hydrateCommentMentionNotifications(notifications), 41 + ]); 42 + 43 + // Combine all hydrated notifications 44 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 45 + 46 + // Sort by created_at to maintain order 47 + allHydrated.sort( 48 + (a, b) => 49 + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), 50 + ); 51 + 52 + return allHydrated; 53 + } 54 + 55 + // Type guard to extract notification type 56 + type ExtractNotificationType<T extends NotificationData["type"]> = Extract< 57 + NotificationData, 58 + { type: T } 59 + >; 60 + 61 + export type HydratedCommentNotification = Awaited< 62 + ReturnType<typeof hydrateCommentNotifications> 63 + >[0]; 64 + 65 + async function hydrateCommentNotifications(notifications: NotificationRow[]) { 66 + const commentNotifications = notifications.filter( 67 + (n): n is NotificationRow & { data: ExtractNotificationType<"comment"> } => 68 + (n.data as NotificationData)?.type === "comment", 69 + ); 70 + 71 + if (commentNotifications.length === 0) { 72 + return []; 73 + } 74 + 75 + // Fetch comment data from the database 76 + const commentUris = commentNotifications.flatMap((n) => 77 + n.data.parent_uri 78 + ? [n.data.comment_uri, n.data.parent_uri] 79 + : [n.data.comment_uri], 80 + ); 81 + const { data: comments } = await supabaseServerClient 82 + .from("comments_on_documents") 83 + .select( 84 + "*,bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 85 + ) 86 + .in("uri", commentUris); 87 + 88 + return commentNotifications 89 + .map((notification) => { 90 + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 91 + if (!commentData) return null; 92 + return { 93 + id: notification.id, 94 + recipient: notification.recipient, 95 + created_at: notification.created_at, 96 + type: "comment" as const, 97 + comment_uri: notification.data.comment_uri, 98 + parentData: notification.data.parent_uri 99 + ? comments?.find((c) => c.uri === notification.data.parent_uri) 100 + : undefined, 101 + commentData, 102 + }; 103 + }) 104 + .filter((n) => n !== null); 105 + } 106 + 107 + export type HydratedSubscribeNotification = Awaited< 108 + ReturnType<typeof hydrateSubscribeNotifications> 109 + >[0]; 110 + 111 + async function hydrateSubscribeNotifications(notifications: NotificationRow[]) { 112 + const subscribeNotifications = notifications.filter( 113 + ( 114 + n, 115 + ): n is NotificationRow & { data: ExtractNotificationType<"subscribe"> } => 116 + (n.data as NotificationData)?.type === "subscribe", 117 + ); 118 + 119 + if (subscribeNotifications.length === 0) { 120 + return []; 121 + } 122 + 123 + // Fetch subscription data from the database with related data 124 + const subscriptionUris = subscribeNotifications.map( 125 + (n) => n.data.subscription_uri, 126 + ); 127 + const { data: subscriptions } = await supabaseServerClient 128 + .from("publication_subscriptions") 129 + .select("*, identities(bsky_profiles(*)), publications(*)") 130 + .in("uri", subscriptionUris); 131 + 132 + return subscribeNotifications 133 + .map((notification) => { 134 + const subscriptionData = subscriptions?.find((s) => s.uri === notification.data.subscription_uri); 135 + if (!subscriptionData) return null; 136 + return { 137 + id: notification.id, 138 + recipient: notification.recipient, 139 + created_at: notification.created_at, 140 + type: "subscribe" as const, 141 + subscription_uri: notification.data.subscription_uri, 142 + subscriptionData, 143 + }; 144 + }) 145 + .filter((n) => n !== null); 146 + } 147 + 148 + export type HydratedQuoteNotification = Awaited< 149 + ReturnType<typeof hydrateQuoteNotifications> 150 + >[0]; 151 + 152 + async function hydrateQuoteNotifications(notifications: NotificationRow[]) { 153 + const quoteNotifications = notifications.filter( 154 + (n): n is NotificationRow & { data: ExtractNotificationType<"quote"> } => 155 + (n.data as NotificationData)?.type === "quote", 156 + ); 157 + 158 + if (quoteNotifications.length === 0) { 159 + return []; 160 + } 161 + 162 + // Fetch bsky post data and document data 163 + const bskyPostUris = quoteNotifications.map((n) => n.data.bsky_post_uri); 164 + const documentUris = quoteNotifications.map((n) => n.data.document_uri); 165 + 166 + const { data: bskyPosts } = await supabaseServerClient 167 + .from("bsky_posts") 168 + .select("*") 169 + .in("uri", bskyPostUris); 170 + 171 + const { data: documents } = await supabaseServerClient 172 + .from("documents") 173 + .select("*, documents_in_publications(publications(*))") 174 + .in("uri", documentUris); 175 + 176 + return quoteNotifications 177 + .map((notification) => { 178 + const bskyPost = bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri); 179 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 180 + if (!bskyPost || !document) return null; 181 + return { 182 + id: notification.id, 183 + recipient: notification.recipient, 184 + created_at: notification.created_at, 185 + type: "quote" as const, 186 + bsky_post_uri: notification.data.bsky_post_uri, 187 + document_uri: notification.data.document_uri, 188 + bskyPost, 189 + document, 190 + }; 191 + }) 192 + .filter((n) => n !== null); 193 + } 194 + 195 + export type HydratedMentionNotification = Awaited< 196 + ReturnType<typeof hydrateMentionNotifications> 197 + >[0]; 198 + 199 + async function hydrateMentionNotifications(notifications: NotificationRow[]) { 200 + const mentionNotifications = notifications.filter( 201 + (n): n is NotificationRow & { data: ExtractNotificationType<"mention"> } => 202 + (n.data as NotificationData)?.type === "mention", 203 + ); 204 + 205 + if (mentionNotifications.length === 0) { 206 + return []; 207 + } 208 + 209 + // Fetch document data from the database 210 + const documentUris = mentionNotifications.map((n) => n.data.document_uri); 211 + const { data: documents } = await supabaseServerClient 212 + .from("documents") 213 + .select("*, documents_in_publications(publications(*))") 214 + .in("uri", documentUris); 215 + 216 + // Extract unique DIDs from document URIs to resolve handles 217 + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; 218 + 219 + // Resolve DIDs to handles in parallel 220 + const didToHandleMap = new Map<string, string | null>(); 221 + await Promise.all( 222 + documentCreatorDids.map(async (did) => { 223 + try { 224 + const resolved = await idResolver.did.resolve(did); 225 + const handle = resolved?.alsoKnownAs?.[0] 226 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 227 + : null; 228 + didToHandleMap.set(did, handle); 229 + } catch (error) { 230 + console.error(`Failed to resolve DID ${did}:`, error); 231 + didToHandleMap.set(did, null); 232 + } 233 + }), 234 + ); 235 + 236 + // Fetch mentioned publications and documents 237 + const mentionedPublicationUris = mentionNotifications 238 + .filter((n) => n.data.mention_type === "publication") 239 + .map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "publication" }>).mentioned_uri); 240 + 241 + const mentionedDocumentUris = mentionNotifications 242 + .filter((n) => n.data.mention_type === "document") 243 + .map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "document" }>).mentioned_uri); 244 + 245 + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ 246 + mentionedPublicationUris.length > 0 247 + ? supabaseServerClient 248 + .from("publications") 249 + .select("*") 250 + .in("uri", mentionedPublicationUris) 251 + : Promise.resolve({ data: [] }), 252 + mentionedDocumentUris.length > 0 253 + ? supabaseServerClient 254 + .from("documents") 255 + .select("*, documents_in_publications(publications(*))") 256 + .in("uri", mentionedDocumentUris) 257 + : Promise.resolve({ data: [] }), 258 + ]); 259 + 260 + return mentionNotifications 261 + .map((notification) => { 262 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 263 + if (!document) return null; 264 + 265 + const mentionedUri = notification.data.mention_type !== "did" 266 + ? (notification.data as Extract<ExtractNotificationType<"mention">, { mentioned_uri: string }>).mentioned_uri 267 + : undefined; 268 + 269 + const documentCreatorDid = new AtUri(notification.data.document_uri).host; 270 + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 271 + 272 + return { 273 + id: notification.id, 274 + recipient: notification.recipient, 275 + created_at: notification.created_at, 276 + type: "mention" as const, 277 + document_uri: notification.data.document_uri, 278 + mention_type: notification.data.mention_type, 279 + mentioned_uri: mentionedUri, 280 + document, 281 + documentCreatorHandle, 282 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 283 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 284 + }; 285 + }) 286 + .filter((n) => n !== null); 287 + } 288 + 289 + export type HydratedCommentMentionNotification = Awaited< 290 + ReturnType<typeof hydrateCommentMentionNotifications> 291 + >[0]; 292 + 293 + async function hydrateCommentMentionNotifications(notifications: NotificationRow[]) { 294 + const commentMentionNotifications = notifications.filter( 295 + (n): n is NotificationRow & { data: ExtractNotificationType<"comment_mention"> } => 296 + (n.data as NotificationData)?.type === "comment_mention", 297 + ); 298 + 299 + if (commentMentionNotifications.length === 0) { 300 + return []; 301 + } 302 + 303 + // Fetch comment data from the database 304 + const commentUris = commentMentionNotifications.map((n) => n.data.comment_uri); 305 + const { data: comments } = await supabaseServerClient 306 + .from("comments_on_documents") 307 + .select( 308 + "*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 309 + ) 310 + .in("uri", commentUris); 311 + 312 + // Extract unique DIDs from comment URIs to resolve handles 313 + const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))]; 314 + 315 + // Resolve DIDs to handles in parallel 316 + const didToHandleMap = new Map<string, string | null>(); 317 + await Promise.all( 318 + commenterDids.map(async (did) => { 319 + try { 320 + const resolved = await idResolver.did.resolve(did); 321 + const handle = resolved?.alsoKnownAs?.[0] 322 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 323 + : null; 324 + didToHandleMap.set(did, handle); 325 + } catch (error) { 326 + console.error(`Failed to resolve DID ${did}:`, error); 327 + didToHandleMap.set(did, null); 328 + } 329 + }), 330 + ); 331 + 332 + // Fetch mentioned publications and documents 333 + const mentionedPublicationUris = commentMentionNotifications 334 + .filter((n) => n.data.mention_type === "publication") 335 + .map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "publication" }>).mentioned_uri); 336 + 337 + const mentionedDocumentUris = commentMentionNotifications 338 + .filter((n) => n.data.mention_type === "document") 339 + .map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "document" }>).mentioned_uri); 340 + 341 + const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([ 342 + mentionedPublicationUris.length > 0 343 + ? supabaseServerClient 344 + .from("publications") 345 + .select("*") 346 + .in("uri", mentionedPublicationUris) 347 + : Promise.resolve({ data: [] }), 348 + mentionedDocumentUris.length > 0 349 + ? supabaseServerClient 350 + .from("documents") 351 + .select("*, documents_in_publications(publications(*))") 352 + .in("uri", mentionedDocumentUris) 353 + : Promise.resolve({ data: [] }), 354 + ]); 355 + 356 + return commentMentionNotifications 357 + .map((notification) => { 358 + const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 359 + if (!commentData) return null; 360 + 361 + const mentionedUri = notification.data.mention_type !== "did" 362 + ? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri 363 + : undefined; 364 + 365 + const commenterDid = new AtUri(notification.data.comment_uri).host; 366 + const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 367 + 368 + return { 369 + id: notification.id, 370 + recipient: notification.recipient, 371 + created_at: notification.created_at, 372 + type: "comment_mention" as const, 373 + comment_uri: notification.data.comment_uri, 374 + mention_type: notification.data.mention_type, 375 + mentioned_uri: mentionedUri, 376 + commentData, 377 + commenterHandle, 378 + mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 379 + mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 380 + }; 381 + }) 382 + .filter((n) => n !== null); 383 + } 384 + 385 + export async function pingIdentityToUpdateNotification(did: string) { 386 + let channel = supabaseServerClient.channel(`identity.atp_did:${did}`); 387 + await channel.send({ 388 + type: "broadcast", 389 + event: "notification", 390 + payload: { message: "poke" }, 391 + }); 392 + await supabaseServerClient.removeChannel(channel); 393 + }
+34 -8
src/replicache/mutations.ts
··· 609 }; 610 611 const updatePublicationDraft: Mutation<{ 612 - title: string; 613 - description: string; 614 }> = async (args, ctx) => { 615 await ctx.runOnServer(async (serverCtx) => { 616 console.log("updating"); 617 - await serverCtx.supabase 618 - .from("leaflets_in_publications") 619 - .update({ description: args.description, title: args.title }) 620 - .eq("leaflet", ctx.permission_token_id); 621 }); 622 await ctx.runOnClient(async ({ tx }) => { 623 - await tx.set("publication_title", args.title); 624 - await tx.set("publication_description", args.description); 625 }); 626 }; 627
··· 609 }; 610 611 const updatePublicationDraft: Mutation<{ 612 + title?: string; 613 + description?: string; 614 + tags?: string[]; 615 }> = async (args, ctx) => { 616 await ctx.runOnServer(async (serverCtx) => { 617 console.log("updating"); 618 + const updates: { 619 + description?: string; 620 + title?: string; 621 + tags?: string[]; 622 + } = {}; 623 + if (args.description !== undefined) updates.description = args.description; 624 + if (args.title !== undefined) updates.title = args.title; 625 + if (args.tags !== undefined) updates.tags = args.tags; 626 + 627 + if (Object.keys(updates).length > 0) { 628 + // First try to update leaflets_in_publications (for publications) 629 + const { data: pubResult } = await serverCtx.supabase 630 + .from("leaflets_in_publications") 631 + .update(updates) 632 + .eq("leaflet", ctx.permission_token_id) 633 + .select("leaflet"); 634 + 635 + // If no rows were updated in leaflets_in_publications, 636 + // try leaflets_to_documents (for standalone documents) 637 + if (!pubResult || pubResult.length === 0) { 638 + await serverCtx.supabase 639 + .from("leaflets_to_documents") 640 + .update(updates) 641 + .eq("leaflet", ctx.permission_token_id); 642 + } 643 + } 644 }); 645 await ctx.runOnClient(async ({ tx }) => { 646 + if (args.title !== undefined) 647 + await tx.set("publication_title", args.title); 648 + if (args.description !== undefined) 649 + await tx.set("publication_description", args.description); 650 + if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 651 }); 652 }; 653
+1
src/utils/codeLanguageStorage.ts
···
··· 1 + export const LAST_USED_CODE_LANGUAGE_KEY = "lastUsedCodeLanguage";
+116
src/utils/deleteBlock.ts
···
··· 1 + import { Replicache } from "replicache"; 2 + import { ReplicacheMutators } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { scanIndex } from "src/replicache/utils"; 5 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 + import { focusBlock } from "src/utils/focusBlock"; 7 + 8 + export async function deleteBlock( 9 + entities: string[], 10 + rep: Replicache<ReplicacheMutators>, 11 + ) { 12 + // get what pagess we need to close as a result of deleting this block 13 + let pagesToClose = [] as string[]; 14 + for (let entity of entities) { 15 + let [type] = await rep.query((tx) => 16 + scanIndex(tx).eav(entity, "block/type"), 17 + ); 18 + if (type.data.value === "card") { 19 + let [childPages] = await rep?.query( 20 + (tx) => scanIndex(tx).eav(entity, "block/card") || [], 21 + ); 22 + pagesToClose = [childPages?.data.value]; 23 + } 24 + if (type.data.value === "mailbox") { 25 + let [archive] = await rep?.query( 26 + (tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [], 27 + ); 28 + let [draft] = await rep?.query( 29 + (tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [], 30 + ); 31 + pagesToClose = [archive?.data.value, draft?.data.value]; 32 + } 33 + } 34 + 35 + // the next and previous blocks in the block list 36 + // if the focused thing is a page and not a block, return 37 + let focusedBlock = useUIState.getState().focusedEntity; 38 + let parent = 39 + focusedBlock?.entityType === "page" 40 + ? focusedBlock.entityID 41 + : focusedBlock?.parent; 42 + 43 + if (parent) { 44 + let parentType = await rep?.query((tx) => 45 + scanIndex(tx).eav(parent, "page/type"), 46 + ); 47 + if (parentType[0]?.data.value === "canvas") { 48 + useUIState 49 + .getState() 50 + .setFocusedBlock({ entityType: "page", entityID: parent }); 51 + useUIState.getState().setSelectedBlocks([]); 52 + } else { 53 + let siblings = 54 + (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 55 + 56 + let selectedBlocks = useUIState.getState().selectedBlocks; 57 + let firstSelected = selectedBlocks[0]; 58 + let lastSelected = selectedBlocks[entities.length - 1]; 59 + 60 + let prevBlock = 61 + siblings?.[ 62 + siblings.findIndex((s) => s.value === firstSelected?.value) - 1 63 + ]; 64 + let prevBlockType = await rep?.query((tx) => 65 + scanIndex(tx).eav(prevBlock?.value, "block/type"), 66 + ); 67 + 68 + let nextBlock = 69 + siblings?.[ 70 + siblings.findIndex((s) => s.value === lastSelected.value) + 1 71 + ]; 72 + let nextBlockType = await rep?.query((tx) => 73 + scanIndex(tx).eav(nextBlock?.value, "block/type"), 74 + ); 75 + 76 + if (prevBlock) { 77 + useUIState.getState().setSelectedBlock({ 78 + value: prevBlock.value, 79 + parent: prevBlock.parent, 80 + }); 81 + 82 + focusBlock( 83 + { 84 + value: prevBlock.value, 85 + type: prevBlockType?.[0].data.value, 86 + parent: prevBlock.parent, 87 + }, 88 + { type: "end" }, 89 + ); 90 + } else { 91 + useUIState.getState().setSelectedBlock({ 92 + value: nextBlock.value, 93 + parent: nextBlock.parent, 94 + }); 95 + 96 + focusBlock( 97 + { 98 + value: nextBlock.value, 99 + type: nextBlockType?.[0]?.data.value, 100 + parent: nextBlock.parent, 101 + }, 102 + { type: "start" }, 103 + ); 104 + } 105 + } 106 + } 107 + 108 + pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 109 + await Promise.all( 110 + entities.map((entity) => 111 + rep?.mutate.removeBlock({ 112 + blockEntity: entity, 113 + }), 114 + ), 115 + ); 116 + }
+37
src/utils/focusElement.ts
···
··· 1 + import { isIOS } from "src/utils/isDevice"; 2 + 3 + export const focusElement = ( 4 + el?: HTMLInputElement | HTMLTextAreaElement | null, 5 + ) => { 6 + if (!isIOS()) { 7 + el?.focus(); 8 + return; 9 + } 10 + 11 + let fakeInput = document.createElement("input"); 12 + fakeInput.setAttribute("type", "text"); 13 + fakeInput.style.position = "fixed"; 14 + fakeInput.style.height = "0px"; 15 + fakeInput.style.width = "0px"; 16 + fakeInput.style.fontSize = "16px"; // disable auto zoom 17 + document.body.appendChild(fakeInput); 18 + fakeInput.focus(); 19 + setTimeout(() => { 20 + if (!el) return; 21 + el.style.transform = "translateY(-2000px)"; 22 + el?.focus(); 23 + fakeInput.remove(); 24 + el.value = " "; 25 + el.setSelectionRange(1, 1); 26 + requestAnimationFrame(() => { 27 + if (el) { 28 + el.style.transform = ""; 29 + } 30 + }); 31 + setTimeout(() => { 32 + if (!el) return; 33 + el.value = ""; 34 + el.setSelectionRange(0, 0); 35 + }, 50); 36 + }, 20); 37 + };
+73
src/utils/focusPage.ts
···
··· 1 + import { Replicache } from "replicache"; 2 + import { Fact, ReplicacheMutators } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { scanIndex } from "src/replicache/utils"; 5 + import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 6 + import { elementId } from "src/utils/elementId"; 7 + import { focusBlock } from "src/utils/focusBlock"; 8 + 9 + export async function focusPage( 10 + pageID: string, 11 + rep: Replicache<ReplicacheMutators>, 12 + focusFirstBlock?: "focusFirstBlock", 13 + ) { 14 + // if this page is already focused, 15 + let focusedBlock = useUIState.getState().focusedEntity; 16 + // else set this page as focused 17 + useUIState.setState(() => ({ 18 + focusedEntity: { 19 + entityType: "page", 20 + entityID: pageID, 21 + }, 22 + })); 23 + 24 + setTimeout(async () => { 25 + //scroll to page 26 + 27 + scrollIntoViewIfNeeded( 28 + document.getElementById(elementId.page(pageID).container), 29 + false, 30 + "smooth", 31 + ); 32 + 33 + // if we asked that the function focus the first block, focus the first block 34 + if (focusFirstBlock === "focusFirstBlock") { 35 + let firstBlock = await rep.query(async (tx) => { 36 + let type = await scanIndex(tx).eav(pageID, "page/type"); 37 + let blocks = await scanIndex(tx).eav( 38 + pageID, 39 + type[0]?.data.value === "canvas" ? "canvas/block" : "card/block", 40 + ); 41 + 42 + let firstBlock = blocks[0]; 43 + 44 + if (!firstBlock) { 45 + return null; 46 + } 47 + 48 + let blockType = ( 49 + await tx 50 + .scan< 51 + Fact<"block/type"> 52 + >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` }) 53 + .toArray() 54 + )[0]; 55 + 56 + if (!blockType) return null; 57 + 58 + return { 59 + value: firstBlock.data.value, 60 + type: blockType.data.value, 61 + parent: firstBlock.entity, 62 + position: firstBlock.data.position, 63 + }; 64 + }); 65 + 66 + if (firstBlock) { 67 + setTimeout(() => { 68 + focusBlock(firstBlock, { type: "start" }); 69 + }, 500); 70 + } 71 + } 72 + }, 50); 73 + }
+1 -1
src/utils/getBlocksAsHTML.tsx
··· 164 return ( 165 <div 166 data-type="card" 167 - data-facts={btoa(JSON.stringify(facts))} 168 data-entityid={card.data.value} 169 /> 170 );
··· 164 return ( 165 <div 166 data-type="card" 167 + data-facts={JSON.stringify(facts)} 168 data-entityid={card.data.value} 169 /> 170 );
+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); 4 const hostname = headersList.get("x-forwarded-host"); 5 let protocol = headersList.get("x-forwarded-proto"); 6 return `${protocol}://${hostname}/`;
··· 1 + import { headers } from "next/headers"; 2 + export async function getCurrentDeploymentDomain() { 3 + const headersList = await headers(); 4 const hostname = headersList.get("x-forwarded-host"); 5 let protocol = headersList.get("x-forwarded-proto"); 6 return `${protocol}://${hostname}/`;
+55 -14
src/utils/getMicroLinkOgImage.ts
··· 2 3 export async function getMicroLinkOgImage( 4 path: string, 5 - options?: { width?: number; height?: number; deviceScaleFactor?: number }, 6 ) { 7 const headersList = await headers(); 8 - const hostname = headersList.get("x-forwarded-host"); 9 let protocol = headersList.get("x-forwarded-proto"); 10 let full_path = `${protocol}://${hostname}${path}`; 11 let response = await fetch( 12 - `https://pro.microlink.io/?url=${encodeURIComponent(full_path)}&screenshot=true&viewport.width=${options?.width || 1400}&viewport.height=${options?.height || 733}&viewport.deviceScaleFactor=${options?.deviceScaleFactor || 1}&meta=false&embed=screenshot.url&force=true`, 13 { 14 headers: { 15 - "x-api-key": process.env.MICROLINK_API_KEY!, 16 }, 17 - next: { 18 - revalidate: 600, 19 - }, 20 }, 21 ); 22 - const clonedResponse = response.clone(); 23 - if (clonedResponse.status == 200) 24 - clonedResponse.headers.set( 25 - "CDN-Cache-Control", 26 - "s-maxage=600, stale-while-revalidate=3600", 27 - ); 28 29 - return clonedResponse; 30 }
··· 2 3 export async function getMicroLinkOgImage( 4 path: string, 5 + options?: { 6 + width?: number; 7 + height?: number; 8 + deviceScaleFactor?: number; 9 + noCache?: boolean; 10 + }, 11 ) { 12 const headersList = await headers(); 13 + let hostname = headersList.get("x-forwarded-host"); 14 let protocol = headersList.get("x-forwarded-proto"); 15 + if (process.env.NODE_ENV === "development") { 16 + protocol === "https"; 17 + hostname = "leaflet.pub"; 18 + } 19 let full_path = `${protocol}://${hostname}${path}`; 20 + return getWebpageImage(full_path, { 21 + ...options, 22 + setJavaScriptEnabled: false, 23 + }); 24 + } 25 + 26 + export async function getWebpageImage( 27 + url: string, 28 + options?: { 29 + setJavaScriptEnabled?: boolean; 30 + width?: number; 31 + height?: number; 32 + deviceScaleFactor?: number; 33 + noCache?: boolean; 34 + }, 35 + ) { 36 let response = await fetch( 37 + `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT}/browser-rendering/screenshot`, 38 { 39 + method: "POST", 40 headers: { 41 + "Content-type": "application/json", 42 + Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`, 43 }, 44 + body: JSON.stringify({ 45 + url, 46 + setJavaScriptEnabled: options?.setJavaScriptEnabled, 47 + scrollPage: true, 48 + addStyleTag: [ 49 + { 50 + content: `* {scrollbar-width:none; }`, 51 + }, 52 + ], 53 + gotoOptions: { 54 + waitUntil: "load", 55 + }, 56 + viewport: { 57 + width: options?.width || 1400, 58 + height: options?.height || 733, 59 + deviceScaleFactor: options?.deviceScaleFactor, 60 + }, 61 + }), 62 + next: !options?.noCache 63 + ? undefined 64 + : { 65 + revalidate: 600, 66 + }, 67 }, 68 ); 69 70 + return response; 71 }
+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 + }
+10
src/utils/scrollIntoView.ts
···
··· 1 + import { scrollIntoViewIfNeeded } from "./scrollIntoViewIfNeeded"; 2 + 3 + export function scrollIntoView( 4 + elementId: string, 5 + scrollContainerId: string = "pages", 6 + threshold: number = 0.9, 7 + ) { 8 + const element = document.getElementById(elementId); 9 + scrollIntoViewIfNeeded(element, false, "smooth"); 10 + }
+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 + }
+175
supabase/database.types.ts
··· 34 } 35 public: { 36 Tables: { 37 bsky_posts: { 38 Row: { 39 cid: string ··· 214 document_mentions_in_bsky: { 215 Row: { 216 document: string 217 link: string 218 uri: string 219 } 220 Insert: { 221 document: string 222 link: string 223 uri: string 224 } 225 Update: { 226 document?: string 227 link?: string 228 uri?: string 229 } ··· 491 } 492 leaflets_in_publications: { 493 Row: { 494 description: string 495 doc: string | null 496 leaflet: string ··· 498 title: string 499 } 500 Insert: { 501 description?: string 502 doc?: string | null 503 leaflet: string ··· 505 title?: string 506 } 507 Update: { 508 description?: string 509 doc?: string | null 510 leaflet?: string ··· 535 }, 536 ] 537 } 538 oauth_session_store: { 539 Row: { 540 key: string ··· 567 } 568 permission_token_on_homepage: { 569 Row: { 570 created_at: string 571 identity: string 572 token: string 573 } 574 Insert: { 575 created_at?: string 576 identity: string 577 token: string 578 } 579 Update: { 580 created_at?: string 581 identity?: string 582 token?: string ··· 991 client_group_id: string 992 } 993 Returns: Database["public"]["CompositeTypes"]["pull_result"] 994 } 995 } 996 Enums: {
··· 34 } 35 public: { 36 Tables: { 37 + atp_poll_records: { 38 + Row: { 39 + cid: string 40 + created_at: string 41 + record: Json 42 + uri: string 43 + } 44 + Insert: { 45 + cid: string 46 + created_at?: string 47 + record: Json 48 + uri: string 49 + } 50 + Update: { 51 + cid?: string 52 + created_at?: string 53 + record?: Json 54 + uri?: string 55 + } 56 + Relationships: [] 57 + } 58 + atp_poll_votes: { 59 + Row: { 60 + indexed_at: string 61 + poll_cid: string 62 + poll_uri: string 63 + record: Json 64 + uri: string 65 + voter_did: string 66 + } 67 + Insert: { 68 + indexed_at?: string 69 + poll_cid: string 70 + poll_uri: string 71 + record: Json 72 + uri: string 73 + voter_did: string 74 + } 75 + Update: { 76 + indexed_at?: string 77 + poll_cid?: string 78 + poll_uri?: string 79 + record?: Json 80 + uri?: string 81 + voter_did?: string 82 + } 83 + Relationships: [ 84 + { 85 + foreignKeyName: "atp_poll_votes_poll_uri_fkey" 86 + columns: ["poll_uri"] 87 + isOneToOne: false 88 + referencedRelation: "atp_poll_records" 89 + referencedColumns: ["uri"] 90 + }, 91 + ] 92 + } 93 + bsky_follows: { 94 + Row: { 95 + follows: string 96 + identity: string 97 + } 98 + Insert: { 99 + follows: string 100 + identity?: string 101 + } 102 + Update: { 103 + follows?: string 104 + identity?: string 105 + } 106 + Relationships: [ 107 + { 108 + foreignKeyName: "bsky_follows_follows_fkey" 109 + columns: ["follows"] 110 + isOneToOne: false 111 + referencedRelation: "identities" 112 + referencedColumns: ["atp_did"] 113 + }, 114 + { 115 + foreignKeyName: "bsky_follows_identity_fkey" 116 + columns: ["identity"] 117 + isOneToOne: false 118 + referencedRelation: "identities" 119 + referencedColumns: ["atp_did"] 120 + }, 121 + ] 122 + } 123 bsky_posts: { 124 Row: { 125 cid: string ··· 300 document_mentions_in_bsky: { 301 Row: { 302 document: string 303 + indexed_at: string 304 link: string 305 uri: string 306 } 307 Insert: { 308 document: string 309 + indexed_at?: string 310 link: string 311 uri: string 312 } 313 Update: { 314 document?: string 315 + indexed_at?: string 316 link?: string 317 uri?: string 318 } ··· 580 } 581 leaflets_in_publications: { 582 Row: { 583 + archived: boolean | null 584 description: string 585 doc: string | null 586 leaflet: string ··· 588 title: string 589 } 590 Insert: { 591 + archived?: boolean | null 592 description?: string 593 doc?: string | null 594 leaflet: string ··· 596 title?: string 597 } 598 Update: { 599 + archived?: boolean | null 600 description?: string 601 doc?: string | null 602 leaflet?: string ··· 627 }, 628 ] 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 + } 669 + notifications: { 670 + Row: { 671 + created_at: string 672 + data: Json 673 + id: string 674 + read: boolean 675 + recipient: string 676 + } 677 + Insert: { 678 + created_at?: string 679 + data: Json 680 + id: string 681 + read?: boolean 682 + recipient: string 683 + } 684 + Update: { 685 + created_at?: string 686 + data?: Json 687 + id?: string 688 + read?: boolean 689 + recipient?: string 690 + } 691 + Relationships: [ 692 + { 693 + foreignKeyName: "notifications_recipient_fkey" 694 + columns: ["recipient"] 695 + isOneToOne: false 696 + referencedRelation: "identities" 697 + referencedColumns: ["atp_did"] 698 + }, 699 + ] 700 + } 701 oauth_session_store: { 702 Row: { 703 key: string ··· 730 } 731 permission_token_on_homepage: { 732 Row: { 733 + archived: boolean | null 734 created_at: string 735 identity: string 736 token: string 737 } 738 Insert: { 739 + archived?: boolean | null 740 created_at?: string 741 identity: string 742 token: string 743 } 744 Update: { 745 + archived?: boolean | null 746 created_at?: string 747 identity?: string 748 token?: string ··· 1157 client_group_id: string 1158 } 1159 Returns: Database["public"]["CompositeTypes"]["pull_result"] 1160 + } 1161 + search_tags: { 1162 + Args: { 1163 + search_query: string 1164 + } 1165 + Returns: { 1166 + name: string 1167 + document_count: number 1168 + }[] 1169 } 1170 } 1171 Enums: {
+62
supabase/migrations/20251014215602_add_bsky_follows_table.sql
···
··· 1 + create table "public"."bsky_follows" ( 2 + "identity" text not null, 3 + "follows" text not null 4 + ); 5 + 6 + alter table "public"."bsky_follows" enable row level security; 7 + 8 + CREATE UNIQUE INDEX bsky_follows_pkey ON public.bsky_follows USING btree (identity, follows); 9 + 10 + CREATE INDEX facts_reference_idx ON public.facts USING btree (((data ->> 'value'::text))) WHERE (((data ->> 'type'::text) = 'reference'::text) OR ((data ->> 'type'::text) = 'ordered-reference'::text)); 11 + 12 + alter table "public"."bsky_follows" add constraint "bsky_follows_pkey" PRIMARY KEY using index "bsky_follows_pkey"; 13 + 14 + alter table "public"."bsky_follows" add constraint "bsky_follows_follows_fkey" FOREIGN KEY (follows) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; 15 + 16 + alter table "public"."bsky_follows" validate constraint "bsky_follows_follows_fkey"; 17 + 18 + alter table "public"."bsky_follows" add constraint "bsky_follows_identity_fkey" FOREIGN KEY (identity) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; 19 + 20 + alter table "public"."bsky_follows" validate constraint "bsky_follows_identity_fkey"; 21 + 22 + grant delete on table "public"."bsky_follows" to "anon"; 23 + 24 + grant insert on table "public"."bsky_follows" to "anon"; 25 + 26 + grant references on table "public"."bsky_follows" to "anon"; 27 + 28 + grant select on table "public"."bsky_follows" to "anon"; 29 + 30 + grant trigger on table "public"."bsky_follows" to "anon"; 31 + 32 + grant truncate on table "public"."bsky_follows" to "anon"; 33 + 34 + grant update on table "public"."bsky_follows" to "anon"; 35 + 36 + grant delete on table "public"."bsky_follows" to "authenticated"; 37 + 38 + grant insert on table "public"."bsky_follows" to "authenticated"; 39 + 40 + grant references on table "public"."bsky_follows" to "authenticated"; 41 + 42 + grant select on table "public"."bsky_follows" to "authenticated"; 43 + 44 + grant trigger on table "public"."bsky_follows" to "authenticated"; 45 + 46 + grant truncate on table "public"."bsky_follows" to "authenticated"; 47 + 48 + grant update on table "public"."bsky_follows" to "authenticated"; 49 + 50 + grant delete on table "public"."bsky_follows" to "service_role"; 51 + 52 + grant insert on table "public"."bsky_follows" to "service_role"; 53 + 54 + grant references on table "public"."bsky_follows" to "service_role"; 55 + 56 + grant select on table "public"."bsky_follows" to "service_role"; 57 + 58 + grant trigger on table "public"."bsky_follows" to "service_role"; 59 + 60 + grant truncate on table "public"."bsky_follows" to "service_role"; 61 + 62 + grant update on table "public"."bsky_follows" to "service_role";
+1
supabase/migrations/20251017160632_add_indexed_at_to_document_mentions_in_bsky.sql
···
··· 1 + alter table "public"."document_mentions_in_bsky" add column "indexed_at" timestamp with time zone not null default now();
+123
supabase/migrations/20251023200453_atp_poll_votes.sql
···
··· 1 + create table "public"."atp_poll_votes" ( 2 + "uri" text not null, 3 + "record" jsonb not null, 4 + "voter_did" text not null, 5 + "poll_uri" text not null, 6 + "poll_cid" text not null, 7 + "option" text not null, 8 + "indexed_at" timestamp with time zone not null default now() 9 + ); 10 + 11 + alter table "public"."atp_poll_votes" enable row level security; 12 + 13 + CREATE UNIQUE INDEX atp_poll_votes_pkey ON public.atp_poll_votes USING btree (uri); 14 + 15 + alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_pkey" PRIMARY KEY using index "atp_poll_votes_pkey"; 16 + 17 + CREATE INDEX atp_poll_votes_poll_uri_idx ON public.atp_poll_votes USING btree (poll_uri); 18 + 19 + CREATE INDEX atp_poll_votes_voter_did_idx ON public.atp_poll_votes USING btree (voter_did); 20 + 21 + grant delete on table "public"."atp_poll_votes" to "anon"; 22 + 23 + grant insert on table "public"."atp_poll_votes" to "anon"; 24 + 25 + grant references on table "public"."atp_poll_votes" to "anon"; 26 + 27 + grant select on table "public"."atp_poll_votes" to "anon"; 28 + 29 + grant trigger on table "public"."atp_poll_votes" to "anon"; 30 + 31 + grant truncate on table "public"."atp_poll_votes" to "anon"; 32 + 33 + grant update on table "public"."atp_poll_votes" to "anon"; 34 + 35 + grant delete on table "public"."atp_poll_votes" to "authenticated"; 36 + 37 + grant insert on table "public"."atp_poll_votes" to "authenticated"; 38 + 39 + grant references on table "public"."atp_poll_votes" to "authenticated"; 40 + 41 + grant select on table "public"."atp_poll_votes" to "authenticated"; 42 + 43 + grant trigger on table "public"."atp_poll_votes" to "authenticated"; 44 + 45 + grant truncate on table "public"."atp_poll_votes" to "authenticated"; 46 + 47 + grant update on table "public"."atp_poll_votes" to "authenticated"; 48 + 49 + grant delete on table "public"."atp_poll_votes" to "service_role"; 50 + 51 + grant insert on table "public"."atp_poll_votes" to "service_role"; 52 + 53 + grant references on table "public"."atp_poll_votes" to "service_role"; 54 + 55 + grant select on table "public"."atp_poll_votes" to "service_role"; 56 + 57 + grant trigger on table "public"."atp_poll_votes" to "service_role"; 58 + 59 + grant truncate on table "public"."atp_poll_votes" to "service_role"; 60 + 61 + grant update on table "public"."atp_poll_votes" to "service_role"; 62 + 63 + create table "public"."atp_poll_records" ( 64 + "uri" text not null, 65 + "cid" text not null, 66 + "record" jsonb not null, 67 + "created_at" timestamp with time zone not null default now() 68 + ); 69 + 70 + 71 + alter table "public"."atp_poll_records" enable row level security; 72 + 73 + alter table "public"."bsky_follows" alter column "identity" set default ''::text; 74 + 75 + CREATE UNIQUE INDEX atp_poll_records_pkey ON public.atp_poll_records USING btree (uri); 76 + 77 + alter table "public"."atp_poll_records" add constraint "atp_poll_records_pkey" PRIMARY KEY using index "atp_poll_records_pkey"; 78 + 79 + alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_poll_uri_fkey" FOREIGN KEY (poll_uri) REFERENCES atp_poll_records(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid; 80 + 81 + alter table "public"."atp_poll_votes" validate constraint "atp_poll_votes_poll_uri_fkey"; 82 + 83 + grant delete on table "public"."atp_poll_records" to "anon"; 84 + 85 + grant insert on table "public"."atp_poll_records" to "anon"; 86 + 87 + grant references on table "public"."atp_poll_records" to "anon"; 88 + 89 + grant select on table "public"."atp_poll_records" to "anon"; 90 + 91 + grant trigger on table "public"."atp_poll_records" to "anon"; 92 + 93 + grant truncate on table "public"."atp_poll_records" to "anon"; 94 + 95 + grant update on table "public"."atp_poll_records" to "anon"; 96 + 97 + grant delete on table "public"."atp_poll_records" to "authenticated"; 98 + 99 + grant insert on table "public"."atp_poll_records" to "authenticated"; 100 + 101 + grant references on table "public"."atp_poll_records" to "authenticated"; 102 + 103 + grant select on table "public"."atp_poll_records" to "authenticated"; 104 + 105 + grant trigger on table "public"."atp_poll_records" to "authenticated"; 106 + 107 + grant truncate on table "public"."atp_poll_records" to "authenticated"; 108 + 109 + grant update on table "public"."atp_poll_records" to "authenticated"; 110 + 111 + grant delete on table "public"."atp_poll_records" to "service_role"; 112 + 113 + grant insert on table "public"."atp_poll_records" to "service_role"; 114 + 115 + grant references on table "public"."atp_poll_records" to "service_role"; 116 + 117 + grant select on table "public"."atp_poll_records" to "service_role"; 118 + 119 + grant trigger on table "public"."atp_poll_records" to "service_role"; 120 + 121 + grant truncate on table "public"."atp_poll_records" to "service_role"; 122 + 123 + grant update on table "public"."atp_poll_records" to "service_role";
+1
supabase/migrations/20251027212752_remove_option_col_from_atp_poll_votes.sql
···
··· 1 + alter table "public"."atp_poll_votes" drop column "option";
+60
supabase/migrations/20251030215033_add_notifications_table.sql
···
··· 1 + create table "public"."notifications" ( 2 + "recipient" text not null, 3 + "created_at" timestamp with time zone not null default now(), 4 + "read" boolean not null default false, 5 + "data" jsonb not null, 6 + "id" uuid not null 7 + ); 8 + 9 + 10 + alter table "public"."notifications" enable row level security; 11 + 12 + CREATE UNIQUE INDEX notifications_pkey ON public.notifications USING btree (id); 13 + 14 + alter table "public"."notifications" add constraint "notifications_pkey" PRIMARY KEY using index "notifications_pkey"; 15 + 16 + alter table "public"."notifications" add constraint "notifications_recipient_fkey" FOREIGN KEY (recipient) REFERENCES identities(atp_did) ON UPDATE CASCADE ON DELETE CASCADE not valid; 17 + 18 + alter table "public"."notifications" validate constraint "notifications_recipient_fkey"; 19 + 20 + grant delete on table "public"."notifications" to "anon"; 21 + 22 + grant insert on table "public"."notifications" to "anon"; 23 + 24 + grant references on table "public"."notifications" to "anon"; 25 + 26 + grant select on table "public"."notifications" to "anon"; 27 + 28 + grant trigger on table "public"."notifications" to "anon"; 29 + 30 + grant truncate on table "public"."notifications" to "anon"; 31 + 32 + grant update on table "public"."notifications" to "anon"; 33 + 34 + grant delete on table "public"."notifications" to "authenticated"; 35 + 36 + grant insert on table "public"."notifications" to "authenticated"; 37 + 38 + grant references on table "public"."notifications" to "authenticated"; 39 + 40 + grant select on table "public"."notifications" to "authenticated"; 41 + 42 + grant trigger on table "public"."notifications" to "authenticated"; 43 + 44 + grant truncate on table "public"."notifications" to "authenticated"; 45 + 46 + grant update on table "public"."notifications" to "authenticated"; 47 + 48 + grant delete on table "public"."notifications" to "service_role"; 49 + 50 + grant insert on table "public"."notifications" to "service_role"; 51 + 52 + grant references on table "public"."notifications" to "service_role"; 53 + 54 + grant select on table "public"."notifications" to "service_role"; 55 + 56 + grant trigger on table "public"."notifications" to "service_role"; 57 + 58 + grant truncate on table "public"."notifications" to "service_role"; 59 + 60 + grant update on table "public"."notifications" to "service_role";
+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 { 2 "compilerOptions": { 3 - "lib": ["dom", "dom.iterable", "esnext"], 4 - "types": ["@cloudflare/workers-types"], 5 "baseUrl": ".", 6 "allowJs": true, 7 "skipLibCheck": true, ··· 15 "moduleResolution": "node", 16 "resolveJsonModule": true, 17 "isolatedModules": true, 18 - "jsx": "preserve", 19 "plugins": [ 20 { 21 "name": "next" ··· 30 "**/*.js", 31 "**/*.ts", 32 "**/*.tsx", 33 - "**/*.mdx" 34 ], 35 - "exclude": ["node_modules"] 36 }
··· 1 { 2 "compilerOptions": { 3 + "lib": [ 4 + "dom", 5 + "dom.iterable", 6 + "esnext" 7 + ], 8 + "types": [ 9 + "@cloudflare/workers-types" 10 + ], 11 "baseUrl": ".", 12 "allowJs": true, 13 "skipLibCheck": true, ··· 21 "moduleResolution": "node", 22 "resolveJsonModule": true, 23 "isolatedModules": true, 24 + "jsx": "react-jsx", 25 "plugins": [ 26 { 27 "name": "next" ··· 36 "**/*.js", 37 "**/*.ts", 38 "**/*.tsx", 39 + "**/*.mdx", 40 + ".next/dev/types/**/*.ts" 41 ], 42 + "exclude": [ 43 + "node_modules" 44 + ] 45 }