a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #135 from hyperlink-academy/feature/create-publication

Feature/create publication

authored by

Jared Pereira and committed by
GitHub
829f0a4f 6dad7893

+5578 -2865
+32 -16
actions/createNewLeaflet.ts
··· 17 17 import { sql, eq, and } from "drizzle-orm"; 18 18 import { cookies } from "next/headers"; 19 19 20 - export async function createNewLeaflet( 21 - pageType: "canvas" | "doc", 22 - redirectUser: boolean, 23 - ) { 20 + export async function createNewLeaflet({ 21 + pageType, 22 + redirectUser, 23 + firstBlockType, 24 + }: { 25 + pageType: "canvas" | "doc"; 26 + redirectUser: boolean; 27 + firstBlockType?: "h1" | "text"; 28 + }) { 24 29 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 25 30 let auth_token = (await cookies()).get("auth_token")?.value; 26 31 const db = drizzle(client); ··· 107 112 attribute: "card/block", 108 113 data: sql`${{ type: "ordered-reference", value: blockEntity.id, position: "a0" }}::jsonb`, 109 114 }, 110 - { 111 - id: v7(), 112 - entity: blockEntity.id, 113 - attribute: "block/type", 114 - data: sql`${{ type: "block-type-union", value: "heading" }}::jsonb`, 115 - }, 116 - { 117 - id: v7(), 118 - entity: blockEntity.id, 119 - attribute: "block/heading-level", 120 - data: sql`${{ type: "number", value: 1 }}::jsonb`, 121 - }, 115 + ...(firstBlockType === "text" 116 + ? [ 117 + { 118 + id: v7(), 119 + entity: blockEntity.id, 120 + attribute: "block/type", 121 + data: sql`${{ type: "block-type-union", value: "text" }}::jsonb`, 122 + }, 123 + ] 124 + : [ 125 + { 126 + id: v7(), 127 + entity: blockEntity.id, 128 + attribute: "block/type", 129 + data: sql`${{ type: "block-type-union", value: "heading" }}::jsonb`, 130 + }, 131 + { 132 + id: v7(), 133 + entity: blockEntity.id, 134 + attribute: "block/heading-level", 135 + data: sql`${{ type: "number", value: 1 }}::jsonb`, 136 + }, 137 + ]), 122 138 ]); 123 139 } 124 140 if (auth_token) {
+3 -3
actions/createNewLeafletFromTemplate.ts
··· 4 4 import { drizzle } from "drizzle-orm/postgres-js"; 5 5 import { NextRequest } from "next/server"; 6 6 import postgres from "postgres"; 7 - import { Fact } from "src/replicache"; 8 - import { Attributes } from "src/replicache/attributes"; 7 + import type { Fact } from "src/replicache"; 8 + import type { Attribute } from "src/replicache/attributes"; 9 9 import { Database } from "supabase/database.types"; 10 10 import { v7 } from "uuid"; 11 11 ··· 41 41 let { data } = await supabase.rpc("get_facts", { 42 42 root: rootEntity, 43 43 }); 44 - let initialFacts = (data as unknown as Fact<keyof typeof Attributes>[]) || []; 44 + let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 45 45 46 46 let oldEntityIDToNewID = {} as { [k: string]: string }; 47 47 let oldEntities = initialFacts.reduce((acc, f) => {
-30
actions/createPublication.ts
··· 1 - "use server"; 2 - import { TID } from "@atproto/common"; 3 - import { AtpBaseClient } from "lexicons/api"; 4 - import { createOauthClient } from "src/atproto-oauth"; 5 - import { getIdentityData } from "actions/getIdentityData"; 6 - import { supabaseServerClient } from "supabase/serverClient"; 7 - 8 - export async function createPublication(name: string) { 9 - const oauthClient = await createOauthClient(); 10 - let identity = await getIdentityData(); 11 - if (!identity || !identity.atp_did) return; 12 - let credentialSession = await oauthClient.restore(identity.atp_did); 13 - let agent = new AtpBaseClient( 14 - credentialSession.fetchHandler.bind(credentialSession), 15 - ); 16 - let record = { 17 - name, 18 - }; 19 - let result = await agent.pub.leaflet.publication.create( 20 - { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false }, 21 - record, 22 - ); 23 - 24 - //optimistically write to our db! 25 - await supabaseServerClient.from("publications").upsert({ 26 - uri: result.uri, 27 - identity_did: credentialSession.did!, 28 - name: record.name, 29 - }); 30 - }
+5 -1
actions/createPublicationDraft.ts
··· 6 6 export async function createPublicationDraft(publication_uri: string) { 7 7 let identity = await getIdentityData(); 8 8 if (!identity || !identity.atp_did) return null; 9 - let newLeaflet = await createNewLeaflet("doc", false); 9 + let newLeaflet = await createNewLeaflet({ 10 + pageType: "doc", 11 + redirectUser: false, 12 + firstBlockType: "text", 13 + }); 10 14 console.log( 11 15 await supabaseServerClient 12 16 .from("leaflets_in_publications")
+2 -6
actions/emailAuth.ts
··· 7 7 import { and, eq } from "drizzle-orm"; 8 8 import { cookies } from "next/headers"; 9 9 import { createIdentity } from "./createIdentity"; 10 + import { setAuthToken } from "src/auth"; 10 11 11 12 async function sendAuthCode(email: string, code: string) { 12 13 if (process.env.NODE_ENV === "development") { ··· 136 137 ) 137 138 .returning(); 138 139 139 - (await cookies()).set("auth_token", confirmedToken.id, { 140 - maxAge: 60 * 60 * 24 * 365, 141 - secure: process.env.NODE_ENV === "production", 142 - httpOnly: true, 143 - sameSite: "lax", 144 - }); 140 + await setAuthToken(confirmedToken.id); 145 141 146 142 client.end(); 147 143 return confirmedToken;
+4 -10
actions/getIdentityData.ts
··· 1 1 "use server"; 2 2 3 3 import { IdResolver } from "@atproto/identity"; 4 - import { createServerClient } from "@supabase/ssr"; 5 4 import { cookies } from "next/headers"; 6 - import { Database } from "supabase/database.types"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 7 6 8 - let supabase = createServerClient<Database>( 9 - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 10 - process.env.SUPABASE_SERVICE_ROLE_KEY as string, 11 - { cookies: {} }, 12 - ); 13 7 let idResolver = new IdResolver(); 14 8 export async function getIdentityData() { 15 9 let cookieStore = await cookies(); 16 10 let auth_token = cookieStore.get("auth_token")?.value; 17 11 let auth_res = auth_token 18 - ? await supabase 12 + ? await supabaseServerClient 19 13 .from("email_auth_tokens") 20 14 .select( 21 15 `*, ··· 24 18 subscribers_to_publications(*), 25 19 custom_domains(*), 26 20 home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), 27 - permission_token_on_homepage(created_at, permission_tokens!inner(id, root_entity, permission_token_rights(*))) 21 + permission_token_on_homepage(created_at, permission_tokens!inner(id, root_entity, permission_token_rights(*), leaflets_in_publications(*))) 28 22 )`, 29 23 ) 30 24 .eq("id", auth_token) ··· 34 28 if (!auth_res?.data?.identities) return null; 35 29 if (auth_res.data.identities.atp_did) { 36 30 //I should create a relationship table so I can do this in the above query 37 - let { data: publications } = await supabase 31 + let { data: publications } = await supabaseServerClient 38 32 .from("publications") 39 33 .select("*") 40 34 .eq("identity_did", auth_res.data.identities.atp_did);
+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 + }
+199 -136
actions/publishToPublication.ts
··· 7 7 import { 8 8 AtpBaseClient, 9 9 PubLeafletBlocksHeader, 10 + PubLeafletBlocksImage, 10 11 PubLeafletBlocksText, 12 + PubLeafletBlocksUnorderedList, 11 13 PubLeafletDocument, 12 14 PubLeafletPagesLinearDocument, 15 + PubLeafletRichtextFacet, 13 16 } from "lexicons/api"; 14 17 import { Block } from "components/Blocks/Block"; 15 18 import { TID } from "@atproto/common"; 16 19 import { supabaseServerClient } from "supabase/serverClient"; 17 20 import { scanIndex, scanIndexLocal } from "src/replicache/utils"; 18 - import { Fact } from "src/replicache"; 19 - import { Attributes } from "src/replicache/attributes"; 20 - import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 21 + import type { Fact } from "src/replicache"; 22 + import type { Attribute } from "src/replicache/attributes"; 23 + import { 24 + Delta, 25 + YJSFragmentToString, 26 + } from "components/Blocks/TextBlock/RenderYJSFragment"; 21 27 import { ids } from "lexicons/api/lexicons"; 22 - import { OmitKey } from "lexicons/api/util"; 23 28 import { BlobRef } from "@atproto/lexicon"; 24 29 import { IdResolver } from "@atproto/identity"; 25 30 import { AtUri } from "@atproto/syntax"; 26 31 import { Json } from "supabase/database.types"; 32 + import { $Typed, UnicodeString } from "@atproto/api"; 33 + import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 27 34 28 35 const idResolver = new IdResolver(); 29 - export async function publishToPublication( 30 - root_entity: string, 31 - blocks: Block[], 32 - publication_uri: string, 33 - ) { 36 + export async function publishToPublication({ 37 + root_entity, 38 + blocks, 39 + publication_uri, 40 + leaflet_id, 41 + title, 42 + description, 43 + }: { 44 + root_entity: string; 45 + blocks: Block[]; 46 + publication_uri: string; 47 + leaflet_id: string; 48 + title?: string; 49 + description?: string; 50 + }) { 34 51 const oauthClient = await createOauthClient(); 35 52 let identity = await getIdentityData(); 36 53 if (!identity || !identity.atp_did) return null; ··· 39 56 let agent = new AtpBaseClient( 40 57 credentialSession.fetchHandler.bind(credentialSession), 41 58 ); 59 + let { data: draft } = await supabaseServerClient 60 + .from("leaflets_in_publications") 61 + .select("*, publications(*)") 62 + .eq("publication", publication_uri) 63 + .eq("leaflet", leaflet_id) 64 + .single(); 42 65 let { data } = await supabaseServerClient.rpc("get_facts", { 43 66 root: root_entity, 44 67 }); 45 - console.log(data); 46 68 47 - let scan = scanIndexLocal( 48 - (data as unknown as Fact<keyof typeof Attributes>[]) || [], 49 - ); 50 - const getBlockContent = (b: string) => { 51 - let [content] = scan.eav(b, "block/text"); 52 - if (!content) return ""; 53 - let doc = new Y.Doc(); 54 - const update = base64.toByteArray(content.data.value); 55 - Y.applyUpdate(doc, update); 56 - let nodes = doc.getXmlElement("prosemirror").toArray(); 57 - let stringValue = YJSFragmentToString(nodes[0]); 58 - return stringValue; 59 - }; 69 + let scan = scanIndexLocal((data as unknown as Fact<Attribute>[]) || []); 60 70 let images = blocks 61 71 .filter((b) => b.type === "image") 62 72 .map((b) => scan.eav(b.value, "block/image")[0]); ··· 68 78 let blob = await agent.com.atproto.repo.uploadBlob(binary, { 69 79 headers: { "Content-Type": binary.type }, 70 80 }); 71 - console.log(blob); 72 81 imageMap.set(b.data.src, blob.data.blob); 73 82 }), 74 83 ); 75 84 76 - let title = "Untitled"; 77 - let titleBlock = blocks.find((f) => f.type === "heading"); 78 - if (titleBlock) title = getBlockContent(titleBlock.value); 79 85 let b: PubLeafletPagesLinearDocument.Block[] = blocksToRecord( 80 86 blocks, 81 87 imageMap, 82 88 scan, 83 89 ); 84 90 85 - let record: OmitKey<PubLeafletDocument.Record, "$type"> = { 91 + let record: PubLeafletDocument.Record = { 92 + $type: "pub.leaflet.document", 86 93 author: credentialSession.did!, 87 - title, 94 + title: title || "Untitled", 88 95 publication: publication_uri, 96 + publishedAt: new Date().toISOString(), 97 + description: description || "", 89 98 pages: [ 90 99 { 91 100 $type: "pub.leaflet.pages.linearDocument", ··· 93 102 }, 94 103 ], 95 104 }; 96 - let rkey = TID.nextStr(); 97 - let result = await agent.pub.leaflet.document.create( 98 - { repo: credentialSession.did!, rkey, validate: false }, 105 + let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); 106 + let { data: result } = await agent.com.atproto.repo.putRecord({ 107 + rkey, 108 + repo: credentialSession.did!, 109 + collection: record.$type, 99 110 record, 100 - ); 111 + validate: false, //TODO publish the lexicon so we can validate! 112 + }); 101 113 102 - await Promise.all([ 103 - //Optimistically put these in! 104 - supabaseServerClient.from("documents").upsert({ 114 + console.log( 115 + await supabaseServerClient.from("documents").upsert({ 105 116 uri: result.uri, 106 117 data: record as Json, 107 118 }), 108 - supabaseServerClient.from("documents_in_publications").insert({ 119 + ); 120 + await Promise.all([ 121 + //Optimistically put these in! 122 + supabaseServerClient.from("documents_in_publications").upsert({ 109 123 publication: record.publication, 110 124 document: result.uri, 111 125 }), 112 - sendPostToEmailSubscribers(publication_uri, { 113 - title, 114 - content: blocksToHtml(blocks, imageMap, scan, publication_uri), 115 - }), 126 + supabaseServerClient 127 + .from("leaflets_in_publications") 128 + .update({ 129 + doc: result.uri, 130 + }) 131 + .eq("leaflet", leaflet_id) 132 + .eq("publication", publication_uri), 116 133 ]); 117 134 118 135 let handle = await idResolver.did.resolve(credentialSession.did!); ··· 123 140 blocks: Block[], 124 141 imageMap: Map<string, BlobRef>, 125 142 scan: ReturnType<typeof scanIndexLocal>, 126 - ) { 127 - const getBlockContent = (b: string) => { 128 - let [content] = scan.eav(b, "block/text"); 129 - if (!content) return ""; 130 - let doc = new Y.Doc(); 131 - const update = base64.toByteArray(content.data.value); 132 - Y.applyUpdate(doc, update); 133 - let nodes = doc.getXmlElement("prosemirror").toArray(); 134 - let stringValue = YJSFragmentToString(nodes[0]); 135 - return stringValue; 136 - }; 137 - return blocks.flatMap((b) => { 138 - if (b.type !== "text" && b.type !== "heading" && b.type !== "image") 139 - return []; 140 - if (b.type === "heading") { 141 - let [headingLevel] = scan.eav(b.value, "block/heading-level"); 142 - 143 - let stringValue = getBlockContent(b.value); 144 - return [ 145 - { 146 - $type: "pub.leaflet.pages.linearDocument#block", 147 - block: { 148 - $type: "pub.leaflet.blocks.header", 149 - level: headingLevel?.data.value || 1, 150 - plaintext: stringValue, 151 - }, 152 - } as PubLeafletPagesLinearDocument.Block, 153 - ]; 143 + ): PubLeafletPagesLinearDocument.Block[] { 144 + let parsedBlocks = parseBlocksToList(blocks); 145 + return parsedBlocks.flatMap((blockOrList) => { 146 + if (blockOrList.type === "block") { 147 + let alignmentValue = 148 + scan.eav(blockOrList.block.value, "block/text-alignment")[0]?.data 149 + .value || "left"; 150 + let alignment = 151 + alignmentValue === "center" 152 + ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 153 + : alignmentValue === "right" 154 + ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 155 + : undefined; 156 + let b = blockToRecord(blockOrList.block, imageMap, scan); 157 + if (!b) return []; 158 + let block: PubLeafletPagesLinearDocument.Block = { 159 + $type: "pub.leaflet.pages.linearDocument#block", 160 + alignment, 161 + block: b, 162 + }; 163 + return [block]; 164 + } else { 165 + let block: PubLeafletPagesLinearDocument.Block = { 166 + $type: "pub.leaflet.pages.linearDocument#block", 167 + block: { 168 + $type: "pub.leaflet.blocks.unorderedList", 169 + children: childrenToRecord(blockOrList.children, imageMap, scan), 170 + }, 171 + }; 172 + return [block]; 154 173 } 174 + }); 175 + } 155 176 156 - if (b.type == "text") { 157 - let stringValue = getBlockContent(b.value); 158 - return [ 159 - { 160 - $type: "pub.leaflet.pages.linearDocument#block", 161 - block: { 162 - $type: ids.PubLeafletBlocksText, 163 - plaintext: stringValue, 164 - }, 165 - } as PubLeafletPagesLinearDocument.Block, 166 - ]; 167 - } 168 - if (b.type == "image") { 169 - let [image] = scan.eav(b.value, "block/image"); 170 - if (!image) return []; 171 - let blobref = imageMap.get(image.data.src); 172 - if (!blobref) return []; 173 - return [ 174 - { 175 - $type: "pub.leaflet.pages.linearDocument#block", 176 - block: { 177 - $type: "pub.leaflet.blocks.image", 178 - image: blobref, 179 - aspectRatio: { 180 - height: image.data.height, 181 - width: image.data.width, 182 - }, 183 - }, 184 - } as PubLeafletPagesLinearDocument.Block, 185 - ]; 186 - } 187 - return []; 177 + function childrenToRecord( 178 + children: List[], 179 + imageMap: Map<string, BlobRef>, 180 + scan: ReturnType<typeof scanIndexLocal>, 181 + ) { 182 + return children.flatMap((child) => { 183 + let content = blockToRecord(child.block, imageMap, scan); 184 + if (!content) return []; 185 + let record: PubLeafletBlocksUnorderedList.ListItem = { 186 + $type: "pub.leaflet.blocks.unorderedList#listItem", 187 + content, 188 + children: childrenToRecord(child.children, imageMap, scan), 189 + }; 190 + return record; 188 191 }); 189 192 } 190 - 191 - function blocksToHtml( 192 - blocks: Block[], 193 + function blockToRecord( 194 + b: Block, 193 195 imageMap: Map<string, BlobRef>, 194 196 scan: ReturnType<typeof scanIndexLocal>, 195 - publication_uri: string, 196 197 ) { 197 198 const getBlockContent = (b: string) => { 198 199 let [content] = scan.eav(b, "block/text"); 199 - if (!content) return ""; 200 + if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; 200 201 let doc = new Y.Doc(); 201 202 const update = base64.toByteArray(content.data.value); 202 203 Y.applyUpdate(doc, update); 203 204 let nodes = doc.getXmlElement("prosemirror").toArray(); 204 205 let stringValue = YJSFragmentToString(nodes[0]); 205 - return stringValue; 206 + let facets = YJSFragmentToFacets(nodes[0]); 207 + return [stringValue, facets] as const; 206 208 }; 207 - return blocks 208 - .flatMap((b) => { 209 - if (b.type !== "text" && b.type !== "heading" && b.type !== "image") 210 - return []; 211 - if (b.type === "heading") { 212 - let [headingLevel] = scan.eav(b.value, "block/heading-level"); 209 + if (b.type !== "text" && b.type !== "heading" && b.type !== "image") return; 210 + let alignmentValue = 211 + scan.eav(b.value, "block/text-alignment")[0]?.data.value || "left"; 213 212 214 - let stringValue = getBlockContent(b.value); 215 - let l = headingLevel?.data.value || 1; 216 - return [`<h${l}>${stringValue}</h${l}>`]; 217 - } 213 + if (b.type === "heading") { 214 + let [headingLevel] = scan.eav(b.value, "block/heading-level"); 218 215 219 - if (b.type == "text") { 220 - let stringValue = getBlockContent(b.value); 221 - return `<p>${stringValue}</p>`; 222 - } 223 - if (b.type == "image") { 224 - let [image] = scan.eav(b.value, "block/image"); 225 - if (!image) return []; 226 - let blobref = imageMap.get(image.data.src); 227 - if (!blobref) return []; 228 - let uri = new AtUri(publication_uri); 229 - return `<img 230 - height=${image.data.height} 231 - width=${image.data.width}> 232 - src="https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${uri.hostname}&cid=${(blobref as unknown as { $link: string })["$link"]}" 233 - </img>`; 234 - } 235 - return [""]; 236 - }) 237 - .join("\n"); 216 + let [stringValue, facets] = getBlockContent(b.value); 217 + let block: $Typed<PubLeafletBlocksHeader.Main> = { 218 + $type: "pub.leaflet.blocks.header", 219 + level: headingLevel?.data.value || 1, 220 + plaintext: stringValue, 221 + facets, 222 + }; 223 + return block; 224 + } 225 + 226 + if (b.type == "text") { 227 + let [stringValue, facets] = getBlockContent(b.value); 228 + let block: $Typed<PubLeafletBlocksText.Main> = { 229 + $type: ids.PubLeafletBlocksText, 230 + plaintext: stringValue, 231 + facets, 232 + }; 233 + return block; 234 + } 235 + if (b.type == "image") { 236 + let [image] = scan.eav(b.value, "block/image"); 237 + if (!image) return; 238 + let blobref = imageMap.get(image.data.src); 239 + if (!blobref) return; 240 + let block: $Typed<PubLeafletBlocksImage.Main> = { 241 + $type: "pub.leaflet.blocks.image", 242 + image: blobref, 243 + aspectRatio: { 244 + height: image.data.height, 245 + width: image.data.width, 246 + }, 247 + }; 248 + return block; 249 + } 250 + return; 238 251 } 239 252 240 253 async function sendPostToEmailSubscribers( ··· 281 294 ), 282 295 }); 283 296 } 297 + 298 + function YJSFragmentToFacets( 299 + node: Y.XmlElement | Y.XmlText | Y.XmlHook, 300 + ): PubLeafletRichtextFacet.Main[] { 301 + if (node.constructor === Y.XmlElement) { 302 + return node 303 + .toArray() 304 + .map((f) => YJSFragmentToFacets(f)) 305 + .flat(); 306 + } 307 + if (node.constructor === Y.XmlText) { 308 + let facets: PubLeafletRichtextFacet.Main[] = []; 309 + let delta = node.toDelta() as Delta[]; 310 + let byteStart = 0; 311 + console.log(delta); 312 + for (let d of delta) { 313 + let unicodestring = new UnicodeString(d.insert); 314 + let facet: PubLeafletRichtextFacet.Main = { 315 + index: { 316 + byteStart, 317 + byteEnd: byteStart + unicodestring.length, 318 + }, 319 + features: [], 320 + }; 321 + 322 + if (d.attributes?.strikethrough) 323 + facet.features.push({ 324 + $type: "pub.leaflet.richtext.facet#strikethrough", 325 + }); 326 + 327 + if (d.attributes?.highlight) 328 + facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" }); 329 + if (d.attributes?.underline) 330 + facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" }); 331 + if (d.attributes?.strong) 332 + facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" }); 333 + if (d.attributes?.em) 334 + facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" }); 335 + if (d.attributes?.link) 336 + facet.features.push({ 337 + $type: "pub.leaflet.richtext.facet#link", 338 + uri: d.attributes.link.href, 339 + }); 340 + facets.push(facet); 341 + byteStart += unicodestring.length; 342 + } 343 + return facets; 344 + } 345 + return []; 346 + }
+1 -3
actions/subscriptions/confirmEmailSubscription.ts
··· 1 1 "use server"; 2 2 3 3 import { createClient } from "@supabase/supabase-js"; 4 - import { createIdentity } from "actions/createIdentity"; 5 4 import { and, eq, sql } from "drizzle-orm"; 6 5 import { drizzle } from "drizzle-orm/postgres-js"; 7 6 import { ··· 10 9 permission_tokens, 11 10 } from "drizzle/schema"; 12 11 import postgres from "postgres"; 13 - import { Fact, PermissionToken } from "src/replicache"; 14 - import { serverMutationContext } from "src/replicache/serverMutationContext"; 12 + import type { Fact } from "src/replicache"; 15 13 import { Database } from "supabase/database.types"; 16 14 import { v7 } from "uuid"; 17 15
+1 -1
actions/subscriptions/deleteSubscription.ts
··· 4 4 import { email_subscriptions_to_entity, facts } from "drizzle/schema"; 5 5 import postgres from "postgres"; 6 6 import { eq, and, sql } from "drizzle-orm"; 7 - import { Fact } from "src/replicache"; 7 + import type { Fact } from "src/replicache"; 8 8 import { v7 } from "uuid"; 9 9 10 10 export async function deleteSubscription(subscriptionID: string) {
+1 -1
actions/subscriptions/sendPostToSubscribers.ts
··· 6 6 import { drizzle } from "drizzle-orm/postgres-js"; 7 7 import { email_subscriptions_to_entity, entities } from "drizzle/schema"; 8 8 import postgres from "postgres"; 9 - import { PermissionToken } from "src/replicache"; 9 + import type { PermissionToken } from "src/replicache"; 10 10 import { Database } from "supabase/database.types"; 11 11 12 12 let supabase = createServerClient<Database>(
+3 -3
actions/subscriptions/subscribeToMailboxWithEmail.ts
··· 7 7 import { email_subscriptions_to_entity } from "drizzle/schema"; 8 8 import postgres from "postgres"; 9 9 import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 10 - import { Fact, PermissionToken } from "src/replicache"; 11 - import { Attributes } from "src/replicache/attributes"; 10 + import type { Fact, PermissionToken } from "src/replicache"; 11 + import type { Attribute } from "src/replicache/attributes"; 12 12 import { Database } from "supabase/database.types"; 13 13 import * as Y from "yjs"; 14 14 import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; ··· 90 90 let { data } = await supabase.rpc("get_facts", { 91 91 root: root_entity, 92 92 }); 93 - let initialFacts = (data as unknown as Fact<keyof typeof Attributes>[]) || []; 93 + let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 94 94 let firstPage = initialFacts.find((f) => f.attribute === "root/page") as 95 95 | Fact<"root/page"> 96 96 | undefined;
+82
app/[leaflet_id]/Actions.tsx
··· 1 + import { publishToPublication } from "actions/publishToPublication"; 2 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 3 + import { ActionButton } from "components/ActionBar/ActionButton"; 4 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 5 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 6 + import { PublishSmall } from "components/Icons/PublishSmall"; 7 + import { useIdentityData } from "components/IdentityProvider"; 8 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 9 + import { useToaster } from "components/Toast"; 10 + import { publications } from "drizzle/schema"; 11 + import Link from "next/link"; 12 + import { useParams } from "next/navigation"; 13 + import { useBlocks } from "src/hooks/queries/useBlocks"; 14 + import { useEntity, useReplicache } from "src/replicache"; 15 + import { Json } from "supabase/database.types"; 16 + export const BackToPubButton = (props: { 17 + publication: { 18 + identity_did: string; 19 + indexed_at: string; 20 + name: string; 21 + record: Json; 22 + uri: string; 23 + }; 24 + }) => { 25 + let { identity } = useIdentityData(); 26 + 27 + let handle = identity?.resolved_did?.alsoKnownAs?.[0].slice(5)!; 28 + let name = props.publication.name; 29 + return ( 30 + <Link 31 + href={`${getPublicationURL(props.publication)}/dashboard`} 32 + className="hover:!no-underline" 33 + > 34 + <ActionButton 35 + icon={<GoBackSmall className="shrink-0" />} 36 + label="To Pub" 37 + /> 38 + </Link> 39 + ); 40 + }; 41 + 42 + export const PublishButton = () => { 43 + let { data, mutate } = useLeafletPublicationData(); 44 + let identity = useIdentityData(); 45 + let { permission_token, rootEntity } = useReplicache(); 46 + let rootPage = useEntity(rootEntity, "root/page")[0]; 47 + let blocks = useBlocks(rootPage?.data.value); 48 + let toaster = useToaster(); 49 + let pub = data[0]; 50 + return ( 51 + <ActionButton 52 + primary 53 + icon={<PublishSmall className="shrink-0" />} 54 + label={pub.doc ? "Update!" : "Publish!"} 55 + onClick={async () => { 56 + if (!pub || !pub.publications) return; 57 + let doc = await publishToPublication({ 58 + root_entity: rootEntity, 59 + blocks, 60 + publication_uri: pub.publications.uri, 61 + leaflet_id: permission_token.id, 62 + title: pub.title, 63 + description: pub.description, 64 + }); 65 + mutate(); 66 + toaster({ 67 + content: ( 68 + <div> 69 + {pub.doc ? "Updated! " : "Published! "} 70 + <Link 71 + href={`${getPublicationURL(pub.publications)}/${doc?.rkey}`} 72 + > 73 + link 74 + </Link> 75 + </div> 76 + ), 77 + type: "success", 78 + }); 79 + }} 80 + /> 81 + ); 82 + };
+19 -6
app/[leaflet_id]/Footer.tsx
··· 9 9 import { useEntitySetContext } from "components/EntitySetProvider"; 10 10 import { HelpPopover } from "components/HelpPopover"; 11 11 import { Watermark } from "components/Watermark"; 12 + import { BackToPubButton, PublishButton } from "./Actions"; 13 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 12 14 13 15 export function LeafletFooter(props: { entityID: string }) { 14 16 let focusedBlock = useUIState((s) => s.focusedEntity); 15 17 let entity_set = useEntitySetContext(); 18 + let { data: publicationData } = useLeafletPublicationData(); 19 + let pub = publicationData?.[0]; 16 20 17 21 return ( 18 22 <Media mobile className="mobileFooter w-full z-10 touch-none -mt-4 "> ··· 31 35 /> 32 36 </div> 33 37 ) : entity_set.permissions.write ? ( 34 - <ActionFooter> 35 - <HomeButton /> 36 - <ShareOptions /> 37 - <HelpPopover /> 38 - <ThemePopover entityID={props.entityID} /> 39 - </ActionFooter> 38 + pub?.publications ? ( 39 + <ActionFooter> 40 + <BackToPubButton publication={pub.publications} /> 41 + <PublishButton /> 42 + <ShareOptions /> 43 + <HelpPopover /> 44 + </ActionFooter> 45 + ) : ( 46 + <ActionFooter> 47 + <HomeButton /> 48 + <ShareOptions /> 49 + <HelpPopover /> 50 + <ThemePopover entityID={props.entityID} /> 51 + </ActionFooter> 52 + ) 40 53 ) : ( 41 54 <div className="pb-2 px-2 z-10 flex justify-end"> 42 55 <Watermark mobile />
+5 -4
app/[leaflet_id]/Leaflet.tsx
··· 1 1 "use client"; 2 2 import { Fact, PermissionToken, ReplicacheProvider } from "src/replicache"; 3 - import { Attributes } from "src/replicache/attributes"; 3 + import type { Attribute } from "src/replicache/attributes"; 4 4 import { SelectionManager } from "components/SelectionManager"; 5 5 import { Pages } from "components/Pages"; 6 6 import { ··· 16 16 17 17 export function Leaflet(props: { 18 18 token: PermissionToken; 19 - initialFacts: Fact<keyof typeof Attributes>[]; 19 + initialFacts: Fact<Attribute>[]; 20 20 leaflet_id: string; 21 21 }) { 22 22 return ( ··· 34 34 <UpdateLeafletTitle entityID={props.leaflet_id} /> 35 35 <AddLeafletToHomepage /> 36 36 <SelectionManager /> 37 - {/* we need the padding bottom here because if we don't have it the mobile footer will cut off 37 + {/* we need the padding bottom here because if we don't have it the mobile footer will cut off... 38 38 the dropshadow on the page... the padding is compensated by a negative top margin in mobile footer */} 39 39 <div 40 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 41 id="page-carousel" 42 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)*/} 43 44 <div 44 45 id="pages" 45 - className="pages flex pt-2 pb-1 sm:pb-8 sm:py-6" 46 + className="pages flex pt-2 pb-1 sm:pb-8 sm:pt-6" 46 47 onClick={(e) => { 47 48 e.currentTarget === e.target && blurPage(); 48 49 }}
+26 -14
app/[leaflet_id]/Sidebar.tsx
··· 1 1 "use client"; 2 + import { ActionButton } from "components/ActionBar/ActionButton"; 2 3 import { Sidebar } from "components/ActionBar/Sidebar"; 3 4 import { useEntitySetContext } from "components/EntitySetProvider"; 4 5 import { HelpPopover } from "components/HelpPopover"; 5 6 import { HomeButton } from "components/HomeButton"; 6 7 import { Media } from "components/Media"; 7 - import { usePublicationContext } from "components/Providers/PublicationContext"; 8 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 9 import { ShareOptions } from "components/ShareOptions"; 9 - import { PublishToPublication } from "components/ShareOptions/PublicationOptions"; 10 10 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 11 11 import { Watermark } from "components/Watermark"; 12 12 import { useUIState } from "src/useUIState"; 13 + import { BackToPubButton, PublishButton } from "./Actions"; 13 14 14 15 export function LeafletSidebar(props: { leaflet_id: string }) { 15 16 let entity_set = useEntitySetContext(); 16 - let publication = usePublicationContext(); 17 + let { data: publicationData } = useLeafletPublicationData(); 18 + let pub = publicationData?.[0]; 19 + 17 20 return ( 18 21 <div 19 22 className="spacer flex justify-end items-start" ··· 24 27 > 25 28 <Media 26 29 mobile={false} 27 - className="sidebarContainer relative flex flex-col justify-between h-full w-16 bg-bg-page/50 border-bg-page" 30 + className="sidebarContainer relative flex flex-col justify-end h-full w-16 bg-bg-page/50 border-bg-page" 28 31 > 29 32 <Sidebar> 30 33 {entity_set.permissions.write ? ( 31 - <> 32 - <ShareOptions /> 33 - <ThemePopover entityID={props.leaflet_id} /> 34 - <HelpPopover /> 35 - <hr className="text-border" /> 36 - <HomeButton /> 37 - </> 34 + pub?.publications ? ( 35 + <> 36 + <PublishButton /> 37 + <ShareOptions /> 38 + <HelpPopover /> 39 + <hr className="text-border" /> 40 + <BackToPubButton publication={pub.publications} /> 41 + </> 42 + ) : ( 43 + <> 44 + <ShareOptions /> 45 + <ThemePopover entityID={props.leaflet_id} /> 46 + <HelpPopover /> 47 + <hr className="text-border" /> 48 + <HomeButton /> 49 + </> 50 + ) 38 51 ) : ( 39 52 <div> 40 53 <HomeButton /> 41 54 </div> 42 55 )} 43 56 </Sidebar> 44 - <div className="justify-end justify-self-end"> 45 - <Watermark /> 46 - </div> 57 + <div className="h-full pointer-events-none" /> 58 + <Watermark /> 47 59 </Media> 48 60 </div> 49 61 );
+3 -4
app/[leaflet_id]/icon.tsx
··· 1 1 import { ImageResponse } from "next/og"; 2 - import { Fact } from "src/replicache"; 3 - import { Attributes } from "src/replicache/attributes"; 2 + import type { Fact } from "src/replicache"; 3 + import type { Attribute } from "src/replicache/attributes"; 4 4 import { Database } from "../../supabase/database.types"; 5 5 import { createServerClient } from "@supabase/ssr"; 6 6 import { parseHSBToRGB } from "src/utils/parseHSB"; ··· 36 36 let { data } = await supabase.rpc("get_facts", { 37 37 root: rootEntity, 38 38 }); 39 - let initialFacts = 40 - (data as unknown as Fact<keyof typeof Attributes>[]) || []; 39 + let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 41 40 let themePageBG = initialFacts.find( 42 41 (f) => f.attribute === "theme/card-background", 43 42 ) as Fact<"theme/card-background"> | undefined;
+20 -33
app/[leaflet_id]/page.tsx
··· 2 2 import * as Y from "yjs"; 3 3 import * as base64 from "base64-js"; 4 4 5 - import { Fact } from "src/replicache"; 6 - import { Database } from "../../supabase/database.types"; 7 - import { Attributes } from "src/replicache/attributes"; 8 - import { createServerClient } from "@supabase/ssr"; 5 + import type { Fact } from "src/replicache"; 6 + import type { Attribute } from "src/replicache/attributes"; 9 7 import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 10 8 import { Leaflet } from "./Leaflet"; 11 9 import { scanIndexLocal } from "src/replicache/utils"; 12 10 import { getRSVPData } from "actions/getRSVPData"; 13 11 import { PageSWRDataProvider } from "components/PageSWRDataProvider"; 14 12 import { getPollData } from "actions/pollActions"; 15 - import { PublicationContextProvider } from "components/Providers/PublicationContext"; 13 + import { supabaseServerClient } from "supabase/serverClient"; 14 + import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 16 15 17 16 export const preferredRegion = ["sfo1"]; 18 17 export const dynamic = "force-dynamic"; 19 18 export const fetchCache = "force-no-store"; 20 19 21 - let supabase = createServerClient<Database>( 22 - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 23 - process.env.SUPABASE_SERVICE_ROLE_KEY as string, 24 - { cookies: {} }, 25 - ); 26 20 type Props = { 27 21 // this is now a token id not leaflet! Should probs rename 28 22 params: Promise<{ leaflet_id: string }>; 29 23 }; 30 24 export default async function LeafletPage(props: Props) { 31 - let res = await supabase 32 - .from("permission_tokens") 33 - .select( 34 - "*, permission_token_rights(*), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*),leaflets_in_publications(publications(*)) ", 35 - ) 36 - .eq("id", (await props.params).leaflet_id) 37 - .single(); 25 + let { result: res } = await get_leaflet_data.handler( 26 + { token_id: (await props.params).leaflet_id }, 27 + { supabase: supabaseServerClient }, 28 + ); 38 29 let rootEntity = res.data?.root_entity; 39 30 if (!rootEntity || !res.data || res.data.blocked_by_admin) 40 31 return ( 41 32 <div className="w-screen h-screen flex place-items-center bg-bg-leaflet"> 42 - <div className="bg-bg-page mx-auto p-4 border border-border rounded-md flex flex-col text-center justify-centergap-1 w-fit"> 33 + <div className="bg-bg-page mx-auto p-4 border border-border rounded-md flex flex-col text-center justify-center gap-1 w-fit"> 43 34 <div className="font-bold"> 44 35 Hmmm…we couldn&apos;t find that Leaflet. 45 36 </div> ··· 55 46 ); 56 47 57 48 let [{ data }, rsvp_data, poll_data] = await Promise.all([ 58 - supabase.rpc("get_facts", { 49 + supabaseServerClient.rpc("get_facts", { 59 50 root: rootEntity, 60 51 }), 61 52 getRSVPData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 62 53 getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 63 54 ]); 64 - let initialFacts = (data as unknown as Fact<keyof typeof Attributes>[]) || []; 55 + let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 65 56 return ( 66 57 <PageSWRDataProvider 67 58 rsvp_data={rsvp_data} 68 59 poll_data={poll_data} 69 60 leaflet_id={res.data.id} 70 - domains={res.data.custom_domain_routes} 61 + leaflet_data={res} 71 62 > 72 - <PublicationContextProvider 73 - publication={res.data.leaflets_in_publications[0]?.publications} 74 - > 75 - <Leaflet 76 - initialFacts={initialFacts} 77 - leaflet_id={rootEntity} 78 - token={res.data} 79 - /> 80 - </PublicationContextProvider> 63 + <Leaflet 64 + initialFacts={initialFacts} 65 + leaflet_id={rootEntity} 66 + token={res.data} 67 + /> 81 68 </PageSWRDataProvider> 82 69 ); 83 70 } 84 71 85 72 export async function generateMetadata(props: Props): Promise<Metadata> { 86 - let res = await supabase 73 + let res = await supabaseServerClient 87 74 .from("permission_tokens") 88 75 .select("*, permission_token_rights(*)") 89 76 .eq("id", (await props.params).leaflet_id) 90 77 .single(); 91 78 let rootEntity = res.data?.root_entity; 92 79 if (!rootEntity || !res.data) return { title: "Leaflet not found" }; 93 - let { data } = await supabase.rpc("get_facts", { 80 + let { data } = await supabaseServerClient.rpc("get_facts", { 94 81 root: rootEntity, 95 82 }); 96 - let initialFacts = (data as unknown as Fact<keyof typeof Attributes>[]) || []; 83 + let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 97 84 let scan = scanIndexLocal(initialFacts); 98 85 let firstPage = 99 86 scan.eav(rootEntity, "root/page")[0]?.data.value || rootEntity;
+3 -8
app/api/oauth/[route]/route.ts
··· 6 6 import { NextRequest, NextResponse } from "next/server"; 7 7 import postgres from "postgres"; 8 8 import { createOauthClient } from "src/atproto-oauth"; 9 + import { setAuthToken } from "src/auth"; 9 10 10 11 import { supabaseServerClient } from "supabase/serverClient"; 11 12 ··· 14 15 }; 15 16 export async function GET( 16 17 req: NextRequest, 17 - props: { params: Promise<{ route: string; handle?: string }> } 18 + props: { params: Promise<{ route: string; handle?: string }> }, 18 19 ) { 19 20 const params = await props.params; 20 21 let client = await createOauthClient(); ··· 89 90 .select() 90 91 .single(); 91 92 92 - if (token) 93 - (await cookies()).set("auth_token", token.id, { 94 - maxAge: 60 * 60 * 24 * 365, 95 - secure: process.env.NODE_ENV === "production", 96 - httpOnly: true, 97 - sameSite: "lax", 98 - }); 93 + if (token) await setAuthToken(token.id); 99 94 100 95 // Process successful authentication here 101 96 console.log("authorize() was called with state:", state);
+26 -13
app/api/rpc/[command]/domain_routes.ts
··· 1 1 import { z } from "zod"; 2 2 import { makeRoute } from "../lib"; 3 - import { Env } from "./route"; 3 + import type { Env } from "./route"; 4 + import { NextApiResponse } from "next"; 4 5 5 6 export const get_domain_status = makeRoute({ 6 7 route: "get_domain_status", ··· 21 22 ]); 22 23 return { status, config }; 23 24 } catch (e) { 25 + console.log(e); 26 + let errorResponse = e as NextApiResponse; 27 + if (errorResponse.statusCode === 404) 28 + return { error: "Not Found" } as const; 24 29 return { error: true }; 25 30 } 26 31 }, 27 32 }); 28 33 29 - export const get_leaflet_domains = makeRoute({ 30 - route: "get_leaflet_domains", 31 - input: z.object({ id: z.string() }), 32 - handler: async ({ id }, { supabase }: Env) => { 33 - let res = await supabase 34 - .from("permission_tokens") 35 - .select( 36 - "*, permission_token_rights(*), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*) ", 37 - ) 38 - .eq("id", id) 39 - .single(); 40 - return res.data?.custom_domain_routes || null; 34 + export const get_leaflet_subdomain_status = makeRoute({ 35 + route: "get_leaflet_subdomain_status", 36 + input: z.object({ 37 + domain: z.string(), 38 + }), 39 + handler: async ({ domain }, { vercel }: Pick<Env, "vercel">) => { 40 + try { 41 + let c = await vercel.projects.getProjectDomain({ 42 + idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", 43 + teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 44 + domain: `${domain}.leaflet.pub`, 45 + }); 46 + return { config: c }; 47 + } catch (e) { 48 + console.log(e); 49 + let errorResponse = e as NextApiResponse; 50 + if (errorResponse.statusCode === 404) 51 + return { error: "Not Found" } as const; 52 + return { error: true }; 53 + } 41 54 }, 42 55 });
+5 -7
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
··· 1 1 import { z } from "zod"; 2 - import { Fact } from "src/replicache"; 3 - import { Attributes } from "src/replicache/attributes"; 2 + import type { Fact } from "src/replicache"; 3 + import type { Attribute } from "src/replicache/attributes"; 4 4 import { makeRoute } from "../lib"; 5 - import { Env } from "./route"; 5 + import type { Env } from "./route"; 6 6 7 7 export const getFactsFromHomeLeaflets = makeRoute({ 8 8 route: "getFactsFromHomeLeaflets", ··· 20 20 result: all_facts.data.reduce( 21 21 (acc, fact) => { 22 22 if (!acc[fact.root_id]) acc[fact.root_id] = []; 23 - acc[fact.root_id].push( 24 - fact as unknown as Fact<keyof typeof Attributes>, 25 - ); 23 + acc[fact.root_id].push(fact as unknown as Fact<Attribute>); 26 24 return acc; 27 25 }, 28 - {} as { [key: string]: Fact<keyof typeof Attributes>[] }, 26 + {} as { [key: string]: Fact<Attribute>[] }, 29 27 ), 30 28 }; 31 29 }
+27
app/api/rpc/[command]/get_leaflet_data.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + 5 + export type GetLeafletDataReturnType = Awaited< 6 + ReturnType<(typeof get_leaflet_data)["handler"]> 7 + >; 8 + export const get_leaflet_data = makeRoute({ 9 + route: "get_leaflet_data", 10 + input: z.object({ 11 + token_id: z.string(), 12 + }), 13 + handler: async ({ token_id }, { supabase }: Pick<Env, "supabase">) => { 14 + let res = await supabase 15 + .from("permission_tokens") 16 + .select( 17 + `*, 18 + permission_token_rights(*), 19 + custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), 20 + leaflets_in_publications(*, publications(*), documents(*)) `, 21 + ) 22 + .eq("id", token_id) 23 + .single(); 24 + 25 + return { result: res }; 26 + }, 27 + });
+36
app/api/rpc/[command]/get_publication_data.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + 5 + export type GetPublicationDataReturnType = Awaited< 6 + ReturnType<(typeof get_publication_data)["handler"]> 7 + >; 8 + export const get_publication_data = makeRoute({ 9 + route: "get_publication_data", 10 + input: z.object({ 11 + did: z.string(), 12 + publication_name: z.string(), 13 + }), 14 + handler: async ( 15 + { did, publication_name }, 16 + { supabase }: Pick<Env, "supabase">, 17 + ) => { 18 + let { data: publication } = await supabase 19 + .from("publications") 20 + .select( 21 + `*, 22 + documents_in_publications(documents(*)), 23 + leaflets_in_publications(*, 24 + permission_tokens(*, 25 + permission_token_rights(*), 26 + custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*) 27 + ) 28 + )`, 29 + ) 30 + .eq("identity_did", did) 31 + .eq("name", publication_name) 32 + .single(); 33 + 34 + return { result: publication }; 35 + }, 36 + });
+4 -6
app/api/rpc/[command]/pull.ts
··· 4 4 PullResponseV1, 5 5 VersionNotSupportedResponse, 6 6 } from "replicache"; 7 - import { Fact } from "src/replicache"; 7 + import type { Fact } from "src/replicache"; 8 8 import { FactWithIndexes } from "src/replicache/utils"; 9 - import { Attributes } from "src/replicache/attributes"; 9 + import type { Attribute } from "src/replicache/attributes"; 10 10 import { makeRoute } from "../lib"; 11 - import { Env } from "./route"; 11 + import type { Env } from "./route"; 12 12 13 13 // First define the sub-types for V0 and V1 requests 14 14 const pullRequestV0 = z.object({ ··· 95 95 return { 96 96 op: "put", 97 97 key: f.id, 98 - value: FactWithIndexes( 99 - f as unknown as Fact<keyof typeof Attributes>, 100 - ), 98 + value: FactWithIndexes(f as unknown as Fact<Attribute>), 101 99 } as const; 102 100 }), 103 101 ],
+1 -1
app/api/rpc/[command]/push.ts
··· 6 6 import { getClientGroup } from "src/replicache/utils"; 7 7 import { makeRoute } from "../lib"; 8 8 import { z } from "zod"; 9 - import { Env } from "./route"; 9 + import type { Env } from "./route"; 10 10 import postgres from "postgres"; 11 11 import { drizzle } from "drizzle-orm/postgres-js"; 12 12
+9 -2
app/api/rpc/[command]/route.ts
··· 7 7 import { pull } from "./pull"; 8 8 import { getFactsFromHomeLeaflets } from "./getFactsFromHomeLeaflets"; 9 9 import { Vercel } from "@vercel/sdk"; 10 - import { get_domain_status, get_leaflet_domains } from "./domain_routes"; 10 + import { 11 + get_domain_status, 12 + get_leaflet_subdomain_status, 13 + } from "./domain_routes"; 14 + import { get_leaflet_data } from "./get_leaflet_data"; 15 + import { get_publication_data } from "./get_publication_data"; 11 16 12 17 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 13 18 let supabase = createClient<Database>( ··· 31 36 pull, 32 37 getFactsFromHomeLeaflets, 33 38 get_domain_status, 34 - get_leaflet_domains, 39 + get_leaflet_subdomain_status, 40 + get_leaflet_data, 41 + get_publication_data, 35 42 ]; 36 43 export async function POST( 37 44 req: Request,
+21 -5
app/globals.css
··· 179 179 background-color: transparent; 180 180 } 181 181 182 + .transparent-outline { 183 + @apply outline; 184 + @apply outline-transparent; 185 + } 186 + 182 187 .selected-outline { 183 188 @apply border; 184 189 @apply focus:outline; ··· 239 244 @apply outline-border; 240 245 } 241 246 242 - .transparent-outline { 243 - @apply outline; 244 - @apply outline-transparent; 245 - } 246 - 247 247 .container { 248 248 background: rgba(var(--bg-page), 0.5); 249 249 @apply border; 250 250 @apply border-bg-page; 251 + @apply rounded-md; 252 + } 253 + 254 + .opaque-container { 255 + @apply bg-bg-page; 256 + @apply border; 257 + @apply border-border-light; 258 + @apply rounded-md; 259 + } 260 + 261 + .accent-container { 262 + background: color-mix( 263 + in oklab, 264 + rgb(var(--accent-contrast)), 265 + rgb(var(--bg-page)) 85% 266 + ); 251 267 @apply rounded-md; 252 268 } 253 269
+8 -2
app/home/CreateNewButton.tsx
··· 62 62 > 63 63 <MenuItem 64 64 onSelect={async () => { 65 - let id = await createNewLeaflet("doc", false); 65 + let id = await createNewLeaflet({ 66 + pageType: "doc", 67 + redirectUser: false, 68 + }); 66 69 openNewLeaflet(id); 67 70 }} 68 71 > ··· 76 79 </MenuItem> 77 80 <MenuItem 78 81 onSelect={async () => { 79 - let id = await createNewLeaflet("canvas", false); 82 + let id = await createNewLeaflet({ 83 + pageType: "canvas", 84 + redirectUser: false, 85 + }); 80 86 openNewLeaflet(id); 81 87 }} 82 88 >
+4 -1
app/home/HomeSidebar.tsx
··· 7 7 import { useIdentityData } from "components/IdentityProvider"; 8 8 import { useReplicache } from "src/replicache"; 9 9 import { LoginActionButton } from "components/LoginButton"; 10 + import { MyPublicationList } from "./Publications"; 10 11 11 12 export const HomeSidebar = () => { 12 13 let { identity } = useIdentityData(); 13 14 let { rootEntity } = useReplicache(); 14 15 15 16 return ( 16 - <Sidebar alwaysOpen className="mt-6"> 17 + <Sidebar alwaysOpen className="my-6"> 17 18 <CreateNewLeafletButton /> 18 19 {identity ? <AccountSettings /> : <LoginActionButton />} 19 20 <HelpPopover noShortcuts /> 20 21 <ThemePopover entityID={rootEntity} home /> 22 + <hr className="border-bg-page" /> 23 + <MyPublicationList /> 21 24 </Sidebar> 22 25 ); 23 26 };
+10 -5
app/home/LeafletList.tsx
··· 3 3 import { useEffect, useState } from "react"; 4 4 import { getHomeDocs, HomeDoc } from "./storage"; 5 5 import useSWR from "swr"; 6 - import { Fact, ReplicacheProvider } from "src/replicache"; 6 + import { Fact, PermissionToken, ReplicacheProvider } from "src/replicache"; 7 7 import { LeafletPreview } from "./LeafletPreview"; 8 8 import { useIdentityData } from "components/IdentityProvider"; 9 - import { Attributes } from "src/replicache/attributes"; 9 + import type { Attribute } from "src/replicache/attributes"; 10 10 import { getIdentityData } from "actions/getIdentityData"; 11 11 import { callRPC } from "app/api/rpc/client"; 12 12 13 13 export function LeafletList(props: { 14 14 initialFacts: { 15 - [root_entity: string]: Fact<keyof typeof Attributes>[]; 15 + [root_entity: string]: Fact<Attribute>[]; 16 16 }; 17 17 }) { 18 18 let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { ··· 36 36 useEffect(() => { 37 37 mutate(); 38 38 }, [localLeaflets.length, mutate]); 39 - let leaflets = identity 39 + let leaflets: Array< 40 + PermissionToken & { leaflets_in_publications?: Array<{ doc: string }> } 41 + > = identity 40 42 ? identity.permission_token_on_homepage 41 43 .sort((a, b) => 42 44 a.created_at === b.created_at ··· 54 56 .map((ll) => ll.token); 55 57 56 58 return ( 57 - <div className="homeLeafletGrid grow w-full h-full overflow-y-scroll no-scrollbar "> 59 + <div className="homeLeafletGrid grow w-full h-full"> 58 60 <div className="grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-6 grow pt-3 pb-28 px-2 sm:pt-6 sm:pb-12 sm:pl-6 sm:pr-1"> 59 61 {leaflets.map((leaflet, index) => ( 60 62 <ReplicacheProvider 63 + disablePull 61 64 initialFactsOnly={!!identity} 62 65 key={leaflet.id} 63 66 rootEntity={leaflet.root_entity} ··· 68 71 <LeafletPreview 69 72 index={index} 70 73 token={leaflet} 74 + draft={!!leaflet.leaflets_in_publications?.length} 75 + published={!!leaflet.leaflets_in_publications?.find((l) => l.doc)} 71 76 leaflet_id={leaflet.root_entity} 72 77 loggedIn={!!identity} 73 78 />
+1 -1
app/home/LeafletOptions.tsx
··· 1 1 "use client"; 2 2 3 3 import { Menu, MenuItem } from "components/Layout"; 4 - import { PermissionToken } from "src/replicache"; 4 + import type { PermissionToken } from "src/replicache"; 5 5 import { hideDoc } from "./storage"; 6 6 import { useState } from "react"; 7 7 import { ButtonPrimary } from "components/Buttons";
+54 -7
app/home/LeafletPreview.tsx
··· 24 24 import { useRouter } from "next/navigation"; 25 25 import Link from "next/link"; 26 26 import { TemplateSmall } from "components/Icons/TemplateSmall"; 27 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 28 + import { 29 + PublicationMetadata, 30 + PublicationMetadataPreview, 31 + } from "components/Pages/PublicationMetadata"; 27 32 28 33 export const LeafletPreview = (props: { 34 + draft?: boolean; 35 + published?: boolean; 29 36 index: number; 30 37 token: PermissionToken; 31 38 leaflet_id: string; ··· 40 47 props.leaflet_id; 41 48 let firstPage = useEntity(root, "root/page")[0]; 42 49 let page = firstPage?.data.value || root; 43 - let router = useRouter(); 50 + 51 + let cardBorderHidden = useCardBorderHidden(root); 52 + let rootBackgroundImage = useEntity(root, "theme/card-background-image"); 53 + let rootBackgroundRepeat = useEntity( 54 + root, 55 + "theme/card-background-image-repeat", 56 + ); 57 + let rootBackgroundOpacity = useEntity( 58 + root, 59 + "theme/card-background-image-opacity", 60 + ); 44 61 45 62 return ( 46 63 <div className="relative max-h-40 h-40"> ··· 51 68 <ThemeBackgroundProvider entityID={root}> 52 69 <div 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"> 53 70 <div 54 - className="leafletContentWrapper h-full sm:w-48 w-40 mx-auto border border-border-light border-b-0 rounded-t-md overflow-clip" 55 - style={{ 56 - backgroundColor: 57 - "rgba(var(--bg-page), var(--bg-page-alpha))", 58 - }} 71 + className={`leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`} 72 + style={ 73 + cardBorderHidden 74 + ? {} 75 + : { 76 + backgroundImage: rootBackgroundImage 77 + ? `url(${rootBackgroundImage.data.src}), url(${rootBackgroundImage.data.fallback})` 78 + : undefined, 79 + backgroundRepeat: rootBackgroundRepeat 80 + ? "repeat" 81 + : "no-repeat", 82 + backgroundPosition: "center", 83 + backgroundSize: !rootBackgroundRepeat 84 + ? "cover" 85 + : rootBackgroundRepeat?.data.value / 3, 86 + opacity: 87 + rootBackgroundImage?.data.src && 88 + rootBackgroundOpacity 89 + ? rootBackgroundOpacity.data.value 90 + : 1, 91 + backgroundColor: 92 + "rgba(var(--bg-page), var(--bg-page-alpha))", 93 + } 94 + } 59 95 > 60 96 <LeafletContent entityID={page} index={props.index} /> 61 97 </div> ··· 67 103 <LeafletAreYouSure token={props.token} setState={setState} /> 68 104 )} 69 105 </div> 70 - <div className="flex justify-end pt-1 shrink-0"> 106 + <div className="flex justify-between pt-1 shrink-0 w-full gap-2"> 107 + {props.draft || props.published ? ( 108 + <div 109 + className={`text-xs container !border-none !w-fit px-0.5 italic ${props.published ? "font-bold text-tertiary" : "text-tertiary"}`} 110 + > 111 + {props.published ? "Published!" : "Draft"} 112 + </div> 113 + ) : ( 114 + <div /> 115 + )} 71 116 <LeafletOptions 72 117 leaflet={props.token} 73 118 isTemplate={isTemplate} ··· 131 176 width: `var(--page-width-units)`, 132 177 }} 133 178 > 179 + <PublicationMetadataPreview /> 180 + 134 181 {isVisible && 135 182 blocks.slice(0, 10).map((b, index, arr) => { 136 183 return (
+134
app/home/Publications.tsx
··· 1 + "use client"; 2 + import { ButtonSecondary } from "components/Buttons"; 3 + import Link from "next/link"; 4 + 5 + import { useIdentityData } from "components/IdentityProvider"; 6 + import { theme } from "tailwind.config"; 7 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 8 + import { AddTiny } from "components/Icons/AddTiny"; 9 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 + import { Json } from "supabase/database.types"; 11 + 12 + export const MyPublicationList = () => { 13 + let { identity } = useIdentityData(); 14 + if (!identity || !identity.atp_did) return <PubListEmpty />; 15 + return ( 16 + <div className="pubListWrapper w-full sm:w-[200px] flex flex-col gap-1 sm:gap-2 container p-2 sm:p-1 sm:-m-1 sm:bg-transparent sm:border-0"> 17 + <div className="flex justify-between items-center font-bold text-tertiary text-sm"> 18 + Publications 19 + <Link 20 + href={"./lish/createPub"} 21 + className="pubListCreateNew text-accent-contrast font-bold hover:text-accent-contrast" 22 + > 23 + <AddTiny /> 24 + </Link> 25 + </div> 26 + <PublicationList publications={identity.publications} /> 27 + </div> 28 + ); 29 + }; 30 + 31 + const PublicationList = (props: { 32 + publications: { 33 + identity_did: string; 34 + indexed_at: string; 35 + name: string; 36 + record: Json; 37 + uri: string; 38 + }[]; 39 + }) => { 40 + let { identity } = useIdentityData(); 41 + 42 + return ( 43 + <div className="pubList w-full flex flex-row sm:flex-col gap-3 sm:gap-2"> 44 + {props.publications?.map((d) => ( 45 + <Publication 46 + {...d} 47 + key={d.uri} 48 + handle={identity?.resolved_did?.alsoKnownAs?.[0].slice(5)!} 49 + record={d.record} 50 + /> 51 + ))} 52 + </div> 53 + ); 54 + }; 55 + 56 + function Publication(props: { 57 + uri: string; 58 + name: string; 59 + handle: string; 60 + record: Json; 61 + }) { 62 + return ( 63 + <Link 64 + className="pubListItem w-full p-3 opaque-container rounded-lg! text-secondary text-center hover:no-underline flex flex-col gap-1 place-items-center transparent-outline outline-2 outline-offset-1 hover:outline-border basis-0 grow min-w-0" 65 + href={`${getPublicationURL(props)}/dashboard`} 66 + > 67 + <div className="w-6 h-6 rounded-full bg-test" /> 68 + <h4 className="font-bold w-full truncate">{props.name}</h4> 69 + </Link> 70 + ); 71 + } 72 + 73 + const PubListEmpty = () => { 74 + let { identity } = useIdentityData(); 75 + return ( 76 + <div className="pubListEmpty accent-container text-sm sm:text-center py-4 px-3 w-full sm:max-w-[200px] flex flex-row sm:flex-col items-start sm:place-items-center justify-center gap-3 sm:gap-1"> 77 + <div className="shrink-0"> 78 + <PubListEmptyIllo /> 79 + </div> 80 + <div className="flex flex-col sm:place-items-center"> 81 + <strong>Publish with Leaflet!</strong> 82 + <div className="text-tertiary"> 83 + {!identity 84 + ? "Link a Bluesky account to publish your writing to a blog or newletter" 85 + : identity && !!identity.atp_did 86 + ? "Publish your writing to a blog or newletter on the ATmosphere" 87 + : ""} 88 + </div> 89 + 90 + {identity && !!identity.atp_did ? ( 91 + <Link href="/lish/createPub"> 92 + <ButtonSecondary compact className="text-sm mt-3"> 93 + Start a Publication! 94 + </ButtonSecondary> 95 + </Link> 96 + ) : ( 97 + <form action="/api/oauth/login?redirect_url=/" method="GET"> 98 + <ButtonSecondary compact className="text-sm mt-3"> 99 + <BlueskyTiny /> Link Bluesky 100 + </ButtonSecondary> 101 + </form> 102 + )} 103 + </div> 104 + </div> 105 + ); 106 + }; 107 + 108 + const PubListEmptyIllo = () => { 109 + return ( 110 + <svg 111 + width="59" 112 + height="44" 113 + viewBox="0 0 59 44" 114 + fill="none" 115 + xmlns="http://www.w3.org/2000/svg" 116 + > 117 + <g clipPath="url(#clip0_1288_1607)"> 118 + <path 119 + d="M38.6621 26.0839C42.9399 28.2494 44.7287 33.455 42.6545 37.8247C40.5473 42.2636 35.1643 42.2021 31.0309 38.7504C26.8976 35.2986 17.5208 36.7293 13.0951 34.6285C8.66952 32.5277 1.04647 23.5131 3.1536 19.0741C5.26079 14.6351 11.6868 11.9665 16.1124 14.0673C20.5381 16.1681 38.4564 25.9829 38.4564 25.9829L38.6621 26.0839Z" 120 + fill={theme.colors["bg-page"]} 121 + /> 122 + <path 123 + d="M44.0793 31.0064C44.7178 30.7014 45.4832 30.9723 45.7882 31.6108L48.1886 36.6362C48.4936 37.2747 48.2236 38.0402 47.5851 38.3452C46.9466 38.6502 46.1811 38.3792 45.8761 37.7407L43.4757 32.7153C43.1708 32.0769 43.4409 31.3114 44.0793 31.0064ZM47.6769 29.396C48.1531 28.8729 48.9632 28.8351 49.4865 29.311L56.049 35.2827C56.5724 35.7589 56.6111 36.5689 56.1349 37.0923C55.6587 37.6156 54.8487 37.6534 54.3254 37.1772L47.7629 31.2065C47.2395 30.7303 47.2007 29.9194 47.6769 29.396ZM39.717 5.68116C42.0981 4.90017 44.5808 4.94473 46.7375 6.12647C49.3605 7.5638 50.8245 10.3133 51.1896 13.4243C51.5549 16.538 50.8421 20.109 49.0216 23.4312C47.2012 26.7534 44.5751 29.2764 41.7541 30.644C38.9355 32.0105 35.8302 32.2552 33.2072 30.8179C30.9031 29.5552 29.4864 27.1999 28.9464 24.6831C28.8301 24.2474 28.6926 23.8222 28.5343 23.3853C27.9734 24.0541 27.3971 24.5221 26.7863 24.7798C25.6169 25.2729 24.5945 24.8983 23.8361 24.3579C22.7923 23.614 21.7765 22.8095 20.9679 21.9312C20.1616 21.0553 19.5008 20.0411 19.2707 18.8745L19.2629 18.8345C19.1678 18.3542 19.054 17.7817 19.2599 16.8921C19.4384 16.1213 19.8478 15.1379 20.6183 13.6987C19.832 13.0689 19.5569 12.0994 19.592 11.1099C19.6232 10.2278 19.8849 9.24332 20.3722 8.35401C20.8366 7.50662 21.4472 6.74544 22.1593 6.22315C22.8689 5.70275 23.7789 5.35579 24.7511 5.57959C25.3946 5.72772 26.0287 5.9727 26.6593 6.16846C27.3433 5.97648 28.0776 5.95162 28.8185 6.16455C29.2306 6.28299 29.6269 6.46238 30.0373 6.58936C30.7081 6.79695 31.4724 6.98507 32.0539 7.01319C35.195 7.16504 36.6776 6.71358 39.6769 5.69385C39.6901 5.68936 39.7037 5.6851 39.717 5.68116ZM45.7111 7.99951C43.9938 7.05853 41.9923 7.16313 40.1877 7.77588C39.8344 7.90062 39.4783 8.04953 39.1222 8.22217C36.7445 9.37492 34.4283 11.5618 32.7961 14.5405C31.1639 17.5192 30.5672 20.6477 30.8752 23.272C30.9368 23.7972 31.0577 24.2812 31.1691 24.7886C31.6931 26.6779 32.7517 28.1338 34.2336 28.9458C36.091 29.9635 38.4422 29.877 40.8224 28.7231C43.2002 27.5704 45.5163 25.3836 47.1486 22.4048C48.7809 19.4259 49.3775 16.2968 49.0695 13.6724C48.7611 11.0452 47.5685 9.0173 45.7111 7.99951ZM49.9005 26.5269C50.1565 25.8672 50.8991 25.5395 51.5587 25.7954L54.547 26.9556C55.2066 27.2116 55.5333 27.9532 55.2775 28.6128C55.0216 29.2724 54.2799 29.6 53.6203 29.3442L50.632 28.1851C49.9724 27.9291 49.6448 27.1865 49.9005 26.5269ZM39.1652 9.26807C41.2542 8.13629 43.5696 7.82566 45.505 8.88623C47.0749 9.74653 48.0237 11.3117 48.4123 13.1314C48.5106 13.5926 48.2163 14.0465 47.755 14.145C47.2939 14.2433 46.8399 13.949 46.7414 13.4878C46.4268 12.0152 45.7071 10.9451 44.6837 10.3843C43.4283 9.69643 41.7566 9.8068 39.9787 10.77C39.5506 11.0019 39.123 11.2806 38.7023 11.603C40.0255 12.7843 41.0004 14.0292 41.5402 15.2642C42.1439 16.6456 42.2208 18.0723 41.5373 19.3198C40.8549 20.5651 39.5891 21.2565 38.1007 21.4917C36.7655 21.7027 35.1903 21.5621 33.4992 21.1011C33.4542 21.6278 33.4497 22.1372 33.4845 22.6216C33.6295 24.6386 34.4361 26.1074 35.6916 26.7954C36.9471 27.4832 38.6187 27.3719 40.3966 26.4087C42.1654 25.4504 43.9291 23.6955 45.217 21.3452C45.7016 20.4607 46.0801 19.562 46.3556 18.6772C46.496 18.227 46.9746 17.9755 47.425 18.1157C47.8752 18.256 48.1266 18.7347 47.9865 19.1851C47.6767 20.1801 47.2535 21.1839 46.715 22.1665C45.2929 24.7616 43.3083 26.7749 41.2101 27.9116C39.1212 29.0432 36.8056 29.354 34.8703 28.2935C32.9348 27.2329 31.9507 25.1135 31.7804 22.7437C31.6095 20.3635 32.2393 17.6083 33.6613 15.0132C35.0834 12.418 37.067 10.4048 39.1652 9.26807ZM22.297 15.0933C21.7014 16.2428 21.4438 16.9252 21.34 17.3735C21.2375 17.8161 21.2789 18.0251 21.3586 18.4282L21.3654 18.4614C21.4906 19.0963 21.8766 19.7651 22.5392 20.4849C23.1994 21.202 24.0755 21.9061 25.0754 22.6187C25.4732 22.9022 25.706 22.918 25.9572 22.812C26.2951 22.6693 26.8456 22.2264 27.5851 21.0552C27.0094 19.7746 26.4315 18.8074 26.0109 18.3853C25.5581 17.9307 24.9198 17.457 24.3625 17.0815C24.0174 16.8492 23.6334 16.6577 23.3009 16.4087C22.8302 16.056 22.5072 15.5915 22.297 15.0933ZM37.4191 12.7485C36.5848 13.607 35.8111 14.6442 35.1593 15.8335C34.5066 17.0247 34.0481 18.237 33.7736 19.4038C35.3748 19.8612 36.7608 19.9738 37.8341 19.8042C38.9745 19.624 39.6854 19.1451 40.0392 18.4995C40.4026 17.8364 40.4284 16.9858 39.9748 15.9478C39.5441 14.9626 38.6986 13.8621 37.4191 12.7485ZM34.2082 9.14893C33.8924 9.16621 33.5635 9.17537 33.2189 9.17627C33.1388 9.23151 33.0511 9.29794 32.9562 9.37647C32.6169 9.65722 32.2522 10.0395 31.8879 10.479C31.1588 11.3585 30.4955 12.3901 30.1144 13.0855C29.6886 13.8625 29.21 14.8403 28.8732 15.8257C28.5838 16.6724 28.4237 17.4545 28.4445 18.0923C28.6141 18.3652 28.7815 18.6595 28.9445 18.9683C29.2882 17.1514 29.9462 15.2969 30.923 13.5142C31.8396 11.8415 32.9612 10.371 34.2082 9.14893ZM28.2306 8.22315C27.9953 8.13158 27.6238 8.07071 27.3918 8.1919C26.8031 8.49937 25.6057 9.41 25.0587 10.4204C24.7889 10.919 24.5664 11.6366 24.3986 12.355C24.273 12.8924 24.0375 13.5361 24.1984 14.0757C24.2869 14.3723 24.4121 14.5578 24.5441 14.6704C24.8879 14.8728 25.225 15.0872 25.5558 15.3101C25.9699 15.589 26.4729 15.9497 26.9435 16.3472C27.0299 15.9811 27.1376 15.6205 27.256 15.2739C27.6363 14.1611 28.164 13.0878 28.6154 12.2642C29.0414 11.4867 29.7649 10.3634 30.5724 9.38916C30.6783 9.26141 30.7871 9.1351 30.8976 9.01123C30.3592 8.90738 29.8338 8.76163 29.4064 8.6294C28.8588 8.45993 28.422 8.2968 28.2306 8.22315ZM24.2443 7.65479C24.0745 7.62582 23.8022 7.66603 23.422 7.94483C23.0226 8.23774 22.6009 8.7314 22.2453 9.38037C21.9128 9.98712 21.7449 10.6469 21.7257 11.1851C21.7078 11.6907 21.8192 11.8972 21.842 11.939C21.8441 11.9429 21.8456 11.9461 21.8459 11.9468L22.6076 12.5562C22.6451 12.3681 22.687 12.1695 22.7345 11.9663C22.9122 11.206 23.1764 10.3097 23.5568 9.60694C23.909 8.95632 24.4229 8.35989 24.9357 7.86866L24.2443 7.65479Z" 124 + fill={theme.colors["accent-contrast"]} 125 + /> 126 + </g> 127 + <defs> 128 + <clipPath id="clip0_1288_1607"> 129 + <rect width="59" height="44" fill="white" /> 130 + </clipPath> 131 + </defs> 132 + </svg> 133 + ); 134 + };
+5 -6
app/home/icon.tsx
··· 1 1 import { ImageResponse } from "next/og"; 2 - import { Fact } from "src/replicache"; 3 - import { Attributes } from "src/replicache/attributes"; 2 + import type { Fact } from "src/replicache"; 3 + import type { Attribute } from "src/replicache/attributes"; 4 4 import { Database } from "../../supabase/database.types"; 5 5 import { createServerClient } from "@supabase/ssr"; 6 6 import { parseHSBToRGB } from "src/utils/parseHSB"; ··· 49 49 let { data } = await supabase.rpc("get_facts", { 50 50 root: rootEntity, 51 51 }); 52 - let initialFacts = 53 - (data as unknown as Fact<keyof typeof Attributes>[]) || []; 52 + let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 54 53 let themePageBG = initialFacts.find( 55 54 (f) => f.attribute === "theme/card-background", 56 55 ) as Fact<"theme/card-background"> | undefined; ··· 67 66 return new ImageResponse( 68 67 ( 69 68 // ImageResponse JSX element 70 - (<div style={{ display: "flex" }}> 69 + <div style={{ display: "flex" }}> 71 70 <svg 72 71 width="32" 73 72 height="32" ··· 91 90 fill={fillColor ? fillColor : "#272727"} 92 91 /> 93 92 </svg> 94 - </div>) 93 + </div> 95 94 ), 96 95 // ImageResponse options 97 96 {
+13 -21
app/home/page.tsx
··· 1 1 import { cookies } from "next/headers"; 2 2 import { Fact, ReplicacheProvider } from "src/replicache"; 3 - import { createServerClient } from "@supabase/ssr"; 4 - import { Database } from "supabase/database.types"; 5 - import { Attributes } from "src/replicache/attributes"; 3 + import type { Attribute } from "src/replicache/attributes"; 6 4 import { 7 5 ThemeBackgroundProvider, 8 6 ThemeProvider, 9 7 } from "components/ThemeManager/ThemeProvider"; 10 8 import { EntitySetProvider } from "components/EntitySetProvider"; 11 - import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 12 9 import { createIdentity } from "actions/createIdentity"; 13 10 import postgres from "postgres"; 14 11 import { drizzle } from "drizzle-orm/postgres-js"; 15 12 import { IdentitySetter } from "./IdentitySetter"; 16 - import { HomeHelp } from "./HomeHelp"; 17 13 import { LeafletList } from "./LeafletList"; 18 - import { CreateNewLeafletButton } from "./CreateNewButton"; 19 14 import { getIdentityData } from "actions/getIdentityData"; 20 - import { LoginButton } from "components/LoginButton"; 21 - import { HelpPopover } from "components/HelpPopover"; 22 - import { AccountSettings } from "./AccountSettings"; 23 - import { LoggedOutWarning } from "./LoggedOutWarning"; 24 15 import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 25 - import { Media } from "components/Media"; 26 - import { Sidebar } from "components/ActionBar/Sidebar"; 27 16 import { HomeSidebar } from "./HomeSidebar"; 28 17 import { HomeFooter } from "./HomeFooter"; 18 + import { Media } from "components/Media"; 19 + import { MyPublicationList } from "./Publications"; 20 + import { supabaseServerClient } from "supabase/serverClient"; 29 21 30 - let supabase = createServerClient<Database>( 31 - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 32 - process.env.SUPABASE_SERVICE_ROLE_KEY as string, 33 - { cookies: {} }, 34 - ); 35 22 export default async function Home() { 36 23 let cookieStore = await cookies(); 37 24 ··· 60 47 61 48 let permission_token = auth_res?.home_leaflet; 62 49 if (!permission_token) { 63 - let res = await supabase 50 + let res = await supabaseServerClient 64 51 .from("identities") 65 52 .select( 66 53 `*, ··· 74 61 75 62 if (!permission_token) return <div>no home page wierdly</div>; 76 63 let [homeLeafletFacts, allLeafletFacts] = await Promise.all([ 77 - supabase.rpc("get_facts", { 64 + supabaseServerClient.rpc("get_facts", { 78 65 root: permission_token.root_entity, 79 66 }), 80 67 auth_res ··· 84 71 (r) => r.permission_tokens.root_entity, 85 72 ), 86 73 }, 87 - { supabase }, 74 + { supabase: supabaseServerClient }, 88 75 ) 89 76 : undefined, 90 77 ]); 91 78 let initialFacts = 92 - (homeLeafletFacts.data as unknown as Fact<keyof typeof Attributes>[]) || []; 79 + (homeLeafletFacts.data as unknown as Fact<Attribute>[]) || []; 93 80 94 81 let root_entity = permission_token.root_entity; 95 82 let home_docs_initialFacts = allLeafletFacts?.result || {}; ··· 110 97 <div className="home relative max-w-screen-lg w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6 "> 111 98 <HomeSidebar /> 112 99 <div className={`h-full overflow-y-scroll`}> 100 + <Media mobile> 101 + <div className="pubListWrapper p-2 "> 102 + <MyPublicationList /> 103 + </div> 104 + </Media> 113 105 <LeafletList initialFacts={home_docs_initialFacts} /> 114 106 </div> 115 107 <HomeFooter />
+1 -1
app/home/storage.ts
··· 1 - import { PermissionToken } from "src/replicache"; 1 + import type { PermissionToken } from "src/replicache"; 2 2 import { mutate } from "swr"; 3 3 4 4 export type HomeDoc = {
-10
app/lish/AlphaBanner.tsx
··· 1 - import Link from "next/link"; 2 - 3 - export const AlphaBanner = () => { 4 - return ( 5 - <div className="w-full h-fit text-center bg-accent-1 text-accent-2"> 6 - We're still in Early Alpha! <Link href="./lish/">Sign Up</Link> for 7 - Updates :) 8 - </div> 9 - ); 10 - };
-97
app/lish/Footer.tsx
··· 1 - "use client"; 2 - import { Menu, MenuItem } from "components/Layout"; 3 - import { useEffect, useState } from "react"; 4 - import { SubscribeButton } from "./Subscribe"; 5 - import Link from "next/link"; 6 - import { ButtonPrimary } from "components/Buttons"; 7 - import { usePublicationRelationship } from "./[handle]/[publication]/usePublicationRelationship"; 8 - import { usePublicationContext } from "components/Providers/PublicationContext"; 9 - import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 10 - 11 - export const Footer = (props: { pageType: "post" | "pub" }) => { 12 - return ( 13 - <div className="footer w-full bg-bg-page border-0 border-t border-border flex flex-col "> 14 - <ScrollProgress /> 15 - <div className="footerContent w-full min-h-12 h-fit max-w-prose mx-auto px-4 py-2 flex justify-between items-center gap-6"> 16 - {/* <MoreOptionsMenu /> */} 17 - <FooterSubscribeButton pageType={props.pageType} /> 18 - </div> 19 - </div> 20 - ); 21 - }; 22 - 23 - const ScrollProgress = () => { 24 - let [scrollPercent, setScrollPercent] = useState(0); 25 - 26 - useEffect(() => { 27 - let post = document.getElementById("post"); 28 - 29 - let onScroll = () => { 30 - if (!post) return; 31 - let currentScroll = post?.scrollTop; 32 - let totalScroll = post?.scrollHeight - post?.clientHeight; 33 - setScrollPercent((currentScroll / totalScroll) * 100); 34 - }; 35 - post?.addEventListener("scroll", onScroll); 36 - return () => post?.removeEventListener("scroll", onScroll); 37 - }, []); 38 - return ( 39 - <div className="footerScrollProgress w-full h-1 bg-bg-page"> 40 - <div 41 - className={`h-full bg-accent-contrast`} 42 - style={{ width: `${scrollPercent}%` }} 43 - ></div> 44 - </div> 45 - ); 46 - }; 47 - 48 - const FooterSubscribeButton = (props: { pageType: "post" | "pub" }) => { 49 - let [pubHeaderIsVisible, setPubHeaderIsVisible] = useState( 50 - props.pageType === "pub" ? true : false, 51 - ); 52 - let rel = usePublicationRelationship(); 53 - let { publication } = usePublicationContext(); 54 - 55 - useEffect(() => { 56 - let pubHeader = document.getElementById("pub-header"); 57 - if (!pubHeader) return; 58 - let observer = new IntersectionObserver( 59 - (entries) => { 60 - entries.forEach((entry) => { 61 - setPubHeaderIsVisible(entry.isIntersecting); 62 - }); 63 - }, 64 - { threshold: 0 }, 65 - ); 66 - observer.observe(pubHeader); 67 - return () => observer.unobserve(pubHeader); 68 - }, []); 69 - 70 - if (rel?.isSubscribed || pubHeaderIsVisible || !publication) return; 71 - if (rel?.isAuthor) 72 - return ( 73 - <div className="flex gap-2"> 74 - <ButtonPrimary>Write a Draft</ButtonPrimary> 75 - {/* <ShareButton /> */} 76 - </div> 77 - ); 78 - return <SubscribeButton compact publication={publication?.uri} />; 79 - }; 80 - const MoreOptionsMenu = () => { 81 - return ( 82 - <Menu trigger={<MoreOptionsTiny className="footerMoreOptions rotate-90" />}> 83 - <MenuItem onSelect={() => {}}>Log in</MenuItem> 84 - <hr className="border-border-light" /> 85 - 86 - <small className="text-tertiary px-3 leading-none pt-2 font-bold"> 87 - Back to... 88 - </small> 89 - <MenuItem onSelect={() => {}}> 90 - <Link href="./publication">Leaflet Explorers</Link> 91 - </MenuItem> 92 - <MenuItem onSelect={() => {}}> 93 - <Link href="./">Your Feed</Link> 94 - </MenuItem> 95 - </Menu> 96 - ); 97 - };
-214
app/lish/LishHome.tsx
··· 1 - "use client"; 2 - import { ButtonPrimary } from "components/Buttons"; 3 - import Link from "next/link"; 4 - import { useState } from "react"; 5 - import { PostList } from "./PostList"; 6 - 7 - import { Input } from "components/Input"; 8 - import { useIdentityData } from "components/IdentityProvider"; 9 - import { NewDraftButton } from "./[handle]/[publication]/NewDraftButton"; 10 - 11 - export const LishHome = () => { 12 - let [state, setState] = useState<"posts" | "subscriptions">("posts"); 13 - return ( 14 - <div className="w-full h-fit min-h-full p-4 bg-bg-leaflet"> 15 - <div className="flex flex-col gap-6 justify-center place-items-center max-w-prose w-full mx-auto"> 16 - <div 17 - className="p-4 rounded-md w-full" 18 - style={{ 19 - backgroundColor: 20 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 21 - }} 22 - > 23 - <MyPublicationList /> 24 - </div> 25 - 26 - {/* <div className="homeFeed w-full flex flex-col"> 27 - <div className="flex gap-1 justify-center pb-2"> 28 - <Tab 29 - name="updates" 30 - active={state === "posts"} 31 - onClick={() => setState("posts")} 32 - /> 33 - <Tab 34 - name="subscriptions" 35 - active={state === "subscriptions"} 36 - onClick={() => setState("subscriptions")} 37 - /> 38 - </div> 39 - <hr className="border-border w-full mb-2" /> 40 - {state === "posts" ? ( 41 - <PostFeed /> 42 - ) : ( 43 - <SubscriptionList publications={Subscriptions} /> 44 - )} 45 - </div> */} 46 - </div> 47 - </div> 48 - ); 49 - }; 50 - 51 - const Tab = (props: { name: string; active: boolean; onClick: () => void }) => { 52 - return ( 53 - <div 54 - className={`border-border px-2 py-1 ${props.active ? "font-bold" : ""}`} 55 - onClick={props.onClick} 56 - > 57 - {props.name} 58 - </div> 59 - ); 60 - }; 61 - 62 - const MyPublicationList = () => { 63 - let { identity } = useIdentityData(); 64 - if (!identity || !identity?.atp_did) { 65 - return ( 66 - <div className="flex flex-col justify-center text-center place-items-center"> 67 - <div className="font-bold text-center"> 68 - Connect to Bluesky <br className="sm:hidden" /> 69 - to start publishing! 70 - </div> 71 - <small className="text-secondary text-center pt-1"> 72 - We use the ATProtocol to store all your publication data on the open 73 - web. That means we cannot lock you into our platform, you will ALWAYS 74 - be free to easily move elsewhere. <a>Learn More.</a> 75 - </small> 76 - <form 77 - action="/api/oauth/login?redirect_url=/lish" 78 - method="GET" 79 - className="relative w-fit mt-4 " 80 - > 81 - <Input 82 - type="text" 83 - className="input-with-border !pr-[88px] !py-1 grow w-full" 84 - name="handle" 85 - placeholder="Enter Bluesky handle..." 86 - required 87 - /> 88 - <ButtonPrimary 89 - compact 90 - className="absolute right-1 top-1 !outline-0" 91 - type="submit" 92 - > 93 - Connect 94 - </ButtonPrimary> 95 - </form> 96 - </div> 97 - ); 98 - } 99 - if (Publications.length === 0) { 100 - return ( 101 - <div> 102 - <Link href={"./lish/createPub"}> 103 - <ButtonPrimary>Start a Publication!</ButtonPrimary> 104 - </Link> 105 - </div> 106 - ); 107 - } 108 - return ( 109 - <div className="w-full flex flex-col gap-2"> 110 - <PublicationList publications={identity.publications} /> 111 - <Link 112 - href={"./lish/createPub"} 113 - className="text-sm place-self-start text-tertiary hover:text-accent-contrast" 114 - > 115 - New Publication 116 - </Link> 117 - </div> 118 - ); 119 - }; 120 - 121 - const PublicationList = (props: { 122 - publications: { 123 - identity_did: string; 124 - indexed_at: string; 125 - name: string; 126 - uri: string; 127 - }[]; 128 - }) => { 129 - let { identity } = useIdentityData(); 130 - 131 - return ( 132 - <div className="w-full flex flex-col gap-2"> 133 - {props.publications?.map((d) => ( 134 - <div 135 - key={d.uri} 136 - className={`pubPostListItem flex hover:no-underline justify-between items-center`} 137 - > 138 - <Link 139 - className="justify-self-start font-bold hover:no-underline" 140 - href={`/lish/${identity?.resolved_did?.alsoKnownAs?.[0].slice(5)}/${d.name}/`} 141 - > 142 - <div key={d.uri}>{d.name}</div> 143 - </Link> 144 - <NewDraftButton publication={d.uri} /> 145 - </div> 146 - ))} 147 - </div> 148 - ); 149 - }; 150 - 151 - const SubscriptionList = (props: { 152 - publications: { title: string; description: string }[]; 153 - }) => { 154 - if (props.publications.length === 0) 155 - return ( 156 - <div className="w-full text-center text-tertiary italic pt-4"> 157 - No subscriptions yet! 158 - </div> 159 - ); 160 - return ( 161 - <div className="w-full flex flex-col gap-2"> 162 - {props.publications.map((pub) => { 163 - return <SubscriptionListItem {...pub} />; 164 - })} 165 - </div> 166 - ); 167 - }; 168 - 169 - const SubscriptionListItem = (props: { 170 - title: string; 171 - description: string; 172 - }) => { 173 - return ( 174 - <Link 175 - href="./lish/publication" 176 - className={`pubPostListItem flex flex-col hover:no-underline justify-between items-center`} 177 - > 178 - <h4 className="justify-self-start">{props.title}</h4> 179 - 180 - <div className="text-secondary text-sm pt-1">{props.description}</div> 181 - <hr className="border-border-light mt-3" /> 182 - </Link> 183 - ); 184 - }; 185 - 186 - const PostFeed = () => { 187 - return <PostList isFeed posts={[]} />; 188 - }; 189 - 190 - let Subscriptions = [ 191 - { 192 - title: "vrk loves paper", 193 - description: 194 - "Exploring software that loves paper as much as I do. I'll be documenting my learnings, loves, confusions and creations in this newsletter!", 195 - }, 196 - { 197 - title: "rhrizome r&d", 198 - description: 199 - "Design, research, and complexity. A field guide for novel problems.", 200 - }, 201 - { 202 - title: "Dead Languages Society ", 203 - description: 204 - "A guided tour through the history of English and its relatives.", 205 - }, 206 - ]; 207 - 208 - let Publications = [ 209 - { 210 - title: "Leaflet Explorers", 211 - description: 212 - "We're making Leaflet, a fast fun web app for making delightful documents. Sign up to follow along as we build Leaflet! We send updates every week or two", 213 - }, 214 - ];
+12 -6
app/lish/PostList.tsx
··· 1 1 "use client"; 2 2 import Link from "next/link"; 3 - import { Separator } from "components/Layout"; 4 3 import { Json } from "supabase/database.types"; 5 4 import { PubLeafletDocument } from "lexicons/api"; 6 - import { ButtonPrimary } from "components/Buttons"; 7 5 import { useIdentityData } from "components/IdentityProvider"; 8 - import { usePublicationRelationship } from "./[handle]/[publication]/usePublicationRelationship"; 9 6 import { useParams } from "next/navigation"; 10 7 import { AtUri } from "@atproto/syntax"; 8 + import { getPublicationURL } from "./createPub/getPublicationURL"; 11 9 12 10 export const PostList = (props: { 13 11 isFeed?: boolean; 12 + publication: { uri: string; record: Json; name: string }; 14 13 posts: { 15 14 documents: { 16 15 data: Json; ··· 41 40 let uri = new AtUri(post.documents?.uri!); 42 41 43 42 return ( 44 - <PostListItem {...p} key={index} isFeed={props.isFeed} uri={uri} /> 43 + <PostListItem 44 + {...p} 45 + publication_data={props.publication} 46 + key={index} 47 + isFeed={props.isFeed} 48 + uri={uri} 49 + /> 45 50 ); 46 51 })} 47 52 </div> ··· 50 55 51 56 const PostListItem = ( 52 57 props: { 58 + publication_data: { uri: string; record: Json; name: string }; 53 59 isFeed?: boolean; 54 60 uri: AtUri; 55 61 } & PubLeafletDocument.Record, ··· 60 66 <div className="pubPostListItem flex flex-col"> 61 67 {props.isFeed && ( 62 68 <Link 63 - href={`/lish/${identity?.resolved_did?.alsoKnownAs?.[0].slice(5)}/${props.publication}/`} 69 + href={getPublicationURL(props.publication_data)} 64 70 className="font-bold text-tertiary hover:no-underline text-sm " 65 71 > 66 72 {props.publication} ··· 68 74 )} 69 75 70 76 <Link 71 - href={`/lish/${params.handle}/${params.publication}/${props.uri.rkey}/`} 77 + href={`${getPublicationURL(props.publication_data)}/${props.uri.rkey}/`} 72 78 className="pubPostListContent flex flex-col hover:no-underline hover:text-accent-contrast" 73 79 > 74 80 <h4>{props.title}</h4>
+115
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
··· 1 + "use client"; 2 + import { UnicodeString } from "@atproto/api"; 3 + import { PubLeafletRichtextFacet } from "lexicons/api"; 4 + import { useMemo } from "react"; 5 + 6 + type Facet = PubLeafletRichtextFacet.Main; 7 + export function TextBlock(props: { plaintext: string; facets?: Facet[] }) { 8 + const children = []; 9 + let richText = useMemo( 10 + () => new RichText({ text: props.plaintext, facets: props.facets || [] }), 11 + [props.plaintext, props.facets], 12 + ); 13 + let counter = 0; 14 + for (const segment of richText.segments()) { 15 + let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 16 + let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 17 + let isStrikethrough = segment.facet?.find( 18 + PubLeafletRichtextFacet.isStrikethrough, 19 + ); 20 + let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 21 + let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 22 + let isHighlighted = segment.facet?.find( 23 + PubLeafletRichtextFacet.isHighlight, 24 + ); 25 + let className = ` 26 + ${isBold ? "font-bold" : ""} 27 + ${isItalic ? "italic" : ""} 28 + ${isUnderline ? "underline" : ""} 29 + ${isStrikethrough ? "line-through decoration-tertiary" : ""} 30 + ${isHighlighted ? "highlight bg-highlight-1" : ""}`; 31 + 32 + if (link) { 33 + children.push( 34 + <a 35 + key={counter} 36 + href={link.uri} 37 + className={`text-accent-contrast hover:underline ${className}`} 38 + target="_blank" 39 + > 40 + {segment.text} 41 + </a>, 42 + ); 43 + } else { 44 + children.push( 45 + <span key={counter} className={className}> 46 + {segment.text} 47 + </span>, 48 + ); 49 + } 50 + 51 + counter++; 52 + } 53 + return <>{children}</>; 54 + } 55 + 56 + type RichTextSegment = { 57 + text: string; 58 + facet?: Exclude<Facet["features"], { $type: string }>; 59 + }; 60 + 61 + export class RichText { 62 + unicodeText: UnicodeString; 63 + facets?: Facet[]; 64 + 65 + constructor(props: { text: string; facets: Facet[] }) { 66 + this.unicodeText = new UnicodeString(props.text); 67 + this.facets = props.facets; 68 + if (this.facets) { 69 + this.facets = this.facets 70 + .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 71 + .sort((a, b) => a.index.byteStart - b.index.byteStart); 72 + } 73 + } 74 + 75 + *segments(): Generator<RichTextSegment, void, void> { 76 + const facets = this.facets || []; 77 + if (!facets.length) { 78 + yield { text: this.unicodeText.utf16 }; 79 + return; 80 + } 81 + 82 + let textCursor = 0; 83 + let facetCursor = 0; 84 + do { 85 + const currFacet = facets[facetCursor]; 86 + if (textCursor < currFacet.index.byteStart) { 87 + yield { 88 + text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 89 + }; 90 + } else if (textCursor > currFacet.index.byteStart) { 91 + facetCursor++; 92 + continue; 93 + } 94 + if (currFacet.index.byteStart < currFacet.index.byteEnd) { 95 + const subtext = this.unicodeText.slice( 96 + currFacet.index.byteStart, 97 + currFacet.index.byteEnd, 98 + ); 99 + if (!subtext.trim()) { 100 + // dont empty string entities 101 + yield { text: subtext }; 102 + } else { 103 + yield { text: subtext, facet: currFacet.features }; 104 + } 105 + } 106 + textCursor = currFacet.index.byteEnd; 107 + facetCursor++; 108 + } while (facetCursor < facets.length); 109 + if (textCursor < this.unicodeText.length) { 110 + yield { 111 + text: this.unicodeText.slice(textCursor, this.unicodeText.length), 112 + }; 113 + } 114 + } 115 + }
+186
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 + import Link from "next/link"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { ids } from "lexicons/api/lexicons"; 5 + import { 6 + PubLeafletBlocksHeader, 7 + PubLeafletBlocksImage, 8 + PubLeafletBlocksText, 9 + PubLeafletBlocksUnorderedList, 10 + PubLeafletDocument, 11 + PubLeafletPagesLinearDocument, 12 + } from "lexicons/api"; 13 + import { Metadata } from "next"; 14 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 15 + import { TextBlock } from "./TextBlock"; 16 + import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 17 + 18 + export async function generateMetadata(props: { 19 + params: Promise<{ publication: string; did: string; rkey: string }>; 20 + }): Promise<Metadata> { 21 + let did = decodeURIComponent((await props.params).did); 22 + if (!did) return { title: "Publication 404" }; 23 + 24 + let { data: document } = await supabaseServerClient 25 + .from("documents") 26 + .select("*") 27 + .eq( 28 + "uri", 29 + AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), 30 + ) 31 + .single(); 32 + 33 + if (!document) return { title: "404" }; 34 + let record = document.data as PubLeafletDocument.Record; 35 + return { 36 + title: 37 + record.title + 38 + " - " + 39 + decodeURIComponent((await props.params).publication), 40 + }; 41 + } 42 + export default async function Post(props: { 43 + params: Promise<{ publication: string; did: string; rkey: string }>; 44 + }) { 45 + let did = decodeURIComponent((await props.params).did); 46 + if (!did) return <div> can't resolve handle</div>; 47 + let { data: document } = await supabaseServerClient 48 + .from("documents") 49 + .select("*, documents_in_publications(publications(*))") 50 + .eq( 51 + "uri", 52 + AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), 53 + ) 54 + .single(); 55 + if (!document?.data || !document.documents_in_publications[0].publications) 56 + return <div>notfound</div>; 57 + let record = document.data as PubLeafletDocument.Record; 58 + let firstPage = record.pages[0]; 59 + let blocks: PubLeafletPagesLinearDocument.Block[] = []; 60 + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 61 + blocks = firstPage.blocks || []; 62 + } 63 + return ( 64 + <ThemeProvider entityID={null}> 65 + <div className="postPage w-full h-screen bg-[#FDFCFA] flex items-stretch"> 66 + <div className="pubWrapper flex flex-col w-full "> 67 + <div className="pubContent flex flex-col px-3 sm:px-4 py-3 sm:py-9 mx-auto max-w-prose h-full w-full overflow-auto"> 68 + <div className="flex flex-col pb-8"> 69 + <Link 70 + className="font-bold hover:no-underline text-accent-contrast" 71 + href={getPublicationURL( 72 + document.documents_in_publications[0].publications, 73 + )} 74 + > 75 + {decodeURIComponent((await props.params).publication)} 76 + </Link> 77 + <h2 className="">{record.title}</h2> 78 + {record.description ? ( 79 + <p className="italic text-secondary">{record.description}</p> 80 + ) : null} 81 + {record.publishedAt ? ( 82 + <p className="text-sm text-tertiary pt-3"> 83 + Published{" "} 84 + {new Date(record.publishedAt).toLocaleDateString(undefined, { 85 + year: "numeric", 86 + month: "long", 87 + day: "2-digit", 88 + })} 89 + </p> 90 + ) : null} 91 + </div> 92 + {blocks.map((b, index) => { 93 + return <Block block={b} did={did} key={index} />; 94 + })} 95 + </div> 96 + </div> 97 + </div> 98 + </ThemeProvider> 99 + ); 100 + } 101 + 102 + let Block = ({ 103 + block, 104 + did, 105 + }: { 106 + block: PubLeafletPagesLinearDocument.Block; 107 + did: string; 108 + }) => { 109 + let b = block; 110 + let className = `${b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight" ? "text-right" : b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignCenter" ? "text-center" : ""}`; 111 + console.log(b.alignment); 112 + switch (true) { 113 + case PubLeafletBlocksUnorderedList.isMain(b.block): { 114 + return ( 115 + <ul> 116 + {b.block.children.map((child, index) => ( 117 + <ListItem item={child} did={did} key={index} /> 118 + ))} 119 + </ul> 120 + ); 121 + } 122 + case PubLeafletBlocksImage.isMain(b.block): { 123 + return ( 124 + <img 125 + height={b.block.aspectRatio?.height} 126 + width={b.block.aspectRatio?.width} 127 + className={`pb-2 sm:pb-3 ${className}`} 128 + src={`https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${(b.block.image.ref as unknown as { $link: string })["$link"]}`} 129 + /> 130 + ); 131 + } 132 + case PubLeafletBlocksText.isMain(b.block): 133 + return ( 134 + <div className={`pt-0 pb-2 sm:pb-3 ${className}`}> 135 + <TextBlock facets={b.block.facets} plaintext={b.block.plaintext} /> 136 + </div> 137 + ); 138 + case PubLeafletBlocksHeader.isMain(b.block): { 139 + if (b.block.level === 1) 140 + return ( 141 + <h1 className={`pb-0 pt-2 sm:pt-3 ${className}`}> 142 + <TextBlock {...b.block} /> 143 + </h1> 144 + ); 145 + if (b.block.level === 2) 146 + return ( 147 + <h3 className={`pb-0 pt-2 sm:pt-3 ${className}`}> 148 + <TextBlock {...b.block} /> 149 + </h3> 150 + ); 151 + if (b.block.level === 3) 152 + return ( 153 + <h4 className={`pb-0 pt-2 sm:pt-3 ${className}`}> 154 + <TextBlock {...b.block} /> 155 + </h4> 156 + ); 157 + // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 158 + // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 159 + return ( 160 + <h6 className={`${className}`}> 161 + <TextBlock {...b.block} /> 162 + </h6> 163 + ); 164 + } 165 + default: 166 + return null; 167 + } 168 + }; 169 + 170 + function ListItem(props: { 171 + item: PubLeafletBlocksUnorderedList.ListItem; 172 + did: string; 173 + }) { 174 + return ( 175 + <li> 176 + <Block block={{ block: props.item.content }} did={props.did} /> 177 + {props.item.children?.length ? ( 178 + <ul> 179 + {props.item.children.map((child, index) => ( 180 + <ListItem item={child} did={props.did} key={index} /> 181 + ))} 182 + </ul> 183 + ) : null} 184 + </li> 185 + ); 186 + }
+119
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 Link from "next/link"; 13 + import { HomeSmall } from "components/Icons/HomeSmall"; 14 + import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; 15 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 16 + import { usePublicationData } from "./PublicationSWRProvider"; 17 + import { useSmoker } from "components/Toast"; 18 + 19 + export const Actions = (props: { publication: string }) => { 20 + return ( 21 + <> 22 + <Media mobile> 23 + <Link 24 + href="/home" 25 + prefetch 26 + className="hover:no-underline" 27 + style={{ textDecorationLine: "none !important" }} 28 + > 29 + <ActionButton icon={<HomeSmall />} label="Go Home" /> 30 + </Link> 31 + </Media> 32 + <NewDraftActionButton publication={props.publication} /> 33 + <PublicationShareButton /> 34 + <PublicationSettingsButton publication={props.publication} /> 35 + <hr className="border-border-light" /> 36 + <Link 37 + href="/home" 38 + prefetch 39 + className="hover:no-underline" 40 + style={{ textDecorationLine: "none !important" }} 41 + > 42 + <ActionButton icon={<HomeSmall />} label="Go Home" /> 43 + </Link> 44 + </> 45 + ); 46 + }; 47 + 48 + function PublicationShareButton() { 49 + let pub = usePublicationData(); 50 + let smoker = useSmoker(); 51 + return ( 52 + <Menu 53 + className="max-w-xs" 54 + asChild 55 + trigger={ 56 + <ActionButton 57 + id="pub-share-button" 58 + icon=<ShareSmall /> 59 + secondary 60 + label="Share" 61 + onClick={() => {}} 62 + /> 63 + } 64 + > 65 + <MenuItem onSelect={() => {}}> 66 + <Link 67 + href={getPublicationURL(pub!)} 68 + className="text-secondary hover:no-underline" 69 + > 70 + <div>Viewer Mode</div> 71 + <div className="font-normal text-tertiary text-sm"> 72 + View your publication as a reader 73 + </div> 74 + </Link> 75 + </MenuItem> 76 + <MenuItem 77 + onSelect={(e) => { 78 + e.preventDefault(); 79 + let rect = (e.currentTarget as Element)?.getBoundingClientRect(); 80 + navigator.clipboard.writeText(getPublicationURL(pub!)); 81 + smoker({ 82 + position: { 83 + x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 84 + y: rect ? rect.top + 26 : 0, 85 + }, 86 + text: "Copied Publicaiton URL!", 87 + }); 88 + }} 89 + > 90 + <div> 91 + <div>Share Your Publication</div> 92 + <div className="font-normal text-tertiary text-sm"> 93 + Copy link for the published site 94 + </div> 95 + </div> 96 + </MenuItem> 97 + </Menu> 98 + ); 99 + } 100 + 101 + function PublicationSettingsButton(props: { publication: string }) { 102 + let router = useRouter(); 103 + 104 + return ( 105 + <Popover 106 + asChild 107 + className="w-80" 108 + trigger={ 109 + <ActionButton 110 + id="pub-settings-button" 111 + icon=<SettingsSmall /> 112 + label="Settings" 113 + /> 114 + } 115 + > 116 + <EditPubForm /> 117 + </Popover> 118 + ); 119 + }
+45
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 1 + "use client"; 2 + 3 + import Link from "next/link"; 4 + import { NewDraftSecondaryButton } from "./NewDraftButton"; 5 + import React from "react"; 6 + import { usePublicationData } from "./PublicationSWRProvider"; 7 + 8 + export function DraftList() { 9 + let pub_data = usePublicationData(); 10 + if (!pub_data) return null; 11 + return ( 12 + <div className="flex flex-col gap-4 pb-8 sm:pb-12"> 13 + <NewDraftSecondaryButton fullWidth publication={pub_data?.uri} /> 14 + {pub_data.leaflets_in_publications 15 + .filter((d) => !d.doc) 16 + .map((d) => { 17 + return ( 18 + <React.Fragment key={d.leaflet}> 19 + <Draft id={d.leaflet} {...d} /> 20 + <hr className="last:hidden border-border-light" /> 21 + </React.Fragment> 22 + ); 23 + })} 24 + </div> 25 + ); 26 + } 27 + 28 + function Draft(props: { id: string; title: string; description: string }) { 29 + return ( 30 + <div className="flex flex-row gap-2 items-start"> 31 + <Link 32 + key={props.id} 33 + href={`/${props.id}`} 34 + className="flex flex-col gap-0 hover:!no-underline grow" 35 + > 36 + {props.title ? ( 37 + <h3 className="text-primary">{props.title}</h3> 38 + ) : ( 39 + <h3 className="text-tertiary italic">Untitled</h3> 40 + )} 41 + <div className="text-secondary italic">{props.description}</div> 42 + </Link> 43 + </div> 44 + ); 45 + }
+44
app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
··· 1 + "use client"; 2 + import { createPublicationDraft } from "actions/createPublicationDraft"; 3 + import { ActionButton } from "components/ActionBar/ActionButton"; 4 + import { ButtonSecondary } from "components/Buttons"; 5 + import { AddTiny } from "components/Icons/AddTiny"; 6 + import { useRouter } from "next/navigation"; 7 + 8 + export function NewDraftActionButton(props: { publication: string }) { 9 + let router = useRouter(); 10 + 11 + return ( 12 + <ActionButton 13 + id="new-leaflet-button" 14 + primary 15 + onClick={async () => { 16 + let newLeaflet = await createPublicationDraft(props.publication); 17 + router.push(`/${newLeaflet}`); 18 + }} 19 + icon=<AddTiny className="m-1 shrink-0" /> 20 + label="New Draft" 21 + /> 22 + ); 23 + } 24 + 25 + export function NewDraftSecondaryButton(props: { 26 + publication: string; 27 + fullWidth?: boolean; 28 + }) { 29 + let router = useRouter(); 30 + 31 + return ( 32 + <ButtonSecondary 33 + fullWidth={props.fullWidth} 34 + id="new-leaflet-button" 35 + onClick={async () => { 36 + let newLeaflet = await createPublicationDraft(props.publication); 37 + router.push(`/${newLeaflet}`); 38 + }} 39 + > 40 + <AddTiny className="m-1 shrink-0" /> 41 + <span>New Draft</span> 42 + </ButtonSecondary> 43 + ); 44 + }
+58
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 1 + "use client"; 2 + import { BlobRef } from "@atproto/lexicon"; 3 + import { useState } from "react"; 4 + 5 + type Tabs = { [tabName: string]: React.ReactNode }; 6 + export function PublicationDashboard<T extends Tabs>(props: { 7 + name: string; 8 + tabs: T; 9 + defaultTab: keyof T; 10 + icon: BlobRef | null; 11 + did: string; 12 + }) { 13 + let [tab, setTab] = useState(props.defaultTab); 14 + let content = props.tabs[tab]; 15 + 16 + return ( 17 + <div className="pubDashWrapper w-full flex flex-col items-stretch px-3"> 18 + <div className="pubDashTabWrapper flex flex-row gap-2 w-full justify-between border-b border-border text-secondary items-center"> 19 + {props.icon && ( 20 + <div 21 + className="shrink-0 w-5 h-5 rounded-full" 22 + style={{ 23 + backgroundImage: `url(https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${props.did}&cid=${(props.icon.ref as unknown as { $link: string })["$link"]})`, 24 + backgroundRepeat: "no-repeat", 25 + backgroundPosition: "center", 26 + backgroundSize: "cover", 27 + }} 28 + /> 29 + )}{" "} 30 + <div className="font-bold grow text-tertiary max-w-full truncate pr-2"> 31 + {props.name} 32 + </div> 33 + <div className="pubDashTabs flex flex-row gap-2"> 34 + {Object.keys(props.tabs).map((t) => ( 35 + <Tab 36 + key={t} 37 + name={t} 38 + selected={t === tab} 39 + onSelect={() => setTab(t)} 40 + /> 41 + ))} 42 + </div> 43 + </div> 44 + <div className="pubDashContent pt-4">{content}</div> 45 + </div> 46 + ); 47 + } 48 + 49 + function Tab(props: { name: string; selected: boolean; onSelect: () => void }) { 50 + return ( 51 + <div 52 + className={`pubTabs border bg-[#FDFCFA] border-b-0 px-2 pt-1 pb-0.5 rounded-t-md border-border hover:cursor-pointer ${props.selected ? "text-accent-1 font-bold -mb-[1px]" : ""}`} 53 + onClick={() => props.onSelect()} 54 + > 55 + {props.name} 56 + </div> 57 + ); 58 + }
+41
app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
··· 1 + "use client"; 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: { 10 + publication_name: string; 11 + publication_did: string; 12 + publication_data: GetPublicationDataReturnType["result"]; 13 + children: React.ReactNode; 14 + }) { 15 + return ( 16 + <PublicationContext 17 + value={{ name: props.publication_name, did: props.publication_did }} 18 + > 19 + <SWRConfig 20 + value={{ 21 + fallback: { 22 + "publication-data": props.publication_data, 23 + }, 24 + }} 25 + > 26 + {props.children} 27 + </SWRConfig> 28 + </PublicationContext> 29 + ); 30 + } 31 + 32 + export function usePublicationData() { 33 + let { name, did } = useContext(PublicationContext); 34 + let { data } = useSWR( 35 + "publication-data", 36 + async () => 37 + (await callRPC("get_publication_data", { publication_name: name, did })) 38 + ?.result, 39 + ); 40 + return data; 41 + }
+72
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 + "use client"; 2 + import Link from "next/link"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { PubLeafletDocument } from "lexicons/api"; 5 + import { EditTiny } from "components/Icons/EditTiny"; 6 + import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 7 + import { Menu, MenuItem } from "components/Layout"; 8 + 9 + import { usePublicationData } from "./PublicationSWRProvider"; 10 + import { Fragment } from "react"; 11 + import { useParams } from "next/navigation"; 12 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 13 + 14 + export function PublishedPostsList() { 15 + let publication = usePublicationData(); 16 + let params = useParams(); 17 + if (!publication) return null; 18 + if (publication.documents_in_publications.length === 0) 19 + return ( 20 + <div className="italic text-tertiary w-full container text-center place-items-center flex flex-col gap-3 p-3"> 21 + Nothing's been published yet... 22 + </div> 23 + ); 24 + return ( 25 + <div className="publishedList w-full flex flex-col gap-4 pb-8 sm:pb-12"> 26 + {publication.documents_in_publications.map((doc) => { 27 + if (!doc.documents) return null; 28 + let leaflet = publication.leaflets_in_publications.find( 29 + (l) => doc.documents && l.doc === doc.documents.uri, 30 + ); 31 + let uri = new AtUri(doc.documents.uri); 32 + let record = doc.documents.data as PubLeafletDocument.Record; 33 + 34 + return ( 35 + <Fragment key={doc.documents?.uri}> 36 + <div className="flex w-full "> 37 + <Link 38 + target="_blank" 39 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 40 + className="publishedPost grow flex flex-col hover:!no-underline" 41 + > 42 + <h3 className="text-primary">{record.title}</h3> 43 + {record.description ? ( 44 + <p className="italic text-secondary">{record.description}</p> 45 + ) : null} 46 + {record.publishedAt ? ( 47 + <p className="text-sm text-tertiary pt-3"> 48 + Published{" "} 49 + {new Date(record.publishedAt).toLocaleDateString( 50 + undefined, 51 + { 52 + year: "numeric", 53 + month: "long", 54 + day: "2-digit", 55 + }, 56 + )} 57 + </p> 58 + ) : null} 59 + </Link> 60 + {leaflet && ( 61 + <Link className="pt-[6px]" href={`/${leaflet.leaflet}`}> 62 + <EditTiny /> 63 + </Link> 64 + )} 65 + </div> 66 + <hr className="last:hidden border-border-light" /> 67 + </Fragment> 68 + ); 69 + })} 70 + </div> 71 + ); 72 + }
+107
app/lish/[did]/[publication]/dashboard/page.tsx
··· 1 + import { IdResolver } from "@atproto/identity"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + import { Metadata } from "next"; 4 + 5 + import { Sidebar } from "components/ActionBar/Sidebar"; 6 + 7 + import { Media } from "components/Media"; 8 + import { Footer } from "components/ActionBar/Footer"; 9 + import { PublicationDashboard } from "./PublicationDashboard"; 10 + import { DraftList } from "./DraftList"; 11 + import { getIdentityData } from "actions/getIdentityData"; 12 + import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 + import { Actions } from "./Actions"; 14 + import React from "react"; 15 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 16 + import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; 17 + import { PublishedPostsList } from "./PublishedPostsLists"; 18 + import { PubLeafletPublication } from "lexicons/api"; 19 + 20 + const idResolver = new IdResolver(); 21 + 22 + export async function generateMetadata(props: { 23 + params: Promise<{ publication: string; did: string }>; 24 + }): Promise<Metadata> { 25 + let did = decodeURIComponent((await props.params).did); 26 + if (!did) return { title: "Publication 404" }; 27 + 28 + let { result: publication } = await get_publication_data.handler( 29 + { 30 + did, 31 + publication_name: decodeURIComponent((await props.params).publication), 32 + }, 33 + { supabase: supabaseServerClient }, 34 + ); 35 + if (!publication) return { title: "404 Publication" }; 36 + return { title: decodeURIComponent((await props.params).publication) }; 37 + } 38 + 39 + //This is the admin dashboard of the publication 40 + export default async function Publication(props: { 41 + params: Promise<{ publication: string; did: string }>; 42 + }) { 43 + let params = await props.params; 44 + let identity = await getIdentityData(); 45 + if (!identity || !identity.atp_did) return <div>not logged in</div>; 46 + let did = decodeURIComponent(params.did); 47 + if (!did) return <PubNotFound />; 48 + let { result: publication } = await get_publication_data.handler( 49 + { 50 + did, 51 + publication_name: decodeURIComponent((await props.params).publication), 52 + }, 53 + { supabase: supabaseServerClient }, 54 + ); 55 + 56 + let record = publication?.record as PubLeafletPublication.Record | null; 57 + if (!publication || identity.atp_did !== publication.identity_did) 58 + return <PubNotFound />; 59 + 60 + try { 61 + return ( 62 + <PublicationSWRDataProvider 63 + publication_did={did} 64 + publication_name={publication.name} 65 + publication_data={publication} 66 + > 67 + <ThemeProvider entityID={null}> 68 + <div className="w-screen h-screen flex place-items-center bg-[#FDFCFA]"> 69 + <div className="relative max-w-prose w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6"> 70 + <div className="w-12 relative"> 71 + <Sidebar className="mt-6 p-2"> 72 + <Actions publication={publication.uri} /> 73 + </Sidebar> 74 + </div> 75 + <div 76 + className={`h-full overflow-y-scroll pt-4 sm:pl-5 sm:pt-8 w-full`} 77 + > 78 + <PublicationDashboard 79 + did={did} 80 + icon={record?.icon ? record.icon : null} 81 + name={publication.name} 82 + tabs={{ 83 + Drafts: <DraftList />, 84 + Published: <PublishedPostsList />, 85 + }} 86 + defaultTab={"Drafts"} 87 + /> 88 + </div> 89 + <Media mobile> 90 + <Footer> 91 + <Actions publication={publication.uri} /> 92 + </Footer> 93 + </Media> 94 + </div> 95 + </div> 96 + </ThemeProvider> 97 + </PublicationSWRDataProvider> 98 + ); 99 + } catch (e) { 100 + console.log(e); 101 + return <pre>{JSON.stringify(e, undefined, 2)}</pre>; 102 + } 103 + } 104 + 105 + const PubNotFound = () => { 106 + return <div>ain't no pub here</div>; 107 + };
+133
app/lish/[did]/[publication]/page.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { Metadata } from "next"; 3 + 4 + import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 5 + import React from "react"; 6 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 7 + import { AtUri } from "@atproto/syntax"; 8 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 + import Link from "next/link"; 10 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 + 12 + export async function generateMetadata(props: { 13 + params: Promise<{ publication: string; did: string }>; 14 + }): Promise<Metadata> { 15 + let params = await props.params; 16 + let did = decodeURIComponent(params.did); 17 + if (!did) return { title: "Publication 404" }; 18 + 19 + let { result: publication } = await get_publication_data.handler( 20 + { 21 + did, 22 + publication_name: decodeURIComponent(params.publication), 23 + }, 24 + { supabase: supabaseServerClient }, 25 + ); 26 + if (!publication) return { title: "404 Publication" }; 27 + return { title: decodeURIComponent(params.publication) }; 28 + } 29 + 30 + export default async function Publication(props: { 31 + params: Promise<{ publication: string; did: string }>; 32 + }) { 33 + let params = await props.params; 34 + let did = decodeURIComponent(params.did); 35 + if (!did) return <PubNotFound />; 36 + let { data: publication } = await supabaseServerClient 37 + .from("publications") 38 + .select( 39 + `*, 40 + documents_in_publications(documents(*)) 41 + `, 42 + ) 43 + .eq("identity_did", did) 44 + .eq("name", decodeURIComponent(params.publication)) 45 + .single(); 46 + 47 + let record = publication?.record as PubLeafletPublication.Record; 48 + 49 + if (!publication) return <PubNotFound />; 50 + try { 51 + return ( 52 + <ThemeProvider entityID={null}> 53 + <div className="publicationWrapper w-screen h-screen flex place-items-center bg-[#FDFCFA]"> 54 + <div className="publication max-w-prose w-full mx-auto h-full sm:pt-8 pt-4 px-3 pb-12 sm:pb-8"> 55 + <div className="flex flex-col pb-8 w-full text-center justify-center "> 56 + <div className="flex flex-col gap-3 justify-center place-items-center"> 57 + {record.icon && ( 58 + <div 59 + className="shrink-0 w-10 h-10 rounded-full" 60 + style={{ 61 + backgroundImage: `url(https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 62 + backgroundRepeat: "no-repeat", 63 + backgroundPosition: "center", 64 + backgroundSize: "cover", 65 + }} 66 + /> 67 + )} 68 + <h2 className="text-accent-contrast sm:text-xl text-[22px]"> 69 + {publication.name} 70 + </h2> 71 + </div> 72 + <p className="sm:text-lg text-tertiary">{record.description} </p> 73 + </div> 74 + <div className="publicationPostList w-full flex flex-col gap-4"> 75 + {publication.documents_in_publications 76 + .filter((d) => !!d?.documents) 77 + .sort((a, b) => { 78 + let aRecord = a.documents?.data! as PubLeafletDocument.Record; 79 + let bRecord = a.documents?.data! as PubLeafletDocument.Record; 80 + const aDate = aRecord.publishedAt 81 + ? new Date(aRecord.publishedAt) 82 + : new Date(0); 83 + const bDate = bRecord.publishedAt 84 + ? new Date(bRecord.publishedAt) 85 + : new Date(0); 86 + return bDate.getTime() - aDate.getTime(); // Sort by most recent first 87 + }) 88 + .map((doc) => { 89 + if (!doc.documents) return null; 90 + let uri = new AtUri(doc.documents.uri); 91 + let record = doc.documents.data as PubLeafletDocument.Record; 92 + return ( 93 + <React.Fragment key={doc.documents?.uri}> 94 + <div className="flex w-full "> 95 + <Link 96 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 97 + className="publishedPost grow flex flex-col hover:!no-underline" 98 + > 99 + <h3 className="text-primary">{record.title}</h3> 100 + <p className="italic text-secondary"> 101 + {record.description} 102 + </p> 103 + <p className="text-sm text-tertiary pt-2"> 104 + {record.publishedAt && 105 + new Date(record.publishedAt).toLocaleDateString( 106 + undefined, 107 + { 108 + year: "numeric", 109 + month: "long", 110 + day: "2-digit", 111 + }, 112 + )}{" "} 113 + </p> 114 + </Link> 115 + </div> 116 + <hr className="last:hidden border-border-light" /> 117 + </React.Fragment> 118 + ); 119 + })} 120 + </div> 121 + </div> 122 + </div> 123 + </ThemeProvider> 124 + ); 125 + } catch (e) { 126 + console.log(e); 127 + return <pre>{JSON.stringify(e, undefined, 2)}</pre>; 128 + } 129 + } 130 + 131 + const PubNotFound = () => { 132 + return <div>ain't no pub here</div>; 133 + };
-41
app/lish/[handle]/[publication]/CallToActionButton.tsx
··· 1 - "use client"; 2 - 3 - import { SubscribeButton } from "app/lish/Subscribe"; 4 - import { usePublicationRelationship } from "./usePublicationRelationship"; 5 - import { usePublicationContext } from "components/Providers/PublicationContext"; 6 - import { NewDraftButton } from "./NewDraftButton"; 7 - import { Menu, MenuItem } from "components/Layout"; 8 - import { useIdentityData } from "components/IdentityProvider"; 9 - import { unsubscribeFromPublication } from "actions/unsubscribeFromPublication"; 10 - import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 11 - 12 - export function CallToActionButton() { 13 - let rel = usePublicationRelationship(); 14 - let { publication } = usePublicationContext(); 15 - if (!publication) return null; 16 - if (rel?.isAuthor) return <NewDraftButton publication={publication.uri} />; 17 - if (rel?.isSubscribed) 18 - return ( 19 - <div className="flex gap-2"> 20 - <div className="font-bold">You're Subscribed!</div> 21 - <ManageSubscriptionMenu publication_uri={publication.uri} /> 22 - </div> 23 - ); 24 - return <SubscribeButton publication={publication.uri} />; 25 - } 26 - 27 - const ManageSubscriptionMenu = (props: { publication_uri: string }) => { 28 - let { mutate } = useIdentityData(); 29 - return ( 30 - <Menu trigger={<MoreOptionsTiny className="rotate-90" />}> 31 - <MenuItem 32 - onSelect={async () => { 33 - await unsubscribeFromPublication(props.publication_uri); 34 - mutate(); 35 - }} 36 - > 37 - Unsub! 38 - </MenuItem> 39 - </Menu> 40 - ); 41 - };
-20
app/lish/[handle]/[publication]/NewDraftButton.tsx
··· 1 - "use client"; 2 - import { createPublicationDraft } from "actions/createPublicationDraft"; 3 - import { ButtonPrimary } from "components/Buttons"; 4 - import { useRouter } from "next/navigation"; 5 - 6 - export function NewDraftButton(props: { publication: string }) { 7 - let router = useRouter(); 8 - return ( 9 - <div className="flex gap-2"> 10 - <ButtonPrimary 11 - onClick={async () => { 12 - let newLeaflet = await createPublicationDraft(props.publication); 13 - router.push(`/${newLeaflet}`); 14 - }} 15 - > 16 - New Draft 17 - </ButtonPrimary> 18 - </div> 19 - ); 20 - }
-104
app/lish/[handle]/[publication]/[rkey]/page.tsx
··· 1 - import Link from "next/link"; 2 - import { Footer } from "../../../Footer"; 3 - import { getPds, IdResolver } from "@atproto/identity"; 4 - import { supabaseServerClient } from "supabase/serverClient"; 5 - import { AtUri } from "@atproto/syntax"; 6 - import { ids } from "lexicons/api/lexicons"; 7 - import { 8 - PubLeafletBlocksHeader, 9 - PubLeafletBlocksImage, 10 - PubLeafletBlocksText, 11 - PubLeafletDocument, 12 - PubLeafletPagesLinearDocument, 13 - } from "lexicons/api"; 14 - import { Metadata } from "next"; 15 - 16 - const idResolver = new IdResolver(); 17 - export async function generateMetadata(props: { 18 - params: Promise<{ publication: string; handle: string; rkey: string }>; 19 - }): Promise<Metadata> { 20 - let did = await idResolver.handle.resolve((await props.params).handle); 21 - if (!did) return { title: "Publication 404" }; 22 - 23 - let { data: document } = await supabaseServerClient 24 - .from("documents") 25 - .select("*") 26 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey)) 27 - .single(); 28 - 29 - if (!document) return { title: "404" }; 30 - let record = document.data as PubLeafletDocument.Record; 31 - return { 32 - title: record.title + " - " + decodeURIComponent((await props.params).publication), 33 - }; 34 - } 35 - export default async function Post(props: { 36 - params: Promise<{ publication: string; handle: string; rkey: string }>; 37 - }) { 38 - let did = await idResolver.handle.resolve((await props.params).handle); 39 - if (!did) return <div> can't resolve handle</div>; 40 - let { data: document } = await supabaseServerClient 41 - .from("documents") 42 - .select("*") 43 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey)) 44 - .single(); 45 - if (!document?.data) return <div>notfound</div>; 46 - let record = document.data as PubLeafletDocument.Record; 47 - let firstPage = record.pages[0]; 48 - let blocks: PubLeafletPagesLinearDocument.Block[] = []; 49 - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 50 - blocks = firstPage.blocks || []; 51 - } 52 - return ( 53 - (<div className="postPage w-full h-screen bg-bg-leaflet flex items-stretch"> 54 - <div className="pubWrapper flex flex-col w-full "> 55 - <div className="pubContent flex flex-col px-4 py-6 mx-auto max-w-prose h-full w-full overflow-auto"> 56 - <Link 57 - className="font-bold hover:no-underline text-accent-contrast -mb-2 sm:-mb-3" 58 - href={`/lish/${(await props.params).handle}/${(await props.params).publication}`} 59 - > 60 - {decodeURIComponent((await props.params).publication)} 61 - </Link> 62 - {/* <h1>{record.title}</h1> */} 63 - {blocks.map((b) => { 64 - switch (true) { 65 - case PubLeafletBlocksImage.isMain(b.block): { 66 - return ( 67 - <img 68 - height={b.block.aspectRatio?.height} 69 - width={b.block.aspectRatio?.width} 70 - className="pb-2 sm:pb-3" 71 - src={`https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${(b.block.image.ref as unknown as { $link: string })["$link"]}`} 72 - /> 73 - ); 74 - } 75 - case PubLeafletBlocksText.isMain(b.block): 76 - return <p className="pt-0 pb-2 sm:pb-3">{b.block.plaintext}</p>; 77 - case PubLeafletBlocksHeader.isMain(b.block): { 78 - if (b.block.level === 1) 79 - return ( 80 - <h1 className="pb-0 pt-2 sm:pt-3">{b.block.plaintext}</h1> 81 - ); 82 - if (b.block.level === 2) 83 - return ( 84 - <h3 className="pb-0 pt-2 sm:pt-3">{b.block.plaintext}</h3> 85 - ); 86 - if (b.block.level === 3) 87 - return ( 88 - <h4 className="pb-0 pt-2 sm:pt-3">{b.block.plaintext}</h4> 89 - ); 90 - // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 91 - // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 92 - return <h6>{b.block.plaintext}</h6>; 93 - } 94 - default: 95 - return null; 96 - } 97 - })} 98 - </div> 99 - 100 - <Footer pageType="post" /> 101 - </div> 102 - </div>) 103 - ); 104 - }
-30
app/lish/[handle]/[publication]/layout.tsx
··· 1 - import { IdResolver } from "@atproto/identity"; 2 - import { PublicationContextProvider } from "components/Providers/PublicationContext"; 3 - import { supabaseServerClient } from "supabase/serverClient"; 4 - 5 - const idResolver = new IdResolver(); 6 - export default async function PublicationLayout(props: { 7 - children: React.ReactNode; 8 - params: Promise<{ 9 - publication: string; 10 - handle: string; 11 - }>; 12 - }) { 13 - let did = await idResolver.handle.resolve((await props.params).handle); 14 - if (!did) return <>{props.children}</>; 15 - let { data: publication } = await supabaseServerClient 16 - .from("publications") 17 - .select( 18 - "*, documents_in_publications(documents(*)), leaflets_in_publications(*, permission_tokens(*, permission_token_rights(*), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*) ))", 19 - ) 20 - .eq("identity_did", did) 21 - .eq("name", decodeURIComponent((await props.params).publication)) 22 - .single(); 23 - 24 - if (!publication) return <>{props.children}</>; 25 - return ( 26 - <PublicationContextProvider publication={publication}> 27 - {props.children} 28 - </PublicationContextProvider> 29 - ); 30 - }
-94
app/lish/[handle]/[publication]/page.tsx
··· 1 - import { Footer } from "../../Footer"; 2 - import { PostList } from "../../PostList"; 3 - import { getPds, IdResolver } from "@atproto/identity"; 4 - import { supabaseServerClient } from "supabase/serverClient"; 5 - import { AtUri } from "@atproto/syntax"; 6 - import { 7 - AtpBaseClient, 8 - PubLeafletDocument, 9 - PubLeafletPublication, 10 - } from "lexicons/api"; 11 - import { CallToActionButton } from "./CallToActionButton"; 12 - import { Metadata } from "next"; 13 - 14 - const idResolver = new IdResolver(); 15 - 16 - export async function generateMetadata(props: { 17 - params: Promise<{ publication: string; handle: string }>; 18 - }): Promise<Metadata> { 19 - let did = await idResolver.handle.resolve((await props.params).handle); 20 - if (!did) return { title: "Publication 404" }; 21 - 22 - let { data: publication } = await supabaseServerClient 23 - .from("publications") 24 - .select( 25 - "*, documents_in_publications(documents(*)), leaflets_in_publications(*, permission_tokens(*, permission_token_rights(*), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*) ))", 26 - ) 27 - .eq("identity_did", did) 28 - .eq("name", decodeURIComponent((await props.params).publication)) 29 - .single(); 30 - if (!publication) return { title: "404 Publication" }; 31 - return { title: decodeURIComponent((await props.params).publication) }; 32 - } 33 - 34 - export default async function Publication(props: { 35 - params: Promise<{ publication: string; handle: string }>; 36 - }) { 37 - let did = await idResolver.handle.resolve((await props.params).handle); 38 - if (!did) return <PubNotFound />; 39 - 40 - let { data: publication } = await supabaseServerClient 41 - .from("publications") 42 - .select( 43 - "*, documents_in_publications(documents(*)), leaflets_in_publications(*, permission_tokens(*, permission_token_rights(*), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*) ))", 44 - ) 45 - .eq("identity_did", did) 46 - .eq("name", decodeURIComponent((await props.params).publication)) 47 - .single(); 48 - if (!publication) return <PubNotFound />; 49 - 50 - let repo = await idResolver.did.resolve(did); 51 - if (!repo) return <PubNotFound />; 52 - const pds = getPds(repo); 53 - let agent = new AtpBaseClient((url, init) => { 54 - return fetch(new URL(url, pds), init); 55 - }); 56 - 57 - try { 58 - let uri = new AtUri(publication.uri); 59 - let publication_record = await agent.pub.leaflet.publication.get({ 60 - repo: (await props.params).handle, 61 - rkey: uri.rkey, 62 - }); 63 - if (!PubLeafletPublication.isRecord(publication_record.value)) { 64 - return <pre>not a publication?</pre>; 65 - } 66 - 67 - return ( 68 - <div className="pubPage w-full h-screen bg-bg-leaflet flex items-stretch"> 69 - <div className="pubWrapper flex flex-col w-full "> 70 - <div className="pubContent flex flex-col gap-8 px-4 py-6 mx-auto max-w-prose h-full w-full overflow-auto"> 71 - <div 72 - id="pub-header" 73 - className="pubHeader flex flex-col gap-6 justify-center text-center" 74 - > 75 - <div className="flex flex-col gap-1 w-full place-items-center"> 76 - <h2>{publication.name}</h2> 77 - <CallToActionButton /> 78 - </div> 79 - </div> 80 - <PostList posts={publication.documents_in_publications} /> 81 - </div> 82 - <Footer pageType="pub" /> 83 - </div> 84 - </div> 85 - ); 86 - } catch (e) { 87 - console.log(e); 88 - return <pre>{JSON.stringify(e, undefined, 2)}</pre>; 89 - } 90 - } 91 - 92 - const PubNotFound = () => { 93 - return <div>ain't no pub here</div>; 94 - };
-16
app/lish/[handle]/[publication]/usePublicationRelationship.ts
··· 1 - import { AtUri } from "@atproto/syntax"; 2 - import { useIdentityData } from "components/IdentityProvider"; 3 - import { usePublicationContext } from "components/Providers/PublicationContext"; 4 - 5 - export const usePublicationRelationship = () => { 6 - let identity = useIdentityData(); 7 - let publication = usePublicationContext(); 8 - if (!publication.publication) return null; 9 - let pubUri = new AtUri(publication.publication.uri); 10 - let isAuthor = 11 - identity.identity?.atp_did && pubUri.hostname === identity.identity.atp_did; 12 - let isSubscribed = identity.identity?.subscribers_to_publications.find( 13 - (p) => p.publication === publication.publication?.uri, 14 - ); 15 - return { isAuthor, isSubscribed }; 16 - };
+180 -23
app/lish/createPub/CreatePubForm.tsx
··· 1 1 "use client"; 2 - import { createPublication } from "actions/createPublication"; 2 + import { callRPC } from "app/api/rpc/client"; 3 + import { createPublication } from "./createPublication"; 3 4 import { ButtonPrimary } from "components/Buttons"; 5 + import { AddSmall } from "components/Icons/AddSmall"; 4 6 import { useIdentityData } from "components/IdentityProvider"; 5 - import { InputWithLabel } from "components/Input"; 6 - import Link from "next/link"; 7 - import { useParams, useRouter } from "next/navigation"; 8 - import { useState } from "react"; 7 + import { Input, InputWithLabel } from "components/Input"; 8 + import { useRouter } from "next/navigation"; 9 + import { useState, useRef, useEffect } from "react"; 10 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 11 + import { theme } from "tailwind.config"; 12 + import { getPublicationURL } from "./getPublicationURL"; 13 + import { string } from "zod"; 14 + 15 + type DomainState = 16 + | { status: "empty" } 17 + | { status: "valid" } 18 + | { status: "invalid" } 19 + | { status: "pending" } 20 + | { status: "error"; message: string }; 9 21 10 22 export const CreatePubForm = () => { 11 23 let [nameValue, setNameValue] = useState(""); 12 24 let [descriptionValue, setDescriptionValue] = useState(""); 25 + let [logoFile, setLogoFile] = useState<File | null>(null); 26 + let [logoPreview, setLogoPreview] = useState<string | null>(null); 27 + let [domainValue, setDomainValue] = useState(""); 28 + let [domainState, setDomainState] = useState<DomainState>({ 29 + status: "empty", 30 + }); 31 + let fileInputRef = useRef<HTMLInputElement>(null); 13 32 14 33 let router = useRouter(); 15 - let { identity } = useIdentityData(); 16 34 return ( 17 35 <form 18 - className="createPubForm w-full flex flex-col gap-3 bg-bg-page rounded-lg p-3 border border-border-light" 36 + className="flex flex-col gap-3" 19 37 onSubmit={async (e) => { 20 38 e.preventDefault(); 21 - await createPublication(nameValue); 22 - router.push( 23 - `/lish/${identity?.resolved_did?.alsoKnownAs?.[0].slice(5)}/${nameValue}/`, 24 - ); 39 + if (!subdomainValidator.safeParse(domainValue).success) return; 40 + let data = await createPublication({ 41 + name: nameValue, 42 + description: descriptionValue, 43 + iconFile: logoFile, 44 + subdomain: domainValue, 45 + }); 46 + if (data?.publication) 47 + router.push(`${getPublicationURL(data.publication)}/dashboard`); 25 48 }} 26 49 > 50 + <div className="flex flex-col items-center mb-4 gap-2"> 51 + <div className="text-center text-secondary flex flex-col "> 52 + <h3 className="-mb-1">Logo</h3> 53 + <p className="italic text-tertiary">(optional)</p> 54 + </div> 55 + <div 56 + className="w-24 h-24 rounded-full border-2 border-dotted border-accent-1 flex items-center justify-center cursor-pointer hover:border-accent-contrast" 57 + onClick={() => fileInputRef.current?.click()} 58 + > 59 + {logoPreview ? ( 60 + <img 61 + src={logoPreview} 62 + alt="Logo preview" 63 + className="w-full h-full rounded-full object-cover" 64 + /> 65 + ) : ( 66 + <AddSmall className="text-accent-1" /> 67 + )} 68 + </div> 69 + <input 70 + type="file" 71 + accept="image/*" 72 + className="hidden" 73 + ref={fileInputRef} 74 + onChange={(e) => { 75 + const file = e.target.files?.[0]; 76 + if (file) { 77 + setLogoFile(file); 78 + const reader = new FileReader(); 79 + reader.onload = (e) => { 80 + setLogoPreview(e.target?.result as string); 81 + }; 82 + reader.readAsDataURL(file); 83 + } 84 + }} 85 + /> 86 + </div> 27 87 <InputWithLabel 28 88 type="text" 29 89 id="pubName" 30 - label="Name" 90 + label="Publication Name" 31 91 value={nameValue} 32 92 onChange={(e) => { 33 93 setNameValue(e.currentTarget.value); 34 94 }} 35 95 /> 36 96 37 - {/* <InputWithLabel 38 - label="Description" 97 + <InputWithLabel 98 + label="Description (optional)" 39 99 textarea 40 100 rows={3} 41 101 id="pubDescription" ··· 43 103 onChange={(e) => { 44 104 setDescriptionValue(e.currentTarget.value); 45 105 }} 46 - /> */} 106 + /> 107 + <DomainInput 108 + domain={domainValue} 109 + setDomain={setDomainValue} 110 + domainState={domainState} 111 + setDomainState={setDomainState} 112 + /> 47 113 48 - <div className="flex justify-between items-center"> 49 - <Link 50 - className="hover:no-underline font-bold text-accent-contrast" 51 - href="./" 114 + <div className="flex w-full justify-center"> 115 + <ButtonPrimary 116 + type="submit" 117 + disabled={ 118 + !nameValue || !domainValue || domainState.status !== "valid" 119 + } 52 120 > 53 - Nevermind 54 - </Link> 55 - <ButtonPrimary className="place-self-end" type="submit"> 56 - Create! 121 + Create Publication! 57 122 </ButtonPrimary> 58 123 </div> 59 124 </form> 60 125 ); 61 126 }; 127 + 128 + let subdomainValidator = string() 129 + .min(3) 130 + .max(63) 131 + .regex(/^[a-z0-9-]+$/); 132 + function DomainInput(props: { 133 + domain: string; 134 + setDomain: (d: string) => void; 135 + domainState: DomainState; 136 + setDomainState: (s: DomainState) => void; 137 + }) { 138 + useEffect(() => { 139 + if (!props.domain) { 140 + props.setDomainState({ status: "empty" }); 141 + } else { 142 + let valid = subdomainValidator.safeParse(props.domain); 143 + if (!valid.success) { 144 + let reason = valid.error.errors[0].code; 145 + props.setDomainState({ 146 + status: "error", 147 + message: 148 + reason === "too_small" 149 + ? "Must be at least 3 characters long" 150 + : reason === "invalid_string" 151 + ? "Must contain only lowercase a-z, 0-9, and -" 152 + : "", 153 + }); 154 + return; 155 + } 156 + props.setDomainState({ status: "pending" }); 157 + } 158 + }, [props.domain]); 159 + 160 + useDebouncedEffect( 161 + async () => { 162 + if (!props.domain) return props.setDomainState({ status: "empty" }); 163 + 164 + let valid = subdomainValidator.safeParse(props.domain); 165 + if (!valid.success) { 166 + return; 167 + } 168 + let status = await callRPC("get_leaflet_subdomain_status", { 169 + domain: props.domain, 170 + }); 171 + console.log(status); 172 + if (status.error === "Not Found") 173 + props.setDomainState({ status: "valid" }); 174 + else props.setDomainState({ status: "invalid" }); 175 + }, 176 + 500, 177 + [props.domain], 178 + ); 179 + 180 + return ( 181 + <div className="flex flex-col gap-1"> 182 + <label className=" input-with-border flex flex-col text-sm text-tertiary font-bold italic leading-tight !py-1 !px-[6px]"> 183 + <div>Choose your domain</div> 184 + <div className="flex flex-row items-center"> 185 + <Input 186 + minLength={3} 187 + maxLength={63} 188 + placeholder="domain" 189 + className="appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 outline-none" 190 + value={props.domain} 191 + onChange={(e) => props.setDomain(e.currentTarget.value)} 192 + /> 193 + .leaflet.pub 194 + </div> 195 + </label> 196 + <div 197 + className={"text-sm italic "} 198 + style={{ 199 + fontWeight: props.domainState.status === "valid" ? "bold" : "normal", 200 + color: 201 + props.domainState.status === "valid" 202 + ? theme.colors["accent-contrast"] 203 + : theme.colors.tertiary, 204 + }} 205 + > 206 + {props.domainState.status === "valid" 207 + ? "Available!" 208 + : props.domainState.status === "error" 209 + ? props.domainState.message 210 + : props.domainState.status === "invalid" 211 + ? "Already Taken ):" 212 + : props.domainState.status === "pending" 213 + ? "Checking Availability..." 214 + : "a-z, 0-9, and - only!"} 215 + </div> 216 + </div> 217 + ); 218 + }
+115
app/lish/createPub/UpdatePubForm.tsx
··· 1 + "use client"; 2 + import { callRPC } from "app/api/rpc/client"; 3 + import { createPublication } from "./createPublication"; 4 + import { ButtonPrimary } from "components/Buttons"; 5 + import { AddSmall } from "components/Icons/AddSmall"; 6 + import { InputWithLabel } from "components/Input"; 7 + import { useState, useRef, useEffect } from "react"; 8 + import { updatePublication } from "./updatePublication"; 9 + import { usePublicationData } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; 10 + import { PubLeafletPublication } from "lexicons/api"; 11 + import { mutate } from "swr"; 12 + import { AddTiny } from "components/Icons/AddTiny"; 13 + 14 + export const EditPubForm = () => { 15 + let pubData = usePublicationData(); 16 + let record = pubData?.record as PubLeafletPublication.Record; 17 + 18 + let [nameValue, setNameValue] = useState(record?.name || ""); 19 + let [descriptionValue, setDescriptionValue] = useState( 20 + record?.description || "", 21 + ); 22 + let [iconFile, setIconFile] = useState<File | null>(null); 23 + let [iconPreview, setIconPreview] = useState<string | null>(null); 24 + let fileInputRef = useRef<HTMLInputElement>(null); 25 + useEffect(() => { 26 + if (!pubData || !pubData.record) return; 27 + setNameValue(record.name); 28 + setDescriptionValue(record.description || ""); 29 + if (record.icon) 30 + setIconPreview( 31 + `url(https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${pubData.identity_did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]})`, 32 + ); 33 + }, [pubData]); 34 + 35 + return ( 36 + <form 37 + className="flex flex-col gap-3 w-full py-1" 38 + onSubmit={async (e) => { 39 + if (!pubData) return; 40 + e.preventDefault(); 41 + let data = await updatePublication({ 42 + uri: pubData.uri, 43 + name: nameValue, 44 + description: descriptionValue, 45 + iconFile: iconFile, 46 + }); 47 + mutate("publication-data"); 48 + }} 49 + > 50 + <div className="flex items-center justify-between gap-2 "> 51 + <div className="text-center text-secondary flex flex-col "> 52 + <p className=" font-bold text-secondary"> 53 + Logo{" "} 54 + <span className="italic text-tertiary font-normal">(optional)</span> 55 + </p> 56 + </div> 57 + <div 58 + 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`} 59 + onClick={() => fileInputRef.current?.click()} 60 + > 61 + {iconPreview ? ( 62 + <img 63 + src={iconPreview} 64 + alt="Logo preview" 65 + className="w-full h-full rounded-full object-cover" 66 + /> 67 + ) : ( 68 + <AddTiny className="text-accent-1" /> 69 + )} 70 + </div> 71 + <input 72 + type="file" 73 + accept="image/*" 74 + className="hidden" 75 + ref={fileInputRef} 76 + onChange={(e) => { 77 + const file = e.target.files?.[0]; 78 + if (file) { 79 + setIconFile(file); 80 + const reader = new FileReader(); 81 + reader.onload = (e) => { 82 + setIconPreview(e.target?.result as string); 83 + }; 84 + reader.readAsDataURL(file); 85 + } 86 + }} 87 + /> 88 + </div> 89 + <InputWithLabel 90 + type="text" 91 + id="pubName" 92 + label="Publication Name" 93 + value={nameValue} 94 + onChange={(e) => { 95 + setNameValue(e.currentTarget.value); 96 + }} 97 + /> 98 + 99 + <InputWithLabel 100 + label="Description (optional)" 101 + textarea 102 + rows={3} 103 + id="pubDescription" 104 + value={descriptionValue} 105 + onChange={(e) => { 106 + setDescriptionValue(e.currentTarget.value); 107 + }} 108 + /> 109 + 110 + <ButtonPrimary className="place-self-end" type="submit"> 111 + Update Publication 112 + </ButtonPrimary> 113 + </form> 114 + ); 115 + };
+104
app/lish/createPub/createPublication.ts
··· 1 + "use server"; 2 + import { TID } from "@atproto/common"; 3 + import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; 4 + import { createOauthClient } from "src/atproto-oauth"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + import { supabaseServerClient } from "supabase/serverClient"; 7 + import { Un$Typed } from "@atproto/api"; 8 + import { Json } from "supabase/database.types"; 9 + import { Vercel } from "@vercel/sdk"; 10 + import { isProductionDomain } from "src/utils/isProductionDeployment"; 11 + import { string } from "zod"; 12 + 13 + const VERCEL_TOKEN = process.env.VERCEL_TOKEN; 14 + const vercel = new Vercel({ 15 + bearerToken: VERCEL_TOKEN, 16 + }); 17 + let subdomainValidator = string() 18 + .min(3) 19 + .max(63) 20 + .regex(/^[a-z0-9-]+$/); 21 + export async function createPublication({ 22 + name, 23 + description, 24 + iconFile, 25 + subdomain, 26 + }: { 27 + name: string; 28 + description: string; 29 + iconFile: File | null; 30 + subdomain: string; 31 + }) { 32 + let isSubdomainValid = subdomainValidator.safeParse(subdomain); 33 + if (!isSubdomainValid.success) { 34 + return { success: false }; 35 + } 36 + const oauthClient = await createOauthClient(); 37 + let identity = await getIdentityData(); 38 + if (!identity || !identity.atp_did) return; 39 + 40 + let domain = `${subdomain}.leaflet.pub`; 41 + 42 + let credentialSession = await oauthClient.restore(identity.atp_did); 43 + let agent = new AtpBaseClient( 44 + credentialSession.fetchHandler.bind(credentialSession), 45 + ); 46 + let record: Un$Typed<PubLeafletPublication.Record> = { 47 + name, 48 + base_path: domain, 49 + }; 50 + 51 + if (description) { 52 + record.description = description; 53 + } 54 + 55 + // Upload the icon if provided 56 + if (iconFile && iconFile.size > 0) { 57 + const buffer = await iconFile.arrayBuffer(); 58 + const uploadResult = await agent.com.atproto.repo.uploadBlob( 59 + new Uint8Array(buffer), 60 + { encoding: iconFile.type }, 61 + ); 62 + 63 + if (uploadResult.data.blob) { 64 + record.icon = uploadResult.data.blob; 65 + } 66 + } 67 + 68 + let result = await agent.pub.leaflet.publication.create( 69 + { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false }, 70 + record, 71 + ); 72 + 73 + //optimistically write to our db! 74 + let { data: publication } = await supabaseServerClient 75 + .from("publications") 76 + .upsert({ 77 + uri: result.uri, 78 + identity_did: credentialSession.did!, 79 + name: record.name, 80 + record: record as Json, 81 + }) 82 + .select() 83 + .single(); 84 + 85 + // Create the custom domain 86 + if (isProductionDomain()) { 87 + console.log("Creating domain! " + domain); 88 + await vercel.projects.addProjectDomain({ 89 + idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", 90 + teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 91 + requestBody: { 92 + name: domain + ".leaflet.pub", 93 + }, 94 + }); 95 + } 96 + await supabaseServerClient 97 + .from("custom_domains") 98 + .insert({ domain, identity: identity.id, confirmed: true }); 99 + await supabaseServerClient 100 + .from("publication_domains") 101 + .insert({ domain, publication: result.uri }); 102 + 103 + return { success: true, publication }; 104 + }
+17
app/lish/createPub/getPublicationURL.ts
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { PubLeafletPublication } from "lexicons/api"; 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 record.base_path; 14 + } 15 + let aturi = new AtUri(pub.uri); 16 + return `/lish/${aturi.host}/${record?.name || pub.name}`; 17 + }
+13 -10
app/lish/createPub/page.tsx
··· 1 + import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 1 2 import { CreatePubForm } from "./CreatePubForm"; 2 - import { createClient } from "@supabase/supabase-js"; 3 - import { Database } from "supabase/database.types"; 4 - import { IdResolver } from "@atproto/identity"; 5 3 6 - export default function CreatePub() { 4 + export default async function CreatePub() { 7 5 return ( 8 - <div className="createPubPage relative w-full h-screen flex items-stretch bg-bg-leaflet p-4"> 9 - <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto "> 10 - <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 11 - <h2 className="text-center">Create a New Publication</h2> 12 - <CreatePubForm /> 6 + // Eventually this can pull from home theme? 7 + <ThemeProvider entityID={null}> 8 + <div className="createPubPage relative w-full h-screen flex items-stretch bg-bg-leaflet p-4"> 9 + <div className="createPubContent h-full flex items-center max-w-sm w-full mx-auto "> 10 + <div className="createPubFormWrapper h-fit w-full flex flex-col gap-4"> 11 + <h2 className="text-center">Create Your Publication!</h2> 12 + <div className="container w-full p-3"> 13 + <CreatePubForm /> 14 + </div> 15 + </div> 13 16 </div> 14 17 </div> 15 - </div> 18 + </ThemeProvider> 16 19 ); 17 20 }
+79
app/lish/createPub/updatePublication.ts
··· 1 + "use server"; 2 + import { TID } from "@atproto/common"; 3 + import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; 4 + import { createOauthClient } from "src/atproto-oauth"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + import { supabaseServerClient } from "supabase/serverClient"; 7 + import { Json } from "supabase/database.types"; 8 + 9 + export async function updatePublication({ 10 + uri, 11 + name, 12 + description, 13 + iconFile, 14 + }: { 15 + uri: string; 16 + name: string; 17 + description: string; 18 + iconFile: File | null; 19 + }) { 20 + const oauthClient = await createOauthClient(); 21 + let identity = await getIdentityData(); 22 + if (!identity || !identity.atp_did) return; 23 + 24 + let credentialSession = await oauthClient.restore(identity.atp_did); 25 + let agent = new AtpBaseClient( 26 + credentialSession.fetchHandler.bind(credentialSession), 27 + ); 28 + let { data: existingPub } = await supabaseServerClient 29 + .from("publications") 30 + .select("*") 31 + .eq("uri", uri) 32 + .single(); 33 + if (!existingPub || existingPub.identity_did! === identity.atp_did) return; 34 + 35 + let record: PubLeafletPublication.Record = { 36 + $type: "pub.leaflet.publication", 37 + name, 38 + ...(existingPub.record as object), 39 + }; 40 + 41 + if (description) { 42 + record.description = description; 43 + } 44 + 45 + // Upload the icon if provided How do I tell if there isn't a new one? 46 + if (iconFile && iconFile.size > 0) { 47 + const buffer = await iconFile.arrayBuffer(); 48 + const uploadResult = await agent.com.atproto.repo.uploadBlob( 49 + new Uint8Array(buffer), 50 + { encoding: iconFile.type }, 51 + ); 52 + 53 + if (uploadResult.data.blob) { 54 + record.icon = uploadResult.data.blob; 55 + } 56 + } 57 + 58 + let result = await agent.com.atproto.repo.putRecord({ 59 + repo: credentialSession.did!, 60 + rkey: TID.nextStr(), 61 + record, 62 + collection: record.$type, 63 + validate: false, 64 + }); 65 + 66 + //optimistically write to our db! 67 + let { data: publication } = await supabaseServerClient 68 + .from("publications") 69 + .update({ 70 + identity_did: credentialSession.did!, 71 + name: record.name, 72 + record: record as Json, 73 + }) 74 + .eq("uri", uri) 75 + .select() 76 + .single(); 77 + 78 + return { success: true, publication }; 79 + }
-13
app/lish/page.tsx
··· 1 - import { LishHome } from "./LishHome"; 2 - import { createClient } from "@supabase/supabase-js"; 3 - import { Database } from "supabase/database.types"; 4 - import { AtpBaseClient, PubLeafletPagesLinearDocument } from "lexicons/api"; 5 - import { CredentialSession } from "@atproto/api"; 6 - import { createOauthClient } from "src/atproto-oauth"; 7 - import { getIdentityData } from "actions/getIdentityData"; 8 - import Link from "next/link"; 9 - import { IdResolver } from "@atproto/identity"; 10 - 11 - export default function Lish() { 12 - return <LishHome />; 13 - }
+1 -1
app/login/LoginForm.tsx
··· 158 158 ); 159 159 } 160 160 161 - function BlueskyLogin() { 161 + export function BlueskyLogin() { 162 162 const [signingWithHandle, setSigningWithHandle] = useState(false); 163 163 const [handle, setHandle] = useState(""); 164 164
+1 -1
app/new/route.ts
··· 5 5 export const fetchCache = "force-no-store"; 6 6 7 7 export async function GET() { 8 - await createNewLeaflet("doc", true); 8 + await createNewLeaflet({ pageType: "doc", redirectUser: true }); 9 9 }
+1 -1
app/route.ts
··· 9 9 export async function GET() { 10 10 let auth_token = (await cookies()).get("auth_token")?.value; 11 11 if (auth_token) redirect("/home"); 12 - else await createNewLeaflet("doc", true); 12 + else await createNewLeaflet({ pageType: "doc", redirectUser: true }); 13 13 }
+5 -6
app/templates/icon.tsx
··· 2 2 // we could make it different so it's clear it's not your personal colors? 3 3 4 4 import { ImageResponse } from "next/og"; 5 - import { Fact } from "src/replicache"; 6 - import { Attributes } from "src/replicache/attributes"; 5 + import type { Fact } from "src/replicache"; 6 + import type { Attribute } from "src/replicache/attributes"; 7 7 import { Database } from "../../supabase/database.types"; 8 8 import { createServerClient } from "@supabase/ssr"; 9 9 import { parseHSBToRGB } from "src/utils/parseHSB"; ··· 52 52 let { data } = await supabase.rpc("get_facts", { 53 53 root: rootEntity, 54 54 }); 55 - let initialFacts = 56 - (data as unknown as Fact<keyof typeof Attributes>[]) || []; 55 + let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 57 56 let themePageBG = initialFacts.find( 58 57 (f) => f.attribute === "theme/card-background", 59 58 ) as Fact<"theme/card-background"> | undefined; ··· 70 69 return new ImageResponse( 71 70 ( 72 71 // ImageResponse JSX element 73 - (<div style={{ display: "flex" }}> 72 + <div style={{ display: "flex" }}> 74 73 <svg 75 74 width="32" 76 75 height="32" ··· 94 93 fill={fillColor ? fillColor : "#272727"} 95 94 /> 96 95 </svg> 97 - </div>) 96 + </div> 98 97 ), 99 98 // ImageResponse options 100 99 {
+4 -17
appview/index.ts
··· 4 4 const idResolver = new IdResolver(); 5 5 import { Firehose, MemoryRunner } from "@atproto/sync"; 6 6 import { ids } from "lexicons/api/lexicons"; 7 - import { 8 - PubLeafletDocument, 9 - PubLeafletPost, 10 - PubLeafletPublication, 11 - } from "lexicons/api"; 7 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 12 8 import { AtUri } from "@atproto/syntax"; 13 9 import { writeFile, readFile } from "fs/promises"; 14 10 15 - const cursorFile = "/cursor/cursor"; 11 + const cursorFile = process.env.CURSOR_FILE || "/cursor/cursor"; 16 12 17 13 let supabase = createClient<Database>( 18 14 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 35 31 excludeIdentity: true, 36 32 runner, 37 33 idResolver, 38 - filterCollections: [ 39 - ids.PubLeafletDocument, 40 - ids.PubLeafletPublication, 41 - ids.PubLeafletPost, 42 - ], 34 + filterCollections: [ids.PubLeafletDocument, ids.PubLeafletPublication], 43 35 handleEvent: async (evt) => { 44 36 if ( 45 37 evt.event == "account" || ··· 84 76 uri: evt.uri.toString(), 85 77 identity_did: evt.did, 86 78 name: record.value.name, 79 + record: record.value as Json, 87 80 }); 88 81 } 89 82 if (evt.event === "delete") { ··· 91 84 .from("publications") 92 85 .delete() 93 86 .eq("uri", evt.uri.toString()); 94 - } 95 - } 96 - if (evt.collection === ids.PubLeafletPost) { 97 - if (evt.event === "create" || evt.event === "update") { 98 - let record = PubLeafletPost.validateRecord(evt.record); 99 - if (!record.success) return; 100 87 } 101 88 } 102 89 },
+14 -3
components/Blocks/BlockCommandBar.tsx
··· 5 5 import { useEntitySetContext } from "components/EntitySetProvider"; 6 6 import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider"; 7 7 import { UndoManager } from "src/undoManager"; 8 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 9 9 10 type Props = { 10 11 parent: string; ··· 29 30 30 31 let { rep, undoManager } = useReplicache(); 31 32 let entity_set = useEntitySetContext(); 33 + let { data: publicationData } = useLeafletPublicationData(); 34 + let pub = publicationData?.[0]; 32 35 33 - let commandResults = blockCommands.filter((command) => 34 - command.name.toLocaleLowerCase().includes(searchValue.toLocaleLowerCase()), 35 - ); 36 + let commandResults = blockCommands.filter((command) => { 37 + const matchesSearch = command.name 38 + .toLocaleLowerCase() 39 + .includes(searchValue.toLocaleLowerCase()); 40 + const isVisible = !pub || !command.hiddenInPublication; 41 + return matchesSearch && isVisible; 42 + }); 43 + 36 44 useEffect(() => { 37 45 if ( 38 46 !highlighted || ··· 186 194 </button> 187 195 ); 188 196 }; 197 + function usePublicationContext() { 198 + throw new Error("Function not implemented."); 199 + }
+11 -1
components/Blocks/BlockCommands.tsx
··· 1 - import { Fact, ReplicacheMutators } from "src/replicache"; 1 + import type { Fact, ReplicacheMutators } from "src/replicache"; 2 2 import { useUIState } from "src/useUIState"; 3 3 4 4 import { generateKeyBetween } from "fractional-indexing"; ··· 98 98 name: string; 99 99 icon: React.ReactNode; 100 100 type: string; 101 + hiddenInPublication?: boolean; 101 102 onSelect: ( 102 103 rep: Replicache<ReplicacheMutators>, 103 104 props: Props & { entity_set: string }, ··· 175 176 name: "Button", 176 177 icon: <BlockButtonSmall />, 177 178 type: "block", 179 + hiddenInPublication: true, 178 180 onSelect: async (rep, props, um) => { 179 181 props.entityID && clearCommandSearchText(props.entityID); 180 182 await createBlockWithType(rep, props, "button"); ··· 190 192 name: "Mailbox", 191 193 icon: <BlockMailboxSmall />, 192 194 type: "block", 195 + hiddenInPublication: true, 193 196 onSelect: async (rep, props, um) => { 194 197 props.entityID && clearCommandSearchText(props.entityID); 195 198 await createBlockWithType(rep, props, "mailbox"); ··· 205 208 name: "Poll", 206 209 icon: <BlockPollSmall />, 207 210 type: "block", 211 + hiddenInPublication: true, 208 212 onSelect: async (rep, props, um) => { 209 213 let entity = await createBlockWithType(rep, props, "poll"); 210 214 let pollOptionEntity = v7(); ··· 250 254 name: "Embed Website", 251 255 icon: <BlockEmbedSmall />, 252 256 type: "block", 257 + hiddenInPublication: true, 253 258 onSelect: async (rep, props) => { 254 259 createBlockWithType(rep, props, "embed"); 255 260 }, ··· 258 263 name: "Bluesky Post", 259 264 icon: <BlockBlueskySmall />, 260 265 type: "block", 266 + hiddenInPublication: true, 261 267 onSelect: async (rep, props) => { 262 268 createBlockWithType(rep, props, "bluesky-post"); 263 269 }, ··· 269 275 name: "RSVP", 270 276 icon: <BlockRSVPSmall />, 271 277 type: "event", 278 + hiddenInPublication: true, 272 279 onSelect: (rep, props) => { 273 280 props.entityID && clearCommandSearchText(props.entityID); 274 281 return createBlockWithType(rep, props, "rsvp"); ··· 278 285 name: "Date and Time", 279 286 icon: <BlockCalendarSmall />, 280 287 type: "event", 288 + hiddenInPublication: true, 281 289 onSelect: (rep, props) => { 282 290 props.entityID && clearCommandSearchText(props.entityID); 283 291 return createBlockWithType(rep, props, "datetime"); ··· 290 298 name: "New Page", 291 299 icon: <BlockDocPageSmall />, 292 300 type: "page", 301 + hiddenInPublication: true, 293 302 onSelect: async (rep, props, um) => { 294 303 props.entityID && clearCommandSearchText(props.entityID); 295 304 let entity = await createBlockWithType(rep, props, "card"); ··· 329 338 name: "New Canvas", 330 339 icon: <BlockCanvasPageSmall />, 331 340 type: "page", 341 + hiddenInPublication: true, 332 342 onSelect: async (rep, props, um) => { 333 343 props.entityID && clearCommandSearchText(props.entityID); 334 344 let entity = await createBlockWithType(rep, props, "card");
+49 -25
components/Blocks/PageLinkBlock.tsx
··· 11 11 import { useBlocks } from "src/hooks/queries/useBlocks"; 12 12 import { Canvas, CanvasBackground, CanvasContent } from "components/Canvas"; 13 13 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 14 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 14 15 15 16 export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { 16 17 let page = useEntity(props.entityID, "block/card"); ··· 138 139 export function PagePreview(props: { entityID: string }) { 139 140 let blocks = useBlocks(props.entityID); 140 141 let previewRef = useRef<HTMLDivElement | null>(null); 142 + let { rootEntity } = useReplicache(); 143 + 144 + let cardBorderHidden = useCardBorderHidden(props.entityID); 145 + let rootBackgroundImage = useEntity( 146 + rootEntity, 147 + "theme/card-background-image", 148 + ); 149 + let rootBackgroundRepeat = useEntity( 150 + rootEntity, 151 + "theme/card-background-image-repeat", 152 + ); 153 + let rootBackgroundOpacity = useEntity( 154 + rootEntity, 155 + "theme/card-background-image-opacity", 156 + ); 141 157 142 158 let cardBackgroundImage = useEntity( 143 159 props.entityID, 144 160 "theme/card-background-image", 145 161 ); 162 + 146 163 let cardBackgroundImageRepeat = useEntity( 147 164 props.entityID, 148 165 "theme/card-background-image-repeat", 149 166 ); 150 167 151 - let cardBackgroundImageOpacity = 152 - useEntity(props.entityID, "theme/card-background-image-opacity")?.data 153 - .value || 1; 168 + let cardBackgroundImageOpacity = useEntity( 169 + props.entityID, 170 + "theme/card-background-image-opacity", 171 + ); 172 + 173 + let backgroundImage = cardBackgroundImage || rootBackgroundImage; 174 + let backgroundImageRepeat = cardBackgroundImage 175 + ? cardBackgroundImageRepeat?.data?.value 176 + : rootBackgroundRepeat?.data.value; 177 + let backgroundImageOpacity = cardBackgroundImage 178 + ? cardBackgroundImageOpacity?.data.value 179 + : rootBackgroundOpacity?.data.value || 1; 154 180 155 181 let pageWidth = `var(--page-width-unitless)`; 156 182 return ( 157 183 <div 158 184 ref={previewRef} 159 - className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 bg-bg-page border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center`} 185 + 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"}`} 160 186 > 161 187 <div 162 188 className="absolute top-0 left-0 origin-top-left pointer-events-none " ··· 167 193 backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 168 194 }} 169 195 > 170 - <div 171 - className={`pageBackground 172 - absolute top-0 left-0 right-0 bottom-0 173 - pointer-events-none 174 - `} 175 - style={{ 176 - backgroundImage: cardBackgroundImage 177 - ? `url(${cardBackgroundImage.data.src}), url(${cardBackgroundImage.data.fallback})` 178 - : undefined, 179 - backgroundRepeat: cardBackgroundImageRepeat 180 - ? "repeat" 181 - : "no-repeat", 182 - backgroundPosition: "center", 183 - backgroundSize: !cardBackgroundImageRepeat 184 - ? "cover" 185 - : cardBackgroundImageRepeat?.data.value, 186 - opacity: cardBackgroundImage?.data.src 187 - ? cardBackgroundImageOpacity 188 - : 1, 189 - }} 190 - /> 196 + {!cardBorderHidden && ( 197 + <div 198 + className={`pageLinkBlockBackground 199 + absolute top-0 left-0 right-0 bottom-0 200 + pointer-events-none 201 + `} 202 + style={{ 203 + backgroundImage: backgroundImage 204 + ? `url(${backgroundImage.data.src}), url(${backgroundImage.data.fallback})` 205 + : undefined, 206 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 207 + backgroundPosition: "center", 208 + backgroundSize: !backgroundImageRepeat 209 + ? "cover" 210 + : backgroundImageRepeat, 211 + opacity: backgroundImage?.data.src ? backgroundImageOpacity : 1, 212 + }} 213 + /> 214 + )} 191 215 {blocks.slice(0, 20).map((b, index, arr) => { 192 216 return ( 193 217 <BlockPreview
+2 -2
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 79 79 } 80 80 }; 81 81 82 - type Delta = { 82 + export type Delta = { 83 83 insert: string; 84 84 attributes?: { 85 85 strong?: {}; ··· 133 133 .map((d) => { 134 134 return d.insert; 135 135 }) 136 - .join(" "); 136 + .join(""); 137 137 } 138 138 return ""; 139 139 }
+27 -21
components/Blocks/TextBlock/index.tsx
··· 1 - import { useRef, useEffect, useState } from "react"; 1 + import { useRef, useEffect, useState, useLayoutEffect } from "react"; 2 2 import { elementId } from "src/utils/elementId"; 3 3 import { baseKeymap } from "prosemirror-commands"; 4 4 import { keymap } from "prosemirror-keymap"; ··· 22 22 import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; 23 23 import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 24 24 import { useEditorStates } from "src/state/useEditorState"; 25 - import { isIOS, useLayoutEffect } from "@react-aria/utils"; 26 25 import { useEntitySetContext } from "components/EntitySetProvider"; 27 26 import { useHandlePaste } from "./useHandlePaste"; 28 27 import { highlightSelectionPlugin } from "./plugins"; ··· 35 34 import { AddTiny } from "components/Icons/AddTiny"; 36 35 import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 37 36 import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 37 + import { isIOS } from "src/utils/isDevice"; 38 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 38 39 39 40 const HeadingStyle = { 40 41 1: "text-xl font-bold", ··· 248 249 .nodeAt(_pos - 1) 249 250 ?.marks.find((f) => f.type === schema.marks.link) || 250 251 node 251 - .nodeAt(_pos - 2) 252 + .nodeAt(Math.min(_pos - 2, 0)) 252 253 ?.marks.find((f) => f.type === schema.marks.link); 253 254 if (mark) { 254 255 window.open(mark.attrs.href, "_blank"); ··· 471 472 const CommandOptions = (props: BlockProps & { className?: string }) => { 472 473 let rep = useReplicache(); 473 474 let entity_set = useEntitySetContext(); 475 + let { data: publicationData } = useLeafletPublicationData(); 476 + let pub = publicationData?.[0]; 477 + 474 478 return ( 475 479 <div 476 480 className={`absolute top-0 right-0 w-fit flex gap-[6px] items-center font-bold rounded-md text-sm text-border ${props.pageType === "canvas" && "mr-[6px]"}`} ··· 494 498 <BlockImageSmall className="hover:text-accent-contrast text-border" /> 495 499 </TooltipButton> 496 500 497 - <TooltipButton 498 - className={props.className} 499 - onMouseDown={async () => { 500 - let command = blockCommands.find((f) => f.name === "New Page"); 501 - if (!rep.rep) return; 502 - await command?.onSelect( 503 - rep.rep, 504 - { ...props, entity_set: entity_set.set }, 505 - rep.undoManager, 506 - ); 507 - }} 508 - side="bottom" 509 - tooltipContent={ 510 - <div className="flex gap-1 font-bold">Add a Subpage</div> 511 - } 512 - > 513 - <BlockDocPageSmall className="hover:text-accent-contrast text-border" /> 514 - </TooltipButton> 501 + {!pub && ( 502 + <TooltipButton 503 + className={props.className} 504 + onMouseDown={async () => { 505 + let command = blockCommands.find((f) => f.name === "New Page"); 506 + if (!rep.rep) return; 507 + await command?.onSelect( 508 + rep.rep, 509 + { ...props, entity_set: entity_set.set }, 510 + rep.undoManager, 511 + ); 512 + }} 513 + side="bottom" 514 + tooltipContent={ 515 + <div className="flex gap-1 font-bold">Add a Subpage</div> 516 + } 517 + > 518 + <BlockDocPageSmall className="hover:text-accent-contrast text-border" /> 519 + </TooltipButton> 520 + )} 515 521 516 522 <TooltipButton 517 523 className={props.className}
+1 -1
components/Blocks/TextBlock/inputRules.ts
··· 5 5 } from "prosemirror-inputrules"; 6 6 import { MutableRefObject } from "react"; 7 7 import { Replicache } from "replicache"; 8 - import { ReplicacheMutators } from "src/replicache"; 8 + import type { ReplicacheMutators } from "src/replicache"; 9 9 import { BlockProps } from "../Block"; 10 10 import { focusBlock } from "src/utils/focusBlock"; 11 11 import { schema } from "./schema";
+15 -15
components/Blocks/TextBlock/keymap.ts
··· 10 10 TextSelection, 11 11 Transaction, 12 12 } from "prosemirror-state"; 13 - import { MutableRefObject } from "react"; 13 + import { RefObject } from "react"; 14 14 import { Replicache } from "replicache"; 15 - import { ReplicacheMutators } from "src/replicache"; 15 + import type { ReplicacheMutators } from "src/replicache"; 16 16 import { elementId } from "src/utils/elementId"; 17 17 import { schema } from "./schema"; 18 18 import { useUIState } from "src/useUIState"; ··· 25 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 26 import { UndoManager } from "src/undoManager"; 27 27 28 - type PropsRef = MutableRefObject<BlockProps & { entity_set: { set: string } }>; 28 + type PropsRef = RefObject<BlockProps & { entity_set: { set: string } }>; 29 29 export const TextBlockKeymap = ( 30 30 propsRef: PropsRef, 31 - repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 31 + repRef: RefObject<Replicache<ReplicacheMutators> | null>, 32 32 um: UndoManager, 33 33 ) => 34 34 ({ ··· 148 148 const moveCursorDown = 149 149 ( 150 150 propsRef: PropsRef, 151 - repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 151 + repRef: RefObject<Replicache<ReplicacheMutators> | null>, 152 152 jumpToNextBlock: boolean = false, 153 153 ) => 154 154 ( ··· 177 177 const moveCursorUp = 178 178 ( 179 179 propsRef: PropsRef, 180 - repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 180 + repRef: RefObject<Replicache<ReplicacheMutators> | null>, 181 181 jumpToNextBlock: boolean = false, 182 182 ) => 183 183 ( ··· 206 206 const backspace = 207 207 ( 208 208 propsRef: PropsRef, 209 - repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 209 + repRef: RefObject<Replicache<ReplicacheMutators> | null>, 210 210 ) => 211 211 ( 212 212 state: EditorState, ··· 352 352 353 353 const shifttab = 354 354 ( 355 - propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 356 - repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 355 + propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 356 + repRef: RefObject<Replicache<ReplicacheMutators> | null>, 357 357 ) => 358 358 () => { 359 359 if (useUIState.getState().selectedBlocks.length > 1) return false; ··· 365 365 366 366 const enter = 367 367 ( 368 - propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 369 - repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 368 + propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 369 + repRef: RefObject<Replicache<ReplicacheMutators> | null>, 370 370 ) => 371 371 ( 372 372 state: EditorState, ··· 579 579 580 580 const CtrlEnter = 581 581 ( 582 - propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 583 - repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 582 + propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 583 + repRef: RefObject<Replicache<ReplicacheMutators> | null>, 584 584 ) => 585 585 ( 586 586 state: EditorState, ··· 595 595 596 596 const metaA = 597 597 ( 598 - propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 599 - repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 598 + propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 599 + repRef: RefObject<Replicache<ReplicacheMutators> | null>, 600 600 ) => 601 601 ( 602 602 state: EditorState,
+1 -1
components/Blocks/TextBlock/useHandlePaste.ts
··· 14 14 import { markdownToHtml } from "src/htmlMarkdownParsers"; 15 15 import { betterIsUrl, isUrl } from "src/utils/isURL"; 16 16 import { TextSelection } from "prosemirror-state"; 17 - import { FilterAttributes } from "src/replicache/attributes"; 17 + import type { FilterAttributes } from "src/replicache/attributes"; 18 18 import { addLinkBlock } from "src/utils/addLinkBlock"; 19 19 import { UndoManager } from "src/undoManager"; 20 20
+1 -1
components/Checkbox.tsx
··· 11 11 }) { 12 12 return ( 13 13 <label 14 - className={`flex w-full gap-2 items-start cursor-pointer ${props.className} ${props.checked ? "text-primary font-bold " : " text-tertiary font-normal"}`} 14 + className={`flex w-full gap-2 items-start cursor-pointer ${props.className} ${props.checked ? "text-primary font-bold " : " text-tertiary font-normal"} ${props.small && "text-sm"}`} 15 15 > 16 16 <input 17 17 type="checkbox"
+1 -1
components/HelpPopover.tsx
··· 1 1 "use client"; 2 - import { isMac } from "@react-aria/utils"; 3 2 import { ShortcutKey } from "./Layout"; 4 3 import { Media } from "./Media"; 5 4 import { Popover } from "./Popover"; ··· 8 7 import { useState } from "react"; 9 8 import { ActionButton } from "components/ActionBar/ActionButton"; 10 9 import { HelpSmall } from "./Icons/HelpSmall"; 10 + import { isMac } from "src/utils/isDevice"; 11 11 12 12 export const HelpPopover = (props: { noShortcuts?: boolean }) => { 13 13 let entity_set = useEntitySetContext();
+7 -2
components/HomeButton.tsx
··· 1 + "use client"; 1 2 import Link from "next/link"; 2 3 import { useEntitySetContext } from "./EntitySetProvider"; 3 4 import { ActionButton } from "components/ActionBar/ActionButton"; ··· 8 9 import { useSmoker } from "./Toast"; 9 10 import { AddToHomeSmall } from "./Icons/AddToHomeSmall"; 10 11 import { HomeSmall } from "./Icons/HomeSmall"; 12 + import { permission } from "process"; 11 13 12 14 export function HomeButton() { 13 15 let { permissions } = useEntitySetContext(); ··· 24 26 > 25 27 <ActionButton icon={<HomeSmall />} label="Go Home" /> 26 28 </Link> 27 - <AddToHomeButton /> 29 + {<AddToHomeButton />} 28 30 </> 29 31 ); 30 32 return null; ··· 53 55 ...identity.permission_token_on_homepage, 54 56 { 55 57 created_at: new Date().toISOString(), 56 - permission_tokens: permission_token, 58 + permission_tokens: { 59 + ...permission_token, 60 + leaflets_in_publications: [], 61 + }, 57 62 }, 58 63 ], 59 64 };
+18
components/Icons/EditTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const EditTiny = (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="M10.1056 1.68769L12.1633 2.08905L12.2336 2.10858C12.3024 2.13294 12.3657 2.17212 12.4181 2.22382L13.8303 3.61933C13.9072 3.69532 13.9578 3.79462 13.9738 3.90155L14.2668 5.8664C14.2902 6.02306 14.2381 6.18207 14.1262 6.29413L7.04803 13.3723C6.99391 13.4263 6.92837 13.4678 6.85662 13.4924L6.78338 13.5109L2.31756 14.3137C2.1587 14.3422 1.99469 14.2925 1.87908 14.1799C1.76354 14.0671 1.70901 13.9049 1.73358 13.7453L2.43768 9.17987L2.45623 9.10272C2.48044 9.02777 2.5221 8.95878 2.5783 8.90253L9.65643 1.8244L9.7033 1.78339C9.8165 1.6953 9.96291 1.6599 10.1056 1.68769ZM3.40155 9.49335L3.10858 11.3879C3.47498 11.4495 3.97079 11.6089 4.1867 11.8312C4.58807 12.2451 4.57767 12.6117 4.5783 12.8908L6.45233 12.5539L13.2404 5.76581L13.1154 4.93085L11.8312 6.21601C11.6361 6.41112 11.3195 6.41084 11.1242 6.21601C10.9289 6.02074 10.9289 5.70424 11.1242 5.50897L12.7111 3.92011L12.05 3.26679L6.89862 8.33808C6.7018 8.53176 6.38528 8.52903 6.19158 8.33222C5.99798 8.13539 6.00066 7.81885 6.19744 7.62519L11.0148 2.88397L10.175 2.71991L3.40155 9.49335ZM10.1213 6.45722C10.3143 6.32736 10.5789 6.34638 10.7512 6.51581C10.9235 6.68535 10.9472 6.94959 10.8205 7.14472L10.757 7.22284L6.67694 11.3693C6.48327 11.5661 6.16674 11.5688 5.9699 11.3752C5.77307 11.1815 5.77037 10.865 5.96405 10.6682L10.0441 6.52167L10.1213 6.45722ZM5.06854 8.71698C5.26259 8.58889 5.5266 8.61065 5.69744 8.78144C5.86828 8.95227 5.88998 9.21627 5.7619 9.41034L5.69744 9.48847L5.35662 9.82929C5.16136 10.0245 4.84485 10.0245 4.64959 9.82929C4.45439 9.63402 4.45435 9.3175 4.64959 9.12226L4.99041 8.78144L5.06854 8.71698Z" 14 + fill="currentColor" 15 + /> 16 + </svg> 17 + ); 18 + };
+18
components/Icons/GoBackSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const GoBackSmall = (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 + > 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" 14 + fill="currentColor" 15 + /> 16 + </svg> 17 + ); 18 + };
+18
components/Icons/GoToArrow.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const GoToArrow = (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="M8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0ZM8.75781 2.69629C8.46492 2.4034 7.98918 2.4034 7.69629 2.69629C7.4034 2.98918 7.4034 3.46492 7.69629 3.75781L11.1895 7.25H3C2.58579 7.25 2.25 7.58579 2.25 8C2.25 8.41421 2.58579 8.75 3 8.75H11.1895L7.69629 12.2422C7.4034 12.5351 7.4034 13.0108 7.69629 13.3037C7.98918 13.5966 8.46492 13.5966 8.75781 13.3037L13.5303 8.53027C13.8232 8.23738 13.8232 7.76262 13.5303 7.46973L8.75781 2.69629Z" 14 + fill="currentColor" 15 + /> 16 + </svg> 17 + ); 18 + };
+18
components/Icons/MoreOptionsVerticalTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const MoreOptionsVerticalTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="12" 7 + height="24" 8 + viewBox="0 0 12 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + > 12 + <path 13 + d="M6 15.5C6.82843 15.5 7.5 16.1716 7.5 17C7.5 17.8284 6.82843 18.5 6 18.5C5.17157 18.5 4.5 17.8284 4.5 17C4.5 16.1716 5.17157 15.5 6 15.5ZM6 10.5C6.82843 10.5 7.5 11.1716 7.5 12C7.5 12.8284 6.82843 13.5 6 13.5C5.17157 13.5 4.5 12.8284 4.5 12C4.5 11.1716 5.17157 10.5 6 10.5ZM6 5.5C6.82843 5.5 7.5 6.17157 7.5 7C7.5 7.82843 6.82843 8.5 6 8.5C5.17157 8.5 4.5 7.82843 4.5 7C4.5 6.17157 5.17157 5.5 6 5.5Z" 14 + fill="currentColor" 15 + /> 16 + </svg> 17 + ); 18 + };
+20
components/Icons/PublishSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const PublishSmall = (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 + > 12 + <path 13 + fillRule="evenodd" 14 + clipRule="evenodd" 15 + d="M9.22186 7.92684C10.1774 6.18312 11.5332 4.90336 12.9251 4.2286C13.1335 4.12754 13.3416 4.04046 13.5484 3.96745C14.6049 3.60869 15.7766 3.54735 16.7819 4.09825C17.8692 4.69405 18.5671 5.88122 18.7476 7.41916C18.9279 8.95543 18.5788 10.7869 17.6233 12.5306C16.6678 14.2743 15.312 15.5541 13.9201 16.2288C12.5267 16.9043 11.1506 16.955 10.0633 16.3592C9.19584 15.8839 8.57626 15.0321 8.26951 13.9262C8.25817 13.8746 8.24668 13.8234 8.23523 13.7724L8.23523 13.7724C8.18078 13.5299 8.12744 13.2924 8.09762 13.0383C7.91733 11.502 8.26635 9.67055 9.22186 7.92684ZM9.46946 4.78715C9.67119 4.78662 9.8633 4.78121 10.0481 4.7711C9.3182 5.48646 8.66218 6.34702 8.12565 7.32615C7.55376 8.36979 7.16847 9.45536 6.96726 10.519C6.87184 10.3382 6.77397 10.1659 6.67468 10.0061C6.66248 9.63279 6.756 9.17519 6.92538 8.67954C7.12252 8.10267 7.40257 7.53025 7.65185 7.07532C7.87489 6.6683 8.26315 6.06477 8.68993 5.5499C8.9033 5.29248 9.11698 5.06859 9.31569 4.90418C9.37126 4.8582 9.42255 4.81949 9.46946 4.78715ZM8.11028 4.69028C7.79498 4.62946 7.4876 4.54412 7.23739 4.46669C6.91656 4.36741 6.66099 4.27202 6.54912 4.22896C6.41134 4.17536 6.19445 4.14 6.05859 4.21094C5.71409 4.39084 5.01295 4.92363 4.69271 5.51519C4.53469 5.8071 4.40424 6.2273 4.30596 6.64793C4.29259 6.70518 4.27708 6.76449 4.26123 6.82511L4.26123 6.82512L4.26122 6.82514C4.18998 7.09762 4.11179 7.39666 4.18884 7.65503C4.24062 7.82867 4.31432 7.93693 4.39162 8.00286C4.59287 8.12133 4.78982 8.24738 4.98348 8.37782C5.22591 8.54111 5.52054 8.75196 5.79607 8.98466C5.84667 8.7703 5.90975 8.55912 5.97911 8.35617C6.20171 7.70478 6.51068 7.07692 6.77488 6.59477C7.02425 6.1397 7.44733 5.482 7.92003 4.91174C7.98204 4.83692 8.04556 4.76282 8.11028 4.69028ZM4.21574 3.89626L4.62051 4.02189C4.3203 4.30946 4.01949 4.65825 3.8133 5.03912C3.59059 5.45053 3.43618 5.9753 3.33219 6.42041C3.30438 6.53942 3.27957 6.65546 3.25762 6.7656L2.81215 6.40882C2.81215 6.40882 2.81126 6.40681 2.80986 6.40423C2.79662 6.37992 2.73103 6.25944 2.74152 5.96321C2.75269 5.6481 2.85108 5.26172 3.04578 4.90642C3.25394 4.52653 3.50079 4.23769 3.73458 4.06623C3.95711 3.90302 4.11635 3.8793 4.21574 3.89626ZM5.25013 10.1776C5.49632 10.4247 5.83445 10.991 6.17145 11.7406C5.73841 12.4265 5.41616 12.6857 5.21838 12.7691C5.07131 12.8312 4.93508 12.822 4.70214 12.656C4.11675 12.2388 3.60414 11.8264 3.21764 11.4066C2.8298 10.9853 2.60401 10.594 2.53069 10.2224L2.52687 10.2031C2.4802 9.9669 2.45604 9.84466 2.51608 9.58542C2.57686 9.32295 2.72752 8.9236 3.07623 8.2506C3.19924 8.54228 3.38803 8.81394 3.66359 9.02041C3.77639 9.10493 3.89934 9.17816 4.02211 9.25128L4.02211 9.25128C4.11121 9.30434 4.20021 9.35735 4.28517 9.41458C4.61144 9.63434 4.98505 9.91153 5.25013 10.1776ZM1.49231 5.91896C1.47179 6.49822 1.63299 7.06591 2.09331 7.43458C1.64229 8.27701 1.40278 8.85224 1.2983 9.30341C1.17766 9.82436 1.24402 10.1596 1.29968 10.4408L1.30433 10.4643C1.43907 11.1472 1.82601 11.7405 2.29799 12.2532C2.77132 12.7673 3.36564 13.2385 3.9767 13.6739C4.42074 13.9904 5.0195 14.2097 5.70419 13.9209C6.06177 13.77 6.39891 13.496 6.72728 13.1045C6.81994 13.3603 6.90026 13.6093 6.96835 13.8644C7.28444 15.3377 8.1138 16.7163 9.46258 17.4554C10.998 18.2968 12.8155 18.1535 14.4654 17.3536C16.1168 16.5531 17.6539 15.0761 18.7195 13.1313C19.7852 11.1865 20.203 9.09618 19.9891 7.27346C19.7753 5.4524 18.918 3.84341 17.3826 3.00204C16.1201 2.31022 14.6669 2.28413 13.2729 2.74137C13.2652 2.74368 13.2574 2.74615 13.2497 2.74878C11.4939 3.34572 10.626 3.60952 8.78711 3.52059C8.44675 3.50414 7.99961 3.39408 7.60693 3.27256C7.49582 3.23818 7.38646 3.19733 7.27712 3.15649C7.15008 3.10903 7.02308 3.06159 6.89344 3.02433C6.45975 2.89969 6.03009 2.91392 5.62971 3.0263C5.50956 2.98901 5.3892 2.94865 5.26851 2.90817C5.01835 2.82428 4.76678 2.73992 4.51267 2.68142C3.94356 2.55041 3.41069 2.75363 2.99533 3.05825C2.57846 3.36398 2.22138 3.8097 1.94957 4.30573C1.66428 4.82635 1.5106 5.40259 1.49231 5.91896ZM10.6051 8.68425C10.9866 7.98795 11.4394 7.38085 11.9278 6.8783C12.6769 7.53018 13.1717 8.17432 13.4238 8.75106C13.6893 9.35867 13.6744 9.85621 13.4617 10.2444C13.2546 10.6223 12.8385 10.9029 12.1709 11.0084C11.5426 11.1076 10.7313 11.0418 9.794 10.7741C9.95466 10.091 10.2229 9.3816 10.6051 8.68425ZM13.4264 5.71995C13.1758 5.85571 12.9254 6.0188 12.6791 6.20754C13.4537 6.89902 14.0241 7.62766 14.3401 8.35057C14.6935 9.15932 14.7389 9.99457 14.3386 10.7249C13.9392 11.4539 13.1982 11.8584 12.327 11.9961C11.5454 12.1196 10.6234 12.0373 9.63348 11.7675C9.60713 12.0758 9.60447 12.3739 9.62485 12.6574C9.70968 13.8381 10.1817 14.6978 10.9166 15.1005C11.6516 15.5033 12.6302 15.4385 13.671 14.8746C14.7064 14.3136 15.7384 13.2861 16.4923 11.9103C16.776 11.3925 16.9977 10.8667 17.159 10.3487C17.2411 10.0851 17.5214 9.93788 17.785 10.02C18.0487 10.1021 18.1959 10.3824 18.1138 10.646C17.9324 11.2285 17.6845 11.8156 17.3693 12.3909C16.5368 13.91 15.3756 15.0884 14.1473 15.7539C12.9245 16.4164 11.569 16.5983 10.4361 15.9775C9.30313 15.3567 8.72709 14.1163 8.62742 12.7291C8.52731 11.3358 8.89565 9.72284 9.72809 8.2037C10.5605 6.68456 11.7218 5.50611 12.95 4.84069C14.1729 4.17819 15.5283 3.99622 16.6613 4.61705C17.5803 5.12063 18.1356 6.03691 18.3631 7.10207C18.4208 7.37213 18.2486 7.6378 17.9785 7.69548C17.7085 7.75315 17.4428 7.58098 17.3851 7.31093C17.201 6.44889 16.7798 5.82228 16.1807 5.49401C15.4458 5.09129 14.4672 5.15607 13.4264 5.71995ZM20.2049 14.5155C19.8187 14.3656 19.3842 14.5572 19.2343 14.9434C19.0845 15.3295 19.2761 15.764 19.6622 15.9139L21.4114 16.5926C21.7976 16.7425 22.2321 16.5509 22.382 16.1648C22.5318 15.7786 22.3402 15.3441 21.9541 15.1942L20.2049 14.5155ZM17.9326 16.6232C18.2114 16.3169 18.6857 16.2945 18.9921 16.5733L22.8336 20.0686C23.1399 20.3474 23.1623 20.8218 22.8836 21.1281C22.6048 21.4345 22.1304 21.4569 21.8241 21.1781L17.9826 17.6827C17.6762 17.404 17.6539 16.9296 17.9326 16.6232ZM16.8269 17.9194C16.6484 17.5456 16.2007 17.3874 15.8269 17.5659C15.4531 17.7444 15.2949 18.1921 15.4734 18.5659L16.8786 21.5078C17.0572 21.8816 17.5049 22.0398 17.8787 21.8613C18.2524 21.6828 18.4107 21.235 18.2322 20.8613L16.8269 17.9194Z" 16 + fill="currentColor" 17 + /> 18 + </svg> 19 + ); 20 + };
+20
components/Icons/SettingsSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const SettingsSmall = (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 + > 12 + <path 13 + fillRule="evenodd" 14 + clipRule="evenodd" 15 + d="M16.6526 1.69545C16.782 1.93355 17.036 2.07109 17.3056 2.0979C17.5753 2.12472 17.8516 2.03986 18.0253 1.8318L18.1344 1.70123C18.4056 1.3765 18.8597 1.25131 19.2249 1.46508C19.4654 1.60589 19.6908 1.76851 19.8987 1.94995C20.217 2.22771 20.2437 2.69724 20.023 3.05747L19.9342 3.20249C19.7926 3.43367 19.8001 3.72115 19.9121 3.96805C20.024 4.21473 20.2358 4.41312 20.5026 4.45954L20.6702 4.48869C21.0868 4.56115 21.4218 4.89193 21.419 5.31478C21.4181 5.45189 21.4109 5.59018 21.397 5.72933C21.3832 5.86841 21.3631 6.00535 21.337 6.13989C21.2565 6.55501 20.8629 6.81336 20.4402 6.80237L20.2702 6.79795C19.9994 6.79091 19.7527 6.94374 19.5944 7.16359C19.436 7.38362 19.372 7.66399 19.4653 7.91854L19.5238 8.07823C19.6693 8.47488 19.5507 8.93 19.1839 9.13967C18.9444 9.2766 18.6914 9.39163 18.4279 9.48233C18.0278 9.62004 17.6072 9.40787 17.4052 9.03607L17.324 8.88661C17.1946 8.64838 16.9404 8.51076 16.6706 8.48394C16.401 8.45713 16.1248 8.54197 15.9511 8.74997L15.8419 8.88068C15.5707 9.20544 15.1165 9.33065 14.7514 9.11687C14.5109 8.97608 14.2855 8.8135 14.0776 8.6321C13.7593 8.35435 13.7326 7.88479 13.9533 7.52454L14.0421 7.37961C14.1837 7.14835 14.1762 6.86078 14.0642 6.6138C13.9522 6.36704 13.7404 6.16858 13.4734 6.12215L13.306 6.09302C12.8894 6.02056 12.5544 5.68975 12.5572 5.26689C12.5581 5.12986 12.5653 4.99166 12.5791 4.85259C12.593 4.71345 12.6131 4.57646 12.6392 4.44187C12.7198 4.02674 13.1134 3.76839 13.5361 3.77938L13.706 3.7838C13.9769 3.79084 14.2236 3.638 14.3819 3.41813C14.5404 3.19806 14.6044 2.91761 14.5111 2.663L14.4526 2.50345C14.3072 2.10683 14.4258 1.65174 14.7925 1.44211C15.0321 1.30519 15.2851 1.19016 15.5487 1.09948C15.9487 0.961805 16.3694 1.17399 16.5713 1.54578L16.6526 1.69545ZM18.4696 5.43825C18.3882 6.25644 17.659 6.85377 16.8408 6.77242C16.0226 6.69107 15.4253 5.96184 15.5066 5.14365C15.588 4.32545 16.3172 3.72812 17.1354 3.80948C17.9536 3.89083 18.5509 4.62005 18.4696 5.43825ZM9.4174 7.83297C9.66092 7.09385 10.4069 6.53441 11.2704 6.68433C11.7496 6.76752 12.2146 6.89274 12.6612 7.05606C13.4851 7.35736 13.8514 8.21547 13.6928 8.97735L13.6852 9.01349C13.6666 9.10322 13.7023 9.25226 13.8586 9.36967C13.897 9.39847 13.935 9.42767 13.9726 9.45727C14.1262 9.57793 14.2794 9.574 14.3613 9.5327L14.393 9.51673C15.0882 9.16609 16.0115 9.29846 16.516 10.0154C16.7908 10.4061 17.0322 10.8222 17.2359 11.2597C17.6063 12.0555 17.2587 12.9221 16.6074 13.3489L16.5775 13.3685C16.5007 13.4188 16.4203 13.5499 16.4477 13.7441C16.4545 13.792 16.4607 13.8399 16.4664 13.8879C16.4893 14.0802 16.5997 14.1852 16.6867 14.2139L16.7219 14.2255C17.461 14.469 18.0205 15.2149 17.8706 16.0785C17.7874 16.5577 17.6622 17.0226 17.4989 17.4692C17.1976 18.2932 16.3395 18.6596 15.5776 18.5009L15.5418 18.4934C15.452 18.4747 15.303 18.5105 15.1856 18.6668C15.1567 18.7052 15.1275 18.7433 15.0978 18.7811C14.9771 18.9347 14.981 19.0879 15.0223 19.1698L15.0382 19.2013C15.3889 19.8965 15.2565 20.8198 14.5396 21.3242C14.1489 21.5991 13.7328 21.8405 13.2954 22.0441C12.4995 22.4146 11.6329 22.067 11.2061 21.4157L11.1865 21.3857C11.1362 21.309 11.0051 21.2286 10.8109 21.256C10.7629 21.2627 10.7149 21.269 10.6668 21.2747C10.4746 21.2976 10.3696 21.4079 10.3409 21.4949L10.3293 21.5303C10.0858 22.2694 9.33983 22.8288 8.47627 22.6789C7.99709 22.5958 7.53219 22.4706 7.08559 22.3073C6.26165 22.006 5.89526 21.1478 6.05393 20.3859L6.06147 20.3497C6.08015 20.26 6.04437 20.111 5.88811 19.9936C5.84972 19.9647 5.81165 19.9355 5.77392 19.9058C5.62033 19.7852 5.46711 19.7891 5.38522 19.8304L5.35347 19.8464C4.65822 20.197 3.73493 20.0647 3.23046 19.3477C2.9556 18.957 2.71424 18.5409 2.51062 18.1035C2.14019 17.3077 2.48778 16.4411 3.13912 16.0143L3.16943 15.9944C3.24618 15.9441 3.32657 15.813 3.29918 15.6188C3.29244 15.5709 3.28621 15.523 3.28051 15.475C3.25764 15.2828 3.14734 15.1778 3.06028 15.1491L3.02463 15.1374C2.2855 14.8939 1.72606 14.1479 1.87599 13.2843C1.95919 12.8051 2.08441 12.3402 2.24773 11.8937C2.54903 11.0697 3.40714 10.7034 4.16902 10.862L4.2055 10.8696C4.29522 10.8883 4.44425 10.8526 4.56166 10.6963C4.59044 10.658 4.61963 10.62 4.64921 10.5824C4.76988 10.4288 4.76595 10.2755 4.72465 10.1937L4.70856 10.1618C4.35791 9.4665 4.4903 8.5432 5.20731 8.03873C5.59796 7.76388 6.0141 7.52252 6.45156 7.3189C7.24735 6.9485 8.11392 7.29609 8.54071 7.94741L8.56055 7.97769C8.61085 8.05444 8.74194 8.13483 8.93621 8.10744C8.98402 8.1007 9.03189 8.09448 9.07981 8.08878C9.27198 8.06591 9.37699 7.95562 9.40567 7.86856L9.4174 7.83297ZM10.9303 8.18199C10.8961 8.20427 10.861 8.24501 10.8421 8.30237L10.8303 8.33796C10.5885 9.07196 9.92333 9.49898 9.25708 9.57827C9.21986 9.5827 9.18271 9.58752 9.14563 9.59275C8.4831 9.68617 7.72988 9.4468 7.30592 8.79982L7.28608 8.76954C7.25296 8.71899 7.20843 8.6888 7.16976 8.6762C7.13446 8.66469 7.10781 8.66797 7.08453 8.67881C6.72827 8.84463 6.38909 9.04132 6.07044 9.26552C6.04918 9.28047 6.03289 9.30221 6.02517 9.33872C6.01674 9.37866 6.02066 9.43235 6.04787 9.48629L6.06396 9.51819C6.41248 10.2092 6.24284 10.9819 5.82872 11.509C5.8058 11.5382 5.78318 11.5676 5.76088 11.5973C5.3587 12.1326 4.65678 12.4958 3.89968 12.3381L3.8632 12.3305C3.80403 12.3182 3.7512 12.3283 3.71494 12.3468C3.68183 12.3636 3.66529 12.3847 3.65648 12.4088C3.52357 12.7723 3.42164 13.1507 3.35389 13.5409C3.34946 13.5664 3.35328 13.5932 3.37365 13.6245C3.39593 13.6586 3.43667 13.6938 3.49403 13.7127L3.52967 13.7245C4.26373 13.9663 4.69075 14.6316 4.77 15.2979C4.77444 15.3351 4.77927 15.3723 4.7845 15.4094C4.87789 16.0719 4.63852 16.8251 3.99156 17.249L3.96125 17.2689C3.91069 17.302 3.8805 17.3466 3.8679 17.3852C3.8564 17.4205 3.85968 17.4472 3.87051 17.4705C4.03634 17.8267 4.23303 18.1659 4.45723 18.4845C4.47218 18.5058 4.49392 18.5221 4.53043 18.5298C4.57037 18.5382 4.62406 18.5343 4.678 18.5071L4.70975 18.4911C5.40081 18.1425 6.17354 18.3122 6.70065 18.7264C6.7299 18.7493 6.75939 18.772 6.78914 18.7944C7.32443 19.1965 7.68763 19.8985 7.52996 20.6556L7.52242 20.6918C7.5101 20.7509 7.52022 20.8038 7.53864 20.84C7.55546 20.8731 7.57662 20.8897 7.60072 20.8985C7.96415 21.0314 8.34254 21.1333 8.7328 21.201C8.75828 21.2054 8.7851 21.2016 8.81635 21.1813C8.85053 21.159 8.88571 21.1182 8.90461 21.0609L8.91625 21.0255C9.15811 20.2915 9.82335 19.8645 10.4896 19.7852C10.527 19.7808 10.5642 19.7759 10.6014 19.7707C11.264 19.6773 12.0172 19.9166 12.4411 20.5636L12.4607 20.5935C12.4939 20.6441 12.5384 20.6743 12.5771 20.6869C12.6124 20.6984 12.639 20.6951 12.6623 20.6843C13.0186 20.5184 13.3577 20.3217 13.6764 20.0975C13.6976 20.0826 13.7139 20.0608 13.7216 20.0243C13.7301 19.9844 13.7261 19.9307 13.6989 19.8767L13.683 19.8452C13.3345 19.1542 13.5042 18.3815 13.9183 17.8544C13.9413 17.8251 13.9639 17.7956 13.9863 17.7658C14.3885 17.2305 15.0904 16.8673 15.8476 17.0249L15.8834 17.0324C15.9426 17.0447 15.9954 17.0346 16.0317 17.0162C16.0648 16.9994 16.0813 16.9782 16.0901 16.9541C16.223 16.5907 16.3249 16.2123 16.3927 15.822C16.3971 15.7965 16.3933 15.7697 16.3729 15.7384C16.3506 15.7043 16.3099 15.6691 16.2525 15.6502L16.2173 15.6386C15.4833 15.3967 15.0563 14.7315 14.977 14.0652C14.9725 14.028 14.9677 13.9908 14.9624 13.9536C14.869 13.2911 15.1083 12.5378 15.7553 12.1138L15.7852 12.0942C15.8358 12.0611 15.866 12.0166 15.8786 11.9779C15.8901 11.9426 15.8868 11.916 15.876 11.8927C15.7101 11.5364 15.5134 11.1973 15.2892 10.8786C15.2743 10.8573 15.2525 10.8411 15.216 10.8333C15.1761 10.8249 15.1224 10.8288 15.0684 10.856L15.0368 10.872C14.3458 11.2205 13.5731 11.0509 13.046 10.6368C13.0168 10.6139 12.9874 10.5913 12.9576 10.5689C12.4223 10.1668 12.0591 9.46482 12.2168 8.70767L12.2243 8.67153C12.2366 8.61237 12.2265 8.55954 12.2081 8.52328C12.1912 8.49016 12.1701 8.47363 12.146 8.46482C11.7825 8.33191 11.4041 8.22998 11.0139 8.16223C10.9884 8.1578 10.9616 8.16162 10.9303 8.18199ZM9.87316 12.311C8.56399 12.311 7.50269 13.3723 7.50269 14.6815C7.50269 15.9907 8.56398 17.052 9.87316 17.052C10.738 17.052 11.4954 16.5892 11.9102 15.8948C12.0871 15.5984 12.4709 15.5016 12.7672 15.6786C13.0636 15.8556 13.1603 16.2393 12.9833 16.5357C12.3524 17.5922 11.196 18.302 9.87316 18.302C7.87363 18.302 6.25269 16.681 6.25269 14.6815C6.25269 12.682 7.87363 11.061 9.87316 11.061C10.5467 11.061 11.1791 11.2456 11.7204 11.5672C12.0172 11.7436 12.1148 12.1271 11.9385 12.4238C11.7621 12.7205 11.3786 12.8182 11.0819 12.6418C10.7285 12.4318 10.3159 12.311 9.87316 12.311ZM12.607 13.51C12.9157 13.3555 13.2912 13.4804 13.4457 13.7891C13.5177 13.9328 13.5677 14.0716 13.5918 14.2369C13.6109 14.3678 13.6108 14.5049 13.6107 14.6239L13.6107 14.647C13.6107 14.9922 13.3309 15.272 12.9857 15.272C12.6405 15.272 12.3607 14.9922 12.3607 14.647C12.3607 14.5742 12.3606 14.5255 12.3594 14.4848C12.3583 14.4459 12.3563 14.4269 12.3548 14.417C12.3538 14.4097 12.3528 14.4061 12.3512 14.4013C12.349 14.3947 12.3433 14.3793 12.328 14.3487C12.1734 14.04 12.2984 13.6646 12.607 13.51Z" 16 + fill="currentColor" 17 + /> 18 + </svg> 19 + ); 20 + };
+1 -1
components/Input.tsx
··· 1 1 "use client"; 2 - import { isIOS } from "@react-aria/utils"; 3 2 import { useCallback, useEffect, useRef, type JSX } from "react"; 4 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 + import { isIOS } from "src/utils/isDevice"; 5 5 6 6 export function Input( 7 7 props: React.DetailedHTMLProps<
+33 -29
components/Layout.tsx
··· 31 31 }} 32 32 open={props.open} 33 33 > 34 - <DropdownMenu.Trigger asChild={props.asChild}> 35 - {props.trigger} 36 - </DropdownMenu.Trigger> 37 - <DropdownMenu.Portal> 38 - <NestedCardThemeProvider> 39 - <DropdownMenu.Content 40 - align={props.align ? props.align : "center"} 41 - sideOffset={4} 42 - collisionPadding={16} 43 - className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`} 44 - > 45 - {props.children} 46 - <DropdownMenu.Arrow 47 - asChild 48 - width={16} 49 - height={8} 50 - viewBox="0 0 16 8" 34 + <PopoverOpenContext value={open}> 35 + <DropdownMenu.Trigger asChild={props.asChild}> 36 + {props.trigger} 37 + </DropdownMenu.Trigger> 38 + <DropdownMenu.Portal> 39 + <NestedCardThemeProvider> 40 + <DropdownMenu.Content 41 + align={props.align ? props.align : "center"} 42 + sideOffset={4} 43 + collisionPadding={16} 44 + className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`} 51 45 > 52 - <PopoverArrow 53 - arrowFill={ 54 - props.background ? props.background : theme.colors["bg-page"] 55 - } 56 - arrowStroke={ 57 - props.border ? props.border : theme.colors["border"] 58 - } 59 - /> 60 - </DropdownMenu.Arrow> 61 - </DropdownMenu.Content> 62 - </NestedCardThemeProvider> 63 - </DropdownMenu.Portal> 46 + {props.children} 47 + <DropdownMenu.Arrow 48 + asChild 49 + width={16} 50 + height={8} 51 + viewBox="0 0 16 8" 52 + > 53 + <PopoverArrow 54 + arrowFill={ 55 + props.background 56 + ? props.background 57 + : theme.colors["bg-page"] 58 + } 59 + arrowStroke={ 60 + props.border ? props.border : theme.colors["border"] 61 + } 62 + /> 63 + </DropdownMenu.Arrow> 64 + </DropdownMenu.Content> 65 + </NestedCardThemeProvider> 66 + </DropdownMenu.Portal> 67 + </PopoverOpenContext> 64 68 </DropdownMenu.Root> 65 69 ); 66 70 };
+19 -5
components/PageSWRDataProvider.tsx
··· 5 5 import useSWR from "swr"; 6 6 import { callRPC } from "app/api/rpc/client"; 7 7 import { getPollData } from "actions/pollActions"; 8 + import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 8 9 9 10 export function PageSWRDataProvider(props: { 10 11 leaflet_id: string; 11 - domains: { domain: string }[]; 12 + leaflet_data: GetLeafletDataReturnType["result"]; 12 13 rsvp_data: Awaited<ReturnType<typeof getRSVPData>>; 13 14 poll_data: Awaited<ReturnType<typeof getPollData>>; 14 15 children: React.ReactNode; ··· 19 20 fallback: { 20 21 rsvp_data: props.rsvp_data, 21 22 poll_data: props.poll_data, 22 - [`${props.leaflet_id}-domains`]: props.domains, 23 + [`${props.leaflet_id}-leaflet_data`]: props.leaflet_data, 23 24 }, 24 25 }} 25 26 > ··· 44 45 ), 45 46 ); 46 47 } 47 - export function useLeafletDomains() { 48 + 49 + let useLeafletData = () => { 48 50 let { permission_token } = useReplicache(); 49 51 return useSWR( 50 - `${permission_token.id}-domains`, 52 + `${permission_token.id}-leaflet_data`, 51 53 async () => 52 - await callRPC("get_leaflet_domains", { id: permission_token.id }), 54 + (await callRPC("get_leaflet_data", { token_id: permission_token.id })) 55 + ?.result, 53 56 ); 57 + }; 58 + export function useLeafletPublicationData() { 59 + let { data, mutate } = useLeafletData(); 60 + return { 61 + data: data?.data?.leaflets_in_publications || [], 62 + mutate, 63 + }; 64 + } 65 + export function useLeafletDomains() { 66 + let { data, mutate } = useLeafletData(); 67 + return { data: data?.data?.custom_domain_routes, mutate: mutate }; 54 68 }
+156
components/Pages/PublicationMetadata.tsx
··· 1 + import Link from "next/link"; 2 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 + import { Input } from "components/Input"; 4 + import { useEffect, useState } from "react"; 5 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 + import { updateLeafletDraftMetadata } from "actions/publications/updateLeafletDraftMetadata"; 7 + import { useReplicache } from "src/replicache"; 8 + import { useIdentityData } from "components/IdentityProvider"; 9 + import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 10 + import { Separator } from "components/Layout"; 11 + import { AtUri } from "@atproto/syntax"; 12 + import { PubLeafletDocument } from "lexicons/api"; 13 + import { publications } from "drizzle/schema"; 14 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 15 + export const PublicationMetadata = ({ 16 + cardBorderHidden, 17 + }: { 18 + cardBorderHidden: boolean; 19 + }) => { 20 + let { permission_token } = useReplicache(); 21 + let { data: publicationData, mutate } = useLeafletPublicationData(); 22 + let pub = publicationData?.[0]; 23 + let [titleState, setTitleState] = useState(pub?.title || ""); 24 + let [descriptionState, setDescriptionState] = useState( 25 + pub?.description || "", 26 + ); 27 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 28 + let publishedAt = record?.publishedAt; 29 + 30 + useEffect(() => { 31 + setTitleState(pub?.title || ""); 32 + setDescriptionState(pub?.description || ""); 33 + }, [pub]); 34 + useDebouncedEffect( 35 + async () => { 36 + if (!pub || !pub.publications) return; 37 + if (pub.title === titleState && pub.description === descriptionState) 38 + return; 39 + await updateLeafletDraftMetadata( 40 + permission_token.id, 41 + pub.publications?.uri, 42 + titleState, 43 + descriptionState, 44 + ); 45 + mutate(); 46 + }, 47 + 1000, 48 + [pub, titleState, descriptionState, permission_token], 49 + ); 50 + if (!pub || !pub.publications) return null; 51 + 52 + return ( 53 + <div 54 + className={`flex flex-col px-3 sm:px-4 pb-4 sm:pb-5 ${cardBorderHidden ? "sm:pt-6 pt-0" : "sm:pt-3 pt-2"}`} 55 + > 56 + <div className="flex gap-2"> 57 + <Link 58 + href={`${getPublicationURL(pub.publications)}/dashboard`} 59 + className="text-accent-contrast font-bold hover:no-underline" 60 + > 61 + {pub.publications?.name} 62 + </Link> 63 + <div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md "> 64 + Editor 65 + </div> 66 + </div> 67 + <Input 68 + className="text-xl font-bold outline-none bg-transparent" 69 + value={titleState} 70 + onChange={(e) => { 71 + setTitleState(e.currentTarget.value); 72 + }} 73 + placeholder="Untitled" 74 + /> 75 + <AutosizeTextarea 76 + placeholder="add an optional description..." 77 + className="italic text-secondary outline-none bg-transparent" 78 + value={descriptionState} 79 + onChange={(e) => { 80 + setDescriptionState(e.currentTarget.value); 81 + }} 82 + /> 83 + {pub.doc ? ( 84 + <div className="flex flex-row items-center gap-2 pt-3"> 85 + <p className="text-sm text-tertiary"> 86 + Published{" "} 87 + {publishedAt && 88 + new Date(publishedAt).toLocaleString(undefined, { 89 + year: "numeric", 90 + month: "2-digit", 91 + day: "2-digit", 92 + hour: "2-digit", 93 + minute: "2-digit", 94 + hour12: true, 95 + })} 96 + </p> 97 + <Separator classname="h-4" /> 98 + <Link 99 + target="_blank" 100 + className="text-sm" 101 + href={`${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`} 102 + > 103 + View Post 104 + </Link> 105 + </div> 106 + ) : ( 107 + <p className="text-sm text-tertiary pt-2">Draft</p> 108 + )} 109 + </div> 110 + ); 111 + }; 112 + 113 + export const PublicationMetadataPreview = () => { 114 + let { data: publicationData } = useLeafletPublicationData(); 115 + let pub = publicationData?.[0]; 116 + let record = pub?.documents?.data as PubLeafletDocument.Record | null; 117 + let publishedAt = record?.publishedAt; 118 + 119 + if (!pub || !pub.publications) return null; 120 + 121 + return ( 122 + <div className={`flex flex-col px-3 sm:px-4 pb-4 sm:pb-5 sm:pt-3 pt-2`}> 123 + <div className="text-accent-contrast font-bold hover:no-underline"> 124 + {pub.publications?.name} 125 + </div> 126 + 127 + <div 128 + className={`text-xl font-bold outline-none bg-transparent ${!pub.title && "text-tertiary italic"}`} 129 + > 130 + {pub.title ? pub.title : "Untitled"} 131 + </div> 132 + <div className="italic text-secondary outline-none bg-transparent"> 133 + {pub.description} 134 + </div> 135 + 136 + {pub.doc ? ( 137 + <div className="flex flex-row items-center gap-2 pt-3"> 138 + <p className="text-sm text-tertiary"> 139 + Published{" "} 140 + {publishedAt && 141 + new Date(publishedAt).toLocaleString(undefined, { 142 + year: "numeric", 143 + month: "2-digit", 144 + day: "2-digit", 145 + hour: "2-digit", 146 + minute: "2-digit", 147 + hour12: true, 148 + })} 149 + </p> 150 + </div> 151 + ) : ( 152 + <p className="text-sm text-tertiary pt-2">Draft</p> 153 + )} 154 + </div> 155 + ); 156 + };
+140 -106
components/Pages/index.tsx
··· 1 1 "use client"; 2 2 3 - import { useState } from "react"; 3 + import React, { JSX, useState } from "react"; 4 4 import { useUIState } from "src/useUIState"; 5 5 import { useEntitySetContext } from "../EntitySetProvider"; 6 6 import { useSearchParams } from "next/navigation"; ··· 30 30 import { PageShareMenu } from "./PageShareMenu"; 31 31 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 32 32 import { useUndoState } from "src/undoManager"; 33 - import { usePublicationContext } from "components/Providers/PublicationContext"; 34 33 import { CloseTiny } from "components/Icons/CloseTiny"; 35 34 import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; 36 35 import { PaintSmall } from "components/Icons/PaintSmall"; 37 36 import { ShareSmall } from "components/Icons/ShareSmall"; 37 + import { PublicationMetadata } from "./PublicationMetadata"; 38 + import { useCardBorderHidden } from "./useCardBorderHidden"; 39 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 38 40 39 41 export function Pages(props: { rootPage: string }) { 40 42 let rootPage = useEntity(props.rootPage, "root/page")[0]; ··· 42 44 let params = useSearchParams(); 43 45 let queryRoot = params.get("page"); 44 46 let firstPage = queryRoot || rootPage?.data.value || props.rootPage; 45 - let entity_set = useEntitySetContext(); 46 - let publication = usePublicationContext(); 47 47 48 48 return ( 49 49 <> ··· 79 79 }; 80 80 81 81 function Page(props: { entityID: string; first?: boolean }) { 82 - let { rep } = useReplicache(); 82 + let { rep, rootEntity } = useReplicache(); 83 83 let isDraft = useReferenceToEntity("mailbox/draft", props.entityID); 84 84 85 85 let isFocused = useUIState((s) => { ··· 91 91 return focusedPageID === props.entityID; 92 92 }); 93 93 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 94 - 94 + let cardBorderHidden = useCardBorderHidden(props.entityID); 95 95 return ( 96 96 <> 97 97 {!props.first && ( ··· 114 114 id={elementId.page(props.entityID).container} 115 115 style={{ 116 116 width: pageType === "doc" ? "var(--page-width-units)" : undefined, 117 - backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 117 + backgroundColor: cardBorderHidden 118 + ? "" 119 + : "rgba(var(--bg-page), var(--bg-page-alpha))", 118 120 }} 119 121 className={` 120 122 ${pageType === "canvas" ? "!lg:max-w-[1152px]" : "max-w-[var(--page-width-units)]"} ··· 122 124 grow flex flex-col 123 125 overscroll-y-none 124 126 overflow-y-auto 125 - rounded-lg border 127 + ${cardBorderHidden ? "border-0 !shadow-none sm:-mt-6 sm:-mb-12 -mt-2 -mb-1 pt-3 " : "border rounded-lg"} 126 128 ${isFocused ? "shadow-md border-border" : "border-border-light"} 127 129 `} 128 130 > ··· 161 163 }; 162 164 163 165 const DocContent = (props: { entityID: string }) => { 166 + let { rootEntity } = useReplicache(); 164 167 let isFocused = useUIState((s) => { 165 168 let focusedElement = s.focusedEntity; 166 169 let focusedPageID = ··· 169 172 : focusedElement?.parent; 170 173 return focusedPageID === props.entityID; 171 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 + 172 190 let cardBackgroundImage = useEntity( 173 191 props.entityID, 174 192 "theme/card-background-image", 175 193 ); 194 + 176 195 let cardBackgroundImageRepeat = useEntity( 177 196 props.entityID, 178 197 "theme/card-background-image-repeat", 179 198 ); 180 - let cardBackgroundImageOpacity = 181 - useEntity(props.entityID, "theme/card-background-image-opacity")?.data 182 - .value || 1; 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 + 183 213 return ( 184 214 <> 185 - <div 186 - className={`pageBackground 215 + {!cardBorderHidden ? ( 216 + <div 217 + className={`pageBackground 187 218 absolute top-0 left-0 right-0 bottom-0 188 219 pointer-events-none 189 220 rounded-lg border 190 221 ${isFocused ? " border-border" : "border-border-light"} 191 222 `} 192 - style={{ 193 - backgroundImage: cardBackgroundImage 194 - ? `url(${cardBackgroundImage.data.src}), url(${cardBackgroundImage.data.fallback})` 195 - : undefined, 196 - backgroundRepeat: cardBackgroundImageRepeat ? "repeat" : "no-repeat", 197 - backgroundPosition: "center", 198 - backgroundSize: !cardBackgroundImageRepeat 199 - ? "cover" 200 - : cardBackgroundImageRepeat?.data.value, 201 - opacity: cardBackgroundImage?.data.src 202 - ? cardBackgroundImageOpacity 203 - : 1, 204 - }} 205 - /> 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} /> 206 237 <Blocks entityID={props.entityID} /> 207 238 {/* we handle page bg in this sepate div so that 208 239 we can apply an opacity the background image ··· 211 242 ); 212 243 }; 213 244 214 - let greyButtonStyle = 215 - "pt-[2px] h-5 w-5 p-0.5 mx-auto bg-border text-bg-page sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:bg-accent-1 hover:text-accent-2"; 216 - let whiteButtonStyle = ` 217 - pageOptionsTrigger 218 - shrink-0 219 - bg-bg-page text-border 220 - outline-none border sm:border-l-0 border-t-1 border-border sm:rounded-r-md sm:rounded-l-none rounded-b-md 221 - hover:shadow-[0_1px_0_theme(colors.border)_inset,_0_-1px_0_theme(colors.border)_inset,_-1px_0_0_theme(colors.border)_inset] 222 - flex items-center justify-center`; 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 + 223 280 const PageOptions = (props: { 224 281 entityID: string; 225 282 first: boolean | undefined; 226 283 }) => { 284 + let { rootEntity } = useReplicache(); 285 + let cardBorderHidden = useCardBorderHidden(props.entityID); 286 + 227 287 return ( 228 - <div className=" z-10 w-fit absolute sm:top-3 sm:-right-[19px] top-0 right-3 flex sm:flex-col flex-row-reverse gap-1 items-start"> 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 + > 229 291 {!props.first && ( 230 - <button 231 - className={greyButtonStyle} 292 + <PageOptionButton 293 + cardBorderHidden={cardBorderHidden} 294 + secondary 232 295 onClick={() => { 233 296 useUIState.getState().closePage(props.entityID); 234 297 }} 235 298 > 236 299 <CloseTiny /> 237 - </button> 300 + </PageOptionButton> 238 301 )} 239 302 <OptionsMenu 240 303 entityID={props.entityID} 241 304 first={!!props.first} 242 - buttonStyle={whiteButtonStyle} 305 + cardBorderHidden={cardBorderHidden} 243 306 /> 244 - <UndoButtons /> 307 + <UndoButtons cardBorderHidden={cardBorderHidden} /> 245 308 </div> 246 309 ); 247 310 }; 248 311 249 - const UndoButtons = () => { 312 + const UndoButtons = (props: { cardBorderHidden: boolean | undefined }) => { 250 313 let undoState = useUndoState(); 251 314 let { undoManager } = useReplicache(); 252 315 return ( 253 316 <Media mobile> 254 - <div className="gap-1 flex sm:flex-col"> 255 - {undoState.canUndo && ( 256 - <button 257 - className={`${whiteButtonStyle} h-5 w-5 p-0.5`} 317 + {undoState.canUndo && ( 318 + <div className="gap-1 flex sm:flex-col"> 319 + <PageOptionButton 320 + secondary 321 + cardBorderHidden={props.cardBorderHidden} 258 322 onClick={() => undoManager.undo()} 259 323 > 260 324 <UndoTiny /> 261 - </button> 262 - )} 263 - {undoState.canRedo ? ( 264 - <button 265 - className={`${whiteButtonStyle} h-5 w-5 p-0.5`} 325 + </PageOptionButton> 326 + 327 + <PageOptionButton 328 + secondary 329 + cardBorderHidden={props.cardBorderHidden} 266 330 onClick={() => undoManager.undo()} 331 + disabled={!undoState.canRedo} 267 332 > 268 333 <RedoTiny /> 269 - </button> 270 - ) : ( 271 - <div className="h-5 w-5 p-0.5" /> 272 - )} 273 - </div> 334 + </PageOptionButton> 335 + </div> 336 + )} 274 337 </Media> 275 338 ); 276 339 }; ··· 278 341 const OptionsMenu = (props: { 279 342 entityID: string; 280 343 first: boolean; 281 - buttonStyle: string; 344 + cardBorderHidden: boolean | undefined; 282 345 }) => { 283 346 let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 284 347 let { permissions } = useEntitySetContext(); 285 348 if (!permissions.write) return null; 349 + 350 + let { data: publicationData, mutate } = useLeafletPublicationData(); 351 + let pub = publicationData?.[0]; 352 + if (pub && props.first) return; 286 353 return ( 287 354 <Menu 288 355 align="end" 356 + asChild 289 357 onOpenChange={(open) => { 290 358 if (!open) setState("normal"); 291 359 }} 292 360 trigger={ 293 - <div 294 - className={`pageOptionsTrigger 295 - ${props.buttonStyle} 296 - sm:h-8 sm:w-5 h-5 w-8 297 - `} 361 + <PageOptionButton 362 + cardBorderHidden={props.cardBorderHidden} 363 + className="!w-8 !h-5 sm:!w-5 sm:!h-8" 298 364 > 299 365 <MoreOptionsTiny className="sm:rotate-90" /> 300 - </div> 366 + </PageOptionButton> 301 367 } 302 368 > 303 369 {state === "normal" ? ( ··· 312 378 <ShareSmall /> Share Page 313 379 </MenuItem> 314 380 )} 315 - <MenuItem 316 - onSelect={(e) => { 317 - e.preventDefault(); 318 - setState("theme"); 319 - }} 320 - > 321 - <PaintSmall /> Theme Page 322 - </MenuItem> 381 + {!pub && ( 382 + <MenuItem 383 + onSelect={(e) => { 384 + e.preventDefault(); 385 + setState("theme"); 386 + }} 387 + > 388 + <PaintSmall /> Theme Page 389 + </MenuItem> 390 + )} 323 391 </> 324 392 ) : state === "theme" ? ( 325 393 <PageThemeSetter entityID={props.entityID} /> ··· 329 397 </Menu> 330 398 ); 331 399 }; 332 - 333 - const PageMenuItem = (props: { 334 - children: React.ReactNode; 335 - onClick: () => void; 336 - }) => { 337 - return ( 338 - <button 339 - className="pageOptionsMenuItem z-10 text-left text-secondary py-1 px-2 flex gap-2 hover:bg-accent-1 hover:text-accent-2" 340 - onClick={() => { 341 - props.onClick(); 342 - }} 343 - > 344 - {props.children} 345 - </button> 346 - ); 347 - }; 348 - 349 - const DeletePageToast = { 350 - content: ( 351 - <div className="flex gap-2"> 352 - You deleted a page.{" "} 353 - <button 354 - className="underline font-bold sm:font-normal sm:hover:font-bold italic" 355 - onClick={() => { 356 - // TODO: WIRE UP UNDO DELETE 357 - }} 358 - > 359 - Undo? 360 - </button> 361 - </div> 362 - ), 363 - type: "info", 364 - duration: 5000, 365 - } as const; 366 400 367 401 export async function focusPage( 368 402 pageID: string,
+17
components/Pages/useCardBorderHidden.ts
··· 1 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 2 + import { useEntity, useReplicache } from "src/replicache"; 3 + 4 + export function useCardBorderHidden(entityID: string) { 5 + let { rootEntity } = useReplicache(); 6 + let { data } = useLeafletPublicationData(); 7 + let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 8 + 9 + let cardBorderHidden = 10 + useEntity(entityID, "theme/card-border-hidden") || rootCardBorderHidden; 11 + console.log(cardBorderHidden, rootCardBorderHidden); 12 + if (!cardBorderHidden && !rootCardBorderHidden) { 13 + if (data?.[0]) return true; 14 + return false; 15 + } 16 + return (cardBorderHidden || rootCardBorderHidden)?.data.value; 17 + }
+6 -3
components/Popover.tsx
··· 16 16 open?: boolean; 17 17 onOpenChange?: (open: boolean) => void; 18 18 asChild?: boolean; 19 + arrowFill?: string; 19 20 }) => { 20 21 let [open, setOpen] = useState(props.open || false); 21 22 return ( ··· 55 56 > 56 57 <PopoverArrow 57 58 arrowFill={ 58 - props.background 59 - ? props.background 60 - : theme.colors["bg-page"] 59 + props.arrowFill 60 + ? props.arrowFill 61 + : props.background 62 + ? props.background 63 + : theme.colors["bg-page"] 61 64 } 62 65 arrowStroke={ 63 66 props.border ? props.border : theme.colors["border"]
-32
components/Providers/PublicationContext.tsx
··· 1 - "use client"; 2 - import { createContext, useContext, ReactNode } from "react"; 3 - 4 - interface PublicationContextType { 5 - publication: { uri: string; name: string } | null; 6 - } 7 - 8 - const PublicationContext = createContext<PublicationContextType | undefined>( 9 - undefined, 10 - ); 11 - 12 - export function PublicationContextProvider({ 13 - children, 14 - publication, 15 - }: { 16 - children: ReactNode; 17 - publication: PublicationContextType["publication"]; 18 - }) { 19 - return ( 20 - <PublicationContext.Provider value={{ publication }}> 21 - {children} 22 - </PublicationContext.Provider> 23 - ); 24 - } 25 - 26 - export function usePublicationContext() { 27 - const context = useContext(PublicationContext); 28 - if (context === undefined) { 29 - throw new Error("usePublication must be used within a PublicationProvider"); 30 - } 31 - return context; 32 - }
-153
components/ShareOptions/PublicationOptions.tsx
··· 1 - import { publishToPublication } from "actions/publishToPublication"; 2 - import { ButtonPrimary } from "components/Buttons"; 3 - import { useIdentityData } from "components/IdentityProvider"; 4 - import { InputWithLabel } from "components/Input"; 5 - import { useState } from "react"; 6 - import { Popover } from "components/Popover"; 7 - import { useBlocks } from "src/hooks/queries/useBlocks"; 8 - import { useEntity, useReplicache } from "src/replicache"; 9 - import { usePublicationContext } from "components/Providers/PublicationContext"; 10 - import { useRouter } from "next/navigation"; 11 - import Link from "next/link"; 12 - 13 - export const PublishToPublication = () => { 14 - type PublishState = 15 - | { state: "default" } 16 - | { state: "test" } 17 - | { state: "testSuccess" } 18 - | { state: "success"; link: string }; 19 - 20 - let [state, setState] = useState<PublishState>({ state: "default" }); 21 - let publication = usePublicationContext(); 22 - let rep = useReplicache(); 23 - let rootPage = useEntity(rep.rootEntity, "root/page")[0]; 24 - let blocks = useBlocks(rootPage?.data.value); 25 - let router = useRouter(); 26 - 27 - let [titleValue, setTitleValue] = useState(""); 28 - let [descriptionValue, setDescriptionValue] = useState(""); 29 - let [testValue, setTestValue] = useState(""); 30 - if (!publication.publication) return null; 31 - 32 - return ( 33 - <Popover 34 - onOpenChange={() => setState({ state: "default" })} 35 - asChild 36 - trigger={<ButtonPrimary className="">Publish</ButtonPrimary>} 37 - > 38 - <div className="publishMenu w-72 flex flex-col gap-3"> 39 - {state.state === "default" ? ( 40 - <> 41 - <div className="w-full flex flex-col"> 42 - <h3 className="place-self-start"> 43 - Publish to {publication.publication.name} 44 - </h3> 45 - {/* <small className="text-tertiary"> 46 - Publish this post to a PUBLICATION HERE and send it as an email 47 - to your XX subscribers. 48 - </small> */} 49 - </div> 50 - <form 51 - className="flex flex-col gap-3 w-full" 52 - onSubmit={async (e) => { 53 - e.preventDefault(); 54 - if (!publication.publication) return; 55 - let data = await publishToPublication( 56 - rep.rootEntity, 57 - blocks, 58 - publication.publication.uri, 59 - ); 60 - if (data) 61 - setState({ 62 - state: "success", 63 - link: `/lish/${data.handle?.alsoKnownAs?.[0].slice(5)}/${publication.publication.name}/${data.rkey}`, 64 - }); 65 - }} 66 - > 67 - <InputWithLabel 68 - textarea 69 - rows={3} 70 - label="Description" 71 - value={descriptionValue} 72 - onChange={(e) => { 73 - setDescriptionValue(e.currentTarget.value); 74 - }} 75 - /> 76 - <div className="flex gap-3 justify-end w-full"> 77 - {/* <button 78 - onClick={() => { 79 - setState({ state: "test" }); 80 - }} 81 - className="font-bold text-accent-contrast" 82 - > 83 - Send Test 84 - </button> */} 85 - <ButtonPrimary>Publish </ButtonPrimary> 86 - </div> 87 - </form> 88 - </> 89 - ) : state.state === "test" ? ( 90 - <> 91 - <h3>Send out a test</h3> 92 - <form 93 - className="flex flex-col gap-3" 94 - onSubmit={() => { 95 - setState({ state: "testSuccess" }); 96 - }} 97 - > 98 - <InputWithLabel 99 - label="Send to" 100 - placeholder="email here..." 101 - value={testValue} 102 - onChange={(e) => { 103 - setTestValue(e.currentTarget.value); 104 - }} 105 - /> 106 - 107 - <ButtonPrimary type="submit" className="place-self-end"> 108 - Send Test 109 - </ButtonPrimary> 110 - </form> 111 - </> 112 - ) : state.state === "testSuccess" ? ( 113 - <> 114 - <div 115 - className="w-full p-4 rounded-md flex flex-col text-center" 116 - style={{ 117 - backgroundColor: 118 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 119 - }} 120 - > 121 - <div>Sent! Check you email!</div> 122 - <div className="italic font-bold">{testValue}</div> 123 - <button 124 - onClick={() => { 125 - setState({ state: "default" }); 126 - }} 127 - className="w-fit mx-auto font-bold text-accent-contrast mt-3" 128 - > 129 - Back 130 - </button> 131 - </div> 132 - </> 133 - ) : state.state === "success" ? ( 134 - <div 135 - className="w-full p-4 rounded-md flex flex-col text-center" 136 - style={{ 137 - backgroundColor: 138 - "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 139 - }} 140 - > 141 - <div className="font-bold">Woot! It's Published!</div> 142 - <Link 143 - href={state.link} 144 - className="w-fit mx-auto font-bold text-accent-contrast mt-1" 145 - > 146 - View Post 147 - </Link> 148 - </div> 149 - ) : null} 150 - </div> 151 - </Popover> 152 - ); 153 - };
+28 -6
components/ShareOptions/index.tsx
··· 10 10 import LoginForm from "app/login/LoginForm"; 11 11 import { CustomDomainMenu } from "./DomainOptions"; 12 12 import { useIdentityData } from "components/IdentityProvider"; 13 - import { useLeafletDomains } from "components/PageSWRDataProvider"; 13 + import { 14 + useLeafletDomains, 15 + useLeafletPublicationData, 16 + } from "components/PageSWRDataProvider"; 14 17 import { ShareSmall } from "components/Icons/ShareSmall"; 15 18 16 19 export type ShareMenuStates = "default" | "login" | "domain"; ··· 38 41 }; 39 42 40 43 export function ShareOptions() { 41 - let { permission_token } = useReplicache(); 42 44 let [menuState, setMenuState] = useState<ShareMenuStates>("default"); 45 + let { data: publicationData } = useLeafletPublicationData(); 46 + let pub = publicationData?.[0]; 43 47 44 48 return ( 45 49 <Menu ··· 48 52 onOpenChange={() => { 49 53 setMenuState("default"); 50 54 }} 51 - trigger={<ActionButton icon=<ShareSmall /> primary label="Share" />} 55 + trigger={ 56 + <ActionButton 57 + icon=<ShareSmall /> 58 + primary={!!!pub} 59 + secondary={!!pub} 60 + label={`Share ${pub ? "Draft" : ""}`} 61 + /> 62 + } 52 63 > 53 64 {menuState === "login" ? ( 54 65 <div className="px-3 py-1"> ··· 57 68 ) : menuState === "domain" ? ( 58 69 <CustomDomainMenu setShareMenuState={setMenuState} /> 59 70 ) : ( 60 - <ShareMenu setMenuState={setMenuState} domainConnected={false} /> 71 + <ShareMenu 72 + setMenuState={setMenuState} 73 + domainConnected={false} 74 + isPub={!!pub} 75 + /> 61 76 )} 62 77 </Menu> 63 78 ); ··· 66 81 const ShareMenu = (props: { 67 82 setMenuState: (state: ShareMenuStates) => void; 68 83 domainConnected: boolean; 84 + isPub?: boolean; 69 85 }) => { 70 86 let { permission_token } = useReplicache(); 87 + 71 88 let publishLink = usePublishLink(); 72 89 let [collabLink, setCollabLink] = useState<null | string>(null); 73 90 useEffect(() => { ··· 79 96 let isTemplate = useTemplateState( 80 97 (s) => !!s.templates.find((t) => t.id === permission_token.id), 81 98 ); 99 + 82 100 return ( 83 101 <> 84 102 {isTemplate && ( ··· 124 142 } 125 143 link={publishLink || ""} 126 144 /> 127 - <hr className="border-border mt-1" /> 128 - <DomainMenuItem setMenuState={props.setMenuState} /> 145 + {!props.isPub && ( 146 + <> 147 + <hr className="border-border mt-1" /> 148 + <DomainMenuItem setMenuState={props.setMenuState} /> 149 + </> 150 + )} 129 151 </> 130 152 ); 131 153 };
+54
components/ThemeManager/AccentThemePickers.tsx
··· 1 + "use client"; 2 + 3 + import { useMemo } from "react"; 4 + import { pickers, setColorAttribute } from "./ThemeSetter"; 5 + import { ColorPicker } from "./ColorPicker"; 6 + import { useReplicache } from "src/replicache"; 7 + import { useColorAttribute } from "./useColorAttribute"; 8 + 9 + export const AccentThemePickers = (props: { 10 + entityID: string; 11 + openPicker: pickers; 12 + setOpenPicker: (thisPicker: pickers) => void; 13 + }) => { 14 + let { rep } = useReplicache(); 15 + let set = useMemo(() => { 16 + return setColorAttribute(rep, props.entityID); 17 + }, [rep, props.entityID]); 18 + 19 + let accent1Value = useColorAttribute( 20 + props.entityID, 21 + "theme/accent-background", 22 + ); 23 + let accent2Value = useColorAttribute(props.entityID, "theme/accent-text"); 24 + 25 + return ( 26 + <> 27 + <div 28 + className="themeLeafletControls text-accent-2 flex flex-col gap-2 h-full bg-bg-leaflet p-2 rounded-md border border-accent-2 shadow-[0_0_0_1px_rgb(var(--accent-1))]" 29 + style={{ 30 + backgroundColor: "rgba(var(--accent-contrast), 0.5)", 31 + }} 32 + > 33 + <ColorPicker 34 + label="Accent" 35 + value={accent1Value} 36 + setValue={set("theme/accent-background")} 37 + thisPicker={"accent-1"} 38 + openPicker={props.openPicker} 39 + setOpenPicker={props.setOpenPicker} 40 + closePicker={() => props.setOpenPicker("null")} 41 + /> 42 + <ColorPicker 43 + label="Text on Accent" 44 + value={accent2Value} 45 + setValue={set("theme/accent-text")} 46 + thisPicker={"accent-2"} 47 + openPicker={props.openPicker} 48 + setOpenPicker={props.setOpenPicker} 49 + closePicker={() => props.setOpenPicker("null")} 50 + /> 51 + </div> 52 + </> 53 + ); 54 + };
+152
components/ThemeManager/ColorPicker.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + ColorPicker as SpectrumColorPicker, 5 + parseColor, 6 + Color, 7 + ColorArea, 8 + ColorThumb, 9 + ColorSlider, 10 + Input, 11 + ColorField, 12 + SliderTrack, 13 + ColorSwatch, 14 + } from "react-aria-components"; 15 + import { pickers } from "./ThemeSetter"; 16 + import { Separator } from "components/Layout"; 17 + import { onMouseDown } from "src/utils/iosInputMouseDown"; 18 + 19 + export let thumbStyle = 20 + "w-4 h-4 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C,_inset_0_0_0_1px_#8C8C8C]"; 21 + 22 + export const ColorPicker = (props: { 23 + label?: string; 24 + value: Color | undefined; 25 + alpha?: boolean; 26 + image?: boolean; 27 + setValue: (c: Color) => void; 28 + openPicker: pickers; 29 + thisPicker: pickers; 30 + setOpenPicker: (thisPicker: pickers) => void; 31 + closePicker: () => void; 32 + disabled?: boolean; 33 + children?: React.ReactNode; 34 + }) => { 35 + return ( 36 + <SpectrumColorPicker value={props.value} onChange={props.setValue}> 37 + <div className="flex flex-col w-full gap-2"> 38 + <div className="colorPickerLabel flex gap-2 items-center "> 39 + <button 40 + disabled={props.disabled} 41 + className="flex gap-2 items-center disabled:text-tertiary" 42 + onClick={() => { 43 + if (props.openPicker === props.thisPicker) { 44 + props.setOpenPicker("null"); 45 + } else { 46 + props.setOpenPicker(props.thisPicker); 47 + } 48 + }} 49 + > 50 + <ColorSwatch 51 + color={props.value} 52 + className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C] ${props.disabled ? "opacity-50" : ""}`} 53 + style={{ 54 + backgroundSize: "cover", 55 + }} 56 + /> 57 + <strong className="">{props.label}</strong> 58 + </button> 59 + 60 + <div className="flex gap-1"> 61 + {props.value === undefined ? ( 62 + <div>default</div> 63 + ) : ( 64 + <ColorField className="w-fit gap-1"> 65 + <Input 66 + disabled={props.disabled} 67 + onMouseDown={onMouseDown} 68 + onFocus={(e) => { 69 + e.currentTarget.setSelectionRange( 70 + 1, 71 + e.currentTarget.value.length, 72 + ); 73 + }} 74 + onKeyDown={(e) => { 75 + if (e.key === "Enter") { 76 + e.currentTarget.blur(); 77 + } else return; 78 + }} 79 + onBlur={(e) => { 80 + props.setValue(parseColor(e.currentTarget.value)); 81 + }} 82 + className="w-[72px] bg-transparent outline-none disabled:text-tertiary" 83 + /> 84 + </ColorField> 85 + )} 86 + {props.alpha && ( 87 + <> 88 + <Separator classname="my-1" /> 89 + <ColorField className="w-fit pl-[6px]" channel="alpha"> 90 + <Input 91 + disabled={props.disabled} 92 + onMouseDown={onMouseDown} 93 + onFocus={(e) => { 94 + e.currentTarget.setSelectionRange( 95 + 0, 96 + e.currentTarget.value.length - 1, 97 + ); 98 + }} 99 + onKeyDown={(e) => { 100 + if (e.key === "Enter") { 101 + e.currentTarget.blur(); 102 + } else return; 103 + }} 104 + className="w-[72px] bg-transparent outline-none text-primary disabled:text-tertiary" 105 + /> 106 + </ColorField> 107 + </> 108 + )} 109 + </div> 110 + </div> 111 + {props.openPicker === props.thisPicker && ( 112 + <div className="w-full flex flex-col gap-2 px-1 pb-2"> 113 + { 114 + <> 115 + <ColorArea 116 + className="w-full h-[128px] rounded-md" 117 + colorSpace="hsb" 118 + xChannel="saturation" 119 + yChannel="brightness" 120 + > 121 + <ColorThumb className={thumbStyle} /> 122 + </ColorArea> 123 + <ColorSlider colorSpace="hsb" className="w-full" channel="hue"> 124 + <SliderTrack className="h-2 w-full rounded-md"> 125 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 126 + </SliderTrack> 127 + </ColorSlider> 128 + {props.alpha && ( 129 + <ColorSlider 130 + colorSpace="hsb" 131 + className="w-full mt-1 rounded-full" 132 + style={{ 133 + backgroundImage: `url(./transparent-bg.png)`, 134 + backgroundRepeat: "repeat", 135 + backgroundSize: "8px", 136 + }} 137 + channel="alpha" 138 + > 139 + <SliderTrack className="h-2 w-full rounded-md"> 140 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 141 + </SliderTrack> 142 + </ColorSlider> 143 + )} 144 + {props.children} 145 + </> 146 + } 147 + </div> 148 + )} 149 + </div> 150 + </SpectrumColorPicker> 151 + ); 152 + };
+176
components/ThemeManager/ImageSetters.tsx
··· 1 + import * as Slider from "@radix-ui/react-slider"; 2 + import { theme } from "../../tailwind.config"; 3 + 4 + import { Color } from "react-aria-components"; 5 + 6 + import { useEntity, useReplicache } from "src/replicache"; 7 + import { addImage } from "src/utils/addImage"; 8 + import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 9 + import { CloseContrastSmall } from "components/Icons/CloseContrastSmall"; 10 + 11 + export const ImageSettings = (props: { 12 + entityID: string; 13 + card?: boolean; 14 + setValue: (c: Color) => void; 15 + }) => { 16 + let image = useEntity( 17 + props.entityID, 18 + props.card ? "theme/card-background-image" : "theme/background-image", 19 + ); 20 + let repeat = useEntity( 21 + props.entityID, 22 + props.card 23 + ? "theme/card-background-image-repeat" 24 + : "theme/background-image-repeat", 25 + ); 26 + let pageType = useEntity(props.entityID, "page/type")?.data.value; 27 + let { rep } = useReplicache(); 28 + return ( 29 + <> 30 + <div 31 + style={{ 32 + backgroundImage: image?.data.src 33 + ? `url(${image.data.src})` 34 + : undefined, 35 + backgroundPosition: "center", 36 + backgroundSize: "cover", 37 + }} 38 + className="themeBGImagePreview flex gap-2 place-items-center justify-center w-full h-[128px] bg-cover bg-center bg-no-repeat" 39 + > 40 + <label className="hover:cursor-pointer "> 41 + <div 42 + className="flex gap-2 rounded-md px-2 py-1 text-accent-contrast font-bold" 43 + style={{ backgroundColor: "rgba(var(--bg-page), .8" }} 44 + > 45 + <BlockImageSmall /> Change Image 46 + </div> 47 + <div className="hidden"> 48 + <ImageInput {...props} /> 49 + </div> 50 + </label> 51 + <button 52 + onClick={() => { 53 + if (image) rep?.mutate.retractFact({ factID: image.id }); 54 + if (repeat) rep?.mutate.retractFact({ factID: repeat.id }); 55 + }} 56 + > 57 + <CloseContrastSmall 58 + fill={theme.colors["accent-1"]} 59 + stroke={theme.colors["accent-2"]} 60 + /> 61 + </button> 62 + </div> 63 + <div className="themeBGImageControls font-bold flex gap-2 items-center"> 64 + {pageType !== "canvas" && ( 65 + <label htmlFor="cover" className="flex shrink-0"> 66 + <input 67 + className="appearance-none" 68 + type="radio" 69 + id="cover" 70 + name="bg-image-options" 71 + value="cover" 72 + checked={!repeat} 73 + onChange={async (e) => { 74 + if (!e.currentTarget.checked) return; 75 + if (!repeat) return; 76 + if (repeat) 77 + await rep?.mutate.retractFact({ factID: repeat.id }); 78 + }} 79 + /> 80 + <div 81 + className={`shink-0 grow-0 w-fit border border-accent-1 rounded-md px-1 py-0.5 cursor-pointer ${!repeat ? "bg-accent-1 text-accent-2" : "bg-transparent text-accent-1"}`} 82 + > 83 + cover 84 + </div> 85 + </label> 86 + )} 87 + <label htmlFor="repeat" className="flex shrink-0"> 88 + <input 89 + className={`appearance-none `} 90 + type="radio" 91 + id="repeat" 92 + name="bg-image-options" 93 + value="repeat" 94 + checked={!!repeat} 95 + onChange={async (e) => { 96 + if (!e.currentTarget.checked) return; 97 + if (repeat) return; 98 + await rep?.mutate.assertFact({ 99 + entity: props.entityID, 100 + attribute: props.card 101 + ? "theme/card-background-image-repeat" 102 + : "theme/background-image-repeat", 103 + data: { type: "number", value: 500 }, 104 + }); 105 + }} 106 + /> 107 + <div 108 + className={`shink-0 grow-0 w-fit z-10 border border-accent-1 rounded-md px-1 py-0.5 cursor-pointer ${repeat ? "bg-accent-1 text-accent-2" : "bg-transparent text-accent-1"}`} 109 + > 110 + repeat 111 + </div> 112 + </label> 113 + {(repeat || pageType === "canvas") && ( 114 + <Slider.Root 115 + className="relative grow flex items-center select-none touch-none w-full h-fit" 116 + value={[repeat?.data.value || 500]} 117 + max={3000} 118 + min={10} 119 + step={10} 120 + onValueChange={(value) => { 121 + rep?.mutate.assertFact({ 122 + entity: props.entityID, 123 + attribute: props.card 124 + ? "theme/card-background-image-repeat" 125 + : "theme/background-image-repeat", 126 + data: { type: "number", value: value[0] }, 127 + }); 128 + }} 129 + > 130 + <Slider.Track className="bg-accent-1 relative grow rounded-full h-[3px]"></Slider.Track> 131 + <Slider.Thumb 132 + className="flex w-4 h-4 rounded-full border-2 border-white bg-accent-1 shadow-[0_0_0_1px_#8C8C8C,_inset_0_0_0_1px_#8C8C8C] cursor-pointer" 133 + aria-label="Volume" 134 + /> 135 + </Slider.Root> 136 + )} 137 + </div> 138 + </> 139 + ); 140 + }; 141 + 142 + export const ImageInput = (props: { 143 + entityID: string; 144 + onChange?: () => void; 145 + card?: boolean; 146 + }) => { 147 + let pageType = useEntity(props.entityID, "page/type")?.data.value; 148 + let { rep } = useReplicache(); 149 + return ( 150 + <input 151 + type="file" 152 + accept="image/*" 153 + onChange={async (e) => { 154 + let file = e.currentTarget.files?.[0]; 155 + if (!file || !rep) return; 156 + 157 + await addImage(file, rep, { 158 + entityID: props.entityID, 159 + attribute: props.card 160 + ? "theme/card-background-image" 161 + : "theme/background-image", 162 + }); 163 + props.onChange?.(); 164 + 165 + if (pageType === "canvas") { 166 + rep && 167 + rep.mutate.assertFact({ 168 + entity: props.entityID, 169 + attribute: "canvas/background-pattern", 170 + data: { type: "canvas-pattern-union", value: "plain" }, 171 + }); 172 + } 173 + }} 174 + /> 175 + ); 176 + };
+223
components/ThemeManager/LeafletBGPicker.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + ColorPicker as SpectrumColorPicker, 5 + parseColor, 6 + Color, 7 + ColorArea, 8 + ColorThumb, 9 + ColorSlider, 10 + Input, 11 + ColorField, 12 + SliderTrack, 13 + ColorSwatch, 14 + } from "react-aria-components"; 15 + import { pickers, setColorAttribute } from "./ThemeSetter"; 16 + import { thumbStyle } from "./ColorPicker"; 17 + import { ImageInput, ImageSettings } from "./ImageSetters"; 18 + import { useEntity, useReplicache } from "src/replicache"; 19 + import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 20 + import { Separator } from "components/Layout"; 21 + import { onMouseDown } from "src/utils/iosInputMouseDown"; 22 + import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 23 + 24 + export const LeafletBGPicker = (props: { 25 + entityID: string; 26 + openPicker: pickers; 27 + thisPicker: pickers; 28 + setOpenPicker: (thisPicker: pickers) => void; 29 + closePicker: () => void; 30 + setValue: (c: Color) => void; 31 + card?: boolean; 32 + }) => { 33 + let bgImage = useEntity( 34 + props.entityID, 35 + props.card ? "theme/card-background-image" : "theme/background-image", 36 + ); 37 + let bgColor = useColorAttribute( 38 + props.entityID, 39 + props.card ? "theme/card-background" : "theme/page-background", 40 + ); 41 + let open = props.openPicker == props.thisPicker; 42 + let { rep } = useReplicache(); 43 + 44 + return ( 45 + <> 46 + <div className="bgPickerLabel flex justify-between place-items-center "> 47 + <div className="bgPickerColorLabel flex gap-2 items-center"> 48 + <button 49 + onClick={() => { 50 + if (props.openPicker === props.thisPicker) { 51 + props.setOpenPicker("null"); 52 + } else { 53 + props.setOpenPicker(props.thisPicker); 54 + } 55 + }} 56 + className="flex gap-2 items-center" 57 + > 58 + <ColorSwatch 59 + color={bgColor} 60 + className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`} 61 + style={{ 62 + backgroundImage: bgImage?.data.src 63 + ? `url(${bgImage.data.src})` 64 + : undefined, 65 + backgroundSize: "cover", 66 + }} 67 + /> 68 + <strong 69 + className={`${props.card ? "text-primary" : "text-[#595959]"}`} 70 + > 71 + {props.card ? "Page" : "Background"} 72 + </strong> 73 + </button> 74 + 75 + <div className="flex"> 76 + {bgImage ? ( 77 + <div 78 + className={`${props.card ? "text-secondary" : "text-[#969696]"}`} 79 + > 80 + Image 81 + </div> 82 + ) : ( 83 + <> 84 + <ColorField className="w-fit gap-1" value={bgColor}> 85 + <Input 86 + onMouseDown={onMouseDown} 87 + onFocus={(e) => { 88 + e.currentTarget.setSelectionRange( 89 + 1, 90 + e.currentTarget.value.length, 91 + ); 92 + }} 93 + onPaste={(e) => { 94 + console.log(e); 95 + }} 96 + onKeyDown={(e) => { 97 + if (e.key === "Enter") { 98 + e.currentTarget.blur(); 99 + } else return; 100 + }} 101 + onBlur={(e) => { 102 + props.setValue(parseColor(e.currentTarget.value)); 103 + }} 104 + className={`w-[72px] bg-transparent outline-none ${props.card ? "text-primary" : "text-[#595959]"}`} 105 + /> 106 + </ColorField> 107 + {props.card && ( 108 + <> 109 + <Separator classname="my-1" /> 110 + 111 + <SpectrumColorPicker 112 + value={bgColor} 113 + onChange={setColorAttribute( 114 + rep, 115 + props.entityID, 116 + )( 117 + props.card 118 + ? "theme/card-background" 119 + : "theme/page-background", 120 + )} 121 + > 122 + <ColorField className="w-fit pl-[6px]" channel="alpha"> 123 + <Input 124 + onMouseDown={onMouseDown} 125 + onFocus={(e) => { 126 + e.currentTarget.setSelectionRange( 127 + 0, 128 + e.currentTarget.value.length - 1, 129 + ); 130 + }} 131 + onKeyDown={(e) => { 132 + if (e.key === "Enter") { 133 + e.currentTarget.blur(); 134 + } else return; 135 + }} 136 + className="w-[48px] bg-transparent outline-none text-primary" 137 + /> 138 + </ColorField> 139 + </SpectrumColorPicker> 140 + </> 141 + )} 142 + </> 143 + )} 144 + </div> 145 + </div> 146 + <label className="hover:cursor-pointer h-fit"> 147 + <div 148 + className={ 149 + props.card 150 + ? "text-tertiary hover:text-accent-contrast" 151 + : "text-[#8C8C8C] hover:text-[#0000FF]" 152 + } 153 + > 154 + <BlockImageSmall /> 155 + </div> 156 + <div className="hidden"> 157 + <ImageInput 158 + {...props} 159 + onChange={() => { 160 + props.setOpenPicker(props.thisPicker); 161 + }} 162 + /> 163 + </div> 164 + </label> 165 + </div> 166 + {open && ( 167 + <div className="bgImageAndColorPicker w-full flex flex-col gap-2 "> 168 + <SpectrumColorPicker 169 + value={bgColor} 170 + onChange={setColorAttribute( 171 + rep, 172 + props.entityID, 173 + )(props.card ? "theme/card-background" : "theme/page-background")} 174 + > 175 + {bgImage ? ( 176 + <ImageSettings 177 + entityID={props.entityID} 178 + card={props.card} 179 + setValue={props.setValue} 180 + /> 181 + ) : ( 182 + <> 183 + <ColorArea 184 + className="w-full h-[128px] rounded-md" 185 + colorSpace="hsb" 186 + xChannel="saturation" 187 + yChannel="brightness" 188 + > 189 + <ColorThumb className={thumbStyle} /> 190 + </ColorArea> 191 + <ColorSlider 192 + colorSpace="hsb" 193 + className="w-full " 194 + channel="hue" 195 + > 196 + <SliderTrack className="h-2 w-full rounded-md"> 197 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 198 + </SliderTrack> 199 + </ColorSlider> 200 + </> 201 + )} 202 + {props.card && ( 203 + <ColorSlider 204 + colorSpace="hsb" 205 + className="w-full mt-1 rounded-full" 206 + style={{ 207 + backgroundImage: `url(./transparent-bg.png)`, 208 + backgroundRepeat: "repeat", 209 + backgroundSize: "8px", 210 + }} 211 + channel="alpha" 212 + > 213 + <SliderTrack className="h-2 w-full rounded-md"> 214 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 215 + </SliderTrack> 216 + </ColorSlider> 217 + )} 218 + </SpectrumColorPicker> 219 + </div> 220 + )} 221 + </> 222 + ); 223 + };
+318
components/ThemeManager/PageThemePickers.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + ColorPicker as SpectrumColorPicker, 5 + parseColor, 6 + Color, 7 + ColorThumb, 8 + ColorSlider, 9 + Input, 10 + ColorField, 11 + SliderTrack, 12 + ColorSwatch, 13 + } from "react-aria-components"; 14 + import { Checkbox } from "components/Checkbox"; 15 + import { useMemo, useState } from "react"; 16 + import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 17 + import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 18 + import { Separator } from "components/Layout"; 19 + import { onMouseDown } from "src/utils/iosInputMouseDown"; 20 + import { pickers, setColorAttribute } from "./ThemeSetter"; 21 + import { ImageInput, ImageSettings } from "./ImageSetters"; 22 + 23 + import { ColorPicker, thumbStyle } from "./ColorPicker"; 24 + import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 25 + import { Replicache } from "replicache"; 26 + import { CanvasBackgroundPattern } from "components/Canvas"; 27 + 28 + export const PageThemePickers = (props: { 29 + entityID: string; 30 + home?: boolean; 31 + openPicker: pickers; 32 + setOpenPicker: (thisPicker: pickers) => void; 33 + }) => { 34 + let { rep } = useReplicache(); 35 + let set = useMemo(() => { 36 + return setColorAttribute(rep, props.entityID); 37 + }, [rep, props.entityID]); 38 + 39 + let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 40 + let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 41 + let primaryValue = useColorAttribute(props.entityID, "theme/primary"); 42 + let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 43 + let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden"); 44 + 45 + return ( 46 + <div 47 + className="pageThemeBG flex flex-col gap-2 h-full text-primary bg-bg-leaflet p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]" 48 + style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }} 49 + > 50 + {pageType === "canvas" && ( 51 + <> 52 + <CanvasBGPatternPicker entityID={props.entityID} rep={rep} />{" "} 53 + <hr className="border-border-light w-full" /> 54 + </> 55 + )} 56 + <ColorPicker 57 + disabled={pageBorderHidden?.data.value} 58 + label="Page" 59 + value={pageValue} 60 + setValue={set("theme/card-background")} 61 + thisPicker={"page"} 62 + openPicker={props.openPicker} 63 + setOpenPicker={props.setOpenPicker} 64 + closePicker={() => props.setOpenPicker("null")} 65 + alpha 66 + > 67 + {(pageBGImage === null || !pageBGImage) && ( 68 + <label 69 + className={`m-0 h-max w-full py-0.5 px-1 70 + bg-accent-1 outline-transparent 71 + rounded-md text-base font-bold text-accent-2 72 + hover:cursor-pointer 73 + flex gap-2 items-center justify-center shrink-0 74 + transparent-outline hover:outline-accent-1 outline-offset-1 75 + `} 76 + > 77 + <BlockImageSmall /> Add Background Image 78 + <div className="hidden"> 79 + <ImageInput 80 + entityID={props.entityID} 81 + onChange={() => props.setOpenPicker("page-background-image")} 82 + card 83 + /> 84 + </div> 85 + </label> 86 + )} 87 + </ColorPicker> 88 + {pageBGImage && pageBGImage !== null && ( 89 + <PageBGPicker 90 + disabled={pageBorderHidden?.data.value} 91 + entityID={props.entityID} 92 + thisPicker={"page-background-image"} 93 + openPicker={props.openPicker} 94 + setOpenPicker={props.setOpenPicker} 95 + closePicker={() => props.setOpenPicker("null")} 96 + setValue={set("theme/card-background")} 97 + /> 98 + )} 99 + <ColorPicker 100 + label="Text" 101 + value={primaryValue} 102 + setValue={set("theme/primary")} 103 + thisPicker={"text"} 104 + openPicker={props.openPicker} 105 + setOpenPicker={props.setOpenPicker} 106 + closePicker={() => props.setOpenPicker("null")} 107 + /> 108 + <hr /> 109 + <PageBorderHider entityID={props.entityID} /> 110 + </div> 111 + ); 112 + }; 113 + 114 + export const PageBorderHider = (props: { entityID: string }) => { 115 + let { rep, rootEntity } = useReplicache(); 116 + let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 117 + let entityPageBorderHidden = useEntity( 118 + props.entityID, 119 + "theme/card-border-hidden", 120 + ); 121 + let pageBorderHidden = 122 + (entityPageBorderHidden || rootPageBorderHidden)?.data.value || false; 123 + 124 + return ( 125 + <> 126 + <Checkbox 127 + small 128 + className="pl-[6px] !gap-3" 129 + checked={pageBorderHidden} 130 + onChange={(e) => { 131 + rep?.mutate.assertFact({ 132 + entity: props.entityID, 133 + attribute: "theme/card-border-hidden", 134 + data: { type: "boolean", value: !pageBorderHidden }, 135 + }); 136 + console.log(pageBorderHidden); 137 + }} 138 + > 139 + No Page Borders 140 + </Checkbox> 141 + </> 142 + ); 143 + }; 144 + 145 + export const PageBGPicker = (props: { 146 + disabled?: boolean; 147 + entityID: string; 148 + openPicker: pickers; 149 + thisPicker: pickers; 150 + setOpenPicker: (thisPicker: pickers) => void; 151 + closePicker: () => void; 152 + setValue: (c: Color) => void; 153 + }) => { 154 + let bgImage = useEntity(props.entityID, "theme/card-background-image"); 155 + let bgColor = useColorAttribute(props.entityID, "theme/card-background"); 156 + let bgAlpha = 157 + useEntity(props.entityID, "theme/card-background-image-opacity")?.data 158 + .value || 1; 159 + let alphaColor = useMemo(() => { 160 + return parseColor(`rgba(0,0,0,${bgAlpha})`); 161 + }, [bgAlpha]); 162 + let open = props.openPicker == props.thisPicker; 163 + let { rep } = useReplicache(); 164 + 165 + return ( 166 + <> 167 + <div className="bgPickerColorLabel flex gap-2 items-center"> 168 + <button 169 + disabled={props.disabled} 170 + onClick={() => { 171 + if (props.openPicker === props.thisPicker) { 172 + props.setOpenPicker("null"); 173 + } else { 174 + props.setOpenPicker(props.thisPicker); 175 + } 176 + }} 177 + className="flex gap-2 items-center disabled:text-tertiary" 178 + > 179 + <ColorSwatch 180 + color={bgColor} 181 + className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C] ${props.disabled ? "opacity-50" : ""}`} 182 + style={{ 183 + backgroundImage: bgImage?.data.src 184 + ? `url(${bgImage.data.src})` 185 + : undefined, 186 + backgroundPosition: "center", 187 + backgroundSize: "cover", 188 + }} 189 + /> 190 + <strong 191 + className={`${props.disabled ? "text-tertiary" : "text-primary "}`} 192 + > 193 + BG Image 194 + </strong> 195 + </button> 196 + 197 + <SpectrumColorPicker 198 + value={alphaColor} 199 + onChange={(c) => { 200 + let alpha = c.getChannelValue("alpha"); 201 + rep?.mutate.assertFact({ 202 + entity: props.entityID, 203 + attribute: "theme/card-background-image-opacity", 204 + data: { type: "number", value: alpha }, 205 + }); 206 + }} 207 + > 208 + <Separator classname="h-5 my-1" /> 209 + <ColorField className="w-fit pl-[6px]" channel="alpha"> 210 + <Input 211 + disabled={props.disabled} 212 + onMouseDown={onMouseDown} 213 + onFocus={(e) => { 214 + e.currentTarget.setSelectionRange( 215 + 0, 216 + e.currentTarget.value.length - 1, 217 + ); 218 + }} 219 + onKeyDown={(e) => { 220 + if (e.key === "Enter") { 221 + e.currentTarget.blur(); 222 + } else return; 223 + }} 224 + className={`w-[48px] bg-transparent outline-none disabled:text-tertiary`} 225 + /> 226 + </ColorField> 227 + </SpectrumColorPicker> 228 + </div> 229 + {open && ( 230 + <div className="pageImagePicker flex flex-col gap-2"> 231 + <ImageSettings 232 + entityID={props.entityID} 233 + card 234 + setValue={props.setValue} 235 + /> 236 + 237 + <SpectrumColorPicker 238 + value={alphaColor} 239 + onChange={(c) => { 240 + let alpha = c.getChannelValue("alpha"); 241 + rep?.mutate.assertFact({ 242 + entity: props.entityID, 243 + attribute: "theme/card-background-image-opacity", 244 + data: { type: "number", value: alpha }, 245 + }); 246 + }} 247 + > 248 + <ColorSlider 249 + colorSpace="hsb" 250 + className="w-full mt-1 rounded-full" 251 + style={{ 252 + backgroundImage: `url(./transparent-bg.png)`, 253 + backgroundRepeat: "repeat", 254 + backgroundSize: "8px", 255 + }} 256 + channel="alpha" 257 + > 258 + <SliderTrack className="h-2 w-full rounded-md"> 259 + <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 260 + </SliderTrack> 261 + </ColorSlider> 262 + </SpectrumColorPicker> 263 + </div> 264 + )} 265 + </> 266 + ); 267 + }; 268 + 269 + const CanvasBGPatternPicker = (props: { 270 + entityID: string; 271 + rep: Replicache<ReplicacheMutators> | null; 272 + }) => { 273 + let selectedPattern = useEntity(props.entityID, "canvas/background-pattern") 274 + ?.data.value; 275 + return ( 276 + <div className="flex gap-2 h-8 "> 277 + <button 278 + className={`w-full rounded-md bg-bg-page border ${selectedPattern === "grid" ? "outline outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`} 279 + onMouseDown={() => { 280 + props.rep && 281 + props.rep.mutate.assertFact({ 282 + entity: props.entityID, 283 + attribute: "canvas/background-pattern", 284 + data: { type: "canvas-pattern-union", value: "grid" }, 285 + }); 286 + }} 287 + > 288 + <CanvasBackgroundPattern pattern="grid" scale={0.5} /> 289 + </button> 290 + <button 291 + className={`w-full rounded-md bg-bg-page border ${selectedPattern === "dot" ? "outline outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`} 292 + onMouseDown={() => { 293 + props.rep && 294 + props.rep.mutate.assertFact({ 295 + entity: props.entityID, 296 + attribute: "canvas/background-pattern", 297 + data: { type: "canvas-pattern-union", value: "dot" }, 298 + }); 299 + }} 300 + > 301 + <CanvasBackgroundPattern pattern="dot" scale={0.5} /> 302 + </button> 303 + <button 304 + className={`w-full rounded-md bg-bg-page border ${selectedPattern === "plain" ? "outline outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`} 305 + onMouseDown={() => { 306 + props.rep && 307 + props.rep.mutate.assertFact({ 308 + entity: props.entityID, 309 + attribute: "canvas/background-pattern", 310 + data: { type: "canvas-pattern-union", value: "plain" }, 311 + }); 312 + }} 313 + > 314 + <CanvasBackgroundPattern pattern="plain" /> 315 + </button> 316 + </div> 317 + ); 318 + };
+130 -227
components/ThemeManager/PageThemeSetter.tsx
··· 1 - import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 2 - import { useColorAttribute } from "./useColorAttribute"; 1 + import { useEntity, useReplicache } from "src/replicache"; 3 2 import { useEntitySetContext } from "components/EntitySetProvider"; 4 - import { 5 - LeafletBGPicker, 6 - ColorPicker, 7 - pickers, 8 - SectionArrow, 9 - setColorAttribute, 10 - PageBGPicker, 11 - ImageInput, 12 - } from "./ThemeSetter"; 13 - import { useMemo, useState } from "react"; 14 - import { CanvasBackgroundPattern } from "components/Canvas"; 15 - import { Replicache } from "replicache"; 3 + import { pickers, SectionArrow } from "./ThemeSetter"; 4 + 5 + import { PageThemePickers } from "./PageThemePickers"; 6 + import { useState } from "react"; 16 7 import { theme } from "tailwind.config"; 17 8 import { ButtonPrimary } from "components/Buttons"; 18 - import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 19 9 import { PaintSmall } from "components/Icons/PaintSmall"; 10 + import { AccentThemePickers } from "./AccentThemePickers"; 20 11 21 12 export const PageThemeSetter = (props: { entityID: string }) => { 22 - let { rep, rootEntity } = useReplicache(); 13 + let { rootEntity } = useReplicache(); 23 14 let permission = useEntitySetContext().permissions.write; 24 15 let [openPicker, setOpenPicker] = useState<pickers>("null"); 25 16 26 - let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 27 - 28 - let accent1Value = useColorAttribute( 29 - props.entityID, 30 - "theme/accent-background", 31 - ); 32 - let accent2Value = useColorAttribute(props.entityID, "theme/accent-text"); 33 - let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 34 - let primaryValue = useColorAttribute(props.entityID, "theme/primary"); 35 - 36 17 let leafletBGImage = useEntity(rootEntity, "theme/background-image"); 37 18 let leafletBGRepeat = useEntity(rootEntity, "theme/background-image-repeat"); 38 - let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 39 - let pageBGRepeat = useEntity( 40 - props.entityID, 41 - "theme/card-background-image-repeat", 42 - ); 43 - let pageBGOpacity = useEntity( 44 - props.entityID, 45 - "theme/card-background-image-opacity", 46 - ); 47 - 48 - let set = useMemo(() => { 49 - return setColorAttribute(rep, props.entityID); 50 - }, [rep, props.entityID]); 51 19 52 20 if (!permission) return null; 53 21 54 22 return ( 55 23 <> 56 - <div className="pageThemeSetter flex flex-row gap-2 px-3 py-1 "> 24 + <div className="pageThemeSetter flex flex-row gap-2 px-3 py-1 z-10"> 57 25 <div className="gap-2 flex font-bold "> 58 26 <PaintSmall /> Theme Page 59 27 </div> 60 - <ButtonPrimary 61 - compact 62 - onClick={() => { 63 - if (!rep) return; 64 - rep.mutate.retractAttribute({ 65 - entity: props.entityID, 66 - attribute: [ 67 - "theme/primary", 68 - "theme/card-background", 69 - "theme/accent-background", 70 - "theme/accent-text", 71 - "theme/card-background-image", 72 - "theme/card-background-image-repeat", 73 - "theme/card-background-image-opacity", 74 - "canvas/background-pattern", 75 - ], 76 - }); 77 - }} 78 - > 79 - reset 80 - </ButtonPrimary> 28 + <ResetButton entityID={props.entityID} /> 81 29 </div> 82 30 <div 83 31 className="pageThemeSetterContent bg-bg-leaflet w-80 p-3 pb-0 flex flex-col gap-2 rounded-md -mb-1" ··· 92 40 : `calc(${leafletBGRepeat.data.value}px / 2 )`, 93 41 }} 94 42 > 95 - <div 96 - className="pageAccentControls text-accent-2 flex flex-col gap-2 h-full bg-bg-leaflet mt-4 p-2 rounded-md border border-accent-2 shadow-[0_0_0_1px_rgb(var(--accent-1))]" 97 - style={{ 98 - backgroundColor: "rgba(var(--accent-1), 0.6)", 99 - }} 100 - > 101 - <ColorPicker 102 - label="Accent" 103 - value={accent1Value} 104 - setValue={set("theme/accent-background")} 105 - thisPicker={"accent-1"} 43 + <AccentThemePickers 44 + entityID={props.entityID} 45 + openPicker={openPicker} 46 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 47 + /> 48 + <div className="flex flex-col -mb-[14px] mt-4 z-10"> 49 + <PageThemePickers 50 + entityID={props.entityID} 106 51 openPicker={openPicker} 107 - setOpenPicker={setOpenPicker} 108 - closePicker={() => setOpenPicker("null")} 52 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 109 53 /> 110 - <ColorPicker 111 - label="Text on Accent" 112 - value={accent2Value} 113 - setValue={set("theme/accent-text")} 114 - thisPicker={"accent-2"} 115 - openPicker={openPicker} 116 - setOpenPicker={setOpenPicker} 117 - closePicker={() => setOpenPicker("null")} 118 - /> 119 - </div> 120 - <div className="flex flex-col -mb-[14px] mt-4 z-10"> 121 - <div 122 - className="pageThemeBG flex flex-col gap-2 h-full text-primary bg-bg-leaflet p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]" 123 - style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }} 124 - > 125 - {pageType === "canvas" && ( 126 - <> 127 - <BackgroundPatternPicker entityID={props.entityID} rep={rep} />{" "} 128 - <hr className="border-border-light w-full" /> 129 - </> 130 - )} 131 - <ColorPicker 132 - label="Page" 133 - value={pageValue} 134 - setValue={set("theme/card-background")} 135 - thisPicker={"page"} 136 - openPicker={openPicker} 137 - setOpenPicker={setOpenPicker} 138 - closePicker={() => setOpenPicker("null")} 139 - alpha 140 - > 141 - {(pageBGImage === null || !pageBGImage) && ( 142 - <label 143 - className={`m-0 h-max w-full py-0.5 px-1 144 - bg-accent-1 outline-transparent 145 - rounded-md text-base font-bold text-accent-2 146 - hover:cursor-pointer 147 - flex gap-2 items-center justify-center shrink-0 148 - transparent-outline hover:outline-accent-1 outline-offset-1 149 - `} 150 - > 151 - <BlockImageSmall /> Add Background Image 152 - <div className="hidden"> 153 - <ImageInput 154 - entityID={props.entityID} 155 - onChange={() => setOpenPicker("page-background-image")} 156 - card 157 - /> 158 - </div> 159 - </label> 160 - )} 161 - </ColorPicker> 162 - {pageBGImage && pageBGImage !== null && ( 163 - <PageBGPicker 164 - entityID={props.entityID} 165 - thisPicker={"page-background-image"} 166 - openPicker={openPicker} 167 - setOpenPicker={setOpenPicker} 168 - closePicker={() => setOpenPicker("null")} 169 - setValue={set("theme/card-background")} 170 - /> 171 - )} 172 - <ColorPicker 173 - label="Text" 174 - value={primaryValue} 175 - setValue={set("theme/primary")} 176 - thisPicker={"text"} 177 - openPicker={openPicker} 178 - setOpenPicker={setOpenPicker} 179 - closePicker={() => setOpenPicker("null")} 180 - /> 181 - </div> 182 54 <SectionArrow 183 55 fill={theme.colors["primary"]} 184 56 stroke={theme.colors["bg-page"]} 185 57 className="ml-2" 186 58 /> 187 59 </div> 188 - <div 189 - className="relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent" 190 - style={{ 191 - backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 192 - }} 193 - > 194 - <div 195 - className="background absolute top-0 right-0 bottom-0 left-0 z-0 rounded-t-lg" 196 - style={{ 197 - backgroundImage: pageBGImage 198 - ? `url(${pageBGImage.data.src})` 199 - : undefined, 200 - 201 - backgroundRepeat: pageBGRepeat ? "repeat" : "no-repeat", 202 - opacity: pageBGOpacity?.data.value || 1, 203 - backgroundSize: !pageBGRepeat 204 - ? "cover" 205 - : `calc(${pageBGRepeat.data.value}px / 2 )`, 206 - }} 207 - /> 208 - <div className="relative"> 209 - <p className="font-bold">Theme Each Page!</p> 210 - <small className=""> 211 - OMG! You can theme each page individually in{" "} 212 - <span className="font-bold text-accent-contrast">Leaflet</span>! 213 - <br /> Buttons and sections appear like: 214 - </small> 215 - <div className="p-2 mt-2 border border-border bg-bg-page rounded-md text-sm flex justify-between items-center font-bold text-secondary"> 216 - Happy Theming! 217 - <div className="bg-accent-1 text-accent-2 py-0.5 px-2 w-fit text-center text-sm font-bold rounded-md"> 218 - Button 219 - </div> 220 - </div> 221 - </div> 222 - </div> 60 + <SamplePage entityID={props.entityID} /> 223 61 </div> 224 62 </> 225 63 ); 226 64 }; 227 65 228 - const BackgroundPatternPicker = (props: { 229 - entityID: string; 230 - rep: Replicache<ReplicacheMutators> | null; 231 - }) => { 232 - let selectedPattern = useEntity(props.entityID, "canvas/background-pattern") 233 - ?.data.value; 66 + const ResetButton = (props: { entityID: string }) => { 67 + let { rep } = useReplicache(); 68 + 234 69 return ( 235 - <div className="flex gap-2 h-8 "> 236 - <button 237 - className={`w-full rounded-md bg-bg-page border ${selectedPattern === "grid" ? "outline outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`} 238 - onMouseDown={() => { 239 - props.rep && 240 - props.rep.mutate.assertFact({ 241 - entity: props.entityID, 242 - attribute: "canvas/background-pattern", 243 - data: { type: "canvas-pattern-union", value: "grid" }, 244 - }); 245 - }} 246 - > 247 - <CanvasBackgroundPattern pattern="grid" scale={0.5} /> 248 - </button> 249 - <button 250 - className={`w-full rounded-md bg-bg-page border ${selectedPattern === "dot" ? "outline outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`} 251 - onMouseDown={() => { 252 - props.rep && 253 - props.rep.mutate.assertFact({ 254 - entity: props.entityID, 255 - attribute: "canvas/background-pattern", 256 - data: { type: "canvas-pattern-union", value: "dot" }, 257 - }); 258 - }} 259 - > 260 - <CanvasBackgroundPattern pattern="dot" scale={0.5} /> 261 - </button> 262 - <button 263 - className={`w-full rounded-md bg-bg-page border ${selectedPattern === "plain" ? "outline outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`} 264 - onMouseDown={() => { 265 - props.rep && 266 - props.rep.mutate.assertFact({ 267 - entity: props.entityID, 268 - attribute: "canvas/background-pattern", 269 - data: { type: "canvas-pattern-union", value: "plain" }, 270 - }); 271 - }} 272 - > 273 - <CanvasBackgroundPattern pattern="plain" /> 274 - </button> 70 + <ButtonPrimary 71 + compact 72 + onClick={() => { 73 + if (!rep) return; 74 + rep.mutate.retractAttribute({ 75 + entity: props.entityID, 76 + attribute: [ 77 + "theme/primary", 78 + "theme/card-background", 79 + "theme/accent-background", 80 + "theme/accent-text", 81 + "theme/card-background-image", 82 + "theme/card-background-image-repeat", 83 + "theme/card-background-image-opacity", 84 + "theme/card-border-hidden", 85 + "canvas/background-pattern", 86 + ], 87 + }); 88 + }} 89 + > 90 + reset 91 + </ButtonPrimary> 92 + ); 93 + }; 94 + 95 + const SamplePage = (props: { entityID: string }) => { 96 + let { rootEntity } = useReplicache(); 97 + 98 + let rootBackgroundImage = useEntity( 99 + rootEntity, 100 + "theme/card-background-image", 101 + ); 102 + let rootBackgroundRepeat = useEntity( 103 + rootEntity, 104 + "theme/card-background-image-repeat", 105 + ); 106 + let rootBackgroundOpacity = useEntity( 107 + rootEntity, 108 + "theme/card-background-image-opacity", 109 + ); 110 + 111 + let pageBackgroundImage = 112 + useEntity(props.entityID, "theme/card-background-image") || 113 + rootBackgroundImage; 114 + let pageBackgroundImageRepeat = 115 + useEntity(props.entityID, "theme/card-background-image-repeat") || 116 + rootBackgroundRepeat; 117 + let pageBackgroundImageOpacity = 118 + useEntity(props.entityID, "theme/card-background-image-opacity") || 119 + rootBackgroundOpacity; 120 + 121 + let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 122 + let entityPageBorderHidden = useEntity( 123 + props.entityID, 124 + "theme/card-border-hidden", 125 + ); 126 + let pageBorderHidden = (entityPageBorderHidden || rootPageBorderHidden)?.data 127 + .value; 128 + 129 + return ( 130 + <div 131 + className={ 132 + pageBorderHidden 133 + ? "py-2 px-0 border border-transparent" 134 + : `relative rounded-t-lg p-2 shadow-md text-primary border border-border border-b-transparent` 135 + } 136 + style={ 137 + pageBorderHidden 138 + ? undefined 139 + : { 140 + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 141 + } 142 + } 143 + > 144 + <div 145 + className="background absolute top-0 right-0 bottom-0 left-0 z-0 rounded-t-lg" 146 + style={ 147 + pageBorderHidden 148 + ? undefined 149 + : { 150 + backgroundImage: pageBackgroundImage 151 + ? `url(${pageBackgroundImage.data.src})` 152 + : undefined, 153 + 154 + backgroundRepeat: pageBackgroundImageRepeat 155 + ? "repeat" 156 + : "no-repeat", 157 + opacity: pageBackgroundImageOpacity?.data.value || 1, 158 + backgroundSize: !pageBackgroundImageRepeat?.data.value 159 + ? "cover" 160 + : `calc(${pageBackgroundImageRepeat.data.value}px / 2 )`, 161 + } 162 + } 163 + /> 164 + <div className="relative"> 165 + <p className="font-bold">Theme Each Page!</p> 166 + <small className=""> 167 + OMG! You can theme each page individually in{" "} 168 + <span className="font-bold text-accent-contrast">Leaflet</span>! 169 + <br /> Buttons and sections appear like: 170 + </small> 171 + <div className="p-2 mt-2 border border-border bg-bg-page rounded-md text-sm flex justify-between items-center font-bold text-secondary"> 172 + Happy Theming! 173 + <div className="bg-accent-1 text-accent-2 py-0.5 px-2 w-fit text-center text-sm font-bold rounded-md"> 174 + Button 175 + </div> 176 + </div> 177 + </div> 275 178 </div> 276 179 ); 277 180 };
+103 -7
components/ThemeManager/ThemeProvider.tsx
··· 5 5 CSSProperties, 6 6 useContext, 7 7 useEffect, 8 + useMemo, 8 9 useState, 9 10 } from "react"; 10 11 import { colorToString, useColorAttribute } from "./useColorAttribute"; ··· 12 13 import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; 13 14 14 15 import { useEntity } from "src/replicache"; 16 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 15 17 16 18 type CSSVariables = { 17 19 "--bg-leaflet": string; ··· 48 50 el?.style.setProperty(name, colorToString(value, "rgb")); 49 51 } 50 52 export function ThemeProvider(props: { 51 - entityID: string; 53 + entityID: string | null; 54 + local?: boolean; 55 + children: React.ReactNode; 56 + }) { 57 + let { data } = useLeafletPublicationData(); 58 + if (!data[0]) return <LeafletThemeProvider {...props} />; 59 + return <PublicationThemeProvider {...props} />; 60 + } 61 + export function PublicationThemeProvider(props: { 62 + entityID: string | null; 63 + local?: boolean; 64 + children: React.ReactNode; 65 + }) { 66 + let bgLeaflet = useMemo(() => { 67 + return parseColor(`#FDFCFA`); 68 + }, []); 69 + let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 70 + let primary = useColorAttribute(props.entityID, "theme/primary"); 71 + 72 + let highlight1 = useEntity(props.entityID, "theme/highlight-1"); 73 + let highlight2 = useColorAttribute(props.entityID, "theme/highlight-2"); 74 + let highlight3 = useColorAttribute(props.entityID, "theme/highlight-3"); 75 + 76 + let accent1 = useColorAttribute(props.entityID, "theme/accent-background"); 77 + let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 78 + // set accent contrast to the accent color that has the highest contrast with the page background 79 + let accentContrast = [accent1, accent2].sort((a, b) => { 80 + return ( 81 + getColorContrast(colorToString(b, "rgb"), colorToString(bgPage, "rgb")) - 82 + getColorContrast(colorToString(a, "rgb"), colorToString(bgPage, "rgb")) 83 + ); 84 + })[0]; 85 + 86 + return ( 87 + <BaseThemeProvider 88 + bgLeaflet={bgLeaflet} 89 + bgPage={bgPage} 90 + primary={primary} 91 + highlight2={highlight2} 92 + highlight3={highlight3} 93 + highlight1={highlight1?.data.value} 94 + accent1={accent1} 95 + accent2={accent2} 96 + accentContrast={accentContrast} 97 + > 98 + {props.children} 99 + </BaseThemeProvider> 100 + ); 101 + } 102 + 103 + export function LeafletThemeProvider(props: { 104 + entityID: string | null; 52 105 local?: boolean; 53 106 children: React.ReactNode; 54 107 }) { ··· 70 123 ); 71 124 })[0]; 72 125 126 + return ( 127 + <BaseThemeProvider 128 + bgLeaflet={bgLeaflet} 129 + bgPage={bgPage} 130 + primary={primary} 131 + highlight2={highlight2} 132 + highlight3={highlight3} 133 + highlight1={highlight1?.data.value} 134 + accent1={accent1} 135 + accent2={accent2} 136 + accentContrast={accentContrast} 137 + > 138 + {props.children} 139 + </BaseThemeProvider> 140 + ); 141 + } 142 + 143 + let BaseThemeProvider = ({ 144 + local, 145 + bgLeaflet, 146 + bgPage, 147 + primary, 148 + accent1, 149 + accent2, 150 + accentContrast, 151 + highlight1, 152 + highlight2, 153 + highlight3, 154 + children, 155 + }: { 156 + local?: boolean; 157 + bgLeaflet: AriaColor; 158 + bgPage: AriaColor; 159 + primary: AriaColor; 160 + accent1: AriaColor; 161 + accent2: AriaColor; 162 + accentContrast: AriaColor; 163 + highlight1?: string; 164 + highlight2: AriaColor; 165 + highlight3: AriaColor; 166 + children: React.ReactNode; 167 + }) => { 73 168 useEffect(() => { 74 - if (props.local) return; 169 + if (local) return; 75 170 let el = document.querySelector(":root") as HTMLElement; 76 171 if (!el) return; 77 172 setCSSVariableToColor(el, "--bg-leaflet", bgLeaflet); ··· 90 185 91 186 //highlight 1 is special because its default value is a calculated value 92 187 if (highlight1) { 93 - let color = parseColor(`hsba(${highlight1.data.value})`); 188 + let color = parseColor(`hsba(${highlight1})`); 94 189 el?.style.setProperty( 95 190 "--highlight-1", 96 191 `rgb(${colorToString(color, "rgb")})`, ··· 112 207 accentContrast === accent1 ? "1" : "0", 113 208 ); 114 209 }, [ 115 - props.local, 210 + local, 116 211 bgLeaflet, 117 212 bgPage, 118 213 primary, ··· 137 232 "--accent-contrast": colorToString(accentContrast, "rgb"), 138 233 "--accent-1-is-contrast": accentContrast === accent1 ? 1 : 0, 139 234 "--highlight-1": highlight1 140 - ? `rgb(${colorToString(parseColor(`hsba(${highlight1.data.value})`), "rgb")})` 235 + ? `rgb(${colorToString(parseColor(`hsba(${highlight1})`), "rgb")})` 141 236 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", 142 237 "--highlight-2": colorToString(highlight2, "rgb"), 143 238 "--highlight-3": colorToString(highlight3, "rgb"), 144 239 } as CSSProperties 145 240 } 146 241 > 147 - {props.children} 242 + {" "} 243 + {children}{" "} 148 244 </div> 149 245 ); 150 - } 246 + }; 151 247 152 248 let CardThemeProviderContext = createContext<null | string>(null); 153 249 export function NestedCardThemeProvider(props: { children: React.ReactNode }) {
+99 -745
components/ThemeManager/ThemeSetter.tsx
··· 1 1 "use client"; 2 2 import { Popover } from "components/Popover"; 3 - import * as Slider from "@radix-ui/react-slider"; 4 3 import { theme } from "../../tailwind.config"; 5 4 6 - import { 7 - ColorPicker as SpectrumColorPicker, 8 - parseColor, 9 - Color, 10 - ColorArea, 11 - ColorThumb, 12 - ColorSlider, 13 - Input, 14 - ColorField, 15 - SliderTrack, 16 - ColorSwatch, 17 - } from "react-aria-components"; 5 + import { Color } from "react-aria-components"; 18 6 19 - import { useEffect, useMemo, useState } from "react"; 7 + import { LeafletBGPicker } from "./LeafletBGPicker"; 8 + import { PageThemePickers } from "./PageThemePickers"; 9 + import { useMemo, useState } from "react"; 20 10 import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 21 11 import { Replicache } from "replicache"; 22 12 import { FilterAttributes } from "src/replicache/attributes"; 23 - import { 24 - colorToString, 25 - useColorAttribute, 26 - } from "components/ThemeManager/useColorAttribute"; 27 - import { addImage } from "src/utils/addImage"; 28 - import { Separator } from "components/Layout"; 13 + import { colorToString } from "components/ThemeManager/useColorAttribute"; 29 14 import { useEntitySetContext } from "components/EntitySetProvider"; 30 - import { isIOS, useViewportSize } from "@react-aria/utils"; 31 - import { onMouseDown } from "src/utils/iosInputMouseDown"; 32 15 import { ActionButton } from "components/ActionBar/ActionButton"; 33 - import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 34 - import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 35 16 import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 36 17 import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 37 - import { CloseContrastSmall } from "components/Icons/CloseContrastSmall"; 38 18 import { PaintSmall } from "components/Icons/PaintSmall"; 19 + import { AccentThemePickers } from "./AccentThemePickers"; 39 20 40 21 export type pickers = 41 22 | "null" ··· 63 44 } 64 45 export const ThemePopover = (props: { entityID: string; home?: boolean }) => { 65 46 let { rep } = useReplicache(); 66 - let pageLoaded = useInitialPageLoad(); 67 - // 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. 68 - let leafletValue = useColorAttribute(props.entityID, "theme/page-background"); 69 - let pageValue = useColorAttribute(props.entityID, "theme/card-background"); 70 - let primaryValue = useColorAttribute(props.entityID, "theme/primary"); 71 - let accent1Value = useColorAttribute( 72 - props.entityID, 73 - "theme/accent-background", 74 - ); 75 - let accent2Value = useColorAttribute(props.entityID, "theme/accent-text"); 76 47 48 + // 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. 77 49 let permission = useEntitySetContext().permissions.write; 78 50 let leafletBGImage = useEntity(props.entityID, "theme/background-image"); 79 51 let leafletBGRepeat = useEntity( 80 52 props.entityID, 81 53 "theme/background-image-repeat", 82 - ); 83 - let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 84 - let pageBGRepeat = useEntity( 85 - props.entityID, 86 - "theme/card-background-image-repeat", 87 54 ); 88 55 89 56 let [openPicker, setOpenPicker] = useState<pickers>( ··· 93 60 return setColorAttribute(rep, props.entityID); 94 61 }, [rep, props.entityID]); 95 62 96 - let randomPositions = useMemo(() => { 97 - let values = [] as string[]; 98 - for (let i = 0; i < 3; i++) { 99 - if (!pageLoaded) values.push(`100% 100%`); 100 - else 101 - values.push( 102 - `${Math.floor(Math.random() * 100)}% ${Math.floor(Math.random() * 100)}%`, 103 - ); 104 - } 105 - return values; 106 - }, [pageLoaded]); 107 - 108 - let gradient = [ 109 - `radial-gradient(at ${randomPositions[0]}, ${accent1Value.toString("hex")}80 2px, transparent 70%)`, 110 - `radial-gradient(at ${randomPositions[1]}, ${pageValue.toString("hex")}66 2px, transparent 60%)`, 111 - `radial-gradient(at ${randomPositions[2]}, ${primaryValue.toString("hex")}B3 2px, transparent 100%)`, 112 - ].join(", "); 113 - let viewheight = useViewportSize().height; 114 63 if (!permission) return null; 115 64 116 65 return ( 117 66 <> 118 67 <Popover 119 - className="w-80" 68 + className="w-80 bg-white" 69 + arrowFill="#FFFFFF" 120 70 asChild 121 71 trigger={<ActionButton icon={<PaintSmall />} label="Theme" />} 122 72 > 123 73 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 124 74 <div className="themeBGLeaflet flex"> 125 75 <div 126 - className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full px-2 pt-3`} 76 + className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 127 77 > 128 - <div className="bgPickerBody w-full flex flex-col gap-2 p-2 border border-[#CCCCCC] rounded-md"> 78 + <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md"> 129 79 <LeafletBGPicker 130 80 entityID={props.entityID} 131 81 thisPicker={"leaflet"} ··· 158 108 ? "cover" 159 109 : `calc(${leafletBGRepeat.data.value}px / 2 )`, 160 110 }} 161 - className={`bg-bg-leaflet mx-2 p-3 mb-2 flex flex-col rounded-md border border-border pb-0`} 111 + className={`bg-bg-leaflet p-3 mb-2 flex flex-col rounded-md border border-border pb-0`} 162 112 > 163 113 <div className={`flex flex-col z-10 mt-4 -mb-[6px] `}> 164 - <div 165 - className="themeLeafletControls text-accent-2 flex flex-col gap-2 h-full bg-bg-leaflet p-2 rounded-md border border-accent-2 shadow-[0_0_0_1px_rgb(var(--accent-1))]" 166 - style={{ 167 - backgroundColor: "rgba(var(--accent-1), 0.6)", 168 - }} 169 - > 170 - <ColorPicker 171 - label="Accent" 172 - value={accent1Value} 173 - setValue={set("theme/accent-background")} 174 - thisPicker={"accent-1"} 175 - openPicker={openPicker} 176 - setOpenPicker={setOpenPicker} 177 - closePicker={() => setOpenPicker("null")} 178 - /> 179 - <ColorPicker 180 - label="Text on Accent" 181 - value={accent2Value} 182 - setValue={set("theme/accent-text")} 183 - thisPicker={"accent-2"} 184 - openPicker={openPicker} 185 - setOpenPicker={setOpenPicker} 186 - closePicker={() => setOpenPicker("null")} 187 - /> 188 - </div> 114 + <AccentThemePickers 115 + entityID={props.entityID} 116 + openPicker={openPicker} 117 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 118 + /> 189 119 <SectionArrow 190 120 fill={theme.colors["accent-2"]} 191 121 stroke={theme.colors["accent-1"]} ··· 193 123 /> 194 124 </div> 195 125 196 - <div 197 - onClick={(e) => { 198 - e.target === e.currentTarget && setOpenPicker("accent-1"); 199 - }} 200 - className="pointer-cursor font-bold relative text-center text-lg py-2 rounded-md bg-accent-1 text-accent-2 shadow-md flex items-center justify-center" 201 - > 202 - <div 203 - className="cursor-pointer w-fit" 204 - onClick={() => { 205 - setOpenPicker("accent-2"); 206 - }} 207 - > 208 - Example Button 209 - </div> 210 - </div> 126 + <SampleButton 127 + entityID={props.entityID} 128 + setOpenPicker={setOpenPicker} 129 + /> 211 130 212 131 <div className="flex flex-col mt-8 -mb-[6px] z-10"> 213 - <div 214 - className="themeLeafletControls flex flex-col gap-2 h-full text-primary bg-bg-leaflet p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]" 215 - style={{ backgroundColor: "rgba(var(--bg-page, 0.6)" }} 216 - > 217 - <ColorPicker 218 - label={props.home ? "Menu" : "Page"} 219 - alpha 220 - value={pageValue} 221 - setValue={set("theme/card-background")} 222 - thisPicker={"page"} 223 - openPicker={openPicker} 224 - setOpenPicker={setOpenPicker} 225 - closePicker={() => setOpenPicker("null")} 226 - /> 227 - <ColorPicker 228 - label={props.home ? "Menu Text" : "Text"} 229 - value={primaryValue} 230 - setValue={set("theme/primary")} 231 - thisPicker={"text"} 232 - openPicker={openPicker} 233 - setOpenPicker={setOpenPicker} 234 - closePicker={() => setOpenPicker("null")} 235 - /> 236 - </div> 132 + <PageThemePickers 133 + home 134 + entityID={props.entityID} 135 + openPicker={openPicker} 136 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 137 + /> 237 138 <SectionArrow 238 139 fill={theme.colors["primary"]} 239 140 stroke={theme.colors["bg-page"]} ··· 241 142 /> 242 143 </div> 243 144 244 - <SamplePage setOpenPicker={setOpenPicker} home={props.home} /> 145 + <SamplePage 146 + setOpenPicker={setOpenPicker} 147 + home={props.home} 148 + entityID={props.entityID} 149 + /> 245 150 </div> 246 151 {!props.home && <WatermarkSetter entityID={props.entityID} />} 247 152 </div> ··· 249 154 </> 250 155 ); 251 156 }; 157 + 252 158 function WatermarkSetter(props: { entityID: string }) { 253 159 let { rep } = useReplicache(); 254 160 let checked = useEntity(props.entityID, "theme/page-leaflet-watermark"); ··· 283 189 ); 284 190 } 285 191 286 - const SamplePage = (props: { 287 - home: boolean | undefined; 288 - setOpenPicker: (picker: "page" | "text") => void; 192 + const SampleButton = (props: { 193 + entityID: string; 194 + setOpenPicker: (thisPicker: pickers) => void; 289 195 }) => { 290 196 return ( 291 197 <div 292 198 onClick={(e) => { 293 - e.currentTarget === e.target && props.setOpenPicker("page"); 199 + e.target === e.currentTarget && props.setOpenPicker("accent-1"); 294 200 }} 295 - className={`${props.home ? "rounded-md " : "rounded-t-lg "} cursor-pointer p-2 border border-border border-b-transparent shadow-md text-primary`} 296 - style={{ 297 - backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 298 - }} 201 + className="pointer-cursor font-bold relative text-center text-lg py-2 rounded-md bg-accent-1 text-accent-2 shadow-md flex items-center justify-center" 299 202 > 300 - <p 203 + <div 204 + className="cursor-pointer w-fit" 301 205 onClick={() => { 302 - props.setOpenPicker("text"); 206 + props.setOpenPicker("accent-2"); 303 207 }} 304 - className=" cursor-pointer font-bold w-fit" 305 208 > 306 - Hello! 307 - </p> 308 - <small onClick={() => props.setOpenPicker("text")}> 309 - Welcome to{" "} 310 - <span className="font-bold text-accent-contrast">Leaflet</span>. 311 - It&apos;s a super easy and fun way to make, share, and collab on little 312 - bits of paper 313 - </small> 314 - </div> 315 - ); 316 - }; 317 - 318 - let thumbStyle = 319 - "w-4 h-4 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C,_inset_0_0_0_1px_#8C8C8C]"; 320 - 321 - export const ColorPicker = (props: { 322 - label?: string; 323 - value: Color | undefined; 324 - alpha?: boolean; 325 - image?: boolean; 326 - setValue: (c: Color) => void; 327 - openPicker: pickers; 328 - thisPicker: pickers; 329 - setOpenPicker: (thisPicker: pickers) => void; 330 - closePicker: () => void; 331 - children?: React.ReactNode; 332 - }) => { 333 - return ( 334 - <SpectrumColorPicker value={props.value} onChange={props.setValue}> 335 - <div className="flex flex-col w-full gap-2"> 336 - <div className="colorPickerLabel flex gap-2 items-center "> 337 - <button 338 - className="flex gap-2 items-center " 339 - onClick={() => { 340 - if (props.openPicker === props.thisPicker) { 341 - props.setOpenPicker("null"); 342 - } else { 343 - props.setOpenPicker(props.thisPicker); 344 - } 345 - }} 346 - > 347 - <ColorSwatch 348 - color={props.value} 349 - className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`} 350 - style={{ 351 - backgroundSize: "cover", 352 - }} 353 - /> 354 - <strong className="">{props.label}</strong> 355 - </button> 356 - 357 - <div className="flex gap-1"> 358 - {props.value === undefined ? ( 359 - <div>default</div> 360 - ) : ( 361 - <ColorField className="w-fit gap-1"> 362 - <Input 363 - onMouseDown={onMouseDown} 364 - onFocus={(e) => { 365 - e.currentTarget.setSelectionRange( 366 - 1, 367 - e.currentTarget.value.length, 368 - ); 369 - }} 370 - onKeyDown={(e) => { 371 - if (e.key === "Enter") { 372 - e.currentTarget.blur(); 373 - } else return; 374 - }} 375 - onBlur={(e) => { 376 - props.setValue(parseColor(e.currentTarget.value)); 377 - }} 378 - className="w-[72px] bg-transparent outline-none" 379 - /> 380 - </ColorField> 381 - )} 382 - {props.alpha && ( 383 - <> 384 - <Separator classname="my-1" /> 385 - <ColorField className="w-fit pl-[6px]" channel="alpha"> 386 - <Input 387 - onMouseDown={onMouseDown} 388 - onFocus={(e) => { 389 - e.currentTarget.setSelectionRange( 390 - 0, 391 - e.currentTarget.value.length - 1, 392 - ); 393 - }} 394 - onKeyDown={(e) => { 395 - if (e.key === "Enter") { 396 - e.currentTarget.blur(); 397 - } else return; 398 - }} 399 - className="w-[72px] bg-transparent outline-none text-primary" 400 - /> 401 - </ColorField> 402 - </> 403 - )} 404 - </div> 405 - </div> 406 - {props.openPicker === props.thisPicker && ( 407 - <div className="w-full flex flex-col gap-2 px-1 pb-2"> 408 - { 409 - <> 410 - <ColorArea 411 - className="w-full h-[128px] rounded-md" 412 - colorSpace="hsb" 413 - xChannel="saturation" 414 - yChannel="brightness" 415 - > 416 - <ColorThumb className={thumbStyle} /> 417 - </ColorArea> 418 - <ColorSlider colorSpace="hsb" className="w-full" channel="hue"> 419 - <SliderTrack className="h-2 w-full rounded-md"> 420 - <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 421 - </SliderTrack> 422 - </ColorSlider> 423 - {props.alpha && ( 424 - <ColorSlider 425 - colorSpace="hsb" 426 - className="w-full mt-1 rounded-full" 427 - style={{ 428 - backgroundImage: `url(./transparent-bg.png)`, 429 - backgroundRepeat: "repeat", 430 - backgroundSize: "8px", 431 - }} 432 - channel="alpha" 433 - > 434 - <SliderTrack className="h-2 w-full rounded-md"> 435 - <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 436 - </SliderTrack> 437 - </ColorSlider> 438 - )} 439 - {props.children} 440 - </> 441 - } 442 - </div> 443 - )} 209 + Example Button 444 210 </div> 445 - </SpectrumColorPicker> 211 + </div> 446 212 ); 447 213 }; 448 - 449 - export const LeafletBGPicker = (props: { 214 + const SamplePage = (props: { 450 215 entityID: string; 451 - openPicker: pickers; 452 - thisPicker: pickers; 453 - setOpenPicker: (thisPicker: pickers) => void; 454 - closePicker: () => void; 455 - setValue: (c: Color) => void; 456 - card?: boolean; 216 + home: boolean | undefined; 217 + setOpenPicker: (picker: "page" | "text") => void; 457 218 }) => { 458 - let bgImage = useEntity( 219 + let pageBGImage = useEntity(props.entityID, "theme/card-background-image"); 220 + let pageBGRepeat = useEntity( 459 221 props.entityID, 460 - props.card ? "theme/card-background-image" : "theme/background-image", 222 + "theme/card-background-image-repeat", 461 223 ); 462 - let bgColor = useColorAttribute( 224 + let pageBGOpacity = useEntity( 463 225 props.entityID, 464 - props.card ? "theme/card-background" : "theme/page-background", 465 - ); 466 - let open = props.openPicker == props.thisPicker; 467 - let { rep } = useReplicache(); 468 - 469 - return ( 470 - <> 471 - <div className="bgPickerLabel flex justify-between place-items-center "> 472 - <div className="bgPickerColorLabel flex gap-2 items-center"> 473 - <button 474 - onClick={() => { 475 - if (props.openPicker === props.thisPicker) { 476 - props.setOpenPicker("null"); 477 - } else { 478 - props.setOpenPicker(props.thisPicker); 479 - } 480 - }} 481 - className="flex gap-2 items-center" 482 - > 483 - <ColorSwatch 484 - color={bgColor} 485 - className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`} 486 - style={{ 487 - backgroundImage: bgImage?.data.src 488 - ? `url(${bgImage.data.src})` 489 - : undefined, 490 - backgroundSize: "cover", 491 - }} 492 - /> 493 - <strong 494 - className={`${props.card ? "text-primary" : "text-[#595959]"}`} 495 - > 496 - {props.card ? "Page" : "Background"} 497 - </strong> 498 - </button> 499 - 500 - <div className="flex"> 501 - {bgImage ? ( 502 - <div 503 - className={`${props.card ? "text-secondary" : "text-[#969696]"}`} 504 - > 505 - Image 506 - </div> 507 - ) : ( 508 - <> 509 - <ColorField className="w-fit gap-1" value={bgColor}> 510 - <Input 511 - onMouseDown={onMouseDown} 512 - onFocus={(e) => { 513 - e.currentTarget.setSelectionRange( 514 - 1, 515 - e.currentTarget.value.length, 516 - ); 517 - }} 518 - onPaste={(e) => { 519 - console.log(e); 520 - }} 521 - onKeyDown={(e) => { 522 - if (e.key === "Enter") { 523 - e.currentTarget.blur(); 524 - } else return; 525 - }} 526 - onBlur={(e) => { 527 - props.setValue(parseColor(e.currentTarget.value)); 528 - }} 529 - className={`w-[72px] bg-transparent outline-none ${props.card ? "text-primary" : "text-[#595959]"}`} 530 - /> 531 - </ColorField> 532 - {props.card && ( 533 - <> 534 - <Separator classname="my-1" /> 535 - 536 - <SpectrumColorPicker 537 - value={bgColor} 538 - onChange={setColorAttribute( 539 - rep, 540 - props.entityID, 541 - )( 542 - props.card 543 - ? "theme/card-background" 544 - : "theme/page-background", 545 - )} 546 - > 547 - <ColorField className="w-fit pl-[6px]" channel="alpha"> 548 - <Input 549 - onMouseDown={onMouseDown} 550 - onFocus={(e) => { 551 - e.currentTarget.setSelectionRange( 552 - 0, 553 - e.currentTarget.value.length - 1, 554 - ); 555 - }} 556 - onKeyDown={(e) => { 557 - if (e.key === "Enter") { 558 - e.currentTarget.blur(); 559 - } else return; 560 - }} 561 - className="w-[48px] bg-transparent outline-none text-primary" 562 - /> 563 - </ColorField> 564 - </SpectrumColorPicker> 565 - </> 566 - )} 567 - </> 568 - )} 569 - </div> 570 - </div> 571 - <label className="hover:cursor-pointer h-fit"> 572 - <div 573 - className={ 574 - props.card 575 - ? "text-tertiary hover:text-accent-contrast" 576 - : "text-[#8C8C8C] hover:text-[#0000FF]" 577 - } 578 - > 579 - <BlockImageSmall /> 580 - </div> 581 - <div className="hidden"> 582 - <ImageInput 583 - {...props} 584 - onChange={() => { 585 - props.setOpenPicker(props.thisPicker); 586 - }} 587 - /> 588 - </div> 589 - </label> 590 - </div> 591 - {open && ( 592 - <div className="bgImageAndColorPicker w-full flex flex-col gap-2 "> 593 - <SpectrumColorPicker 594 - value={bgColor} 595 - onChange={setColorAttribute( 596 - rep, 597 - props.entityID, 598 - )(props.card ? "theme/card-background" : "theme/page-background")} 599 - > 600 - {bgImage ? ( 601 - <ImageSettings 602 - entityID={props.entityID} 603 - card={props.card} 604 - setValue={props.setValue} 605 - /> 606 - ) : ( 607 - <> 608 - <ColorArea 609 - className="w-full h-[128px] rounded-md" 610 - colorSpace="hsb" 611 - xChannel="saturation" 612 - yChannel="brightness" 613 - > 614 - <ColorThumb className={thumbStyle} /> 615 - </ColorArea> 616 - <ColorSlider 617 - colorSpace="hsb" 618 - className="w-full " 619 - channel="hue" 620 - > 621 - <SliderTrack className="h-2 w-full rounded-md"> 622 - <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 623 - </SliderTrack> 624 - </ColorSlider> 625 - </> 626 - )} 627 - {props.card && ( 628 - <ColorSlider 629 - colorSpace="hsb" 630 - className="w-full mt-1 rounded-full" 631 - style={{ 632 - backgroundImage: `url(./transparent-bg.png)`, 633 - backgroundRepeat: "repeat", 634 - backgroundSize: "8px", 635 - }} 636 - channel="alpha" 637 - > 638 - <SliderTrack className="h-2 w-full rounded-md"> 639 - <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 640 - </SliderTrack> 641 - </ColorSlider> 642 - )} 643 - </SpectrumColorPicker> 644 - </div> 645 - )} 646 - </> 226 + "theme/card-background-image-opacity", 647 227 ); 648 - }; 649 - 650 - export const PageBGPicker = (props: { 651 - entityID: string; 652 - openPicker: pickers; 653 - thisPicker: pickers; 654 - setOpenPicker: (thisPicker: pickers) => void; 655 - closePicker: () => void; 656 - setValue: (c: Color) => void; 657 - }) => { 658 - let bgImage = useEntity(props.entityID, "theme/card-background-image"); 659 - let bgColor = useColorAttribute(props.entityID, "theme/card-background"); 660 - let bgAlpha = 661 - useEntity(props.entityID, "theme/card-background-image-opacity")?.data 662 - .value || 1; 663 - let alphaColor = useMemo(() => { 664 - return parseColor(`rgba(0,0,0,${bgAlpha})`); 665 - }, [bgAlpha]); 666 - let open = props.openPicker == props.thisPicker; 667 - let { rep } = useReplicache(); 228 + let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden") 229 + ?.data.value; 668 230 669 231 return ( 670 - <> 671 - <div className="bgPickerColorLabel flex gap-2 items-center"> 672 - <button 673 - onClick={() => { 674 - if (props.openPicker === props.thisPicker) { 675 - props.setOpenPicker("null"); 676 - } else { 677 - props.setOpenPicker(props.thisPicker); 232 + <div 233 + onClick={(e) => { 234 + e.currentTarget === e.target && props.setOpenPicker("page"); 235 + }} 236 + className={ 237 + pageBorderHidden 238 + ? "py-2 px-0 border border-transparent" 239 + : `${props.home ? "rounded-md " : "rounded-t-lg "} relative cursor-pointer p-2 border border-border border-b-transparent shadow-md text-primary 240 + ` 241 + } 242 + style={ 243 + pageBorderHidden 244 + ? undefined 245 + : { 246 + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 678 247 } 679 - }} 680 - className="flex gap-2 items-center" 681 - > 682 - <ColorSwatch 683 - color={bgColor} 684 - className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]`} 685 - style={{ 686 - backgroundImage: bgImage?.data.src 687 - ? `url(${bgImage.data.src})` 688 - : undefined, 689 - backgroundPosition: "center", 690 - backgroundSize: "cover", 691 - }} 692 - /> 693 - <strong className={`text-primary`}>Background Image</strong> 694 - </button> 695 - 696 - <SpectrumColorPicker 697 - value={alphaColor} 698 - onChange={(c) => { 699 - let alpha = c.getChannelValue("alpha"); 700 - rep?.mutate.assertFact({ 701 - entity: props.entityID, 702 - attribute: "theme/card-background-image-opacity", 703 - data: { type: "number", value: alpha }, 704 - }); 705 - }} 706 - > 707 - <Separator classname="h-5 my-1" /> 708 - <ColorField className="w-fit pl-[6px]" channel="alpha"> 709 - <Input 710 - onMouseDown={onMouseDown} 711 - onFocus={(e) => { 712 - e.currentTarget.setSelectionRange( 713 - 0, 714 - e.currentTarget.value.length - 1, 715 - ); 716 - }} 717 - onKeyDown={(e) => { 718 - if (e.key === "Enter") { 719 - e.currentTarget.blur(); 720 - } else return; 721 - }} 722 - className="w-[48px] bg-transparent outline-none text-primary" 723 - /> 724 - </ColorField> 725 - </SpectrumColorPicker> 726 - </div> 727 - {open && ( 728 - <div className="pageImagePicker flex flex-col gap-2"> 729 - <ImageSettings 730 - entityID={props.entityID} 731 - card 732 - setValue={props.setValue} 733 - /> 734 - 735 - <SpectrumColorPicker 736 - value={alphaColor} 737 - onChange={(c) => { 738 - let alpha = c.getChannelValue("alpha"); 739 - rep?.mutate.assertFact({ 740 - entity: props.entityID, 741 - attribute: "theme/card-background-image-opacity", 742 - data: { type: "number", value: alpha }, 743 - }); 744 - }} 745 - > 746 - <ColorSlider 747 - colorSpace="hsb" 748 - className="w-full mt-1 rounded-full" 749 - style={{ 750 - backgroundImage: `url(./transparent-bg.png)`, 751 - backgroundRepeat: "repeat", 752 - backgroundSize: "8px", 753 - }} 754 - channel="alpha" 755 - > 756 - <SliderTrack className="h-2 w-full rounded-md"> 757 - <ColorThumb className={`${thumbStyle} mt-[4px]`} /> 758 - </SliderTrack> 759 - </ColorSlider> 760 - </SpectrumColorPicker> 761 - </div> 762 - )} 763 - </> 764 - ); 765 - }; 766 - 767 - export const ImageInput = (props: { 768 - entityID: string; 769 - onChange?: () => void; 770 - card?: boolean; 771 - }) => { 772 - let pageType = useEntity(props.entityID, "page/type")?.data.value; 773 - let { rep } = useReplicache(); 774 - return ( 775 - <input 776 - type="file" 777 - accept="image/*" 778 - onChange={async (e) => { 779 - let file = e.currentTarget.files?.[0]; 780 - if (!file || !rep) return; 781 - 782 - await addImage(file, rep, { 783 - entityID: props.entityID, 784 - attribute: props.card 785 - ? "theme/card-background-image" 786 - : "theme/background-image", 787 - }); 788 - props.onChange?.(); 248 + } 249 + > 250 + <div 251 + className="background absolute top-0 right-0 bottom-0 left-0 z-0 rounded-t-lg" 252 + style={ 253 + pageBorderHidden 254 + ? undefined 255 + : { 256 + backgroundImage: pageBGImage 257 + ? `url(${pageBGImage.data.src})` 258 + : undefined, 789 259 790 - if (pageType === "canvas") { 791 - rep && 792 - rep.mutate.assertFact({ 793 - entity: props.entityID, 794 - attribute: "canvas/background-pattern", 795 - data: { type: "canvas-pattern-union", value: "plain" }, 796 - }); 260 + backgroundRepeat: pageBGRepeat ? "repeat" : "no-repeat", 261 + opacity: pageBGOpacity?.data.value || 1, 262 + backgroundSize: !pageBGRepeat 263 + ? "cover" 264 + : `calc(${pageBGRepeat.data.value}px / 2 )`, 265 + } 797 266 } 798 - }} 799 - /> 800 - ); 801 - }; 802 - 803 - export const ImageSettings = (props: { 804 - entityID: string; 805 - card?: boolean; 806 - setValue: (c: Color) => void; 807 - }) => { 808 - let image = useEntity( 809 - props.entityID, 810 - props.card ? "theme/card-background-image" : "theme/background-image", 811 - ); 812 - let repeat = useEntity( 813 - props.entityID, 814 - props.card 815 - ? "theme/card-background-image-repeat" 816 - : "theme/background-image-repeat", 817 - ); 818 - let pageType = useEntity(props.entityID, "page/type")?.data.value; 819 - let { rep } = useReplicache(); 820 - return ( 821 - <> 822 - <div 823 - style={{ 824 - backgroundImage: image?.data.src 825 - ? `url(${image.data.src})` 826 - : undefined, 827 - backgroundPosition: "center", 828 - backgroundSize: "cover", 829 - }} 830 - className="themeBGImagePreview flex gap-2 place-items-center justify-center w-full h-[128px] bg-cover bg-center bg-no-repeat" 831 - > 832 - <label className="hover:cursor-pointer "> 833 - <div 834 - className="flex gap-2 rounded-md px-2 py-1 text-accent-contrast font-bold" 835 - style={{ backgroundColor: "rgba(var(--bg-page), .6" }} 836 - > 837 - <BlockImageSmall /> Change Image 838 - </div> 839 - <div className="hidden"> 840 - <ImageInput {...props} /> 841 - </div> 842 - </label> 843 - <button 267 + /> 268 + <div> 269 + <p 844 270 onClick={() => { 845 - if (image) rep?.mutate.retractFact({ factID: image.id }); 846 - if (repeat) rep?.mutate.retractFact({ factID: repeat.id }); 271 + props.setOpenPicker("text"); 847 272 }} 273 + className=" cursor-pointer font-bold w-fit" 848 274 > 849 - <CloseContrastSmall 850 - fill={theme.colors["accent-1"]} 851 - stroke={theme.colors["accent-2"]} 852 - /> 853 - </button> 275 + Hello! 276 + </p> 277 + <small onClick={() => props.setOpenPicker("text")}> 278 + Welcome to{" "} 279 + <span className="font-bold text-accent-contrast">Leaflet</span>. 280 + It&apos;s a super easy and fun way to make, share, and collab on 281 + little bits of paper 282 + </small> 854 283 </div> 855 - <div className="themeBGImageControls font-bold flex gap-2 items-center"> 856 - {pageType !== "canvas" && ( 857 - <label htmlFor="cover" className="flex shrink-0"> 858 - <input 859 - className="appearance-none" 860 - type="radio" 861 - id="cover" 862 - name="bg-image-options" 863 - value="cover" 864 - checked={!repeat} 865 - onChange={async (e) => { 866 - if (!e.currentTarget.checked) return; 867 - if (!repeat) return; 868 - if (repeat) 869 - await rep?.mutate.retractFact({ factID: repeat.id }); 870 - }} 871 - /> 872 - <div 873 - className={`shink-0 grow-0 w-fit border border-accent-1 rounded-md px-1 py-0.5 cursor-pointer ${!repeat ? "bg-accent-1 text-accent-2" : "bg-transparent text-accent-1"}`} 874 - > 875 - cover 876 - </div> 877 - </label> 878 - )} 879 - <label htmlFor="repeat" className="flex shrink-0"> 880 - <input 881 - className={`appearance-none `} 882 - type="radio" 883 - id="repeat" 884 - name="bg-image-options" 885 - value="repeat" 886 - checked={!!repeat} 887 - onChange={async (e) => { 888 - if (!e.currentTarget.checked) return; 889 - if (repeat) return; 890 - await rep?.mutate.assertFact({ 891 - entity: props.entityID, 892 - attribute: props.card 893 - ? "theme/card-background-image-repeat" 894 - : "theme/background-image-repeat", 895 - data: { type: "number", value: 500 }, 896 - }); 897 - }} 898 - /> 899 - <div 900 - className={`shink-0 grow-0 w-fit z-10 border border-accent-1 rounded-md px-1 py-0.5 cursor-pointer ${repeat ? "bg-accent-1 text-accent-2" : "bg-transparent text-accent-1"}`} 901 - > 902 - repeat 903 - </div> 904 - </label> 905 - {(repeat || pageType === "canvas") && ( 906 - <Slider.Root 907 - className="relative grow flex items-center select-none touch-none w-full h-fit" 908 - value={[repeat?.data.value || 500]} 909 - max={3000} 910 - min={10} 911 - step={10} 912 - onValueChange={(value) => { 913 - rep?.mutate.assertFact({ 914 - entity: props.entityID, 915 - attribute: props.card 916 - ? "theme/card-background-image-repeat" 917 - : "theme/background-image-repeat", 918 - data: { type: "number", value: value[0] }, 919 - }); 920 - }} 921 - > 922 - <Slider.Track className="bg-accent-1 relative grow rounded-full h-[3px]"></Slider.Track> 923 - <Slider.Thumb 924 - className="flex w-4 h-4 rounded-full border-2 border-white bg-accent-1 shadow-[0_0_0_1px_#8C8C8C,_inset_0_0_0_1px_#8C8C8C] cursor-pointer" 925 - aria-label="Volume" 926 - /> 927 - </Slider.Root> 928 - )} 929 - </div> 930 - </> 284 + </div> 931 285 ); 932 286 }; 933 287
+1 -1
components/ThemeManager/useColorAttribute.ts
··· 5 5 import { ThemeDefaults } from "./ThemeProvider"; 6 6 7 7 export function useColorAttribute( 8 - entity: string, 8 + entity: string | null, 9 9 attribute: keyof FilterAttributes<{ type: "color"; cardinality: "one" }>, 10 10 ) { 11 11 let { rootEntity } = useReplicache();
+3 -2
components/Toolbar/HighlightToolbar.tsx
··· 10 10 import * as Tooltip from "@radix-ui/react-tooltip"; 11 11 import { theme } from "../../tailwind.config"; 12 12 import { 13 - ColorPicker, 14 13 pickers, 15 14 SectionArrow, 16 15 setColorAttribute, 17 16 } from "components/ThemeManager/ThemeSetter"; 17 + import { ColorPicker } from "components/ThemeManager/ColorPicker"; 18 18 import { useEntity, useReplicache } from "src/replicache"; 19 19 import { useEffect, useMemo, useState } from "react"; 20 20 import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; ··· 22 22 import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 23 23 24 24 import { Separator, ShortcutKey } from "components/Layout"; 25 - import { isMac } from "@react-aria/utils"; 26 25 import { ToolbarButton } from "."; 27 26 import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider"; 28 27 import { Props } from "components/Icons/Props"; 29 28 import { PopoverArrow } from "components/Icons/PopoverArrow"; 30 29 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 31 30 import { PaintSmall } from "components/Icons/PaintSmall"; 31 + import { Color } from "react-aria-components"; 32 + import { isMac } from "src/utils/isDevice"; 32 33 33 34 export const HighlightButton = (props: { 34 35 lastUsedHighlight: string;
+1 -1
components/Toolbar/TextToolbar.tsx
··· 1 - import { isMac } from "@react-aria/utils"; 2 1 import { Separator, ShortcutKey } from "components/Layout"; 3 2 import { metaKey } from "src/utils/metaKey"; 4 3 import { LinkButton } from "./InlineLinkToolbar"; ··· 11 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 12 11 import { LockBlockButton } from "./LockBlockButton"; 13 12 import { Props } from "components/Icons/Props"; 13 + import { isMac } from "src/utils/isDevice"; 14 14 15 15 export const TextToolbar = (props: { 16 16 lastUsedHighlight: string;
+37 -1
components/ViewportSizeLayout.tsx
··· 1 1 "use client"; 2 - import { isIOS, useViewportSize } from "@react-aria/utils"; 3 2 import { useEffect, useState } from "react"; 3 + import { isIOS } from "src/utils/isDevice"; 4 4 5 5 export function ViewportSizeLayout(props: { children: React.ReactNode }) { 6 6 let viewheight = useViewportSize().height; ··· 53 53 height: visualViewport?.height || window?.innerHeight, 54 54 }; 55 55 } 56 + 57 + export function useViewportSize(): { 58 + width: number; 59 + height: number; 60 + } { 61 + let [size, setSize] = useState(() => getViewportSize()); 62 + 63 + useEffect(() => { 64 + // Use visualViewport api to track available height even on iOS virtual keyboard opening 65 + let onResize = () => { 66 + setSize((size) => { 67 + let newSize = getViewportSize(); 68 + if (newSize.width === size.width && newSize.height === size.height) { 69 + return size; 70 + } 71 + return newSize; 72 + }); 73 + }; 74 + 75 + if (!visualViewport) { 76 + window.addEventListener("resize", onResize); 77 + } else { 78 + visualViewport.addEventListener("resize", onResize); 79 + } 80 + 81 + return () => { 82 + if (!visualViewport) { 83 + window.removeEventListener("resize", onResize); 84 + } else { 85 + visualViewport.removeEventListener("resize", onResize); 86 + } 87 + }; 88 + }, []); 89 + 90 + return size; 91 + }
+24
components/utils/AutosizeTextarea.tsx
··· 1 + import { forwardRef, useImperativeHandle, useRef } from "react"; 2 + import styles from "./textarea-styles.module.css"; 3 + 4 + type Props = React.DetailedHTMLProps< 5 + React.TextareaHTMLAttributes<HTMLTextAreaElement>, 6 + HTMLTextAreaElement 7 + >; 8 + export const AutosizeTextarea = forwardRef<HTMLTextAreaElement, Props>( 9 + (props: Props, ref) => { 10 + let textarea = useRef<HTMLTextAreaElement | null>(null); 11 + useImperativeHandle(ref, () => textarea.current as HTMLTextAreaElement); 12 + 13 + return ( 14 + <div 15 + className={`${styles["grow-wrap"]} ${props.className}`} 16 + data-replicated-value={props.value} 17 + style={props.style} 18 + > 19 + <textarea rows={1} {...props} ref={textarea} /> 20 + </div> 21 + ); 22 + }, 23 + ); 24 + AutosizeTextarea.displayName = "Textarea";
+38
components/utils/textarea-styles.module.css
··· 1 + .grow-wrap { 2 + /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ 3 + display: grid; 4 + position: relative; 5 + max-width: 100%; 6 + overflow-wrap: anywhere; /* limit width in chrome */ 7 + } 8 + 9 + .grow-wrap::after { 10 + /* Note the weird space! Needed to preventy jumpy behavior */ 11 + content: attr(data-replicated-value) " "; 12 + 13 + /* This is how textarea text behaves */ 14 + white-space: pre-wrap; 15 + 16 + /* Hidden from view, clicks, and screen readers */ 17 + visibility: hidden; 18 + } 19 + .grow-wrap > textarea { 20 + /* You could leave this, but after a user resizes, then it ruins the auto sizing */ 21 + resize: none; 22 + 23 + /* Firefox shows scrollbar on growth, you can hide like this. */ 24 + overflow: hidden; 25 + } 26 + .grow-wrap > textarea, 27 + .grow-wrap::after { 28 + padding: 0; 29 + width: 100%; 30 + font: inherit; 31 + border: none; 32 + /* Place on top of each other */ 33 + grid-area: 1 / 1 / 2 / 2; 34 + } 35 + 36 + .grow-wrap > textarea:focus { 37 + outline: none; 38 + }
+1
cursor
··· 1 + 9135914657
+19 -6
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { entities, facts, entity_sets, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, poll_votes_on_entity, subscribers_to_publications, publications, permission_token_on_homepage, documents, documents_in_publications, leaflets_in_publications, permission_token_rights } from "./schema"; 2 + import { entities, facts, entity_sets, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, poll_votes_on_entity, publication_domains, publications, subscribers_to_publications, permission_token_on_homepage, documents, documents_in_publications, leaflets_in_publications, permission_token_rights } from "./schema"; 3 3 4 4 export const factsRelations = relations(facts, ({one}) => ({ 5 5 entity: one(entities, { ··· 107 107 fields: [custom_domains.identity], 108 108 references: [identities.email] 109 109 }), 110 + publication_domains: many(publication_domains), 110 111 })); 111 112 112 113 export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ ··· 122 123 }), 123 124 })); 124 125 125 - export const subscribers_to_publicationsRelations = relations(subscribers_to_publications, ({one}) => ({ 126 - identity: one(identities, { 127 - fields: [subscribers_to_publications.identity], 128 - references: [identities.email] 126 + export const publication_domainsRelations = relations(publication_domains, ({one}) => ({ 127 + custom_domain: one(custom_domains, { 128 + fields: [publication_domains.domain], 129 + references: [custom_domains.domain] 129 130 }), 130 131 publication: one(publications, { 131 - fields: [subscribers_to_publications.publication], 132 + fields: [publication_domains.publication], 132 133 references: [publications.uri] 133 134 }), 134 135 })); 135 136 136 137 export const publicationsRelations = relations(publications, ({many}) => ({ 138 + publication_domains: many(publication_domains), 137 139 subscribers_to_publications: many(subscribers_to_publications), 138 140 documents_in_publications: many(documents_in_publications), 139 141 leaflets_in_publications: many(leaflets_in_publications), 142 + })); 143 + 144 + export const subscribers_to_publicationsRelations = relations(subscribers_to_publications, ({one}) => ({ 145 + identity: one(identities, { 146 + fields: [subscribers_to_publications.identity], 147 + references: [identities.email] 148 + }), 149 + publication: one(publications, { 150 + fields: [subscribers_to_publications.publication], 151 + references: [publications.uri] 152 + }), 140 153 })); 141 154 142 155 export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({
+14
drizzle/schema.ts
··· 29 29 indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 30 30 name: text("name").notNull(), 31 31 identity_did: text("identity_did").notNull(), 32 + record: jsonb("record"), 32 33 }); 33 34 34 35 export const facts = pgTable("facts", { ··· 159 160 voter_token: uuid("voter_token").notNull(), 160 161 }); 161 162 163 + export const publication_domains = pgTable("publication_domains", { 164 + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 165 + domain: text("domain").notNull().references(() => custom_domains.domain, { onDelete: "cascade" } ), 166 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 167 + }, 168 + (table) => { 169 + return { 170 + publication_domains_pkey: primaryKey({ columns: [table.publication, table.domain], name: "publication_domains_pkey"}), 171 + } 172 + }); 173 + 162 174 export const subscribers_to_publications = pgTable("subscribers_to_publications", { 163 175 identity: text("identity").notNull().references(() => identities.email, { onUpdate: "cascade" } ), 164 176 publication: text("publication").notNull().references(() => publications.uri), ··· 196 208 publication: text("publication").notNull().references(() => publications.uri), 197 209 doc: text("doc").default('').references(() => documents.uri), 198 210 leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id), 211 + description: text("description").default('').notNull(), 212 + title: text("title").default('').notNull(), 199 213 }, 200 214 (table) => { 201 215 return {
+54 -105
lexicons/api/index.ts
··· 2 2 * GENERATED CODE - DO NOT MODIFY 3 3 */ 4 4 import { XrpcClient, FetchHandler, FetchHandlerOptions } from '@atproto/xrpc' 5 - import { schemas } from './lexicons.js' 5 + import { schemas } from './lexicons' 6 6 import { CID } from 'multiformats/cid' 7 - import { OmitKey, Un$Typed } from './util.js' 8 - import * as PubLeafletDocument from './types/pub/leaflet/document.js' 9 - import * as PubLeafletPost from './types/pub/leaflet/post.js' 10 - import * as PubLeafletPublication from './types/pub/leaflet/publication.js' 11 - import * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header.js' 12 - import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image.js' 13 - import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text.js' 14 - import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument.js' 15 - import * as ComAtprotoLabelDefs from './types/com/atproto/label/defs.js' 16 - import * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites.js' 17 - import * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createRecord.js' 18 - import * as ComAtprotoRepoDefs from './types/com/atproto/repo/defs.js' 19 - import * as ComAtprotoRepoDeleteRecord from './types/com/atproto/repo/deleteRecord.js' 20 - import * as ComAtprotoRepoDescribeRepo from './types/com/atproto/repo/describeRepo.js' 21 - import * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord.js' 22 - import * as ComAtprotoRepoImportRepo from './types/com/atproto/repo/importRepo.js' 23 - import * as ComAtprotoRepoListMissingBlobs from './types/com/atproto/repo/listMissingBlobs.js' 24 - import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords.js' 25 - import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord.js' 26 - import * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef.js' 27 - import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob.js' 7 + import { OmitKey, Un$Typed } from './util' 8 + import * as PubLeafletDocument from './types/pub/leaflet/document' 9 + import * as PubLeafletPublication from './types/pub/leaflet/publication' 10 + import * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 11 + import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 12 + import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 13 + import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 14 + import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 15 + import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 16 + import * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' 17 + import * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites' 18 + import * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createRecord' 19 + import * as ComAtprotoRepoDefs from './types/com/atproto/repo/defs' 20 + import * as ComAtprotoRepoDeleteRecord from './types/com/atproto/repo/deleteRecord' 21 + import * as ComAtprotoRepoDescribeRepo from './types/com/atproto/repo/describeRepo' 22 + import * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord' 23 + import * as ComAtprotoRepoImportRepo from './types/com/atproto/repo/importRepo' 24 + import * as ComAtprotoRepoListMissingBlobs from './types/com/atproto/repo/listMissingBlobs' 25 + import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords' 26 + import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' 27 + import * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' 28 + import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' 28 29 29 - export * as PubLeafletDocument from './types/pub/leaflet/document.js' 30 - export * as PubLeafletPost from './types/pub/leaflet/post.js' 31 - export * as PubLeafletPublication from './types/pub/leaflet/publication.js' 32 - export * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header.js' 33 - export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image.js' 34 - export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text.js' 35 - export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument.js' 36 - export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs.js' 37 - export * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites.js' 38 - export * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createRecord.js' 39 - export * as ComAtprotoRepoDefs from './types/com/atproto/repo/defs.js' 40 - export * as ComAtprotoRepoDeleteRecord from './types/com/atproto/repo/deleteRecord.js' 41 - export * as ComAtprotoRepoDescribeRepo from './types/com/atproto/repo/describeRepo.js' 42 - export * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord.js' 43 - export * as ComAtprotoRepoImportRepo from './types/com/atproto/repo/importRepo.js' 44 - export * as ComAtprotoRepoListMissingBlobs from './types/com/atproto/repo/listMissingBlobs.js' 45 - export * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords.js' 46 - export * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord.js' 47 - export * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef.js' 48 - export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob.js' 30 + export * as PubLeafletDocument from './types/pub/leaflet/document' 31 + export * as PubLeafletPublication from './types/pub/leaflet/publication' 32 + export * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 33 + export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 34 + export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 35 + export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 36 + export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 37 + export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 38 + export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' 39 + export * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites' 40 + export * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createRecord' 41 + export * as ComAtprotoRepoDefs from './types/com/atproto/repo/defs' 42 + export * as ComAtprotoRepoDeleteRecord from './types/com/atproto/repo/deleteRecord' 43 + export * as ComAtprotoRepoDescribeRepo from './types/com/atproto/repo/describeRepo' 44 + export * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord' 45 + export * as ComAtprotoRepoImportRepo from './types/com/atproto/repo/importRepo' 46 + export * as ComAtprotoRepoListMissingBlobs from './types/com/atproto/repo/listMissingBlobs' 47 + export * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords' 48 + export * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' 49 + export * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' 50 + export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' 49 51 50 52 export const PUB_LEAFLET_PAGES = { 51 53 LinearDocumentTextAlignLeft: 'pub.leaflet.pages.linearDocument#textAlignLeft', ··· 84 86 export class PubLeafletNS { 85 87 _client: XrpcClient 86 88 document: DocumentRecord 87 - post: PostRecord 88 89 publication: PublicationRecord 89 90 blocks: PubLeafletBlocksNS 90 91 pages: PubLeafletPagesNS 92 + richtext: PubLeafletRichtextNS 91 93 92 94 constructor(client: XrpcClient) { 93 95 this._client = client 94 96 this.blocks = new PubLeafletBlocksNS(client) 95 97 this.pages = new PubLeafletPagesNS(client) 98 + this.richtext = new PubLeafletRichtextNS(client) 96 99 this.document = new DocumentRecord(client) 97 - this.post = new PostRecord(client) 98 100 this.publication = new PublicationRecord(client) 99 101 } 100 102 } ··· 115 117 } 116 118 } 117 119 120 + export class PubLeafletRichtextNS { 121 + _client: XrpcClient 122 + 123 + constructor(client: XrpcClient) { 124 + this._client = client 125 + } 126 + } 127 + 118 128 export class DocumentRecord { 119 129 _client: XrpcClient 120 130 ··· 171 181 'com.atproto.repo.deleteRecord', 172 182 undefined, 173 183 { collection: 'pub.leaflet.document', ...params }, 174 - { headers }, 175 - ) 176 - } 177 - } 178 - 179 - export class PostRecord { 180 - _client: XrpcClient 181 - 182 - constructor(client: XrpcClient) { 183 - this._client = client 184 - } 185 - 186 - async list( 187 - params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 188 - ): Promise<{ 189 - cursor?: string 190 - records: { uri: string; value: PubLeafletPost.Record }[] 191 - }> { 192 - const res = await this._client.call('com.atproto.repo.listRecords', { 193 - collection: 'pub.leaflet.post', 194 - ...params, 195 - }) 196 - return res.data 197 - } 198 - 199 - async get( 200 - params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 201 - ): Promise<{ uri: string; cid: string; value: PubLeafletPost.Record }> { 202 - const res = await this._client.call('com.atproto.repo.getRecord', { 203 - collection: 'pub.leaflet.post', 204 - ...params, 205 - }) 206 - return res.data 207 - } 208 - 209 - async create( 210 - params: OmitKey< 211 - ComAtprotoRepoCreateRecord.InputSchema, 212 - 'collection' | 'record' 213 - >, 214 - record: Un$Typed<PubLeafletPost.Record>, 215 - headers?: Record<string, string>, 216 - ): Promise<{ uri: string; cid: string }> { 217 - const collection = 'pub.leaflet.post' 218 - const res = await this._client.call( 219 - 'com.atproto.repo.createRecord', 220 - undefined, 221 - { collection, ...params, record: { ...record, $type: collection } }, 222 - { encoding: 'application/json', headers }, 223 - ) 224 - return res.data 225 - } 226 - 227 - async delete( 228 - params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 229 - headers?: Record<string, string>, 230 - ): Promise<void> { 231 - await this._client.call( 232 - 'com.atproto.repo.deleteRecord', 233 - undefined, 234 - { collection: 'pub.leaflet.post', ...params }, 235 184 { headers }, 236 185 ) 237 186 }
+165 -34
lexicons/api/lexicons.ts
··· 7 7 ValidationError, 8 8 ValidationResult, 9 9 } from '@atproto/lexicon' 10 - import { $Typed, is$typed, maybe$typed } from './util.js' 10 + import { $Typed, is$typed, maybe$typed } from './util' 11 11 12 12 export const schemaDict = { 13 13 PubLeafletDocument: { ··· 26 26 properties: { 27 27 title: { 28 28 type: 'string', 29 - maxLength: 128, 29 + maxLength: 1280, 30 + maxGraphemes: 128, 31 + }, 32 + description: { 33 + type: 'string', 34 + maxLength: 3000, 35 + maxGraphemes: 300, 30 36 }, 31 37 publishedAt: { 32 38 type: 'string', ··· 52 58 }, 53 59 }, 54 60 }, 55 - PubLeafletPost: { 56 - lexicon: 1, 57 - id: 'pub.leaflet.post', 58 - defs: { 59 - main: { 60 - type: 'record', 61 - key: 'tid', 62 - description: 'Record putting a post in a document', 63 - record: { 64 - type: 'object', 65 - required: ['post', 'publishedAt'], 66 - properties: { 67 - publication: { 68 - type: 'string', 69 - format: 'at-uri', 70 - }, 71 - post: { 72 - type: 'ref', 73 - ref: 'lex:com.atproto.repo.strongRef', 74 - }, 75 - publishedAt: { 76 - type: 'string', 77 - format: 'datetime', 78 - }, 79 - }, 80 - }, 81 - }, 82 - }, 83 - }, 84 61 PubLeafletPublication: { 85 62 lexicon: 1, 86 63 id: 'pub.leaflet.publication', ··· 96 73 name: { 97 74 type: 'string', 98 75 maxLength: 2000, 76 + }, 77 + base_path: { 78 + type: 'string', 79 + format: 'uri', 99 80 }, 100 81 description: { 101 82 type: 'string', 102 83 maxLength: 2000, 103 84 }, 85 + icon: { 86 + type: 'blob', 87 + accept: ['image/*'], 88 + maxSize: 1000000, 89 + }, 104 90 }, 105 91 }, 106 92 }, ··· 112 98 defs: { 113 99 main: { 114 100 type: 'object', 115 - required: [], 101 + required: ['plaintext'], 116 102 properties: { 117 103 level: { 118 104 type: 'integer', ··· 121 107 }, 122 108 plaintext: { 123 109 type: 'string', 110 + }, 111 + facets: { 112 + type: 'array', 113 + items: { 114 + type: 'ref', 115 + ref: 'lex:pub.leaflet.richtext.facet', 116 + }, 124 117 }, 125 118 }, 126 119 }, ··· 170 163 defs: { 171 164 main: { 172 165 type: 'object', 173 - required: [], 166 + required: ['plaintext'], 174 167 properties: { 175 168 plaintext: { 176 169 type: 'string', 170 + }, 171 + facets: { 172 + type: 'array', 173 + items: { 174 + type: 'ref', 175 + ref: 'lex:pub.leaflet.richtext.facet', 176 + }, 177 + }, 178 + }, 179 + }, 180 + }, 181 + }, 182 + PubLeafletBlocksUnorderedList: { 183 + lexicon: 1, 184 + id: 'pub.leaflet.blocks.unorderedList', 185 + defs: { 186 + main: { 187 + type: 'object', 188 + required: ['children'], 189 + properties: { 190 + children: { 191 + type: 'array', 192 + items: { 193 + type: 'ref', 194 + ref: 'lex:pub.leaflet.blocks.unorderedList#listItem', 195 + }, 196 + }, 197 + }, 198 + }, 199 + listItem: { 200 + type: 'object', 201 + required: ['content'], 202 + properties: { 203 + content: { 204 + type: 'union', 205 + refs: [ 206 + 'lex:pub.leaflet.blocks.text', 207 + 'lex:pub.leaflet.blocks.header', 208 + 'lex:pub.leaflet.blocks.image', 209 + ], 210 + }, 211 + children: { 212 + type: 'array', 213 + items: { 214 + type: 'ref', 215 + ref: 'lex:pub.leaflet.blocks.unorderedList#listItem', 216 + }, 177 217 }, 178 218 }, 179 219 }, ··· 205 245 'lex:pub.leaflet.blocks.text', 206 246 'lex:pub.leaflet.blocks.header', 207 247 'lex:pub.leaflet.blocks.image', 248 + 'lex:pub.leaflet.blocks.unorderedList', 208 249 ], 209 250 }, 210 251 alignment: { ··· 225 266 }, 226 267 textAlignRight: { 227 268 type: 'token', 269 + }, 270 + }, 271 + }, 272 + PubLeafletRichtextFacet: { 273 + lexicon: 1, 274 + id: 'pub.leaflet.richtext.facet', 275 + defs: { 276 + main: { 277 + type: 'object', 278 + description: 'Annotation of a sub-string within rich text.', 279 + required: ['index', 'features'], 280 + properties: { 281 + index: { 282 + type: 'ref', 283 + ref: 'lex:pub.leaflet.richtext.facet#byteSlice', 284 + }, 285 + features: { 286 + type: 'array', 287 + items: { 288 + type: 'union', 289 + refs: [ 290 + 'lex:pub.leaflet.richtext.facet#link', 291 + 'lex:pub.leaflet.richtext.facet#highlight', 292 + 'lex:pub.leaflet.richtext.facet#underline', 293 + 'lex:pub.leaflet.richtext.facet#strikethrough', 294 + 'lex:pub.leaflet.richtext.facet#bold', 295 + 'lex:pub.leaflet.richtext.facet#italic', 296 + ], 297 + }, 298 + }, 299 + }, 300 + }, 301 + byteSlice: { 302 + type: 'object', 303 + description: 304 + 'Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.', 305 + required: ['byteStart', 'byteEnd'], 306 + properties: { 307 + byteStart: { 308 + type: 'integer', 309 + minimum: 0, 310 + }, 311 + byteEnd: { 312 + type: 'integer', 313 + minimum: 0, 314 + }, 315 + }, 316 + }, 317 + link: { 318 + type: 'object', 319 + description: 320 + 'Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.', 321 + required: ['uri'], 322 + properties: { 323 + uri: { 324 + type: 'string', 325 + format: 'uri', 326 + }, 327 + }, 328 + }, 329 + highlight: { 330 + type: 'object', 331 + description: 'Facet feature for highlighted text.', 332 + required: [], 333 + properties: {}, 334 + }, 335 + underline: { 336 + type: 'object', 337 + description: 'Facet feature for underline markup', 338 + required: [], 339 + properties: {}, 340 + }, 341 + strikethrough: { 342 + type: 'object', 343 + description: 'Facet feature for strikethrough markup', 344 + required: [], 345 + properties: {}, 346 + }, 347 + bold: { 348 + type: 'object', 349 + description: 'Facet feature for bold text', 350 + required: [], 351 + properties: {}, 352 + }, 353 + italic: { 354 + type: 'object', 355 + description: 'Facet feature for italic text', 356 + required: [], 357 + properties: {}, 228 358 }, 229 359 }, 230 360 }, ··· 1208 1338 1209 1339 export const ids = { 1210 1340 PubLeafletDocument: 'pub.leaflet.document', 1211 - PubLeafletPost: 'pub.leaflet.post', 1212 1341 PubLeafletPublication: 'pub.leaflet.publication', 1213 1342 PubLeafletBlocksHeader: 'pub.leaflet.blocks.header', 1214 1343 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 1215 1344 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 1345 + PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 1216 1346 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 1347 + PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 1217 1348 ComAtprotoLabelDefs: 'com.atproto.label.defs', 1218 1349 ComAtprotoRepoApplyWrites: 'com.atproto.repo.applyWrites', 1219 1350 ComAtprotoRepoCreateRecord: 'com.atproto.repo.createRecord',
+1 -1
lexicons/api/types/com/atproto/repo/applyWrites.ts
··· 6 6 import { CID } from 'multiformats/cid' 7 7 import { validate as _validate } from '../../../../lexicons' 8 8 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 9 - import type * as ComAtprotoRepoDefs from './defs.js' 9 + import type * as ComAtprotoRepoDefs from './defs' 10 10 11 11 const is$typed = _is$typed, 12 12 validate = _validate
+1 -1
lexicons/api/types/com/atproto/repo/createRecord.ts
··· 6 6 import { CID } from 'multiformats/cid' 7 7 import { validate as _validate } from '../../../../lexicons' 8 8 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 9 - import type * as ComAtprotoRepoDefs from './defs.js' 9 + import type * as ComAtprotoRepoDefs from './defs' 10 10 11 11 const is$typed = _is$typed, 12 12 validate = _validate
+1 -1
lexicons/api/types/com/atproto/repo/deleteRecord.ts
··· 6 6 import { CID } from 'multiformats/cid' 7 7 import { validate as _validate } from '../../../../lexicons' 8 8 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 9 - import type * as ComAtprotoRepoDefs from './defs.js' 9 + import type * as ComAtprotoRepoDefs from './defs' 10 10 11 11 const is$typed = _is$typed, 12 12 validate = _validate
+1 -1
lexicons/api/types/com/atproto/repo/putRecord.ts
··· 6 6 import { CID } from 'multiformats/cid' 7 7 import { validate as _validate } from '../../../../lexicons' 8 8 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 9 - import type * as ComAtprotoRepoDefs from './defs.js' 9 + import type * as ComAtprotoRepoDefs from './defs' 10 10 11 11 const is$typed = _is$typed, 12 12 validate = _validate
+3 -1
lexicons/api/types/pub/leaflet/blocks/header.ts
··· 5 5 import { CID } from 'multiformats/cid' 6 6 import { validate as _validate } from '../../../../lexicons' 7 7 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 8 + import type * as PubLeafletRichtextFacet from '../richtext/facet' 8 9 9 10 const is$typed = _is$typed, 10 11 validate = _validate ··· 13 14 export interface Main { 14 15 $type?: 'pub.leaflet.blocks.header' 15 16 level?: number 16 - plaintext?: string 17 + plaintext: string 18 + facets?: PubLeafletRichtextFacet.Main[] 17 19 } 18 20 19 21 const hashMain = 'main'
+3 -1
lexicons/api/types/pub/leaflet/blocks/text.ts
··· 5 5 import { CID } from 'multiformats/cid' 6 6 import { validate as _validate } from '../../../../lexicons' 7 7 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 8 + import type * as PubLeafletRichtextFacet from '../richtext/facet' 8 9 9 10 const is$typed = _is$typed, 10 11 validate = _validate ··· 12 13 13 14 export interface Main { 14 15 $type?: 'pub.leaflet.blocks.text' 15 - plaintext?: string 16 + plaintext: string 17 + facets?: PubLeafletRichtextFacet.Main[] 16 18 } 17 19 18 20 const hashMain = 'main'
+49
lexicons/api/types/pub/leaflet/blocks/unorderedList.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 8 + import type * as PubLeafletBlocksText from './text' 9 + import type * as PubLeafletBlocksHeader from './header' 10 + import type * as PubLeafletBlocksImage from './image' 11 + 12 + const is$typed = _is$typed, 13 + validate = _validate 14 + const id = 'pub.leaflet.blocks.unorderedList' 15 + 16 + export interface Main { 17 + $type?: 'pub.leaflet.blocks.unorderedList' 18 + children: ListItem[] 19 + } 20 + 21 + const hashMain = 'main' 22 + 23 + export function isMain<V>(v: V) { 24 + return is$typed(v, id, hashMain) 25 + } 26 + 27 + export function validateMain<V>(v: V) { 28 + return validate<Main & V>(v, id, hashMain) 29 + } 30 + 31 + export interface ListItem { 32 + $type?: 'pub.leaflet.blocks.unorderedList#listItem' 33 + content: 34 + | $Typed<PubLeafletBlocksText.Main> 35 + | $Typed<PubLeafletBlocksHeader.Main> 36 + | $Typed<PubLeafletBlocksImage.Main> 37 + | { $type: string } 38 + children?: ListItem[] 39 + } 40 + 41 + const hashListItem = 'listItem' 42 + 43 + export function isListItem<V>(v: V) { 44 + return is$typed(v, id, hashListItem) 45 + } 46 + 47 + export function validateListItem<V>(v: V) { 48 + return validate<ListItem & V>(v, id, hashListItem) 49 + }
+2 -1
lexicons/api/types/pub/leaflet/document.ts
··· 5 5 import { CID } from 'multiformats/cid' 6 6 import { validate as _validate } from '../../../lexicons' 7 7 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../util' 8 - import type * as PubLeafletPagesLinearDocument from './pages/linearDocument.js' 8 + import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 9 9 10 10 const is$typed = _is$typed, 11 11 validate = _validate ··· 14 14 export interface Record { 15 15 $type: 'pub.leaflet.document' 16 16 title: string 17 + description?: string 17 18 publishedAt?: string 18 19 publication: string 19 20 author: string
+5 -3
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 5 5 import { CID } from 'multiformats/cid' 6 6 import { validate as _validate } from '../../../../lexicons' 7 7 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 8 - import type * as PubLeafletBlocksText from '../blocks/text.js' 9 - import type * as PubLeafletBlocksHeader from '../blocks/header.js' 10 - import type * as PubLeafletBlocksImage from '../blocks/image.js' 8 + import type * as PubLeafletBlocksText from '../blocks/text' 9 + import type * as PubLeafletBlocksHeader from '../blocks/header' 10 + import type * as PubLeafletBlocksImage from '../blocks/image' 11 + import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 11 12 12 13 const is$typed = _is$typed, 13 14 validate = _validate ··· 34 35 | $Typed<PubLeafletBlocksText.Main> 35 36 | $Typed<PubLeafletBlocksHeader.Main> 36 37 | $Typed<PubLeafletBlocksImage.Main> 38 + | $Typed<PubLeafletBlocksUnorderedList.Main> 37 39 | { $type: string } 38 40 alignment?: 39 41 | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft'
-30
lexicons/api/types/pub/leaflet/post.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 - import { CID } from 'multiformats/cid' 6 - import { validate as _validate } from '../../../lexicons' 7 - import { $Typed, is$typed as _is$typed, OmitKey } from '../../../util' 8 - import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef.js' 9 - 10 - const is$typed = _is$typed, 11 - validate = _validate 12 - const id = 'pub.leaflet.post' 13 - 14 - export interface Record { 15 - $type: 'pub.leaflet.post' 16 - publication?: string 17 - post: ComAtprotoRepoStrongRef.Main 18 - publishedAt: string 19 - [k: string]: unknown 20 - } 21 - 22 - const hashRecord = 'main' 23 - 24 - export function isRecord<V>(v: V) { 25 - return is$typed(v, id, hashRecord) 26 - } 27 - 28 - export function validateRecord<V>(v: V) { 29 - return validate<Record & V>(v, id, hashRecord, true) 30 - }
+2
lexicons/api/types/pub/leaflet/publication.ts
··· 13 13 export interface Record { 14 14 $type: 'pub.leaflet.publication' 15 15 name: string 16 + base_path?: string 16 17 description?: string 18 + icon?: BlobRef 17 19 [k: string]: unknown 18 20 } 19 21
+144
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'pub.leaflet.richtext.facet' 12 + 13 + /** Annotation of a sub-string within rich text. */ 14 + export interface Main { 15 + $type?: 'pub.leaflet.richtext.facet' 16 + index: ByteSlice 17 + features: ( 18 + | $Typed<Link> 19 + | $Typed<Highlight> 20 + | $Typed<Underline> 21 + | $Typed<Strikethrough> 22 + | $Typed<Bold> 23 + | $Typed<Italic> 24 + | { $type: string } 25 + )[] 26 + } 27 + 28 + const hashMain = 'main' 29 + 30 + export function isMain<V>(v: V) { 31 + return is$typed(v, id, hashMain) 32 + } 33 + 34 + export function validateMain<V>(v: V) { 35 + return validate<Main & V>(v, id, hashMain) 36 + } 37 + 38 + /** Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets. */ 39 + export interface ByteSlice { 40 + $type?: 'pub.leaflet.richtext.facet#byteSlice' 41 + byteStart: number 42 + byteEnd: number 43 + } 44 + 45 + const hashByteSlice = 'byteSlice' 46 + 47 + export function isByteSlice<V>(v: V) { 48 + return is$typed(v, id, hashByteSlice) 49 + } 50 + 51 + export function validateByteSlice<V>(v: V) { 52 + return validate<ByteSlice & V>(v, id, hashByteSlice) 53 + } 54 + 55 + /** Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL. */ 56 + export interface Link { 57 + $type?: 'pub.leaflet.richtext.facet#link' 58 + uri: string 59 + } 60 + 61 + const hashLink = 'link' 62 + 63 + export function isLink<V>(v: V) { 64 + return is$typed(v, id, hashLink) 65 + } 66 + 67 + export function validateLink<V>(v: V) { 68 + return validate<Link & V>(v, id, hashLink) 69 + } 70 + 71 + /** Facet feature for highlighted text. */ 72 + export interface Highlight { 73 + $type?: 'pub.leaflet.richtext.facet#highlight' 74 + } 75 + 76 + const hashHighlight = 'highlight' 77 + 78 + export function isHighlight<V>(v: V) { 79 + return is$typed(v, id, hashHighlight) 80 + } 81 + 82 + export function validateHighlight<V>(v: V) { 83 + return validate<Highlight & V>(v, id, hashHighlight) 84 + } 85 + 86 + /** Facet feature for underline markup */ 87 + export interface Underline { 88 + $type?: 'pub.leaflet.richtext.facet#underline' 89 + } 90 + 91 + const hashUnderline = 'underline' 92 + 93 + export function isUnderline<V>(v: V) { 94 + return is$typed(v, id, hashUnderline) 95 + } 96 + 97 + export function validateUnderline<V>(v: V) { 98 + return validate<Underline & V>(v, id, hashUnderline) 99 + } 100 + 101 + /** Facet feature for strikethrough markup */ 102 + export interface Strikethrough { 103 + $type?: 'pub.leaflet.richtext.facet#strikethrough' 104 + } 105 + 106 + const hashStrikethrough = 'strikethrough' 107 + 108 + export function isStrikethrough<V>(v: V) { 109 + return is$typed(v, id, hashStrikethrough) 110 + } 111 + 112 + export function validateStrikethrough<V>(v: V) { 113 + return validate<Strikethrough & V>(v, id, hashStrikethrough) 114 + } 115 + 116 + /** Facet feature for bold text */ 117 + export interface Bold { 118 + $type?: 'pub.leaflet.richtext.facet#bold' 119 + } 120 + 121 + const hashBold = 'bold' 122 + 123 + export function isBold<V>(v: V) { 124 + return is$typed(v, id, hashBold) 125 + } 126 + 127 + export function validateBold<V>(v: V) { 128 + return validate<Bold & V>(v, id, hashBold) 129 + } 130 + 131 + /** Facet feature for italic text */ 132 + export interface Italic { 133 + $type?: 'pub.leaflet.richtext.facet#italic' 134 + } 135 + 136 + const hashItalic = 'italic' 137 + 138 + export function isItalic<V>(v: V) { 139 + return is$typed(v, id, hashItalic) 140 + } 141 + 142 + export function validateItalic<V>(v: V) { 143 + return validate<Italic & V>(v, id, hashItalic) 144 + }
+2
lexicons/build.ts
··· 5 5 6 6 import * as fs from "fs"; 7 7 import * as path from "path"; 8 + import { PubLeafletRichTextFacet } from "./src/facet"; 8 9 9 10 const outdir = path.join("lexicons", "pub", "leaflet"); 10 11 ··· 15 16 16 17 const lexicons = [ 17 18 PubLeafletDocument, 19 + PubLeafletRichTextFacet, 18 20 PageLexicons.PubLeafletPagesLinearDocument, 19 21 ...BlockLexicons, 20 22 ...Object.values(PublicationLexicons),
+10 -1
lexicons/pub/leaflet/blocks/header.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "object", 7 - "required": [], 7 + "required": [ 8 + "plaintext" 9 + ], 8 10 "properties": { 9 11 "level": { 10 12 "type": "integer", ··· 13 15 }, 14 16 "plaintext": { 15 17 "type": "string" 18 + }, 19 + "facets": { 20 + "type": "array", 21 + "items": { 22 + "type": "ref", 23 + "ref": "pub.leaflet.richtext.facet" 24 + } 16 25 } 17 26 } 18 27 }
+10 -1
lexicons/pub/leaflet/blocks/text.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "object", 7 - "required": [], 7 + "required": [ 8 + "plaintext" 9 + ], 8 10 "properties": { 9 11 "plaintext": { 10 12 "type": "string" 13 + }, 14 + "facets": { 15 + "type": "array", 16 + "items": { 17 + "type": "ref", 18 + "ref": "pub.leaflet.richtext.facet" 19 + } 11 20 } 12 21 } 13 22 }
+44
lexicons/pub/leaflet/blocks/unorderedList.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.unorderedList", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "children" 9 + ], 10 + "properties": { 11 + "children": { 12 + "type": "array", 13 + "items": { 14 + "type": "ref", 15 + "ref": "#listItem" 16 + } 17 + } 18 + } 19 + }, 20 + "listItem": { 21 + "type": "object", 22 + "required": [ 23 + "content" 24 + ], 25 + "properties": { 26 + "content": { 27 + "type": "union", 28 + "refs": [ 29 + "pub.leaflet.blocks.text", 30 + "pub.leaflet.blocks.header", 31 + "pub.leaflet.blocks.image" 32 + ] 33 + }, 34 + "children": { 35 + "type": "array", 36 + "items": { 37 + "type": "ref", 38 + "ref": "#listItem" 39 + } 40 + } 41 + } 42 + } 43 + } 44 + }
+7 -1
lexicons/pub/leaflet/document.json
··· 19 19 "properties": { 20 20 "title": { 21 21 "type": "string", 22 - "maxLength": 128 22 + "maxLength": 1280, 23 + "maxGraphemes": 128 24 + }, 25 + "description": { 26 + "type": "string", 27 + "maxLength": 3000, 28 + "maxGraphemes": 300 23 29 }, 24 30 "publishedAt": { 25 31 "type": "string",
+2 -1
lexicons/pub/leaflet/pages/linearDocument.json
··· 25 25 "refs": [ 26 26 "pub.leaflet.blocks.text", 27 27 "pub.leaflet.blocks.header", 28 - "pub.leaflet.blocks.image" 28 + "pub.leaflet.blocks.image", 29 + "pub.leaflet.blocks.unorderedList" 29 30 ] 30 31 }, 31 32 "alignment": {
-32
lexicons/pub/leaflet/post.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "pub.leaflet.post", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "description": "Record putting a post in a document", 9 - "record": { 10 - "type": "object", 11 - "required": [ 12 - "post", 13 - "publishedAt" 14 - ], 15 - "properties": { 16 - "publication": { 17 - "type": "string", 18 - "format": "at-uri" 19 - }, 20 - "post": { 21 - "type": "ref", 22 - "ref": "com.atproto.repo.strongRef" 23 - }, 24 - "publishedAt": { 25 - "type": "string", 26 - "format": "datetime" 27 - } 28 - } 29 - } 30 - } 31 - } 32 - }
+11
lexicons/pub/leaflet/publication.json
··· 16 16 "type": "string", 17 17 "maxLength": 2000 18 18 }, 19 + "base_path": { 20 + "type": "string", 21 + "format": "uri" 22 + }, 19 23 "description": { 20 24 "type": "string", 21 25 "maxLength": 2000 26 + }, 27 + "icon": { 28 + "type": "blob", 29 + "accept": [ 30 + "image/*" 31 + ], 32 + "maxSize": 1000000 22 33 } 23 34 } 24 35 }
+95
lexicons/pub/leaflet/richtext/facet.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.richtext.facet", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Annotation of a sub-string within rich text.", 8 + "required": [ 9 + "index", 10 + "features" 11 + ], 12 + "properties": { 13 + "index": { 14 + "type": "ref", 15 + "ref": "#byteSlice" 16 + }, 17 + "features": { 18 + "type": "array", 19 + "items": { 20 + "type": "union", 21 + "refs": [ 22 + "#link", 23 + "#highlight", 24 + "#underline", 25 + "#strikethrough", 26 + "#bold", 27 + "#italic" 28 + ] 29 + } 30 + } 31 + } 32 + }, 33 + "byteSlice": { 34 + "type": "object", 35 + "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", 36 + "required": [ 37 + "byteStart", 38 + "byteEnd" 39 + ], 40 + "properties": { 41 + "byteStart": { 42 + "type": "integer", 43 + "minimum": 0 44 + }, 45 + "byteEnd": { 46 + "type": "integer", 47 + "minimum": 0 48 + } 49 + } 50 + }, 51 + "link": { 52 + "type": "object", 53 + "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", 54 + "required": [ 55 + "uri" 56 + ], 57 + "properties": { 58 + "uri": { 59 + "type": "string", 60 + "format": "uri" 61 + } 62 + } 63 + }, 64 + "highlight": { 65 + "type": "object", 66 + "description": "Facet feature for highlighted text.", 67 + "required": [], 68 + "properties": {} 69 + }, 70 + "underline": { 71 + "type": "object", 72 + "description": "Facet feature for underline markup", 73 + "required": [], 74 + "properties": {} 75 + }, 76 + "strikethrough": { 77 + "type": "object", 78 + "description": "Facet feature for strikethrough markup", 79 + "required": [], 80 + "properties": {} 81 + }, 82 + "bold": { 83 + "type": "object", 84 + "description": "Facet feature for bold text", 85 + "required": [], 86 + "properties": {} 87 + }, 88 + "italic": { 89 + "type": "object", 90 + "description": "Facet feature for italic text", 91 + "required": [], 92 + "properties": {} 93 + } 94 + } 95 + }
+41 -3
lexicons/src/blocks.ts
··· 1 1 import { LexiconDoc, LexRefUnion } from "@atproto/lexicon"; 2 + import { PubLeafletRichTextFacet } from "./facet"; 2 3 3 4 export const PubLeafletBlocksText: LexiconDoc = { 4 5 lexicon: 1, ··· 6 7 defs: { 7 8 main: { 8 9 type: "object", 9 - required: [], 10 + required: ["plaintext"], 10 11 properties: { 11 12 plaintext: { type: "string" }, 13 + facets: { 14 + type: "array", 15 + items: { type: "ref", ref: PubLeafletRichTextFacet.id }, 16 + }, 12 17 }, 13 18 }, 14 19 }, ··· 20 25 defs: { 21 26 main: { 22 27 type: "object", 23 - required: [], 28 + required: ["plaintext"], 24 29 properties: { 25 30 level: { type: "integer", minimum: 1, maximum: 6 }, 26 31 plaintext: { type: "string" }, 32 + facets: { 33 + type: "array", 34 + items: { type: "ref", ref: PubLeafletRichTextFacet.id }, 35 + }, 27 36 }, 28 37 }, 29 38 }, ··· 59 68 }, 60 69 }; 61 70 71 + export const PubLeafletBlocksUnorderedList: LexiconDoc = { 72 + lexicon: 1, 73 + id: "pub.leaflet.blocks.unorderedList", 74 + defs: { 75 + main: { 76 + type: "object", 77 + required: ["children"], 78 + properties: { 79 + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 80 + }, 81 + }, 82 + listItem: { 83 + type: "object", 84 + required: ["content"], 85 + properties: { 86 + content: { 87 + type: "union", 88 + refs: [ 89 + PubLeafletBlocksText, 90 + PubLeafletBlocksHeader, 91 + PubLeafletBlocksImage, 92 + ].map((l) => l.id), 93 + }, 94 + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 95 + }, 96 + }, 97 + }, 98 + }; 62 99 export const BlockLexicons = [ 63 100 PubLeafletBlocksText, 64 101 PubLeafletBlocksHeader, 65 102 PubLeafletBlocksImage, 103 + PubLeafletBlocksUnorderedList, 66 104 ]; 67 105 export const BlockUnion: LexRefUnion = { 68 106 type: "union", 69 - refs: BlockLexicons.map((lexicon) => lexicon.id), 107 + refs: [...BlockLexicons.map((lexicon) => lexicon.id)], 70 108 };
+2 -1
lexicons/src/document.ts
··· 15 15 type: "object", 16 16 required: ["pages", "author", "title", "publication"], 17 17 properties: { 18 - title: { type: "string", maxLength: 128 }, 18 + title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, 19 + description: { type: "string", maxLength: 3000, maxGraphemes: 300 }, 19 20 publishedAt: { type: "string", format: "datetime" }, 20 21 publication: { type: "string", format: "at-uri" }, 21 22 author: { type: "string", format: "at-identifier" },
+75
lexicons/src/facet.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + const FacetItems: LexiconDoc["defs"] = { 3 + link: { 4 + type: "object", 5 + description: 6 + "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", 7 + required: ["uri"], 8 + properties: { 9 + uri: { type: "string", format: "uri" }, 10 + }, 11 + }, 12 + highlight: { 13 + type: "object", 14 + description: "Facet feature for highlighted text.", 15 + required: [], 16 + properties: {}, 17 + }, 18 + underline: { 19 + type: "object", 20 + description: "Facet feature for underline markup", 21 + required: [], 22 + properties: {}, 23 + }, 24 + strikethrough: { 25 + type: "object", 26 + description: "Facet feature for strikethrough markup", 27 + required: [], 28 + properties: {}, 29 + }, 30 + bold: { 31 + type: "object", 32 + description: "Facet feature for bold text", 33 + required: [], 34 + properties: {}, 35 + }, 36 + italic: { 37 + type: "object", 38 + description: "Facet feature for italic text", 39 + required: [], 40 + properties: {}, 41 + }, 42 + }; 43 + 44 + export const PubLeafletRichTextFacet = { 45 + lexicon: 1, 46 + id: "pub.leaflet.richtext.facet", 47 + defs: { 48 + main: { 49 + type: "object", 50 + description: "Annotation of a sub-string within rich text.", 51 + required: ["index", "features"], 52 + properties: { 53 + index: { type: "ref", ref: "#byteSlice" }, 54 + features: { 55 + type: "array", 56 + items: { 57 + type: "union", 58 + refs: Object.keys(FacetItems).map((k) => `#${k}`), 59 + }, 60 + }, 61 + }, 62 + }, 63 + byteSlice: { 64 + type: "object", 65 + description: 66 + "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", 67 + required: ["byteStart", "byteEnd"], 68 + properties: { 69 + byteStart: { type: "integer", minimum: 0 }, 70 + byteEnd: { type: "integer", minimum: 0 }, 71 + }, 72 + }, 73 + ...FacetItems, 74 + }, 75 + };
+2 -21
lexicons/src/publication.ts
··· 13 13 required: ["name"], 14 14 properties: { 15 15 name: { type: "string", maxLength: 2000 }, 16 + base_path: { type: "string", format: "uri" }, 16 17 description: { type: "string", maxLength: 2000 }, 17 - }, 18 - }, 19 - }, 20 - }, 21 - }; 22 - 23 - export const PubLeafletPublicationPost: LexiconDoc = { 24 - lexicon: 1, 25 - id: "pub.leaflet.post", 26 - defs: { 27 - main: { 28 - type: "record", 29 - key: "tid", 30 - description: "Record putting a post in a document", 31 - record: { 32 - type: "object", 33 - required: ["post", "publishedAt"], 34 - properties: { 35 - publication: { type: "string", format: "at-uri" }, 36 - post: { type: "ref", ref: "com.atproto.repo.strongRef" }, 37 - publishedAt: { type: "string", format: "datetime" }, 18 + icon: { type: "blob", accept: ["image/*"], maxSize: 1000000 }, 38 19 }, 39 20 }, 40 21 },
+6 -1
middleware.ts
··· 1 + import { AtUri } from "@atproto/syntax"; 1 2 import { createClient } from "@supabase/supabase-js"; 2 3 import { NextRequest, NextResponse } from "next/server"; 3 4 import { Database } from "supabase/database.types"; ··· 25 26 if (req.nextUrl.pathname === "/not-found") return; 26 27 let { data: routes } = await supabase 27 28 .from("custom_domains") 28 - .select("*, custom_domain_routes(*)") 29 + .select("*, custom_domain_routes(*), publication_domains(*)") 29 30 .eq("domain", hostname) 30 31 .single(); 32 + if (routes?.publication_domains[0]) { 33 + let aturi = new AtUri(routes.publication_domains[0].publication); 34 + return NextResponse.rewrite(new URL(`/lish/${aturi.host}`, req.url)); 35 + } 31 36 if (routes) { 32 37 let route = routes.custom_domain_routes.find( 33 38 (r) => r.route === req.nextUrl.pathname,
+6 -2
next.config.js
··· 5 5 turbopack: { 6 6 resolveExtensions: [".mdx", ".tsx", ".ts", ".jsx", ".js", ".mjs", ".json"], 7 7 }, 8 + allowedDevOrigins: ["localhost", "127.0.0.1"], 8 9 webpack: (config) => { 9 10 config.resolve.extensionAlias = { 10 11 ".js": [".ts", ".tsx", ".js"], ··· 30 31 ], 31 32 }, 32 33 experimental: { 34 + reactCompiler: true, 33 35 serverActions: { 34 36 bodySizeLimit: "5mb", 35 37 }, ··· 43 45 const withMDX = require("@next/mdx")({ 44 46 extension: /\.mdx?$/, 45 47 }); 46 - 47 - module.exports = withMDX(nextConfig); 48 + const withBundleAnalyzer = require("@next/bundle-analyzer")({ 49 + enabled: process.env.ANALYZE === "true", 50 + }); 51 + module.exports = withBundleAnalyzer(withMDX(nextConfig));
+210 -34
package-lock.json
··· 18 18 "@atproto/xrpc": "^0.6.9", 19 19 "@mdx-js/loader": "^3.1.0", 20 20 "@mdx-js/react": "^3.1.0", 21 + "@next/bundle-analyzer": "^15.3.2", 21 22 "@next/mdx": "15.3.2", 22 23 "@radix-ui/react-dropdown-menu": "^2.1.14", 23 24 "@radix-ui/react-popover": "^1.1.13", 24 25 "@radix-ui/react-slider": "^1.3.4", 25 26 "@radix-ui/react-tooltip": "^1.2.6", 26 - "@react-aria/utils": "^3.24.1", 27 27 "@react-spring/web": "^10.0.0-beta.0", 28 28 "@rocicorp/undo": "^0.2.1", 29 29 "@supabase/ssr": "^0.3.0", ··· 31 31 "@tiptap/core": "^2.11.5", 32 32 "@types/mdx": "^2.0.13", 33 33 "@vercel/analytics": "^1.3.1", 34 - "@vercel/kv": "^1.0.1", 35 34 "@vercel/sdk": "^1.3.1", 35 + "babel-plugin-react-compiler": "^19.1.0-rc.1", 36 36 "base64-js": "^1.5.1", 37 37 "colorjs.io": "^0.5.2", 38 38 "drizzle-orm": "^0.30.10", ··· 74 74 "@atproto/lex-cli": "^0.6.1", 75 75 "@atproto/lexicon": "^0.4.7", 76 76 "@cloudflare/workers-types": "^4.20240512.0", 77 + "@types/node": "^22.15.17", 77 78 "@types/react": "19.1.3", 78 79 "@types/react-dom": "19.1.3", 79 80 "@types/uuid": "^10.0.0", ··· 547 548 "node": ">=18.7.0" 548 549 } 549 550 }, 551 + "node_modules/@babel/helper-string-parser": { 552 + "version": "7.27.1", 553 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", 554 + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", 555 + "license": "MIT", 556 + "engines": { 557 + "node": ">=6.9.0" 558 + } 559 + }, 560 + "node_modules/@babel/helper-validator-identifier": { 561 + "version": "7.27.1", 562 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", 563 + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", 564 + "license": "MIT", 565 + "engines": { 566 + "node": ">=6.9.0" 567 + } 568 + }, 569 + "node_modules/@babel/types": { 570 + "version": "7.27.1", 571 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", 572 + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", 573 + "license": "MIT", 574 + "dependencies": { 575 + "@babel/helper-string-parser": "^7.27.1", 576 + "@babel/helper-validator-identifier": "^7.27.1" 577 + }, 578 + "engines": { 579 + "node": ">=6.9.0" 580 + } 581 + }, 550 582 "node_modules/@badrap/valita": { 551 583 "version": "0.3.8", 552 584 "resolved": "https://registry.npmjs.org/@badrap/valita/-/valita-0.3.8.tgz", ··· 757 789 "version": "1.2.0", 758 790 "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", 759 791 "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==" 792 + }, 793 + "node_modules/@discoveryjs/json-ext": { 794 + "version": "0.5.7", 795 + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", 796 + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", 797 + "license": "MIT", 798 + "engines": { 799 + "node": ">=10.0.0" 800 + } 760 801 }, 761 802 "node_modules/@emnapi/runtime": { 762 803 "version": "1.4.3", ··· 2485 2526 "react": ">=16" 2486 2527 } 2487 2528 }, 2529 + "node_modules/@next/bundle-analyzer": { 2530 + "version": "15.3.2", 2531 + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-15.3.2.tgz", 2532 + "integrity": "sha512-zY5O1PNKNxWEjaFX8gKzm77z2oL0cnj+m5aiqNBgay9LPLCDO13Cf+FJONeNq/nJjeXptwHFT9EMmTecF9U4Iw==", 2533 + "license": "MIT", 2534 + "dependencies": { 2535 + "webpack-bundle-analyzer": "4.10.1" 2536 + } 2537 + }, 2488 2538 "node_modules/@next/env": { 2489 2539 "version": "15.3.2", 2490 2540 "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.2.tgz", ··· 2769 2819 "engines": { 2770 2820 "node": ">=14" 2771 2821 } 2822 + }, 2823 + "node_modules/@polka/url": { 2824 + "version": "1.0.0-next.29", 2825 + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", 2826 + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", 2827 + "license": "MIT" 2772 2828 }, 2773 2829 "node_modules/@radix-ui/number": { 2774 2830 "version": "1.1.1", ··· 5641 5697 "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" 5642 5698 }, 5643 5699 "node_modules/@types/node": { 5644 - "version": "20.12.12", 5645 - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", 5646 - "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", 5700 + "version": "22.15.17", 5701 + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", 5702 + "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", 5703 + "license": "MIT", 5647 5704 "dependencies": { 5648 - "undici-types": "~5.26.4" 5705 + "undici-types": "~6.21.0" 5649 5706 } 5650 5707 }, 5651 5708 "node_modules/@types/node-forge": { ··· 5924 5981 "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", 5925 5982 "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" 5926 5983 }, 5927 - "node_modules/@upstash/redis": { 5928 - "version": "1.25.1", 5929 - "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.25.1.tgz", 5930 - "integrity": "sha512-ACj0GhJ4qrQyBshwFgPod6XufVEfKX2wcaihsEvSdLYnY+m+pa13kGt1RXm/yTHKf4TQi/Dy2A8z/y6WUEOmlg==", 5931 - "dependencies": { 5932 - "crypto-js": "^4.2.0" 5933 - } 5934 - }, 5935 5984 "node_modules/@vercel/analytics": { 5936 5985 "version": "1.3.1", 5937 5986 "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.3.1.tgz", ··· 5950 5999 "react": { 5951 6000 "optional": true 5952 6001 } 5953 - } 5954 - }, 5955 - "node_modules/@vercel/kv": { 5956 - "version": "1.0.1", 5957 - "resolved": "https://registry.npmjs.org/@vercel/kv/-/kv-1.0.1.tgz", 5958 - "integrity": "sha512-uTKddsqVYS2GRAM/QMNNXCTuw9N742mLoGRXoNDcyECaxEXvIHG0dEY+ZnYISV4Vz534VwJO+64fd9XeSggSKw==", 5959 - "dependencies": { 5960 - "@upstash/redis": "1.25.1" 5961 - }, 5962 - "engines": { 5963 - "node": ">=14.6" 5964 6002 } 5965 6003 }, 5966 6004 "node_modules/@vercel/sdk": { ··· 6019 6057 "version": "8.3.2", 6020 6058 "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", 6021 6059 "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", 6022 - "dev": true, 6023 6060 "engines": { 6024 6061 "node": ">=0.4.0" 6025 6062 } ··· 6426 6463 "node": ">= 0.4" 6427 6464 } 6428 6465 }, 6466 + "node_modules/babel-plugin-react-compiler": { 6467 + "version": "19.1.0-rc.1", 6468 + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.1.tgz", 6469 + "integrity": "sha512-M4fpG+Hfq5gWzsJeeMErdRokzg0fdJ8IAk+JDhfB/WLT+U3WwJWR8edphypJrk447/JEvYu6DBFwsTn10bMW4Q==", 6470 + "license": "MIT", 6471 + "dependencies": { 6472 + "@babel/types": "^7.26.0" 6473 + } 6474 + }, 6429 6475 "node_modules/bail": { 6430 6476 "version": "2.0.2", 6431 6477 "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", ··· 7092 7138 "node": ">= 8" 7093 7139 } 7094 7140 }, 7095 - "node_modules/crypto-js": { 7096 - "version": "4.2.0", 7097 - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", 7098 - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" 7099 - }, 7100 7141 "node_modules/cssesc": { 7101 7142 "version": "3.0.0", 7102 7143 "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", ··· 7544 7585 "engines": { 7545 7586 "node": ">= 0.4" 7546 7587 } 7588 + }, 7589 + "node_modules/duplexer": { 7590 + "version": "0.1.2", 7591 + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", 7592 + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", 7593 + "license": "MIT" 7547 7594 }, 7548 7595 "node_modules/eastasianwidth": { 7549 7596 "version": "0.2.0", ··· 9212 9259 "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", 9213 9260 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 9214 9261 }, 9262 + "node_modules/gzip-size": { 9263 + "version": "6.0.0", 9264 + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", 9265 + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", 9266 + "license": "MIT", 9267 + "dependencies": { 9268 + "duplexer": "^0.1.2" 9269 + }, 9270 + "engines": { 9271 + "node": ">=10" 9272 + }, 9273 + "funding": { 9274 + "url": "https://github.com/sponsors/sindresorhus" 9275 + } 9276 + }, 9215 9277 "node_modules/hanji": { 9216 9278 "version": "0.0.5", 9217 9279 "resolved": "https://registry.npmjs.org/hanji/-/hanji-0.0.5.tgz", ··· 9627 9689 "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", 9628 9690 "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", 9629 9691 "dev": true 9692 + }, 9693 + "node_modules/html-escaper": { 9694 + "version": "2.0.2", 9695 + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", 9696 + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", 9697 + "license": "MIT" 9630 9698 }, 9631 9699 "node_modules/html-void-elements": { 9632 9700 "version": "3.0.0", ··· 10116 10184 }, 10117 10185 "funding": { 10118 10186 "url": "https://github.com/sponsors/sindresorhus" 10187 + } 10188 + }, 10189 + "node_modules/is-plain-object": { 10190 + "version": "5.0.0", 10191 + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", 10192 + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", 10193 + "license": "MIT", 10194 + "engines": { 10195 + "node": ">=0.10.0" 10119 10196 } 10120 10197 }, 10121 10198 "node_modules/is-promise": { ··· 11960 12037 "url": "https://github.com/sponsors/isaacs" 11961 12038 } 11962 12039 }, 12040 + "node_modules/mrmime": { 12041 + "version": "2.0.1", 12042 + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", 12043 + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", 12044 + "license": "MIT", 12045 + "engines": { 12046 + "node": ">=10" 12047 + } 12048 + }, 11963 12049 "node_modules/ms": { 11964 12050 "version": "2.1.2", 11965 12051 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", ··· 12361 12447 "wrappy": "1" 12362 12448 } 12363 12449 }, 12450 + "node_modules/opener": { 12451 + "version": "1.5.2", 12452 + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", 12453 + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", 12454 + "license": "(WTFPL OR MIT)", 12455 + "bin": { 12456 + "opener": "bin/opener-bin.js" 12457 + } 12458 + }, 12364 12459 "node_modules/optionator": { 12365 12460 "version": "0.9.4", 12366 12461 "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", ··· 14421 14516 "is-arrayish": "^0.3.1" 14422 14517 } 14423 14518 }, 14519 + "node_modules/sirv": { 14520 + "version": "2.0.4", 14521 + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", 14522 + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", 14523 + "license": "MIT", 14524 + "dependencies": { 14525 + "@polka/url": "^1.0.0-next.24", 14526 + "mrmime": "^2.0.0", 14527 + "totalist": "^3.0.0" 14528 + }, 14529 + "engines": { 14530 + "node": ">= 10" 14531 + } 14532 + }, 14424 14533 "node_modules/sisteransi": { 14425 14534 "version": "1.0.5", 14426 14535 "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", ··· 15070 15179 "license": "MIT", 15071 15180 "engines": { 15072 15181 "node": ">=0.6" 15182 + } 15183 + }, 15184 + "node_modules/totalist": { 15185 + "version": "3.0.1", 15186 + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", 15187 + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", 15188 + "license": "MIT", 15189 + "engines": { 15190 + "node": ">=6" 15073 15191 } 15074 15192 }, 15075 15193 "node_modules/tr46": { ··· 15839 15957 } 15840 15958 }, 15841 15959 "node_modules/undici-types": { 15842 - "version": "5.26.5", 15843 - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 15844 - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" 15960 + "version": "6.21.0", 15961 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 15962 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 15963 + "license": "MIT" 15845 15964 }, 15846 15965 "node_modules/unified": { 15847 15966 "version": "11.0.5", ··· 16152 16271 "license": "BSD-2-Clause", 16153 16272 "engines": { 16154 16273 "node": ">=12" 16274 + } 16275 + }, 16276 + "node_modules/webpack-bundle-analyzer": { 16277 + "version": "4.10.1", 16278 + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", 16279 + "integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", 16280 + "license": "MIT", 16281 + "dependencies": { 16282 + "@discoveryjs/json-ext": "0.5.7", 16283 + "acorn": "^8.0.4", 16284 + "acorn-walk": "^8.0.0", 16285 + "commander": "^7.2.0", 16286 + "debounce": "^1.2.1", 16287 + "escape-string-regexp": "^4.0.0", 16288 + "gzip-size": "^6.0.0", 16289 + "html-escaper": "^2.0.2", 16290 + "is-plain-object": "^5.0.0", 16291 + "opener": "^1.5.2", 16292 + "picocolors": "^1.0.0", 16293 + "sirv": "^2.0.3", 16294 + "ws": "^7.3.1" 16295 + }, 16296 + "bin": { 16297 + "webpack-bundle-analyzer": "lib/bin/analyzer.js" 16298 + }, 16299 + "engines": { 16300 + "node": ">= 10.13.0" 16301 + } 16302 + }, 16303 + "node_modules/webpack-bundle-analyzer/node_modules/commander": { 16304 + "version": "7.2.0", 16305 + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", 16306 + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", 16307 + "license": "MIT", 16308 + "engines": { 16309 + "node": ">= 10" 16310 + } 16311 + }, 16312 + "node_modules/webpack-bundle-analyzer/node_modules/ws": { 16313 + "version": "7.5.10", 16314 + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", 16315 + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", 16316 + "license": "MIT", 16317 + "engines": { 16318 + "node": ">=8.3.0" 16319 + }, 16320 + "peerDependencies": { 16321 + "bufferutil": "^4.0.1", 16322 + "utf-8-validate": "^5.0.2" 16323 + }, 16324 + "peerDependenciesMeta": { 16325 + "bufferutil": { 16326 + "optional": true 16327 + }, 16328 + "utf-8-validate": { 16329 + "optional": true 16330 + } 16155 16331 } 16156 16332 }, 16157 16333 "node_modules/whatwg-url": {
+6 -4
package.json
··· 4 4 "description": "", 5 5 "main": "index.js", 6 6 "scripts": { 7 - "dev": "next dev", 8 - "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* --yes", 7 + "dev": "next dev --turbo", 8 + "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 9 + "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;", 9 10 "wrangler-dev": "wrangler dev", 10 11 "start-appview-dev": "tsx --env-file='./.env.local' --watch appview/index.ts", 11 12 "start-appview-prod": "tsx appview/index.ts" ··· 23 24 "@atproto/xrpc": "^0.6.9", 24 25 "@mdx-js/loader": "^3.1.0", 25 26 "@mdx-js/react": "^3.1.0", 27 + "@next/bundle-analyzer": "^15.3.2", 26 28 "@next/mdx": "15.3.2", 27 29 "@radix-ui/react-dropdown-menu": "^2.1.14", 28 30 "@radix-ui/react-popover": "^1.1.13", 29 31 "@radix-ui/react-slider": "^1.3.4", 30 32 "@radix-ui/react-tooltip": "^1.2.6", 31 - "@react-aria/utils": "^3.24.1", 32 33 "@react-spring/web": "^10.0.0-beta.0", 33 34 "@rocicorp/undo": "^0.2.1", 34 35 "@supabase/ssr": "^0.3.0", ··· 36 37 "@tiptap/core": "^2.11.5", 37 38 "@types/mdx": "^2.0.13", 38 39 "@vercel/analytics": "^1.3.1", 39 - "@vercel/kv": "^1.0.1", 40 40 "@vercel/sdk": "^1.3.1", 41 + "babel-plugin-react-compiler": "^19.1.0-rc.1", 41 42 "base64-js": "^1.5.1", 42 43 "colorjs.io": "^0.5.2", 43 44 "drizzle-orm": "^0.30.10", ··· 79 80 "@atproto/lex-cli": "^0.6.1", 80 81 "@atproto/lexicon": "^0.4.7", 81 82 "@cloudflare/workers-types": "^4.20240512.0", 83 + "@types/node": "^22.15.17", 82 84 "@types/react": "19.1.3", 83 85 "@types/react-dom": "19.1.3", 84 86 "@types/uuid": "^10.0.0",
src/IdResolver.ts

This is a binary file and will not be displayed.

+13
src/auth.ts
··· 1 + import { cookies } from "next/headers"; 2 + import { isProductionDomain } from "./utils/isProductionDeployment"; 3 + 4 + export async function setAuthToken(tokenID: string) { 5 + let c = await cookies(); 6 + c.set("auth_token", tokenID, { 7 + maxAge: 60 * 60 * 24 * 365, 8 + secure: process.env.NODE_ENV === "production", 9 + domain: isProductionDomain() ? "leaflet.pub" : undefined, 10 + httpOnly: true, 11 + sameSite: "lax", 12 + }); 13 + }
+18
src/hooks/useDebouncedEffect.ts
··· 1 + import { useEffect, useState, useRef } from "react"; 2 + 3 + export function useDebouncedEffect( 4 + fn: () => void, 5 + delay: number, 6 + deps: React.DependencyList = [], 7 + ): void { 8 + useEffect(() => { 9 + const handler = setTimeout(() => { 10 + fn(); 11 + }, delay); 12 + 13 + return () => { 14 + clearTimeout(handler); 15 + }; 16 + // eslint-disable-next-line react-hooks/exhaustive-deps 17 + }, [...deps, delay]); 18 + }
+14 -10
src/replicache/attributes.ts
··· 1 - import { AppBskyFeedGetPostThread } from "@atproto/api"; 2 - import { 3 - PostView, 4 - ThreadViewPost, 5 - } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 6 - import { DeepAsReadonlyJSONValue } from "./utils"; 1 + import type { AppBskyFeedGetPostThread } from "@atproto/api"; 2 + import type { DeepAsReadonlyJSONValue } from "./utils"; 7 3 8 4 const RootAttributes = { 9 5 "root/page": { ··· 203 199 type: "number", 204 200 cardinality: "one", 205 201 }, 202 + "theme/card-border-hidden": { 203 + type: "boolean", 204 + cardinality: "one", 205 + }, 206 206 "theme/primary": { 207 207 type: "color", 208 208 cardinality: "one", ··· 242 242 ...ImageBlockAttributes, 243 243 ...PollBlockAttributes, 244 244 }; 245 - type Attribute = typeof Attributes; 245 + export type Attributes = typeof Attributes; 246 + export type Attribute = keyof Attributes; 246 247 export type Data<A extends keyof typeof Attributes> = { 247 248 text: { type: "text"; value: string }; 248 249 string: { type: "string"; value: string }; ··· 316 317 }; 317 318 color: { type: "color"; value: string }; 318 319 }[(typeof Attributes)[A]["type"]]; 319 - export type FilterAttributes<F extends Partial<Attribute[keyof Attribute]>> = { 320 - [A in keyof Attribute as Attribute[A] extends F ? A : never]: Attribute[A]; 321 - }; 320 + export type FilterAttributes<F extends Partial<Attributes[keyof Attributes]>> = 321 + { 322 + [A in keyof Attributes as Attributes[A] extends F 323 + ? A 324 + : never]: Attributes[A]; 325 + };
+5 -5
src/replicache/clientMutationContext.ts
··· 2 2 import * as Y from "yjs"; 3 3 import * as base64 from "base64-js"; 4 4 import { FactWithIndexes, scanIndex } from "./utils"; 5 - import { Attributes, FilterAttributes } from "./attributes"; 6 - import { Fact, ReplicacheMutators } from "."; 5 + import { Attribute, Attributes, FilterAttributes } from "./attributes"; 6 + import type { Fact, ReplicacheMutators } from "."; 7 7 import { FactInput, MutationContext } from "./mutations"; 8 8 import { supabaseBrowserClient } from "supabase/browserClient"; 9 9 import { v7 } from "uuid"; ··· 39 39 }, 40 40 }, 41 41 async assertFact(f) { 42 - let attribute = Attributes[f.attribute as keyof typeof Attributes]; 42 + let attribute = Attributes[f.attribute as Attribute]; 43 43 if (!attribute) return; 44 44 let id = f.id || v7(); 45 45 let data = { ...f.data }; ··· 110 110 }, 111 111 async deleteEntity(entity) { 112 112 let existingFacts = await tx 113 - .scan<Fact<keyof typeof Attributes>>({ 113 + .scan<Fact<Attribute>>({ 114 114 indexName: "eav", 115 115 prefix: `${entity}`, 116 116 }) 117 117 .toArray(); 118 118 let references = await tx 119 - .scan<Fact<keyof typeof Attributes>>({ 119 + .scan<Fact<Attribute>>({ 120 120 indexName: "vae", 121 121 prefix: entity, 122 122 })
+19 -12
src/replicache/index.tsx
··· 17 17 WriteTransaction, 18 18 } from "replicache"; 19 19 import { mutations } from "./mutations"; 20 - import { Attributes, Data, FilterAttributes } from "./attributes"; 20 + import { Attributes } from "./attributes"; 21 + import { Attribute, Data, FilterAttributes } from "./attributes"; 21 22 import { clientMutationContext } from "./clientMutationContext"; 22 23 import { supabaseBrowserClient } from "supabase/browserClient"; 23 24 import { callRPC } from "app/api/rpc/client"; 24 25 import { UndoManager } from "@rocicorp/undo"; 25 26 import { addShortcut } from "src/shortcuts"; 26 27 import { createUndoManager } from "src/undoManager"; 28 + import { RealtimeChannel } from "@supabase/supabase-js"; 27 29 28 - export type Fact<A extends keyof typeof Attributes> = { 30 + export type Fact<A extends Attribute> = { 29 31 id: string; 30 32 entity: string; 31 33 attribute: A; ··· 36 38 undoManager: createUndoManager(), 37 39 rootEntity: "" as string, 38 40 rep: null as null | Replicache<ReplicacheMutators>, 39 - initialFacts: [] as Fact<keyof typeof Attributes>[], 41 + initialFacts: [] as Fact<Attribute>[], 40 42 permission_token: {} as PermissionToken, 41 43 }); 42 44 export function useReplicache() { ··· 64 66 }; 65 67 export function ReplicacheProvider(props: { 66 68 rootEntity: string; 67 - initialFacts: Fact<keyof typeof Attributes>[]; 69 + initialFacts: Fact<Attribute>[]; 68 70 token: PermissionToken; 69 71 name: string; 70 72 children: React.ReactNode; 71 73 initialFactsOnly?: boolean; 74 + disablePull?: boolean; 72 75 }) { 73 76 let [rep, setRep] = useState<null | Replicache<ReplicacheMutators>>(null); 74 77 let [undoManager] = useState(createUndoManager()); ··· 103 106 if (props.initialFactsOnly) return; 104 107 let supabase = supabaseBrowserClient(); 105 108 let newRep = new Replicache({ 109 + pullInterval: props.disablePull ? null : undefined, 106 110 pushDelay: 500, 107 111 mutators: Object.fromEntries( 108 112 Object.keys(mutations).map((m) => { ··· 158 162 }, 159 163 }); 160 164 setRep(newRep); 161 - let channel = supabase.channel(`rootEntity:${props.name}`); 165 + let channel: RealtimeChannel | null = null; 166 + if (!props.disablePull) { 167 + channel = supabase.channel(`rootEntity:${props.name}`); 162 168 163 - channel.on("broadcast", { event: "poke" }, () => { 164 - newRep.pull(); 165 - }); 166 - channel.subscribe(); 169 + channel.on("broadcast", { event: "poke" }, () => { 170 + newRep.pull(); 171 + }); 172 + channel.subscribe(); 173 + } 167 174 return () => { 168 175 newRep.close(); 169 176 setRep(null); 170 - channel.unsubscribe(); 177 + channel?.unsubscribe(); 171 178 }; 172 179 }, [props.name, props.initialFactsOnly, props.token]); 173 180 return ( ··· 185 192 ); 186 193 } 187 194 188 - type CardinalityResult<A extends keyof typeof Attributes> = 195 + type CardinalityResult<A extends Attribute> = 189 196 (typeof Attributes)[A]["cardinality"] extends "one" 190 197 ? DeepReadonlyObject<Fact<A>> | null 191 198 : DeepReadonlyObject<Fact<A>>[]; 192 - export function useEntity<A extends keyof typeof Attributes>( 199 + export function useEntity<A extends Attribute>( 193 200 entity: string | null, 194 201 attribute: A, 195 202 ): CardinalityResult<A> {
+6 -6
src/replicache/mutations.ts
··· 1 1 import { DeepReadonly, Replicache } from "replicache"; 2 - import { Fact, ReplicacheMutators } from "."; 3 - import { Attributes, FilterAttributes } from "./attributes"; 2 + import type { Fact, ReplicacheMutators } from "."; 3 + import type { Attribute, Attributes, FilterAttributes } from "./attributes"; 4 4 import { SupabaseClient } from "@supabase/supabase-js"; 5 5 import { Database } from "supabase/database.types"; 6 6 import { generateKeyBetween } from "fractional-indexing"; ··· 11 11 permission_set: string; 12 12 }) => Promise<boolean>; 13 13 scanIndex: { 14 - eav: <A extends keyof typeof Attributes>( 14 + eav: <A extends Attribute>( 15 15 entity: string, 16 16 attribute: A, 17 17 ) => Promise<DeepReadonly<Fact<A>[]>>; 18 18 }; 19 19 deleteEntity: (entity: string) => Promise<void>; 20 - assertFact: <A extends keyof typeof Attributes>( 20 + assertFact: <A extends Attribute>( 21 21 f: Omit<Fact<A>, "id"> & { id?: string }, 22 22 ) => Promise<void>; 23 23 retractFact: (id: string) => Promise<void>; ··· 332 332 }; 333 333 334 334 export type FactInput = { 335 - [k in keyof typeof Attributes]: Omit<Fact<k>, "id"> & { id?: string }; 336 - }[keyof typeof Attributes]; 335 + [k in Attribute]: Omit<Fact<k>, "id"> & { id?: string }; 336 + }[Attribute]; 337 337 const assertFact: Mutation<FactInput | Array<FactInput>> = async ( 338 338 args, 339 339 ctx,
+2 -2
src/replicache/serverMutationContext.ts
··· 4 4 import * as Y from "yjs"; 5 5 import { MutationContext } from "./mutations"; 6 6 import { entities, facts } from "drizzle/schema"; 7 - import { Attributes, FilterAttributes } from "./attributes"; 7 + import { Attribute, Attributes, FilterAttributes } from "./attributes"; 8 8 import { Fact, PermissionToken } from "."; 9 9 import { DeepReadonly } from "replicache"; 10 10 import { createClient } from "@supabase/supabase-js"; ··· 77 77 }, 78 78 async assertFact(f) { 79 79 if (!f.entity) return; 80 - let attribute = Attributes[f.attribute as keyof typeof Attributes]; 80 + let attribute = Attributes[f.attribute as Attribute]; 81 81 if (!attribute) return; 82 82 let id = f.id || v7(); 83 83 let data = { ...f.data };
+5 -8
src/replicache/utils.ts
··· 1 1 import { PostgresJsDatabase } from "drizzle-orm/postgres-js"; 2 2 import * as driz from "drizzle-orm"; 3 - import { Fact } from "."; 3 + import type { Fact } from "."; 4 4 import { replicache_clients } from "drizzle/schema"; 5 - import { Attributes, FilterAttributes } from "./attributes"; 5 + import type { Attribute, FilterAttributes } from "./attributes"; 6 6 import { ReadTransaction, WriteTransaction } from "replicache"; 7 7 8 - export function FactWithIndexes(f: Fact<keyof typeof Attributes>) { 8 + export function FactWithIndexes(f: Fact<Attribute>) { 9 9 let indexes: { 10 10 eav: string; 11 11 aev: string; ··· 42 42 } 43 43 44 44 export const scanIndex = (tx: ReadTransaction) => ({ 45 - async eav<A extends keyof typeof Attributes>( 46 - entity: string, 47 - attribute: A | "", 48 - ) { 45 + async eav<A extends Attribute>(entity: string, attribute: A | "") { 49 46 return ( 50 47 ( 51 48 await tx ··· 69 66 }); 70 67 71 68 export const scanIndexLocal = (initialFacts: Fact<any>[]) => ({ 72 - eav<A extends keyof typeof Attributes>(entity: string, attribute: A) { 69 + eav<A extends Attribute>(entity: string, attribute: A) { 73 70 return initialFacts.filter( 74 71 (f) => f.entity === entity && f.attribute === attribute, 75 72 ) as Fact<A>[];
+1 -1
src/shortcuts.ts
··· 1 - import { isIOS, isMac } from "@react-aria/utils"; 2 1 import { useUIState } from "./useUIState"; 3 2 import { Replicache } from "replicache"; 4 3 import { ReplicacheMutators } from "./replicache"; 4 + import { isMac } from "./utils/isDevice"; 5 5 6 6 type Shortcut = { 7 7 metaKey?: boolean;
+1 -1
src/utils/addImage.ts
··· 1 1 import { Replicache } from "replicache"; 2 2 import { ReplicacheMutators } from "../replicache"; 3 3 import { supabaseBrowserClient } from "supabase/browserClient"; 4 - import { FilterAttributes } from "src/replicache/attributes"; 4 + import type { FilterAttributes } from "src/replicache/attributes"; 5 5 import { rgbaToDataURL, rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; 6 6 import { v7 } from "uuid"; 7 7
+1 -1
src/utils/addLinkBlock.ts
··· 4 4 LinkPreviewMetadataResult, 5 5 } from "app/api/link_previews/route"; 6 6 import { Replicache } from "replicache"; 7 - import { ReplicacheMutators } from "src/replicache"; 7 + import type { ReplicacheMutators } from "src/replicache"; 8 8 import { AtpAgent } from "@atproto/api"; 9 9 import { v7 } from "uuid"; 10 10
+1 -1
src/utils/copySelection.ts
··· 1 1 import { getBlocksAsHTML } from "src/utils/getBlocksAsHTML"; 2 2 import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 3 3 import { Replicache } from "replicache"; 4 - import { ReplicacheMutators } from "src/replicache"; 4 + import type { ReplicacheMutators } from "src/replicache"; 5 5 import { Block } from "components/Blocks/Block"; 6 6 7 7 export async function copySelection(
+1
src/utils/focusBlock.ts
··· 30 30 // focus the editor using the mouse position if needed 31 31 let nextBlockID = block.value; 32 32 let nextBlock = useEditorStates.getState().editorStates[nextBlockID]; 33 + console.log(nextBlock); 33 34 if (!nextBlock || !nextBlock.view) return; 34 35 let nextBlockViewClientRect = nextBlock.view.dom.getBoundingClientRect(); 35 36 let tr = nextBlock.editor.tr;
+3 -74
src/utils/getBlocksAsHTML.tsx
··· 1 1 import { ReadTransaction, Replicache } from "replicache"; 2 - import { Fact, ReplicacheMutators } from "src/replicache"; 2 + import type { Fact, ReplicacheMutators } from "src/replicache"; 3 3 import { scanIndex } from "src/replicache/utils"; 4 4 import { renderToStaticMarkup } from "react-dom/server"; 5 5 import * as Y from "yjs"; 6 6 import * as base64 from "base64-js"; 7 7 import { RenderYJSFragment } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 8 import { Block } from "components/Blocks/Block"; 9 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 9 + import { List, parseBlocksToList } from "./parseBlocksToList"; 10 10 11 11 export async function getBlocksAsHTML( 12 12 rep: Replicache<ReplicacheMutators>, ··· 14 14 ) { 15 15 let data = await rep?.query(async (tx) => { 16 16 let result: string[] = []; 17 - let parsed = parseBlocks(selectedBlocks); 17 + let parsed = parseBlocksToList(selectedBlocks); 18 18 for (let pb of parsed) { 19 19 if (pb.type === "block") result.push(await renderBlock(pb.block, tx)); 20 20 else ··· 150 150 />, 151 151 ); 152 152 } 153 - 154 - function parseBlocks(blocks: Block[]) { 155 - let parsed: ParsedBlocks = []; 156 - for (let i = 0; i < blocks.length; i++) { 157 - let b = blocks[i]; 158 - if (!b.listData) parsed.push({ type: "block", block: b }); 159 - else { 160 - let previousBlock = parsed[parsed.length - 1]; 161 - if ( 162 - !previousBlock || 163 - previousBlock.type !== "list" || 164 - previousBlock.depth > b.listData.depth 165 - ) 166 - parsed.push({ 167 - type: "list", 168 - depth: b.listData.depth, 169 - children: [ 170 - { 171 - type: "list", 172 - block: b, 173 - depth: b.listData.depth, 174 - children: [], 175 - }, 176 - ], 177 - }); 178 - else { 179 - let depth = b.listData.depth; 180 - if (depth === previousBlock.depth) 181 - previousBlock.children.push({ 182 - type: "list", 183 - block: b, 184 - depth: b.listData.depth, 185 - children: [], 186 - }); 187 - else { 188 - let parent = 189 - previousBlock.children[previousBlock.children.length - 1]; 190 - while (depth > 1) { 191 - if ( 192 - parent.children[parent.children.length - 1] && 193 - parent.children[parent.children.length - 1].depth < 194 - b.listData.depth 195 - ) { 196 - parent = parent.children[parent.children.length - 1]; 197 - } 198 - depth -= 1; 199 - } 200 - parent.children.push({ 201 - type: "list", 202 - block: b, 203 - depth: b.listData.depth, 204 - children: [], 205 - }); 206 - } 207 - } 208 - } 209 - } 210 - return parsed; 211 - } 212 - 213 - type ParsedBlocks = Array< 214 - | { type: "block"; block: Block } 215 - | { type: "list"; depth: number; children: List[] } 216 - >; 217 - 218 - type List = { 219 - type: "list"; 220 - block: Block; 221 - depth: number; 222 - children: List[]; 223 - };
+1 -1
src/utils/iosInputMouseDown.ts
··· 1 - import { isIOS } from "@react-aria/utils"; 1 + import { isIOS } from "./isDevice"; 2 2 3 3 export function onMouseDown(e: React.MouseEvent<HTMLInputElement>) { 4 4 if (!isIOS()) return;
+87
src/utils/isDevice.ts
··· 1 + /* 2 + * Copyright 2020 Adobe. All rights reserved. 3 + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 + * you may not use this file except in compliance with the License. You may obtain a copy 5 + * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 + * 7 + * Unless required by applicable law or agreed to in writing, software distributed under 8 + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 + * OF ANY KIND, either express or implied. See the License for the specific language 10 + * governing permissions and limitations under the License. 11 + */ 12 + 13 + function testUserAgent(re: RegExp) { 14 + if (typeof window === "undefined" || window.navigator == null) { 15 + return false; 16 + } 17 + return ( 18 + //@ts-ignore 19 + window.navigator["userAgentData"]?.brands.some( 20 + (brand: { brand: string; version: string }) => re.test(brand.brand), 21 + ) || re.test(window.navigator.userAgent) 22 + ); 23 + } 24 + 25 + function testPlatform(re: RegExp) { 26 + return typeof window !== "undefined" && window.navigator != null 27 + ? re.test( 28 + //@ts-ignore 29 + window.navigator["userAgentData"]?.platform || 30 + window.navigator.platform, 31 + ) 32 + : false; 33 + } 34 + 35 + function cached(fn: () => boolean) { 36 + if (process.env.NODE_ENV === "test") { 37 + return fn; 38 + } 39 + 40 + let res: boolean | null = null; 41 + return () => { 42 + if (res == null) { 43 + res = fn(); 44 + } 45 + return res; 46 + }; 47 + } 48 + 49 + export const isMac = cached(function () { 50 + return testPlatform(/^Mac/i); 51 + }); 52 + 53 + export const isIPhone = cached(function () { 54 + return testPlatform(/^iPhone/i); 55 + }); 56 + 57 + export const isIPad = cached(function () { 58 + return ( 59 + testPlatform(/^iPad/i) || 60 + // iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support. 61 + (isMac() && navigator.maxTouchPoints > 1) 62 + ); 63 + }); 64 + 65 + export const isIOS = cached(function () { 66 + return isIPhone() || isIPad(); 67 + }); 68 + 69 + export const isAppleDevice = cached(function () { 70 + return isMac() || isIOS(); 71 + }); 72 + 73 + export const isWebKit = cached(function () { 74 + return testUserAgent(/AppleWebKit/i) && !isChrome(); 75 + }); 76 + 77 + export const isChrome = cached(function () { 78 + return testUserAgent(/Chrome/i); 79 + }); 80 + 81 + export const isAndroid = cached(function () { 82 + return testUserAgent(/Android/i); 83 + }); 84 + 85 + export const isFirefox = cached(function () { 86 + return testUserAgent(/Firefox/i); 87 + });
+7
src/utils/isProductionDeployment.ts
··· 1 + export function isProductionDomain() { 2 + let url = 3 + process.env.NEXT_PUBLIC_VERCEL_URL || 4 + process.env.VERCEL_URL || 5 + "http://localhost:3000"; 6 + return process.env.NODE_ENV === "production" && url.includes("leaflet.pub"); 7 + }
+1 -1
src/utils/list-operations.ts
··· 1 1 import { Block } from "components/Blocks/Block"; 2 2 import { Replicache } from "replicache"; 3 - import { ReplicacheMutators } from "src/replicache"; 3 + import type { ReplicacheMutators } from "src/replicache"; 4 4 import { useUIState } from "src/useUIState"; 5 5 import { v7 } from "uuid"; 6 6
+72
src/utils/parseBlocksToList.ts
··· 1 + import type { Block } from "components/Blocks/Block"; 2 + 3 + export function parseBlocksToList(blocks: Block[]) { 4 + let parsed: ParsedBlocks = []; 5 + for (let i = 0; i < blocks.length; i++) { 6 + let b = blocks[i]; 7 + if (!b.listData) parsed.push({ type: "block", block: b }); 8 + else { 9 + let previousBlock = parsed[parsed.length - 1]; 10 + if ( 11 + !previousBlock || 12 + previousBlock.type !== "list" || 13 + previousBlock.depth > b.listData.depth 14 + ) 15 + parsed.push({ 16 + type: "list", 17 + depth: b.listData.depth, 18 + children: [ 19 + { 20 + type: "list", 21 + block: b, 22 + depth: b.listData.depth, 23 + children: [], 24 + }, 25 + ], 26 + }); 27 + else { 28 + let depth = b.listData.depth; 29 + if (depth === previousBlock.depth) 30 + previousBlock.children.push({ 31 + type: "list", 32 + block: b, 33 + depth: b.listData.depth, 34 + children: [], 35 + }); 36 + else { 37 + let parent = 38 + previousBlock.children[previousBlock.children.length - 1]; 39 + while (depth > 1) { 40 + if ( 41 + parent.children[parent.children.length - 1] && 42 + parent.children[parent.children.length - 1].depth < 43 + b.listData.depth 44 + ) { 45 + parent = parent.children[parent.children.length - 1]; 46 + } 47 + depth -= 1; 48 + } 49 + parent.children.push({ 50 + type: "list", 51 + block: b, 52 + depth: b.listData.depth, 53 + children: [], 54 + }); 55 + } 56 + } 57 + } 58 + } 59 + return parsed; 60 + } 61 + 62 + type ParsedBlocks = Array< 63 + | { type: "block"; block: Block } 64 + | { type: "list"; depth: number; children: List[] } 65 + >; 66 + 67 + export type List = { 68 + type: "list"; 69 + block: Block; 70 + depth: number; 71 + children: List[]; 72 + };
+42
supabase/database.types.ts
··· 356 356 } 357 357 leaflets_in_publications: { 358 358 Row: { 359 + description: string 359 360 doc: string | null 360 361 leaflet: string 361 362 publication: string 363 + title: string 362 364 } 363 365 Insert: { 366 + description?: string 364 367 doc?: string | null 365 368 leaflet: string 366 369 publication: string 370 + title?: string 367 371 } 368 372 Update: { 373 + description?: string 369 374 doc?: string | null 370 375 leaflet?: string 371 376 publication?: string 377 + title?: string 372 378 } 373 379 Relationships: [ 374 380 { ··· 635 641 }, 636 642 ] 637 643 } 644 + publication_domains: { 645 + Row: { 646 + created_at: string 647 + domain: string 648 + publication: string 649 + } 650 + Insert: { 651 + created_at?: string 652 + domain: string 653 + publication: string 654 + } 655 + Update: { 656 + created_at?: string 657 + domain?: string 658 + publication?: string 659 + } 660 + Relationships: [ 661 + { 662 + foreignKeyName: "publication_domains_domain_fkey" 663 + columns: ["domain"] 664 + isOneToOne: false 665 + referencedRelation: "custom_domains" 666 + referencedColumns: ["domain"] 667 + }, 668 + { 669 + foreignKeyName: "publication_domains_publication_fkey" 670 + columns: ["publication"] 671 + isOneToOne: false 672 + referencedRelation: "publications" 673 + referencedColumns: ["uri"] 674 + }, 675 + ] 676 + } 638 677 publications: { 639 678 Row: { 640 679 identity_did: string 641 680 indexed_at: string 642 681 name: string 682 + record: Json | null 643 683 uri: string 644 684 } 645 685 Insert: { 646 686 identity_did: string 647 687 indexed_at?: string 648 688 name: string 689 + record?: Json | null 649 690 uri: string 650 691 } 651 692 Update: { 652 693 identity_did?: string 653 694 indexed_at?: string 654 695 name?: string 696 + record?: Json | null 655 697 uri?: string 656 698 } 657 699 Relationships: []
+2
supabase/migrations/20250514230813_add_metadata_to_pub_drafts.sql
··· 1 + alter table "public"."leaflets_in_publications" add column "description" text not null default ''::text; 2 + alter table "public"."leaflets_in_publications" add column "title" text not null default ''::text;
+1
supabase/migrations/20250519200406_add_record_to_publications.sql
··· 1 + alter table "public"."publications" add column "record" jsonb;
+33
supabase/migrations/20250520190442_add_publication_domains_table.sql
··· 1 + create table "public"."publication_domains" ( 2 + "publication" text not null, 3 + "domain" text not null, 4 + "created_at" timestamp with time zone not null default now() 5 + ); 6 + alter table "public"."publication_domains" enable row level security; 7 + CREATE UNIQUE INDEX publication_domains_pkey ON public.publication_domains USING btree (publication, domain); 8 + alter table "public"."publication_domains" add constraint "publication_domains_pkey" PRIMARY KEY using index "publication_domains_pkey"; 9 + alter table "public"."publication_domains" add constraint "publication_domains_domain_fkey" FOREIGN KEY (domain) REFERENCES custom_domains(domain) ON DELETE CASCADE not valid; 10 + alter table "public"."publication_domains" validate constraint "publication_domains_domain_fkey"; 11 + alter table "public"."publication_domains" add constraint "publication_domains_publication_fkey" FOREIGN KEY (publication) REFERENCES publications(uri) ON DELETE CASCADE not valid; 12 + alter table "public"."publication_domains" validate constraint "publication_domains_publication_fkey"; 13 + grant delete on table "public"."publication_domains" to "anon"; 14 + grant insert on table "public"."publication_domains" to "anon"; 15 + grant references on table "public"."publication_domains" to "anon"; 16 + grant select on table "public"."publication_domains" to "anon"; 17 + grant trigger on table "public"."publication_domains" to "anon"; 18 + grant truncate on table "public"."publication_domains" to "anon"; 19 + grant update on table "public"."publication_domains" to "anon"; 20 + grant delete on table "public"."publication_domains" to "authenticated"; 21 + grant insert on table "public"."publication_domains" to "authenticated"; 22 + grant references on table "public"."publication_domains" to "authenticated"; 23 + grant select on table "public"."publication_domains" to "authenticated"; 24 + grant trigger on table "public"."publication_domains" to "authenticated"; 25 + grant truncate on table "public"."publication_domains" to "authenticated"; 26 + grant update on table "public"."publication_domains" to "authenticated"; 27 + grant delete on table "public"."publication_domains" to "service_role"; 28 + grant insert on table "public"."publication_domains" to "service_role"; 29 + grant references on table "public"."publication_domains" to "service_role"; 30 + grant select on table "public"."publication_domains" to "service_role"; 31 + grant trigger on table "public"."publication_domains" to "service_role"; 32 + grant truncate on table "public"."publication_domains" to "service_role"; 33 + grant update on table "public"."publication_domains" to "service_role";
+1 -1
supabase/serverClient.ts
··· 1 1 import { createClient } from "@supabase/supabase-js"; 2 - import { Database } from "supabase/database.types"; 2 + import type { Database } from "supabase/database.types"; 3 3 export const supabaseServerClient = createClient<Database>( 4 4 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 5 5 process.env.SUPABASE_SERVICE_ROLE_KEY as string,
+1 -1
tailwind.config.js
··· 21 21 //TEXT COLORS. 22 22 primary: "rgb(var(--primary))", 23 23 secondary: 24 - "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 15%)", 24 + "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 25%)", 25 25 tertiary: 26 26 "color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-page)) 55%)", 27 27 border: