a tool for shared writing and social publishing
0

Configure Feed

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

Merge branch 'main' of https://github.com/hyperlink-academy/minilink into refactor/pub-settings

celine (Apr 6, 2026, 7:52 PM EDT) 32ffd27c c4239358

+2048 -1595
-61
actions/getRSVPData.ts
··· 1 - "use server"; 2 - 3 - import { cookies } from "next/headers"; 4 - import { supabaseServerClient } from "supabase/serverClient"; 5 - 6 - export async function getRSVPData(entity_sets: string[]) { 7 - const token = (await cookies()).get("phone_auth_token"); 8 - 9 - let authToken: { 10 - id: string; 11 - created_at: string; 12 - confirmed: boolean; 13 - confirmation_code: string; 14 - phone_number: string; 15 - country_code: string; 16 - } | null = null; 17 - if (token) { 18 - let { data } = await supabaseServerClient 19 - .from("phone_number_auth_tokens") 20 - .select("*") 21 - .eq("id", token.value) 22 - .single(); 23 - authToken = data; 24 - } 25 - 26 - const { data: rsvps } = await supabaseServerClient 27 - .from("phone_rsvps_to_entity") 28 - .select( 29 - ` 30 - *, 31 - entities!inner(*) 32 - `, 33 - ) 34 - .in("entities.set", entity_sets); 35 - 36 - return { 37 - authToken, 38 - rsvps: 39 - rsvps?.map((rsvp) => { 40 - if ( 41 - rsvp.phone_number === authToken?.phone_number && 42 - rsvp.country_code === authToken.country_code 43 - ) 44 - return { 45 - phone_number: rsvp.phone_number, 46 - country_code: rsvp.country_code, 47 - name: rsvp.name, 48 - entity: rsvp.entities.id, 49 - status: rsvp.status, 50 - plus_ones: rsvp.plus_ones, 51 - }; 52 - else 53 - return { 54 - name: rsvp.name, 55 - entity: rsvp.entities.id, 56 - status: rsvp.status, 57 - plus_ones: rsvp.plus_ones, 58 - }; 59 - }) || [], 60 - }; 61 - }
-46
actions/get_phone_rsvp_to_event_state.ts
··· 1 - "use server"; 2 - 3 - import { drizzle } from "drizzle-orm/node-postgres"; 4 - import { and, eq } from "drizzle-orm"; 5 - import postgres from "postgres"; 6 - import { 7 - phone_number_auth_tokens, 8 - phone_rsvps_to_entity, 9 - } from "drizzle/schema"; 10 - import { cookies } from "next/headers"; 11 - import { Database } from "supabase/database.types"; 12 - import { pool } from "supabase/pool"; 13 - 14 - export async function getPhoneRSVPToEventState(entityId: string) { 15 - const token = (await cookies()).get("phone_auth_token"); 16 - 17 - if (!token) { 18 - return null; 19 - } 20 - 21 - const client = await pool.connect(); 22 - const db = drizzle(client); 23 - 24 - const [authToken] = await db 25 - .select() 26 - .from(phone_number_auth_tokens) 27 - .where(eq(phone_number_auth_tokens.id, token.value)); 28 - 29 - if (!authToken || !authToken.confirmed) { 30 - client.release(); 31 - return null; 32 - } 33 - 34 - const [rsvp] = await db 35 - .select() 36 - .from(phone_rsvps_to_entity) 37 - .where( 38 - and( 39 - eq(phone_rsvps_to_entity.phone_number, authToken.phone_number), 40 - eq(phone_rsvps_to_entity.entity, entityId), 41 - ), 42 - ); 43 - 44 - client.release(); 45 - return rsvp; 46 - }
-56
actions/phone_auth/confirm_phone_auth_token.ts
··· 1 - "use server"; 2 - 3 - import { drizzle } from "drizzle-orm/node-postgres"; 4 - import { and, eq } from "drizzle-orm"; 5 - import postgres from "postgres"; 6 - import { phone_number_auth_tokens } from "drizzle/schema"; 7 - import { cookies } from "next/headers"; 8 - import { pool } from "supabase/pool"; 9 - 10 - export async function confirmPhoneAuthToken(tokenId: string, code: string) { 11 - const client = await pool.connect(); 12 - const db = drizzle(client); 13 - 14 - const [token] = await db 15 - .select() 16 - .from(phone_number_auth_tokens) 17 - .where(eq(phone_number_auth_tokens.id, tokenId)); 18 - 19 - if (!token) { 20 - client.release(); 21 - throw new Error("Invalid token"); 22 - } 23 - 24 - if (token.confirmation_code !== code) { 25 - client.release(); 26 - throw new Error("Invalid confirmation code"); 27 - } 28 - 29 - if (token.confirmed) { 30 - client.release(); 31 - throw new Error("Token already confirmed"); 32 - } 33 - 34 - const [confirmedToken] = await db 35 - .update(phone_number_auth_tokens) 36 - .set({ 37 - confirmed: true, 38 - }) 39 - .where( 40 - and( 41 - eq(phone_number_auth_tokens.id, tokenId), 42 - eq(phone_number_auth_tokens.confirmation_code, code), 43 - ), 44 - ) 45 - .returning(); 46 - 47 - (await cookies()).set("phone_auth_token", confirmedToken.id, { 48 - maxAge: 60 * 60 * 24 * 30, 49 - secure: process.env.NODE_ENV === "production", 50 - httpOnly: true, 51 - sameSite: "strict", 52 - }); 53 - 54 - client.release(); 55 - return confirmedToken; 56 - }
-71
actions/phone_auth/request_phone_auth_token.ts
··· 1 - "use server"; 2 - 3 - import { randomBytes } from "crypto"; 4 - import { drizzle } from "drizzle-orm/node-postgres"; 5 - import postgres from "postgres"; 6 - import { phone_number_auth_tokens } from "drizzle/schema"; 7 - import twilio from "twilio"; 8 - import { pool } from "supabase/pool"; 9 - 10 - async function sendAuthCode({ 11 - country_code, 12 - phone_number, 13 - code, 14 - }: { 15 - country_code: string; 16 - phone_number: string; 17 - code: string; 18 - }) { 19 - let phoneNumber = `+${country_code}${phone_number}`; 20 - const accountSid = process.env.TWILIO_ACCOUNT_SID; 21 - const authToken = process.env.TWILIO_AUTH_TOKEN; 22 - const client = twilio(accountSid, authToken); 23 - if (country_code === "1") { 24 - const message = await client.messages.create({ 25 - body: `${code} is your verification code 26 - 27 - @leaflet.pub #${code}`, 28 - from: `+18449523391`, 29 - to: phoneNumber, 30 - }); 31 - console.log(message); 32 - } else { 33 - const message = await client.messages.create({ 34 - contentSid: "HX5ebfae4d2a423808486e773e8a22488d", 35 - contentVariables: JSON.stringify({ 1: code }), 36 - from: "whatsapp:+18449523391", 37 - messagingServiceSid: "MGffbf9a66770350b25caf3b80b9aac481", 38 - to: `whatsapp:${phoneNumber}`, 39 - }); 40 - } 41 - } 42 - 43 - export async function createPhoneAuthToken({ 44 - phone_number, 45 - country_code, 46 - }: { 47 - phone_number: string; 48 - country_code: string; 49 - }) { 50 - const client = await pool.connect(); 51 - const db = drizzle(client); 52 - 53 - const code = randomBytes(3).toString("hex").toUpperCase(); 54 - 55 - const [token] = await db 56 - .insert(phone_number_auth_tokens) 57 - .values({ 58 - phone_number, 59 - country_code, 60 - confirmation_code: code, 61 - confirmed: false, 62 - }) 63 - .returning({ 64 - id: phone_number_auth_tokens.id, 65 - }); 66 - 67 - await sendAuthCode({ country_code, phone_number, code }); 68 - 69 - client.release(); 70 - return token.id; 71 - }
-64
actions/phone_rsvp_to_event.ts
··· 1 - "use server"; 2 - 3 - import { drizzle } from "drizzle-orm/node-postgres"; 4 - import { 5 - entities, 6 - phone_number_auth_tokens, 7 - phone_rsvps_to_entity, 8 - } from "drizzle/schema"; 9 - import { redirect } from "next/navigation"; 10 - import postgres from "postgres"; 11 - import { v7 } from "uuid"; 12 - import { eq, sql } from "drizzle-orm"; 13 - import { Database } from "supabase/database.types"; 14 - import { createServerClient } from "@supabase/ssr"; 15 - import { cookies } from "next/headers"; 16 - import { pool } from "supabase/pool"; 17 - 18 - export async function submitRSVP(args: { 19 - entity: string; 20 - status: Database["public"]["Enums"]["rsvp_status"]; 21 - name: string; 22 - plus_ones: number; 23 - }) { 24 - const client = await pool.connect(); 25 - const db = drizzle(client); 26 - let token = (await cookies()).get("phone_auth_token"); 27 - if (!token) throw new Error("No auth token found"); 28 - 29 - let [auth_token] = await db 30 - .select() 31 - .from(phone_number_auth_tokens) 32 - .where(eq(phone_number_auth_tokens.id, token.value)); 33 - if (!auth_token) throw new Error("Invalid auth token"); 34 - if (!auth_token.confirmed) throw new Error("Auth token not confirmed"); 35 - 36 - await db.transaction(async (tx) => { 37 - await tx 38 - .insert(phone_rsvps_to_entity) 39 - .values([ 40 - { 41 - status: args.status, 42 - entity: args.entity, 43 - phone_number: auth_token.phone_number, 44 - country_code: auth_token.country_code, 45 - name: args.name, 46 - plus_ones: args.plus_ones, 47 - }, 48 - ]) 49 - .onConflictDoUpdate({ 50 - target: [ 51 - phone_rsvps_to_entity.entity, 52 - phone_rsvps_to_entity.phone_number, 53 - ], 54 - set: { 55 - name: args.name, 56 - status: args.status, 57 - plus_ones: args.plus_ones, 58 - }, 59 - }); 60 - }); 61 - 62 - client.release(); 63 - return { success: true }; 64 - }
+2
actions/publishToPublication.ts
··· 724 724 let [image] = scan.eav(b.value, "block/image"); 725 725 if (!image) return; 726 726 let [altText] = scan.eav(b.value, "image/alt"); 727 + let [fullBleed] = scan.eav(b.value, "image/full-bleed"); 727 728 let blobref = await uploadImage(image.data.src); 728 729 if (!blobref) return; 729 730 let block: $Typed<PubLeafletBlocksImage.Main> = { ··· 734 735 width: Math.floor(image.data.width), 735 736 }, 736 737 alt: altText ? altText.data.value : undefined, 738 + fullBleed: fullBleed?.data.value || undefined, 737 739 }; 738 740 return block; 739 741 }
-71
actions/sendUpdateToRSVPS.ts
··· 1 - "use server"; 2 - import { drizzle } from "drizzle-orm/node-postgres"; 3 - import { eq } from "drizzle-orm"; 4 - import { 5 - entities, 6 - permission_token_rights, 7 - phone_rsvps_to_entity, 8 - } from "drizzle/schema"; 9 - import twilio from "twilio"; 10 - import { pool } from "supabase/pool"; 11 - 12 - export async function sendUpdateToRSVPS( 13 - token: { id: string }, 14 - { 15 - entity, 16 - message, 17 - eventName, 18 - sendto, 19 - publicLeafletID, 20 - }: { 21 - entity: string; 22 - message: string; 23 - eventName: string; 24 - publicLeafletID: string; 25 - sendto: { GOING: boolean; MAYBE: boolean; NOT_GOING: boolean }; 26 - }, 27 - ) { 28 - let dbclient = await pool.connect(); 29 - const db = drizzle(dbclient); 30 - let token_rights = await db 31 - .select() 32 - .from(permission_token_rights) 33 - .where(eq(permission_token_rights.token, token.id)); 34 - 35 - let RSVPS = db 36 - .select() 37 - .from(phone_rsvps_to_entity) 38 - .innerJoin(entities, eq(phone_rsvps_to_entity.entity, entities.id)) 39 - .where(eq(phone_rsvps_to_entity.entity, entity)); 40 - 41 - dbclient.release(); 42 - 43 - if (!token_rights[0]?.write) return; 44 - let rsvps = await RSVPS; 45 - let entity_set = rsvps[0]?.entities.set; 46 - if (!token_rights.find((r) => r.entity_set === entity_set)) { 47 - return; 48 - } 49 - 50 - const accountSid = process.env.TWILIO_ACCOUNT_SID; 51 - const authToken = process.env.TWILIO_AUTH_TOKEN; 52 - const client = twilio(accountSid, authToken); 53 - 54 - for (let rsvp of rsvps) { 55 - if (sendto[rsvp.phone_rsvps_to_entity.status]) { 56 - let { country_code, phone_number } = rsvp.phone_rsvps_to_entity; 57 - let number = `+${country_code}${phone_number}`; 58 - await client.messages.create({ 59 - contentSid: "HX8e1217f791d38fa4cf7b7b24a02fe10c", 60 - contentVariables: JSON.stringify({ 61 - 1: eventName, 62 - 2: message, 63 - 3: `https://leaflet.pub/${publicLeafletID}`, 64 - }), 65 - from: `${country_code === "1" ? "" : "whatsapp:"}+18449523391`, 66 - messagingServiceSid: "MGffbf9a66770350b25caf3b80b9aac481", 67 - to: country_code === "1" ? number : `whatsapp:${number}`, 68 - }); 69 - } 70 - } 71 - }
+6 -5
app/[leaflet_id]/page.tsx
··· 7 7 import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 8 8 import { Leaflet } from "./Leaflet"; 9 9 import { scanIndexLocal } from "src/replicache/utils"; 10 - import { getRSVPData } from "actions/getRSVPData"; 10 + 11 11 import { PageSWRDataProvider } from "components/PageSWRDataProvider"; 12 12 import { getPollData } from "actions/pollActions"; 13 13 import { supabaseServerClient } from "supabase/serverClient"; ··· 41 41 </NotFoundLayout> 42 42 ); 43 43 44 - let [{ data }, rsvp_data, poll_data] = await Promise.all([ 44 + let [{ data }, poll_data] = await Promise.all([ 45 45 supabaseServerClient.rpc("get_facts", { 46 46 root: rootEntity, 47 47 }), 48 - getRSVPData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 49 48 getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 50 49 ]); 51 50 let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 52 51 53 52 // Extract font settings from facts for server-side font loading 54 - const { headingFontId, bodyFontId } = extractFontsFromFacts(initialFacts as any, rootEntity); 53 + const { headingFontId, bodyFontId } = extractFontsFromFacts( 54 + initialFacts as any, 55 + rootEntity, 56 + ); 55 57 56 58 return ( 57 59 <> 58 60 {/* Server-side font loading with preload and @font-face */} 59 61 <FontLoader headingFontId={headingFontId} bodyFontId={bodyFontId} /> 60 62 <PageSWRDataProvider 61 - rsvp_data={rsvp_data} 62 63 poll_data={poll_data} 63 64 leaflet_id={res.data.id} 64 65 leaflet_data={res}
+375
app/api/ai/blocks/[blockId]/route.ts
··· 1 + import { NextRequest } from "next/server"; 2 + import { drizzle } from "drizzle-orm/node-postgres"; 3 + import { sql, eq, and } from "drizzle-orm"; 4 + import { pool } from "supabase/pool"; 5 + import { facts, entities } from "drizzle/schema"; 6 + import { 7 + authenticateToken, 8 + broadcastPoke, 9 + tokenHash, 10 + hasWriteAccess, 11 + editYjsText, 12 + EditOperation, 13 + } from "../../lib"; 14 + 15 + type Params = { params: Promise<{ blockId: string }> }; 16 + 17 + // --- DELETE --- 18 + 19 + export async function DELETE(req: NextRequest, { params }: Params) { 20 + let auth = await authenticateToken(req); 21 + if (auth instanceof Response) return auth; 22 + 23 + if (!hasWriteAccess(auth)) { 24 + return Response.json({ error: "No write access" }, { status: 403 }); 25 + } 26 + 27 + let { blockId } = await params; 28 + 29 + let client = await pool.connect(); 30 + try { 31 + let db = drizzle(client); 32 + await db.transaction(async (tx) => { 33 + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); 34 + 35 + // Verify the block entity exists 36 + let [entity] = await tx 37 + .select({ id: entities.id, set: entities.set }) 38 + .from(entities) 39 + .where(eq(entities.id, blockId)); 40 + 41 + if (!entity) { 42 + throw Response.json({ error: "Block not found" }, { status: 404 }); 43 + } 44 + 45 + // Verify permission 46 + let hasAccess = auth.tokenRights.some( 47 + (r) => r.entity_set === entity.set && r.write, 48 + ); 49 + if (!hasAccess) { 50 + throw Response.json({ error: "Block not found" }, { status: 404 }); 51 + } 52 + 53 + // Check for image to clean up 54 + let [imageFact] = await tx 55 + .select({ data: facts.data }) 56 + .from(facts) 57 + .where(and(eq(facts.entity, blockId), eq(facts.attribute, "block/image"))); 58 + 59 + if (imageFact) { 60 + let { createClient } = await import("@supabase/supabase-js"); 61 + let supabase = createClient( 62 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 63 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 64 + ); 65 + let src = (imageFact.data as any).src; 66 + if (src) { 67 + let paths = src.split("/"); 68 + await supabase.storage 69 + .from("minilink-user-assets") 70 + .remove([paths[paths.length - 1]]); 71 + } 72 + } 73 + 74 + // Delete the entity (cascades to facts) 75 + await tx.delete(entities).where(eq(entities.id, blockId)); 76 + 77 + // Also delete referencing facts (card/block pointing to this entity) 78 + await tx.delete(facts).where( 79 + and( 80 + eq(facts.attribute, "card/block"), 81 + sql`data->>'value' = ${blockId}`, 82 + ), 83 + ); 84 + }); 85 + 86 + await broadcastPoke(auth.rootEntity); 87 + return Response.json({ deleted: blockId }); 88 + } catch (e) { 89 + if (e instanceof Response) return e; 90 + console.error("AI API delete error:", e); 91 + return Response.json({ error: "Internal error" }, { status: 500 }); 92 + } finally { 93 + client.release(); 94 + } 95 + } 96 + 97 + // --- PATCH --- 98 + 99 + export async function PATCH(req: NextRequest, { params }: Params) { 100 + let auth = await authenticateToken(req); 101 + if (auth instanceof Response) return auth; 102 + 103 + if (!hasWriteAccess(auth)) { 104 + return Response.json({ error: "No write access" }, { status: 403 }); 105 + } 106 + 107 + let { blockId } = await params; 108 + 109 + let body: { 110 + action?: "replace" | "insert"; 111 + content?: string; 112 + position?: "start" | "end" | { before: string } | { after: string }; 113 + language?: string | null; 114 + }; 115 + try { 116 + body = await req.json(); 117 + } catch { 118 + return Response.json({ error: "Invalid JSON" }, { status: 400 }); 119 + } 120 + 121 + let hasContentEdit = body.action !== undefined || body.content !== undefined; 122 + if (hasContentEdit && (!body.action || body.content === undefined)) { 123 + return Response.json( 124 + { error: "action and content required together" }, 125 + { status: 400 }, 126 + ); 127 + } 128 + if (!hasContentEdit && body.language === undefined) { 129 + return Response.json( 130 + { error: "must provide action+content or language" }, 131 + { status: 400 }, 132 + ); 133 + } 134 + 135 + let client = await pool.connect(); 136 + try { 137 + let db = drizzle(client); 138 + let result: { blockId: string; newText: string } | null = null; 139 + 140 + await db.transaction(async (tx) => { 141 + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); 142 + 143 + // Verify the block entity exists 144 + let [entity] = await tx 145 + .select({ id: entities.id, set: entities.set }) 146 + .from(entities) 147 + .where(eq(entities.id, blockId)); 148 + 149 + if (!entity) { 150 + throw Response.json({ error: "Block not found" }, { status: 404 }); 151 + } 152 + 153 + let hasAccess = auth.tokenRights.some( 154 + (r) => r.entity_set === entity.set && r.write, 155 + ); 156 + if (!hasAccess) { 157 + throw Response.json({ error: "Block not found" }, { status: 404 }); 158 + } 159 + 160 + // Get block type 161 + let [typeFact] = await tx 162 + .select({ id: facts.id, data: facts.data }) 163 + .from(facts) 164 + .where(and(eq(facts.entity, blockId), eq(facts.attribute, "block/type"))); 165 + 166 + if (!typeFact) { 167 + throw Response.json({ error: "Block has no type" }, { status: 400 }); 168 + } 169 + 170 + let blockType = (typeFact.data as any).value; 171 + 172 + if ( 173 + blockType === "text" || 174 + blockType === "heading" || 175 + blockType === "blockquote" 176 + ) { 177 + if (body.language !== undefined) { 178 + throw Response.json( 179 + { error: "language only applies to code blocks" }, 180 + { status: 400 }, 181 + ); 182 + } 183 + if (!hasContentEdit) { 184 + throw Response.json( 185 + { error: "action and content required" }, 186 + { status: 400 }, 187 + ); 188 + } 189 + // YJS content 190 + let [textFact] = await tx 191 + .select({ id: facts.id, data: facts.data }) 192 + .from(facts) 193 + .where( 194 + and(eq(facts.entity, blockId), eq(facts.attribute, "block/text")), 195 + ); 196 + 197 + let existingBase64 = textFact ? (textFact.data as any).value : null; 198 + let content = body.content as string; 199 + 200 + let operation: EditOperation; 201 + if (body.action === "replace") { 202 + operation = { type: "replace", content }; 203 + } else { 204 + operation = { 205 + type: "insert", 206 + position: body.position || "end", 207 + content, 208 + } as EditOperation; 209 + } 210 + 211 + if (!existingBase64) { 212 + // No existing text, create new 213 + let { createYjsText } = await import("../../lib"); 214 + let newBase64 = createYjsText(content); 215 + if (textFact) { 216 + await tx 217 + .update(facts) 218 + .set({ data: sql`jsonb_set(data, '{value}', ${JSON.stringify(newBase64)}::jsonb)` }) 219 + .where(eq(facts.id, textFact.id)); 220 + } else { 221 + let { v7 } = await import("uuid"); 222 + await tx.insert(facts).values({ 223 + id: v7(), 224 + entity: blockId, 225 + attribute: "block/text", 226 + data: sql`${JSON.stringify({ type: "text", value: newBase64 })}::jsonb`, 227 + }); 228 + } 229 + result = { blockId, newText: content }; 230 + } else { 231 + let editResult = editYjsText(existingBase64, operation); 232 + 233 + if ("error" in editResult) { 234 + throw Response.json( 235 + { 236 + error: "search_not_found", 237 + blockText: editResult.fullText, 238 + }, 239 + { status: 400 }, 240 + ); 241 + } 242 + 243 + await tx 244 + .update(facts) 245 + .set({ 246 + data: sql`jsonb_set(data, '{value}', ${JSON.stringify(editResult.result)}::jsonb)`, 247 + }) 248 + .where(eq(facts.id, textFact.id)); 249 + 250 + result = { blockId, newText: editResult.plaintext }; 251 + } 252 + } else if (blockType === "code") { 253 + // Plain string content 254 + let [codeFact] = await tx 255 + .select({ id: facts.id, data: facts.data }) 256 + .from(facts) 257 + .where( 258 + and(eq(facts.entity, blockId), eq(facts.attribute, "block/code")), 259 + ); 260 + 261 + let existingCode = codeFact ? ((codeFact.data as any).value as string) : ""; 262 + let newCode = existingCode; 263 + 264 + if (hasContentEdit) { 265 + let content = body.content as string; 266 + if (body.action === "replace") { 267 + newCode = content; 268 + } else { 269 + let pos = body.position || "end"; 270 + if (pos === "start") { 271 + newCode = content + existingCode; 272 + } else if (pos === "end") { 273 + newCode = existingCode + content; 274 + } else if (typeof pos === "object" && "before" in pos) { 275 + let idx = existingCode.indexOf(pos.before); 276 + if (idx === -1) { 277 + throw Response.json( 278 + { error: "search_not_found", blockText: existingCode }, 279 + { status: 400 }, 280 + ); 281 + } 282 + newCode = 283 + existingCode.slice(0, idx) + 284 + content + 285 + existingCode.slice(idx); 286 + } else if (typeof pos === "object" && "after" in pos) { 287 + let idx = existingCode.indexOf(pos.after); 288 + if (idx === -1) { 289 + throw Response.json( 290 + { error: "search_not_found", blockText: existingCode }, 291 + { status: 400 }, 292 + ); 293 + } 294 + newCode = 295 + existingCode.slice(0, idx + pos.after.length) + 296 + content + 297 + existingCode.slice(idx + pos.after.length); 298 + } else { 299 + newCode = existingCode + content; 300 + } 301 + } 302 + 303 + if (codeFact) { 304 + await tx 305 + .update(facts) 306 + .set({ 307 + data: sql`jsonb_set(data, '{value}', ${JSON.stringify(newCode)}::jsonb)`, 308 + }) 309 + .where(eq(facts.id, codeFact.id)); 310 + } else { 311 + let { v7 } = await import("uuid"); 312 + await tx.insert(facts).values({ 313 + id: v7(), 314 + entity: blockId, 315 + attribute: "block/code", 316 + data: sql`${JSON.stringify({ type: "string", value: newCode })}::jsonb`, 317 + }); 318 + } 319 + } 320 + 321 + // Handle language update 322 + if (body.language !== undefined) { 323 + let [langFact] = await tx 324 + .select({ id: facts.id }) 325 + .from(facts) 326 + .where( 327 + and( 328 + eq(facts.entity, blockId), 329 + eq(facts.attribute, "block/code-language"), 330 + ), 331 + ); 332 + 333 + if (body.language === null || body.language === "") { 334 + // Remove language 335 + if (langFact) { 336 + await tx.delete(facts).where(eq(facts.id, langFact.id)); 337 + } 338 + } else { 339 + let langData = { type: "string", value: body.language }; 340 + if (langFact) { 341 + await tx 342 + .update(facts) 343 + .set({ data: sql`${JSON.stringify(langData)}::jsonb` }) 344 + .where(eq(facts.id, langFact.id)); 345 + } else { 346 + let { v7 } = await import("uuid"); 347 + await tx.insert(facts).values({ 348 + id: v7(), 349 + entity: blockId, 350 + attribute: "block/code-language", 351 + data: sql`${JSON.stringify(langData)}::jsonb`, 352 + }); 353 + } 354 + } 355 + } 356 + 357 + result = { blockId, newText: newCode }; 358 + } else { 359 + throw Response.json( 360 + { error: `Cannot edit blocks of type '${blockType}'` }, 361 + { status: 400 }, 362 + ); 363 + } 364 + }); 365 + 366 + await broadcastPoke(auth.rootEntity); 367 + return Response.json(result); 368 + } catch (e) { 369 + if (e instanceof Response) return e; 370 + console.error("AI API patch error:", e); 371 + return Response.json({ error: "Internal error" }, { status: 500 }); 372 + } finally { 373 + client.release(); 374 + } 375 + }
+242
app/api/ai/blocks/route.ts
··· 1 + import { NextRequest } from "next/server"; 2 + import { drizzle } from "drizzle-orm/node-postgres"; 3 + import { sql, eq } from "drizzle-orm"; 4 + import { pool } from "supabase/pool"; 5 + import { permission_token_rights } from "drizzle/schema"; 6 + import { cachedServerMutationContext } from "src/replicache/cachedServerMutationContext"; 7 + import { generateKeyBetween } from "fractional-indexing"; 8 + import { v7 } from "uuid"; 9 + import { 10 + authenticateToken, 11 + resolvePageEntity, 12 + getPageBlocks, 13 + createYjsText, 14 + broadcastPoke, 15 + tokenHash, 16 + hasWriteAccess, 17 + } from "../lib"; 18 + 19 + type BlockInput = 20 + | { type: "text"; content: string } 21 + | { type: "heading"; content: string; level?: number } 22 + | { type: "code"; content: string; language?: string } 23 + | { type: "blockquote"; content: string } 24 + | { type: "horizontal-rule" }; 25 + 26 + type PositionInput = 27 + | "start" 28 + | "end" 29 + | { after: string } 30 + | { before: string }; 31 + 32 + export async function POST(req: NextRequest) { 33 + let auth = await authenticateToken(req); 34 + if (auth instanceof Response) return auth; 35 + 36 + if (!hasWriteAccess(auth)) { 37 + return Response.json({ error: "No write access" }, { status: 403 }); 38 + } 39 + 40 + let body: { page?: string; position: PositionInput; blocks: BlockInput[] }; 41 + try { 42 + body = await req.json(); 43 + } catch { 44 + return Response.json({ error: "Invalid JSON" }, { status: 400 }); 45 + } 46 + 47 + if (!body.blocks || !Array.isArray(body.blocks) || body.blocks.length === 0) { 48 + return Response.json({ error: "blocks array required" }, { status: 400 }); 49 + } 50 + if (!body.position) { 51 + return Response.json({ error: "position required" }, { status: 400 }); 52 + } 53 + 54 + let client = await pool.connect(); 55 + try { 56 + let db = drizzle(client); 57 + let createdBlocks: { blockId: string; type: string }[] = []; 58 + 59 + await db.transaction(async (tx) => { 60 + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); 61 + 62 + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, body.page); 63 + if (pageEntity instanceof Response) throw pageEntity; 64 + 65 + let token_rights = await tx 66 + .select() 67 + .from(permission_token_rights) 68 + .where(eq(permission_token_rights.token, auth.tokenId)); 69 + 70 + let { getContext, flush } = cachedServerMutationContext( 71 + tx, 72 + auth.tokenId, 73 + token_rights, 74 + ); 75 + let ctx = getContext("ai-api", 0); 76 + 77 + let existingBlocks = await getPageBlocks(tx, pageEntity as string); 78 + let sorted = existingBlocks.sort((a, b) => 79 + a.position > b.position ? 1 : -1, 80 + ); 81 + 82 + // Compute initial position based on body.position 83 + let currentPosition: string; 84 + let pos = body.position; 85 + 86 + if (pos === "start") { 87 + currentPosition = generateKeyBetween( 88 + null, 89 + sorted[0]?.position || null, 90 + ); 91 + } else if (pos === "end") { 92 + currentPosition = generateKeyBetween( 93 + sorted[sorted.length - 1]?.position || null, 94 + null, 95 + ); 96 + } else if ("after" in pos) { 97 + let targetIdx = sorted.findIndex((b) => b.value === pos.after); 98 + if (targetIdx === -1) { 99 + throw Response.json({ error: "Block not found for 'after'" }, { status: 404 }); 100 + } 101 + currentPosition = generateKeyBetween( 102 + sorted[targetIdx].position, 103 + sorted[targetIdx + 1]?.position || null, 104 + ); 105 + } else if ("before" in pos) { 106 + let targetIdx = sorted.findIndex((b) => b.value === pos.before); 107 + if (targetIdx === -1) { 108 + throw Response.json({ error: "Block not found for 'before'" }, { status: 404 }); 109 + } 110 + currentPosition = generateKeyBetween( 111 + sorted[targetIdx - 1]?.position || null, 112 + sorted[targetIdx].position, 113 + ); 114 + } else { 115 + throw Response.json({ error: "Invalid position" }, { status: 400 }); 116 + } 117 + 118 + // Track the next position boundary for chaining 119 + let nextBound: string | null = null; 120 + if (pos === "start" && sorted.length > 0) { 121 + nextBound = sorted[0].position; 122 + } else if (typeof pos === "object" && "before" in pos) { 123 + let targetIdx = sorted.findIndex((b) => b.value === pos.before); 124 + nextBound = sorted[targetIdx].position; 125 + } 126 + 127 + for (let i = 0; i < body.blocks.length; i++) { 128 + let block = body.blocks[i]; 129 + let newEntityID = v7(); 130 + let factID = v7(); 131 + 132 + // For subsequent blocks, chain after the previous position 133 + if (i > 0) { 134 + currentPosition = generateKeyBetween(currentPosition, nextBound); 135 + } 136 + 137 + await ctx.createEntity({ 138 + entityID: newEntityID, 139 + permission_set: auth.permissionSet!, 140 + }); 141 + 142 + await ctx.assertFact({ 143 + entity: pageEntity as string, 144 + id: factID, 145 + data: { 146 + type: "ordered-reference" as const, 147 + value: newEntityID, 148 + position: currentPosition, 149 + }, 150 + attribute: "card/block" as const, 151 + }); 152 + 153 + let blockType: string; 154 + 155 + if (block.type === "text") { 156 + blockType = "text"; 157 + await ctx.assertFact({ 158 + entity: newEntityID, 159 + data: { type: "block-type-union" as const, value: "text" }, 160 + attribute: "block/type" as const, 161 + }); 162 + await ctx.assertFact({ 163 + entity: newEntityID, 164 + data: { type: "text" as const, value: createYjsText(block.content) }, 165 + attribute: "block/text" as const, 166 + }); 167 + } else if (block.type === "heading") { 168 + blockType = "heading"; 169 + await ctx.assertFact({ 170 + entity: newEntityID, 171 + data: { type: "block-type-union" as const, value: "heading" }, 172 + attribute: "block/type" as const, 173 + }); 174 + await ctx.assertFact({ 175 + entity: newEntityID, 176 + data: { type: "text" as const, value: createYjsText(block.content) }, 177 + attribute: "block/text" as const, 178 + }); 179 + await ctx.assertFact({ 180 + entity: newEntityID, 181 + data: { type: "number" as const, value: block.level || 1 }, 182 + attribute: "block/heading-level" as const, 183 + }); 184 + } else if (block.type === "code") { 185 + blockType = "code"; 186 + await ctx.assertFact({ 187 + entity: newEntityID, 188 + data: { type: "block-type-union" as const, value: "code" }, 189 + attribute: "block/type" as const, 190 + }); 191 + await ctx.assertFact({ 192 + entity: newEntityID, 193 + data: { type: "string" as const, value: block.content }, 194 + attribute: "block/code" as const, 195 + }); 196 + if (block.language) { 197 + await ctx.assertFact({ 198 + entity: newEntityID, 199 + data: { type: "string" as const, value: block.language }, 200 + attribute: "block/code-language" as const, 201 + }); 202 + } 203 + } else if (block.type === "blockquote") { 204 + blockType = "blockquote"; 205 + await ctx.assertFact({ 206 + entity: newEntityID, 207 + data: { type: "block-type-union" as const, value: "blockquote" }, 208 + attribute: "block/type" as const, 209 + }); 210 + await ctx.assertFact({ 211 + entity: newEntityID, 212 + data: { type: "text" as const, value: createYjsText(block.content) }, 213 + attribute: "block/text" as const, 214 + }); 215 + } else if (block.type === "horizontal-rule") { 216 + blockType = "horizontal-rule"; 217 + await ctx.assertFact({ 218 + entity: newEntityID, 219 + data: { type: "block-type-union" as const, value: "horizontal-rule" }, 220 + attribute: "block/type" as const, 221 + }); 222 + } else { 223 + continue; 224 + } 225 + 226 + createdBlocks.push({ blockId: newEntityID, type: blockType }); 227 + } 228 + 229 + await flush(); 230 + }); 231 + 232 + await broadcastPoke(auth.rootEntity); 233 + 234 + return Response.json({ blocks: createdBlocks }); 235 + } catch (e) { 236 + if (e instanceof Response) return e; 237 + console.error("AI API blocks error:", e); 238 + return Response.json({ error: "Internal error" }, { status: 500 }); 239 + } finally { 240 + client.release(); 241 + } 242 + }
+138
app/api/ai/doc/route.ts
··· 1 + import { NextRequest } from "next/server"; 2 + import { drizzle } from "drizzle-orm/node-postgres"; 3 + import { pool } from "supabase/pool"; 4 + import { 5 + authenticateToken, 6 + resolvePageEntity, 7 + getPageBlocks, 8 + getAllFactsForEntities, 9 + blocksToMarkdown, 10 + extractPlaintext, 11 + } from "../lib"; 12 + 13 + export async function GET(req: NextRequest) { 14 + let auth = await authenticateToken(req); 15 + if (auth instanceof Response) return auth; 16 + 17 + let pageParam = req.nextUrl.searchParams.get("page"); 18 + 19 + let client = await pool.connect(); 20 + try { 21 + let db = drizzle(client); 22 + return await db.transaction(async (tx) => { 23 + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, pageParam); 24 + if (pageEntity instanceof Response) return pageEntity; 25 + 26 + let blocks = await getPageBlocks(tx, pageEntity); 27 + 28 + // Collect all entity IDs we need facts for 29 + let entityIds = new Set<string>(); 30 + for (let b of blocks) { 31 + entityIds.add(b.value); 32 + } 33 + let allFacts = await getAllFactsForEntities(tx, [...entityIds]); 34 + 35 + // For card blocks, also fetch subpage facts 36 + let subpages: { id: string; title: string }[] = []; 37 + for (let b of blocks) { 38 + if (b.type === "card") { 39 + let cardFacts = allFacts.filter( 40 + (f) => f.entity === b.value && f.attribute === "block/card", 41 + ); 42 + if (cardFacts[0]) { 43 + let cardEntityId = (cardFacts[0].data as any).value; 44 + entityIds.add(cardEntityId); 45 + } 46 + } 47 + } 48 + 49 + // Re-fetch with subpage entities included 50 + allFacts = await getAllFactsForEntities(tx, [...entityIds]); 51 + 52 + // Also fetch subpage block entities for titles 53 + let subpageBlockEntityIds = new Set<string>(); 54 + for (let b of blocks) { 55 + if (b.type === "card") { 56 + let cardFacts = allFacts.filter( 57 + (f) => f.entity === b.value && f.attribute === "block/card", 58 + ); 59 + if (cardFacts[0]) { 60 + let cardEntityId = (cardFacts[0].data as any).value; 61 + let blockRefs = allFacts 62 + .filter( 63 + (f) => 64 + f.entity === cardEntityId && f.attribute === "card/block", 65 + ) 66 + .sort( 67 + (a, b) => 68 + (a.data as any).position > (b.data as any).position ? 1 : -1, 69 + ); 70 + for (let ref of blockRefs) { 71 + subpageBlockEntityIds.add((ref.data as any).value); 72 + } 73 + } 74 + } 75 + } 76 + 77 + if (subpageBlockEntityIds.size > 0) { 78 + let subpageBlockFacts = await getAllFactsForEntities(tx, [ 79 + ...subpageBlockEntityIds, 80 + ]); 81 + allFacts = [...allFacts, ...subpageBlockFacts]; 82 + } 83 + 84 + // Build subpages list 85 + for (let b of blocks) { 86 + if (b.type === "card") { 87 + let cardFacts = allFacts.filter( 88 + (f) => f.entity === b.value && f.attribute === "block/card", 89 + ); 90 + if (cardFacts[0]) { 91 + let cardEntityId = (cardFacts[0].data as any).value; 92 + let blockRefs = allFacts 93 + .filter( 94 + (f) => 95 + f.entity === cardEntityId && f.attribute === "card/block", 96 + ) 97 + .sort( 98 + (a, b) => 99 + (a.data as any).position > (b.data as any).position ? 1 : -1, 100 + ); 101 + let title = ""; 102 + if (blockRefs[0]) { 103 + let firstBlockId = (blockRefs[0].data as any).value; 104 + let textFact = allFacts.find( 105 + (f) => 106 + f.entity === firstBlockId && f.attribute === "block/text", 107 + ); 108 + if (textFact) { 109 + title = extractPlaintext((textFact.data as any).value); 110 + } 111 + } 112 + subpages.push({ id: cardEntityId, title: title || "Untitled" }); 113 + } 114 + } 115 + } 116 + 117 + let markdown = await blocksToMarkdown(blocks, allFacts); 118 + 119 + // Extract document title from first heading 120 + let titleBlock = blocks.find( 121 + (b) => b.type === "heading" || b.type === "text", 122 + ); 123 + let title = ""; 124 + if (titleBlock) { 125 + let textFact = allFacts.find( 126 + (f) => f.entity === titleBlock.value && f.attribute === "block/text", 127 + ); 128 + if (textFact) { 129 + title = extractPlaintext((textFact.data as any).value); 130 + } 131 + } 132 + 133 + return Response.json({ title, markdown, subpages }); 134 + }); 135 + } finally { 136 + client.release(); 137 + } 138 + }
+609
app/api/ai/lib.tsx
··· 1 + import { createClient } from "@supabase/supabase-js"; 2 + import type { Database } from "supabase/database.types"; 3 + import { permission_tokens, permission_token_rights } from "drizzle/schema"; 4 + import { entities, facts } from "drizzle/schema"; 5 + import * as driz from "drizzle-orm"; 6 + import { PgTransaction } from "drizzle-orm/pg-core"; 7 + import * as Y from "yjs"; 8 + import * as base64 from "base64-js"; 9 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 10 + import { Block } from "components/Blocks/Block"; 11 + import { parseBlocksToList, List } from "src/utils/parseBlocksToList"; 12 + import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 13 + 14 + // --- Auth --- 15 + 16 + export type AuthResult = { 17 + tokenId: string; 18 + rootEntity: string; 19 + tokenRights: { 20 + token: string; 21 + entity_set: string; 22 + read: boolean; 23 + write: boolean; 24 + create_token: boolean; 25 + change_entity_set: boolean; 26 + }[]; 27 + permissionSet: string | null; 28 + }; 29 + 30 + export async function authenticateToken( 31 + request: Request, 32 + ): Promise<AuthResult | Response> { 33 + let auth = request.headers.get("Authorization"); 34 + if (!auth || !auth.startsWith("Bearer ")) { 35 + return Response.json({ error: "Missing Authorization header" }, { status: 401 }); 36 + } 37 + let tokenId = auth.slice("Bearer ".length).trim(); 38 + if (!tokenId) { 39 + return Response.json({ error: "Invalid token" }, { status: 401 }); 40 + } 41 + 42 + let supabase = createClient<Database>( 43 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 44 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 45 + ); 46 + 47 + let { data: token } = await supabase 48 + .from("permission_tokens") 49 + .select("id, root_entity, blocked_by_admin") 50 + .eq("id", tokenId) 51 + .single(); 52 + 53 + if (!token) { 54 + return Response.json({ error: "Invalid token" }, { status: 401 }); 55 + } 56 + if (token.blocked_by_admin) { 57 + return Response.json({ error: "Token blocked" }, { status: 403 }); 58 + } 59 + 60 + let { data: rights } = await supabase 61 + .from("permission_token_rights") 62 + .select("token, entity_set, read, write, create_token, change_entity_set") 63 + .eq("token", tokenId); 64 + 65 + let tokenRights = rights || []; 66 + let permissionSet = 67 + tokenRights.find((r) => r.write)?.entity_set ?? null; 68 + 69 + return { 70 + tokenId, 71 + rootEntity: token.root_entity, 72 + tokenRights, 73 + permissionSet, 74 + }; 75 + } 76 + 77 + // --- Page resolution --- 78 + 79 + export async function resolvePageEntity( 80 + tx: PgTransaction<any, any, any>, 81 + rootEntity: string, 82 + pageParam?: string | null, 83 + ): Promise<string | Response> { 84 + let rootPageFacts = await tx 85 + .select({ data: facts.data }) 86 + .from(facts) 87 + .where( 88 + driz.and( 89 + driz.eq(facts.entity, rootEntity), 90 + driz.eq(facts.attribute, "root/page"), 91 + ), 92 + ); 93 + 94 + let mainPage = (rootPageFacts[0]?.data as any)?.value as string | undefined; 95 + if (!mainPage) { 96 + return Response.json({ error: "No main page found" }, { status: 404 }); 97 + } 98 + 99 + if (!pageParam) return mainPage; 100 + 101 + // Verify the requested page exists as an entity in this document 102 + let [pageEntity] = await tx 103 + .select({ id: entities.id }) 104 + .from(entities) 105 + .where(driz.eq(entities.id, pageParam)); 106 + 107 + if (!pageEntity) { 108 + return Response.json({ error: "Page not found" }, { status: 404 }); 109 + } 110 + 111 + return pageParam; 112 + } 113 + 114 + // --- Block fetching (server-side version of getBlocksWithTypeLocal) --- 115 + 116 + type FactRow = { 117 + id: string; 118 + entity: string; 119 + attribute: string; 120 + data: any; 121 + }; 122 + 123 + export async function getPageBlocks( 124 + tx: PgTransaction<any, any, any>, 125 + pageEntity: string, 126 + ): Promise<Block[]> { 127 + // Get all facts for this page's blocks in bulk 128 + let blockRefs = await tx 129 + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) 130 + .from(facts) 131 + .where( 132 + driz.and( 133 + driz.eq(facts.entity, pageEntity), 134 + driz.eq(facts.attribute, "card/block"), 135 + ), 136 + ); 137 + 138 + blockRefs.sort((a, b) => { 139 + let posA = (a.data as any).position; 140 + let posB = (b.data as any).position; 141 + if (posA === posB) return a.id > b.id ? 1 : -1; 142 + return posA > posB ? 1 : -1; 143 + }); 144 + 145 + if (blockRefs.length === 0) return []; 146 + 147 + // Collect all block entity IDs 148 + let blockEntityIds = blockRefs.map((r) => (r.data as any).value as string); 149 + 150 + // Fetch all facts for these block entities in one query 151 + let allBlockFacts = await tx 152 + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) 153 + .from(facts) 154 + .where(driz.inArray(facts.entity, blockEntityIds)); 155 + 156 + let factsByEntity = new Map<string, FactRow[]>(); 157 + for (let f of allBlockFacts) { 158 + let arr = factsByEntity.get(f.entity); 159 + if (!arr) { 160 + arr = []; 161 + factsByEntity.set(f.entity, arr); 162 + } 163 + arr.push(f); 164 + } 165 + 166 + let result: Block[] = []; 167 + 168 + for (let ref of blockRefs) { 169 + let blockEntityId = (ref.data as any).value as string; 170 + let blockFacts = factsByEntity.get(blockEntityId) || []; 171 + let typeFact = blockFacts.find((f) => f.attribute === "block/type"); 172 + if (!typeFact) continue; 173 + 174 + let isListFact = blockFacts.find((f) => f.attribute === "block/is-list"); 175 + if (isListFact && (isListFact.data as any).value) { 176 + let children = await getListChildren(tx, ref, pageEntity, 1, []); 177 + result.push(...children); 178 + } else { 179 + result.push({ 180 + value: blockEntityId, 181 + position: (ref.data as any).position, 182 + factID: ref.id, 183 + type: (typeFact.data as any).value, 184 + parent: pageEntity, 185 + }); 186 + } 187 + } 188 + 189 + computeDisplayNumbers(result); 190 + return result; 191 + } 192 + 193 + async function getListChildren( 194 + tx: PgTransaction<any, any, any>, 195 + root: FactRow, 196 + pageParent: string, 197 + depth: number, 198 + path: { depth: number; entity: string }[], 199 + ): Promise<Block[]> { 200 + let rootValue = (root.data as any).value as string; 201 + 202 + let childRefs = await tx 203 + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) 204 + .from(facts) 205 + .where( 206 + driz.and( 207 + driz.eq(facts.entity, rootValue), 208 + driz.eq(facts.attribute, "card/block"), 209 + ), 210 + ); 211 + childRefs.sort((a, b) => 212 + (a.data as any).position > (b.data as any).position ? 1 : -1, 213 + ); 214 + 215 + let rootFacts = await tx 216 + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) 217 + .from(facts) 218 + .where(driz.eq(facts.entity, rootValue)); 219 + 220 + let typeFact = rootFacts.find((f) => f.attribute === "block/type"); 221 + if (!typeFact) return []; 222 + 223 + let listStyleFact = rootFacts.find((f) => f.attribute === "block/list-style"); 224 + let listNumberFact = rootFacts.find((f) => f.attribute === "block/list-number"); 225 + 226 + let newPath = [...path, { entity: rootValue, depth }]; 227 + 228 + let childBlocks: Block[] = []; 229 + for (let c of childRefs) { 230 + let children = await getListChildren(tx, c, rootValue, depth + 1, newPath); 231 + childBlocks.push(...children); 232 + } 233 + 234 + return [ 235 + { 236 + value: rootValue, 237 + position: (root.data as any).position, 238 + factID: root.id, 239 + type: (typeFact.data as any).value, 240 + parent: pageParent, 241 + listData: { 242 + depth, 243 + parent: root.entity, 244 + path: newPath, 245 + listStyle: listStyleFact ? (listStyleFact.data as any).value : undefined, 246 + listStart: listNumberFact ? (listNumberFact.data as any).value : undefined, 247 + }, 248 + }, 249 + ...childBlocks, 250 + ]; 251 + } 252 + 253 + function computeDisplayNumbers(blocks: Block[]): void { 254 + let counters = new Map<string, number>(); 255 + for (let block of blocks) { 256 + if (!block.listData) { 257 + counters.clear(); 258 + continue; 259 + } 260 + if (block.listData.listStyle !== "ordered") continue; 261 + let parent = block.listData.parent; 262 + if (block.listData.listStart !== undefined) { 263 + counters.set(parent, block.listData.listStart); 264 + } else if (!counters.has(parent)) { 265 + counters.set(parent, 1); 266 + } 267 + block.listData.displayNumber = counters.get(parent)!; 268 + counters.set(parent, counters.get(parent)! + 1); 269 + } 270 + } 271 + 272 + // --- Server-side YJS to HTML rendering --- 273 + 274 + function escapeHtml(s: string): string { 275 + return s 276 + .replace(/&/g, "&amp;") 277 + .replace(/</g, "&lt;") 278 + .replace(/>/g, "&gt;") 279 + .replace(/"/g, "&quot;"); 280 + } 281 + 282 + function renderYjsToHTML( 283 + base64Value: string, 284 + wrapper: "p" | "h1" | "h2" | "h3" | "blockquote", 285 + attrs?: Record<string, string>, 286 + ): string { 287 + let attrStr = attrs 288 + ? Object.entries(attrs) 289 + .filter(([, v]) => v !== undefined) 290 + .map(([k, v]) => ` ${k}="${escapeHtml(v)}"`) 291 + .join("") 292 + : ""; 293 + 294 + if (!base64Value) return `<${wrapper}${attrStr}></${wrapper}>`; 295 + 296 + let doc = new Y.Doc(); 297 + Y.applyUpdate(doc, base64.toByteArray(base64Value)); 298 + let [node] = doc.getXmlElement("prosemirror").toArray(); 299 + if (!node || node.constructor !== Y.XmlElement) return `<${wrapper}${attrStr}></${wrapper}>`; 300 + 301 + let children = node.toArray(); 302 + if (children.length === 0) return `<${wrapper}${attrStr}><br/></${wrapper}>`; 303 + 304 + let inner = children 305 + .map((child) => { 306 + if (child.constructor === Y.XmlText) { 307 + let deltas = child.toDelta() as { insert: string; attributes?: any }[]; 308 + if (deltas.length === 0) return "<br/>"; 309 + return deltas 310 + .map((d) => { 311 + let text = escapeHtml(d.insert); 312 + if (d.attributes?.link) return `<a href="${escapeHtml(d.attributes.link.href)}">${text}</a>`; 313 + if (d.attributes?.strong) text = `<strong>${text}</strong>`; 314 + if (d.attributes?.em) text = `<em>${text}</em>`; 315 + if (d.attributes?.code) text = `<code>${text}</code>`; 316 + return text; 317 + }) 318 + .join(""); 319 + } 320 + if (child.constructor === Y.XmlElement) { 321 + if (child.nodeName === "hard_break") return "<br/>"; 322 + if (child.nodeName === "didMention" || child.nodeName === "atMention") { 323 + let text = child.getAttribute("text") || ""; 324 + return escapeHtml(text); 325 + } 326 + } 327 + return ""; 328 + }) 329 + .join(""); 330 + 331 + return `<${wrapper}${attrStr}>${inner}</${wrapper}>`; 332 + } 333 + 334 + // --- Block-to-HTML (server-side, reads from pre-fetched facts) --- 335 + 336 + export async function getAllFactsForEntities( 337 + tx: PgTransaction<any, any, any>, 338 + entityIds: string[], 339 + ): Promise<FactRow[]> { 340 + if (entityIds.length === 0) return []; 341 + return tx 342 + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) 343 + .from(facts) 344 + .where(driz.inArray(facts.entity, entityIds)); 345 + } 346 + 347 + function factsLookup(allFacts: FactRow[], entity: string, attribute: string): FactRow[] { 348 + return allFacts.filter((f) => f.entity === entity && f.attribute === attribute); 349 + } 350 + 351 + async function renderBlockToHTML( 352 + b: Block, 353 + allFacts: FactRow[], 354 + ): Promise<string> { 355 + let [alignment] = factsLookup(allFacts, b.value, "block/text-alignment"); 356 + let a = alignment ? (alignment.data as any).value : undefined; 357 + 358 + switch (b.type) { 359 + case "text": { 360 + let [value] = factsLookup(allFacts, b.value, "block/text"); 361 + return renderYjsToHTML(value?.data.value, "p", a ? { "data-alignment": a } : undefined); 362 + } 363 + case "heading": { 364 + let [value] = factsLookup(allFacts, b.value, "block/text"); 365 + let [headingLevel] = factsLookup(allFacts, b.value, "block/heading-level"); 366 + let wrapper = ("h" + ((headingLevel?.data as any)?.value || 1)) as "h1" | "h2" | "h3"; 367 + return renderYjsToHTML(value?.data.value, wrapper, a ? { "data-alignment": a } : undefined); 368 + } 369 + case "blockquote": { 370 + let [value] = factsLookup(allFacts, b.value, "block/text"); 371 + return renderYjsToHTML(value?.data.value, "blockquote", a ? { "data-alignment": a } : undefined); 372 + } 373 + case "code": { 374 + let [code] = factsLookup(allFacts, b.value, "block/code"); 375 + let [lang] = factsLookup(allFacts, b.value, "block/code-language"); 376 + let langValue = (lang?.data as any)?.value as string | undefined; 377 + let codeAttr = langValue ? ` class="language-${escapeHtml(langValue)}"` : ""; 378 + return `<pre><code${codeAttr}>${escapeHtml((code?.data as any)?.value || "")}</code></pre>`; 379 + } 380 + case "image": { 381 + let [src] = factsLookup(allFacts, b.value, "block/image"); 382 + if (!src) return ""; 383 + let alignAttr = a ? ` data-alignment="${escapeHtml(a)}"` : ""; 384 + return `<img src="${escapeHtml((src.data as any).src)}"${alignAttr}/>`; 385 + } 386 + case "horizontal-rule": 387 + return "<hr/>"; 388 + case "card": { 389 + let [card] = factsLookup(allFacts, b.value, "block/card"); 390 + if (!card) return ""; 391 + let cardEntityId = (card.data as any).value; 392 + let title = await getSubpageTitle(allFacts, cardEntityId); 393 + return `<a href="subpage:${cardEntityId}">${escapeHtml(title || "Untitled")}</a>`; 394 + } 395 + case "link": { 396 + let [url] = factsLookup(allFacts, b.value, "link/url"); 397 + let [title] = factsLookup(allFacts, b.value, "link/title"); 398 + if (!url) return ""; 399 + return `<a href="${escapeHtml((url.data as any).value)}" target="_blank">${escapeHtml((title?.data as any)?.value || "")}</a>`; 400 + } 401 + case "button": { 402 + let [text] = factsLookup(allFacts, b.value, "button/text"); 403 + let [url] = factsLookup(allFacts, b.value, "button/url"); 404 + if (!text || !url) return ""; 405 + return `<a href="${escapeHtml((url.data as any).value)}">${escapeHtml((text.data as any).value)}</a>`; 406 + } 407 + case "math": { 408 + let [math] = factsLookup(allFacts, b.value, "block/math"); 409 + return `<code>${escapeHtml((math?.data as any)?.value || "")}</code>`; 410 + } 411 + default: 412 + return ""; 413 + } 414 + } 415 + 416 + async function getSubpageTitle( 417 + allFacts: FactRow[], 418 + cardEntityId: string, 419 + ): Promise<string> { 420 + // Look for card/block children of the subpage to find first heading 421 + let blockRefs = allFacts 422 + .filter((f) => f.entity === cardEntityId && f.attribute === "card/block") 423 + .sort((a, b) => ((a.data as any).position > (b.data as any).position ? 1 : -1)); 424 + 425 + if (blockRefs.length === 0) return ""; 426 + 427 + let firstBlockId = (blockRefs[0].data as any).value; 428 + let [textFact] = allFacts.filter( 429 + (f) => f.entity === firstBlockId && f.attribute === "block/text", 430 + ); 431 + 432 + if (textFact) { 433 + return extractPlaintext((textFact.data as any).value); 434 + } 435 + return ""; 436 + } 437 + 438 + async function renderListToHTML(l: List, allFacts: FactRow[]): Promise<string> { 439 + let children = ( 440 + await Promise.all(l.children.map((c) => renderListToHTML(c, allFacts))) 441 + ).join("\n"); 442 + 443 + let checkedFacts = factsLookup(allFacts, l.block.value, "block/check-list"); 444 + let checked = checkedFacts[0]; 445 + 446 + let isOrdered = l.children[0]?.block.listData?.listStyle === "ordered"; 447 + let tag = isOrdered ? "ol" : "ul"; 448 + 449 + return `<li ${checked ? `data-checked=${(checked.data as any).value}` : ""}>${await renderBlockToHTML(l.block, allFacts)} ${ 450 + l.children.length > 0 ? `<${tag}>${children}</${tag}>` : "" 451 + }</li>`; 452 + } 453 + 454 + export async function blocksToHTML( 455 + blocks: Block[], 456 + allFacts: FactRow[], 457 + ): Promise<string[]> { 458 + let result: string[] = []; 459 + let parsed = parseBlocksToList(blocks); 460 + 461 + for (let pb of parsed) { 462 + if (pb.type === "block") { 463 + result.push(await renderBlockToHTML(pb.block, allFacts)); 464 + } else { 465 + let isOrdered = pb.children[0]?.block.listData?.listStyle === "ordered"; 466 + let tag = isOrdered ? "ol" : "ul"; 467 + let listItems = ( 468 + await Promise.all( 469 + pb.children.map((c) => renderListToHTML(c, allFacts)), 470 + ) 471 + ).join("\n"); 472 + result.push(`<${tag}>${listItems}</${tag}>`); 473 + } 474 + } 475 + return result; 476 + } 477 + 478 + // --- Blocks-to-markdown --- 479 + 480 + export async function blocksToMarkdown( 481 + blocks: Block[], 482 + allFacts: FactRow[], 483 + ): Promise<string> { 484 + let htmlParts = await blocksToHTML(blocks, allFacts); 485 + let html = htmlParts.join("\n"); 486 + return htmlToMarkdown(html); 487 + } 488 + 489 + // --- YJS plaintext extraction --- 490 + 491 + export function extractPlaintext(base64Value: string): string { 492 + if (!base64Value) return ""; 493 + let doc = new Y.Doc(); 494 + Y.applyUpdate(doc, base64.toByteArray(base64Value)); 495 + let nodes = doc.getXmlElement("prosemirror").toArray(); 496 + if (nodes.length === 0) return ""; 497 + return YJSFragmentToString(nodes[0]); 498 + } 499 + 500 + // --- YJS text creation --- 501 + 502 + export function createYjsText(plaintext: string): string { 503 + let doc = new Y.Doc(); 504 + let fragment = doc.getXmlFragment("prosemirror"); 505 + let paragraph = new Y.XmlElement("paragraph"); 506 + let textNode = new Y.XmlText(); 507 + textNode.insert(0, plaintext); 508 + paragraph.insert(0, [textNode]); 509 + fragment.insert(0, [paragraph]); 510 + return base64.fromByteArray(Y.encodeStateAsUpdate(doc)); 511 + } 512 + 513 + // --- YJS text editing --- 514 + 515 + export type EditOperation = 516 + | { type: "replace"; content: string } 517 + | { type: "insert"; position: "start" | "end"; content: string } 518 + | { type: "insert"; position: { before: string } | { after: string }; content: string }; 519 + 520 + export function editYjsText( 521 + existingBase64: string, 522 + operation: EditOperation, 523 + ): { result: string; plaintext: string } | { error: "search_not_found"; fullText: string } { 524 + let doc = new Y.Doc(); 525 + Y.applyUpdate(doc, base64.toByteArray(existingBase64)); 526 + 527 + let element = doc.getXmlElement("prosemirror"); 528 + let paragraph = element.toArray()[0]; 529 + if (!paragraph || paragraph.constructor !== Y.XmlElement) { 530 + return { error: "search_not_found", fullText: "" }; 531 + } 532 + 533 + // Find the XmlText child 534 + let textNodes = paragraph.toArray(); 535 + let xmlText: Y.XmlText | null = null; 536 + for (let n of textNodes) { 537 + if (n.constructor === Y.XmlText) { 538 + xmlText = n; 539 + break; 540 + } 541 + } 542 + 543 + if (!xmlText) { 544 + // No text node exists yet, create one for replace/insert 545 + xmlText = new Y.XmlText(); 546 + paragraph.insert(0, [xmlText]); 547 + } 548 + 549 + let currentText = (xmlText.toDelta() as { insert: string }[]) 550 + .map((d) => d.insert) 551 + .join(""); 552 + 553 + if (operation.type === "replace") { 554 + xmlText.delete(0, currentText.length); 555 + xmlText.insert(0, operation.content); 556 + } else if (operation.type === "insert") { 557 + let pos = operation.position; 558 + if (pos === "start") { 559 + xmlText.insert(0, operation.content); 560 + } else if (pos === "end") { 561 + xmlText.insert(currentText.length, operation.content); 562 + } else if ("before" in pos) { 563 + let idx = currentText.indexOf(pos.before); 564 + if (idx === -1) return { error: "search_not_found", fullText: currentText }; 565 + xmlText.insert(idx, operation.content); 566 + } else if ("after" in pos) { 567 + let idx = currentText.indexOf(pos.after); 568 + if (idx === -1) return { error: "search_not_found", fullText: currentText }; 569 + xmlText.insert(idx + pos.after.length, operation.content); 570 + } 571 + } 572 + 573 + let newText = (xmlText.toDelta() as { insert: string }[]) 574 + .map((d) => d.insert) 575 + .join(""); 576 + 577 + return { 578 + result: base64.fromByteArray(Y.encodeStateAsUpdate(doc)), 579 + plaintext: newText, 580 + }; 581 + } 582 + 583 + // --- Realtime poke --- 584 + 585 + export async function broadcastPoke(rootEntity: string) { 586 + let supabase = createClient<Database>( 587 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 588 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 589 + ); 590 + let channel = supabase.channel(`rootEntity:${rootEntity}`); 591 + await channel.send({ 592 + type: "broadcast", 593 + event: "poke", 594 + payload: { message: "poke" }, 595 + }); 596 + await supabase.removeChannel(channel); 597 + } 598 + 599 + // --- Helpers --- 600 + 601 + export function tokenHash(tokenId: string): number { 602 + return tokenId.split("").reduce((acc, char) => { 603 + return ((acc << 5) - acc + char.charCodeAt(0)) | 0; 604 + }, 0); 605 + } 606 + 607 + export function hasWriteAccess(auth: AuthResult): boolean { 608 + return auth.tokenRights.some((r) => r.write); 609 + }
+87
app/api/ai/search/route.ts
··· 1 + import { NextRequest } from "next/server"; 2 + import { drizzle } from "drizzle-orm/node-postgres"; 3 + import { pool } from "supabase/pool"; 4 + import { 5 + authenticateToken, 6 + resolvePageEntity, 7 + getPageBlocks, 8 + getAllFactsForEntities, 9 + extractPlaintext, 10 + } from "../lib"; 11 + 12 + export async function GET(req: NextRequest) { 13 + let auth = await authenticateToken(req); 14 + if (auth instanceof Response) return auth; 15 + 16 + let query = req.nextUrl.searchParams.get("q"); 17 + if (!query) { 18 + return Response.json({ error: "Missing q parameter" }, { status: 400 }); 19 + } 20 + 21 + let pageParam = req.nextUrl.searchParams.get("page"); 22 + let queryLower = query.toLowerCase(); 23 + 24 + let client = await pool.connect(); 25 + try { 26 + let db = drizzle(client); 27 + return await db.transaction(async (tx) => { 28 + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, pageParam); 29 + if (pageEntity instanceof Response) return pageEntity; 30 + 31 + let blocks = await getPageBlocks(tx, pageEntity); 32 + let entityIds = blocks.map((b) => b.value); 33 + let allFacts = await getAllFactsForEntities(tx, entityIds); 34 + 35 + let results: { 36 + blockId: string; 37 + type: string; 38 + text: string; 39 + language?: string; 40 + }[] = []; 41 + 42 + for (let b of blocks) { 43 + if ( 44 + b.type === "text" || 45 + b.type === "heading" || 46 + b.type === "blockquote" 47 + ) { 48 + let textFact = allFacts.find( 49 + (f) => f.entity === b.value && f.attribute === "block/text", 50 + ); 51 + if (textFact) { 52 + let plaintext = extractPlaintext((textFact.data as any).value); 53 + if (plaintext.toLowerCase().includes(queryLower)) { 54 + results.push({ blockId: b.value, type: b.type, text: plaintext }); 55 + } 56 + } 57 + } else if (b.type === "code") { 58 + let codeFact = allFacts.find( 59 + (f) => f.entity === b.value && f.attribute === "block/code", 60 + ); 61 + if (codeFact) { 62 + let code = (codeFact.data as any).value as string; 63 + if (code.toLowerCase().includes(queryLower)) { 64 + let langFact = allFacts.find( 65 + (f) => 66 + f.entity === b.value && f.attribute === "block/code-language", 67 + ); 68 + let language = langFact 69 + ? ((langFact.data as any).value as string) 70 + : undefined; 71 + results.push({ 72 + blockId: b.value, 73 + type: b.type, 74 + text: code, 75 + ...(language ? { language } : {}), 76 + }); 77 + } 78 + } 79 + } 80 + } 81 + 82 + return Response.json({ results }); 83 + }); 84 + } finally { 85 + client.release(); 86 + } 87 + }
+15 -5
app/globals.css
··· 599 599 } 600 600 601 601 .footnote-side-item { 602 - max-height: 4.5em; 603 602 overflow: hidden; 604 603 transition: 605 604 max-height 200ms ease, 606 - mask-image 200ms ease; 605 + opacity 200ms ease; 606 + background: rgba(var(--bg-page), var(--bg-page-alpha)); 607 + border-radius: 4px; 608 + padding: 4px 6px; 607 609 } 608 - .footnote-side-item.has-overflow { 609 - mask-image: linear-gradient(to bottom, white 50%, transparent 100%); 610 + .footnote-side-item:not(:hover):not(:focus-within):not(.footnote-side-focused) { 611 + max-height: calc(4.5em + 8px); 612 + opacity: calc(var(--bg-page-alpha) * 0.7); 613 + } 614 + .footnote-side-item:not(:hover):not(:focus-within):not(.footnote-side-focused) .footnote-item > div:last-of-type { 615 + display: -webkit-box; 616 + -webkit-line-clamp: 3; 617 + -webkit-box-orient: vertical; 618 + overflow: hidden; 619 + text-overflow: ellipsis; 610 620 } 611 621 .footnote-side-item:hover, 612 622 .footnote-side-item:focus-within, 613 623 .footnote-side-item.footnote-side-focused { 614 624 max-height: 40em; 615 - mask-image: none; 625 + opacity: var(--bg-page-alpha); 616 626 }
+55 -8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 77 77 did={did} 78 78 key={index} 79 79 previousBlock={blocks[index - 1]} 80 + nextBlock={blocks[index + 1]} 80 81 index={[index]} 81 82 preview={preview} 82 83 prerenderedCodeBlocks={prerenderedCodeBlocks} 83 84 pollData={pollData} 84 85 footnoteIndexMap={footnoteIndexMap} 86 + isFirst={index === 0} 87 + isLast={index === blocks.length - 1} 85 88 /> 86 89 ); 87 90 })} ··· 96 99 index, 97 100 preview, 98 101 previousBlock, 102 + nextBlock, 99 103 prerenderedCodeBlocks, 100 104 bskyPostData, 101 105 pageId, 102 106 pages, 103 107 pollData, 104 108 footnoteIndexMap, 109 + isFirst, 110 + isLast, 105 111 }: { 106 112 pageId?: string; 107 113 preview?: boolean; ··· 111 117 isList?: boolean; 112 118 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 113 119 previousBlock?: PubLeafletPagesLinearDocument.Block; 120 + nextBlock?: PubLeafletPagesLinearDocument.Block; 114 121 prerenderedCodeBlocks?: Map<string, string>; 115 122 bskyPostData: AppBskyFeedDefs.PostView[]; 116 123 pollData: PollData[]; 117 124 footnoteIndexMap?: Map<string, number>; 125 + isFirst?: boolean; 126 + isLast?: boolean; 118 127 }) => { 119 128 let b = block; 120 129 let blockProps = { ··· 329 338 ); 330 339 } 331 340 case PubLeafletBlocksImage.isMain(b.block): { 341 + let isFullBleed = b.block.fullBleed; 342 + let prevIsFullBleed = 343 + previousBlock?.block && 344 + PubLeafletBlocksImage.isMain(previousBlock.block) && 345 + previousBlock.block.fullBleed; 346 + let nextIsFullBleed = 347 + nextBlock?.block && 348 + PubLeafletBlocksImage.isMain(nextBlock.block) && 349 + nextBlock.block.fullBleed; 350 + 351 + let fullBleedClassName = isFullBleed 352 + ? ` 353 + -mx-3 sm:-mx-4 rounded-[0px]! sm:w-[calc(100%+32px)] w-[calc(100%+24px)] 354 + ${isFirst ? "-mt-2 sm:-mt-3" : prevIsFullBleed ? "-mt-[5px]" : ""} 355 + ${isLast ? "-mb-1 sm:-mb-4" : nextIsFullBleed ? "-mb-[9px]" : ""} 356 + ` 357 + : ""; 358 + 332 359 return ( 333 360 <div 334 - className={`imageBlock relative flex ${alignment}`} 361 + className={`imageBlock relative flex ${isFullBleed ? "" : alignment} ${fullBleedClassName}`} 335 362 {...blockProps} 336 363 > 337 364 <img 338 365 alt={b.block.alt} 339 366 height={b.block.aspectRatio?.height} 340 367 width={b.block.aspectRatio?.width} 341 - className={`rounded-lg border border-transparent ${className}`} 368 + className={`${isFullBleed ? "w-full border-none" : "rounded-lg border border-transparent "} ${className}`} 342 369 src={blobRefToSrc(b.block.image.ref, did)} 343 370 /> 344 371 {b.block.alt && ( ··· 417 444 ); 418 445 if (b.block.level === 1) 419 446 return ( 420 - <h1 className={`h1Block ${className}`} {...headingProps} style={{ ...headingProps.style, fontSize: blockTextSize.h1 }}> 447 + <h1 448 + className={`h1Block ${className}`} 449 + {...headingProps} 450 + style={{ ...headingProps.style, fontSize: blockTextSize.h1 }} 451 + > 421 452 {link(<TextBlock {...textBlockProps} />)} 422 453 </h1> 423 454 ); 424 455 if (b.block.level === 2) 425 456 return ( 426 - <h2 className={`h2Block ${className}`} {...headingProps} style={{ ...headingProps.style, fontSize: blockTextSize.h2 }}> 457 + <h2 458 + className={`h2Block ${className}`} 459 + {...headingProps} 460 + style={{ ...headingProps.style, fontSize: blockTextSize.h2 }} 461 + > 427 462 {link(<TextBlock {...textBlockProps} />)} 428 463 </h2> 429 464 ); 430 465 if (b.block.level === 3) 431 466 return ( 432 - <h3 className={`h3Block ${className}`} {...headingProps} style={{ ...headingProps.style, fontSize: blockTextSize.h3 }}> 467 + <h3 468 + className={`h3Block ${className}`} 469 + {...headingProps} 470 + style={{ ...headingProps.style, fontSize: blockTextSize.h3 }} 471 + > 433 472 {link(<TextBlock {...textBlockProps} />)} 434 473 </h3> 435 474 ); 436 475 return ( 437 - <h6 className={`h6Block ${className}`} {...headingProps} style={{ ...headingProps.style, fontSize: blockTextSize.h4 }}> 476 + <h6 477 + className={`h6Block ${className}`} 478 + {...headingProps} 479 + style={{ ...headingProps.style, fontSize: blockTextSize.h4 }} 480 + > 438 481 {link(<TextBlock {...textBlockProps} />)} 439 482 </h6> 440 483 ); ··· 496 539 className={`listMarker shrink-0 mx-2 z-1 mt-[14px] h-[5px] w-[5px] ${props.item.content?.$type !== "null" ? "rounded-full bg-secondary" : ""}`} 497 540 /> 498 541 {isChecklist && ( 499 - <div className={`pr-2 ${props.item.checked ? "text-accent-contrast" : "text-border"}`}> 542 + <div 543 + className={`pr-2 ${props.item.checked ? "text-accent-contrast" : "text-border"}`} 544 + > 500 545 {props.item.checked ? <CheckboxChecked /> : <CheckboxEmpty />} 501 546 </div> 502 547 )} ··· 573 618 {calculatedIndex}. 574 619 </div> 575 620 {isChecklist && ( 576 - <div className={`pr-2 ${props.item.checked ? "text-accent-contrast" : "text-border"}`}> 621 + <div 622 + className={`pr-2 ${props.item.checked ? "text-accent-contrast" : "text-border"}`} 623 + > 577 624 {props.item.checked ? <CheckboxChecked /> : <CheckboxEmpty />} 578 625 </div> 579 626 )}
+6 -5
components/Blocks/ImageBlock.tsx
··· 94 94 hasBackground="accent" 95 95 isSelected={!!isSelected} 96 96 borderOnHover 97 - className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 97 + className={` group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg `} 98 98 > 99 99 <label 100 100 className={` ··· 144 144 let blockClassName = ` 145 145 relative group/image border-transparent! p-0! w-fit! 146 146 ${isFullBleed && "-mx-[14px] sm:-mx-[18px] rounded-[0px]! sm:outline-offset-[-16px]! -outline-offset[-12px]!"} 147 - ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 148 - ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} 147 + ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-[5px]" : "") : ""} 148 + ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-[9px]" : "") : ""} 149 149 `; 150 150 151 151 return ( ··· 153 153 hasAlignment 154 154 isSelected={!!isSelected} 155 155 className={blockClassName} 156 - optionsClassName={isFullBleed ? "top-[-8px]!" : ""} 156 + optionsClassName={isFullBleed ? "top-[-8px]! border-none!" : ""} 157 157 > 158 158 {isLocalUpload || image.data.local ? ( 159 159 <img ··· 209 209 // Only show if focused, in a publication, has write permissions, and no cover image is set 210 210 if (!isFocused || !pubData?.publications || !entity_set.permissions.write) 211 211 return null; 212 - if (coverImage) 212 + if (coverImage === props.entityID) 213 213 return ( 214 214 <ButtonSecondary 215 215 className="absolute top-2 right-2" ··· 225 225 <ImageCoverImageRemove /> 226 226 </ButtonSecondary> 227 227 ); 228 + 228 229 return ( 229 230 <ButtonPrimary 230 231 className="absolute top-2 right-2"
-68
components/Blocks/RSVPBlock/Atendees.tsx
··· 1 - "use client"; 2 - import { useRSVPData } from "components/PageSWRDataProvider"; 3 - import { ButtonTertiary } from "components/Buttons"; 4 - import { Popover } from "components/Popover"; 5 - 6 - export function Attendees(props: { entityID: string; className?: string }) { 7 - let { data } = useRSVPData(); 8 - let attendees = 9 - data?.rsvps?.filter((rsvp) => rsvp.entity === props.entityID) || []; 10 - let going = attendees.filter((rsvp) => rsvp.status === "GOING"); 11 - let maybe = attendees.filter((rsvp) => rsvp.status === "MAYBE"); 12 - let notGoing = attendees.filter((rsvp) => rsvp.status === "NOT_GOING"); 13 - 14 - return ( 15 - <Popover 16 - align="start" 17 - className="text-sm text-secondary flex flex-col gap-2 max-w-sm" 18 - asChild 19 - trigger={ 20 - going.length === 0 && maybe.length === 0 ? ( 21 - <button 22 - className={`text-sm font-normal w-max text-tertiary italic hover:underline ${props.className}`} 23 - > 24 - No RSVPs yet 25 - </button> 26 - ) : ( 27 - <ButtonTertiary className={`text-sm font-normal ${props.className}`}> 28 - {going.length > 0 && 29 - `${going.reduce((acc, g) => acc + 1 + g.plus_ones, 0)} Going`} 30 - {maybe.length > 0 && 31 - `${going.length > 0 ? ", " : ""}${maybe.reduce((acc, m) => acc + 1 + m.plus_ones, 0)} Maybe`} 32 - </ButtonTertiary> 33 - ) 34 - } 35 - > 36 - {going.length === 0 && maybe.length === 0 && notGoing.length === 0 && ( 37 - <div className="text-tertiary italic">No RSVPs yet</div> 38 - )} 39 - <AttendeeStatusList rsvps={going} title="Going" /> 40 - <AttendeeStatusList rsvps={maybe} title="Maybe" /> 41 - <AttendeeStatusList rsvps={notGoing} title="Can't Go" /> 42 - </Popover> 43 - ); 44 - } 45 - 46 - function AttendeeStatusList(props: { 47 - rsvps: Array<{ 48 - name: string; 49 - phone_number?: string; 50 - plus_ones: number; 51 - status: string; 52 - }>; 53 - title: string; 54 - }) { 55 - if (props.rsvps.length === 0) return null; 56 - return ( 57 - <div className="flex flex-col gap-0.5"> 58 - <div className="font-bold text-tertiary"> 59 - {props.title} ({props.rsvps.length}) 60 - </div> 61 - {props.rsvps.map((rsvp) => ( 62 - <div key={rsvp.phone_number}> 63 - {rsvp.name} {rsvp.plus_ones > 0 ? `+${rsvp.plus_ones}` : ""} 64 - </div> 65 - ))} 66 - </div> 67 - ); 68 - }
-366
components/Blocks/RSVPBlock/ContactDetailsForm.tsx
··· 1 - "use client"; 2 - import { useSmoker, useToaster } from "components/Toast"; 3 - import { RSVP_Status, RSVPButtons, State, useRSVPNameState } from "."; 4 - import { createContext, useContext, useState } from "react"; 5 - import { useRSVPData } from "components/PageSWRDataProvider"; 6 - import { confirmPhoneAuthToken } from "actions/phone_auth/confirm_phone_auth_token"; 7 - import { submitRSVP } from "actions/phone_rsvp_to_event"; 8 - 9 - import { countryCodes } from "src/constants/countryCodes"; 10 - import { Checkbox } from "components/Checkbox"; 11 - import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 12 - import { Separator } from "components/Layout"; 13 - import { createPhoneAuthToken } from "actions/phone_auth/request_phone_auth_token"; 14 - import { Input, InputWithLabel } from "components/Input"; 15 - import { RequestHeadersContext } from "components/Providers/RequestHeadersProvider"; 16 - import { Popover } from "components/Popover"; 17 - import { theme } from "tailwind.config"; 18 - import { InfoSmall } from "components/Icons/InfoSmall"; 19 - 20 - export function ContactDetailsForm(props: { 21 - status: RSVP_Status; 22 - entityID: string; 23 - setState: (s: State) => void; 24 - setStatus: (s: RSVP_Status) => void; 25 - }) { 26 - let { status, entityID, setState, setStatus } = props; 27 - let focusWithinStyles = 28 - "focus-within:border-tertiary focus-within:outline-solid focus-within:outline-2 focus-within:outline-tertiary focus-within:outline-offset-1"; 29 - let toaster = useToaster(); 30 - let { data, mutate } = useRSVPData(); 31 - let [contactFormState, setContactFormState] = useState< 32 - { state: "details" } | { state: "confirm"; token: string } 33 - >({ state: "details" }); 34 - let { name, setName } = useRSVPNameState(); 35 - let [plus_ones, setPlusOnes] = useState( 36 - data?.rsvps?.find( 37 - (rsvp) => 38 - data.authToken && 39 - rsvp.entity === props.entityID && 40 - data.authToken.country_code === rsvp.country_code && 41 - data.authToken.phone_number === rsvp.phone_number, 42 - )?.plus_ones || 0, 43 - ); 44 - let requestHeaders = useContext(RequestHeadersContext); 45 - const [formState, setFormState] = useState({ 46 - country_code: 47 - countryCodes.find((c) => c[1].toUpperCase() === (requestHeaders.country || "US"))?.[2] || "1", 48 - phone_number: "", 49 - confirmationCode: "", 50 - }); 51 - 52 - let submit = async ( 53 - token: Awaited<ReturnType<typeof confirmPhoneAuthToken>>, 54 - ) => { 55 - try { 56 - await submitRSVP({ 57 - status, 58 - name: name, 59 - entity: entityID, 60 - plus_ones, 61 - }); 62 - } catch (e) { 63 - //handle failed confirm 64 - return false; 65 - } 66 - 67 - mutate({ 68 - authToken: token, 69 - rsvps: [ 70 - ...(data?.rsvps || []).filter((r) => r.entity !== entityID), 71 - { 72 - name: name, 73 - status, 74 - plus_ones, 75 - entity: entityID, 76 - phone_number: token.phone_number, 77 - country_code: token.country_code, 78 - }, 79 - ], 80 - }); 81 - props.setState({ state: "default" }); 82 - return true; 83 - }; 84 - return contactFormState.state === "details" ? ( 85 - <> 86 - <form 87 - className="rsvpForm flex flex-col gap-2" 88 - onSubmit={async (e) => { 89 - e.preventDefault(); 90 - if (data?.authToken) { 91 - submit(data.authToken); 92 - toaster({ 93 - content: ( 94 - <div className="font-bold"> 95 - {status === "GOING" 96 - ? "Yay! You're Going!" 97 - : status === "MAYBE" 98 - ? "You're a Maybe" 99 - : "Sorry you can't make it D:"} 100 - </div> 101 - ), 102 - type: "success", 103 - }); 104 - } else { 105 - let tokenId = await createPhoneAuthToken(formState); 106 - setContactFormState({ state: "confirm", token: tokenId }); 107 - } 108 - }} 109 - > 110 - <RSVPButtons setStatus={props.setStatus} status={props.status} /> 111 - 112 - <div className="rsvpInputs flex sm:flex-row flex-col gap-2 w-fit place-self-center "> 113 - <label 114 - htmlFor="rsvp-name-input" 115 - className={` 116 - rsvpNameInput input-with-border h-fit 117 - flex flex-col ${focusWithinStyles}`} 118 - > 119 - <div className="text-xs font-bold italic text-tertiary">name</div> 120 - <Input 121 - autoFocus 122 - id="rsvp-name-input" 123 - placeholder="..." 124 - className=" bg-transparent disabled:text-tertiary w-full appearance-none focus:outline-0" 125 - value={name} 126 - onKeyDown={(e) => { 127 - if (e.key === "Backspace" && !e.currentTarget.value) 128 - e.preventDefault(); 129 - }} 130 - onChange={(e) => setName(e.target.value)} 131 - /> 132 - </label> 133 - <div 134 - className={`rsvpPhoneInputWrapper relative flex flex-col gap-0.5 w-full basis-2/3`} 135 - > 136 - <label 137 - htmlFor="rsvp-phone-input" 138 - className={` 139 - rsvpPhoneInput input-with-border 140 - flex flex-col ${focusWithinStyles} 141 - ${!!data?.authToken?.phone_number && "bg-border-light border-border-light text-tertiary"}`} 142 - > 143 - <div className=" text-xs font-bold italic text-tertiary"> 144 - Phone Number 145 - </div> 146 - <div className="flex gap-2 "> 147 - <div className="flex items-center gap-1"> 148 - <span 149 - style={{ 150 - color: 151 - formState.country_code === "" || 152 - !!data?.authToken?.phone_number 153 - ? theme.colors.tertiary 154 - : theme.colors.primary, 155 - }} 156 - > 157 - + 158 - </span> 159 - <Input 160 - onKeyDown={(e) => { 161 - if (e.key === "Backspace" && !e.currentTarget.value) 162 - e.preventDefault(); 163 - }} 164 - disabled={!!data?.authToken?.phone_number} 165 - className="w-10 bg-transparent appearance-none focus:outline-0" 166 - placeholder="1" 167 - maxLength={4} 168 - inputMode="numeric" 169 - pattern="[0-9]*" 170 - value={formState.country_code} 171 - onChange={(e) => 172 - setFormState((s) => ({ 173 - ...s, 174 - country_code: e.target.value.replace(/[^0-9]/g, ""), 175 - })) 176 - } 177 - /> 178 - </div> 179 - <Separator /> 180 - 181 - <Input 182 - id="rsvp-phone-input" 183 - inputMode="numeric" 184 - placeholder="0000000000" 185 - pattern="[0-9]*" 186 - className=" bg-transparent disabled:text-tertiary w-full appearance-none focus:outline-0" 187 - disabled={!!data?.authToken?.phone_number} 188 - onKeyDown={(e) => { 189 - if (e.key === "Backspace" && !e.currentTarget.value) 190 - e.preventDefault(); 191 - }} 192 - value={ 193 - data?.authToken?.phone_number || formState.phone_number 194 - } 195 - onChange={(e) => 196 - setFormState((state) => ({ 197 - ...state, 198 - phone_number: e.target.value.replace(/[^0-9]/g, ""), 199 - })) 200 - } 201 - /> 202 - </div> 203 - </label> 204 - <div className="text-xs italic text-tertiary leading-tight"> 205 - {formState.country_code !== "1" ? ( 206 - <> 207 - Messages to non-US/Canada numbers will be sent via{" "} 208 - <strong>WhatsApp</strong> 209 - </> 210 - ) : null} 211 - </div> 212 - </div> 213 - <div className="flex flex-row gap-2 w-full sm:w-32 h-fit"> 214 - <InputWithLabel 215 - className="appearance-none!" 216 - placeholder="0" 217 - label="Plus ones?" 218 - type="number" 219 - min={0} 220 - max={4} 221 - value={plus_ones} 222 - onChange={(e) => setPlusOnes(parseInt(e.currentTarget.value))} 223 - onKeyDown={(e) => { 224 - if (e.key === "Backspace" && !e.currentTarget.value) 225 - e.preventDefault(); 226 - }} 227 - /> 228 - </div> 229 - </div> 230 - 231 - <hr className="border-border" /> 232 - <div className="flex flex-row gap-2 w-full items-center justify-end"> 233 - <ConsentPopover country_code={formState.country_code} /> 234 - <ButtonTertiary 235 - onMouseDown={() => { 236 - setState({ state: "default" }); 237 - }} 238 - > 239 - Back 240 - </ButtonTertiary> 241 - <ButtonPrimary 242 - disabled={ 243 - (!data?.authToken?.phone_number && 244 - (!formState.phone_number || !formState.country_code)) || 245 - !name 246 - } 247 - className="place-self-end" 248 - type="submit" 249 - > 250 - RSVP as{" "} 251 - {status === "GOING" 252 - ? "Going" 253 - : status === "MAYBE" 254 - ? "Maybe" 255 - : "Can't Go"} 256 - </ButtonPrimary> 257 - </div> 258 - </form> 259 - </> 260 - ) : ( 261 - <ConfirmationForm 262 - country_code={formState.country_code} 263 - phoneNumber={formState.phone_number} 264 - token={contactFormState.token} 265 - value={formState.confirmationCode} 266 - submit={submit} 267 - status={status} 268 - onChange={(value) => 269 - setFormState((state) => ({ ...state, confirmationCode: value })) 270 - } 271 - /> 272 - ); 273 - } 274 - 275 - const ConfirmationForm = (props: { 276 - country_code: string; 277 - phoneNumber: string; 278 - value: string; 279 - token: string; 280 - status: RSVP_Status; 281 - submit: ( 282 - token: Awaited<ReturnType<typeof confirmPhoneAuthToken>>, 283 - ) => Promise<boolean>; 284 - onChange: (v: string) => void; 285 - }) => { 286 - let smoker = useSmoker(); 287 - let toaster = useToaster(); 288 - return ( 289 - <form 290 - className="flex flex-col gap-3 w-full" 291 - onSubmit={async (e) => { 292 - e.preventDefault(); 293 - let rect = document 294 - .getElementById("rsvp-code-confirm-button") 295 - ?.getBoundingClientRect(); 296 - try { 297 - let token = await confirmPhoneAuthToken(props.token, props.value); 298 - props.submit(token); 299 - toaster({ 300 - content: ( 301 - <div className="font-bold"> 302 - {props.status === "GOING" 303 - ? "Yay! You're Going!" 304 - : props.status === "MAYBE" 305 - ? "You're a Maybe" 306 - : "Sorry you can't make it D:"} 307 - </div> 308 - ), 309 - type: "success", 310 - }); 311 - } catch (error) { 312 - smoker({ 313 - alignOnMobile: "left", 314 - error: true, 315 - text: "invalid code!", 316 - position: { 317 - x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 318 - y: rect ? rect.top + 26 : 0, 319 - }, 320 - }); 321 - return; 322 - } 323 - }} 324 - > 325 - <label className="rsvpNameInput relative w-full flex flex-col gap-0.5"> 326 - <div className="absolute top-0.5 left-[6px] text-xs font-bold italic text-tertiary"> 327 - confirmation code 328 - </div> 329 - 330 - <Input 331 - autoFocus 332 - placeholder="000000" 333 - className="input-with-border pt-5! w-full " 334 - value={props.value} 335 - autoComplete="one-time-code" 336 - onChange={(e) => props.onChange(e.target.value)} 337 - /> 338 - <div className="text-sm italic text-tertiary leading-tight"> 339 - Code was sent to your{" "} 340 - {props.country_code === "1" ? "phone" : <strong>WhatsApp</strong>}{" "} 341 - number: +{props.country_code} {props.phoneNumber}! 342 - </div> 343 - </label> 344 - 345 - <ButtonPrimary 346 - id="rsvp-code-confirm-button" 347 - className="place-self-end" 348 - type="submit" 349 - > 350 - Confirm 351 - </ButtonPrimary> 352 - </form> 353 - ); 354 - }; 355 - 356 - const ConsentPopover = (props: { country_code: string }) => { 357 - return ( 358 - <Popover trigger={<InfoSmall className="text-accent-contrast" />}> 359 - <div className="text-sm text-secondary"> 360 - By RSVPing I to consent to receive 361 - {props.country_code === "1" ? "" : " WhatsApp"} messages from the event 362 - host, via Leaflet! 363 - </div> 364 - </Popover> 365 - ); 366 - };
-7
components/Blocks/RSVPBlock/RSVPBackground.module.css
··· 1 - .RSVPWavyBG { 2 - opacity: calc(1 - (var(--accent-1-is-contrast) * 0.6)); 3 - mask-image: url("/RSVPBackground/wavy.svg"); 4 - mask-repeat: repeat repeat; 5 - mask-position: center; 6 - mask-size: 64px; 7 - }
-194
components/Blocks/RSVPBlock/SendUpdate.tsx
··· 1 - "use client"; 2 - import { useState } from "react"; 3 - import { useRSVPData } from "components/PageSWRDataProvider"; 4 - import { useEntitySetContext } from "components/EntitySetProvider"; 5 - import { ButtonPrimary } from "components/Buttons"; 6 - import { Popover } from "components/Popover"; 7 - import { theme } from "tailwind.config"; 8 - import { useToaster } from "components/Toast"; 9 - import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; 10 - import { useReplicache } from "src/replicache"; 11 - import { Checkbox } from "components/Checkbox"; 12 - import { useReadOnlyShareLink } from "app/[leaflet_id]/actions/ShareOptions"; 13 - 14 - export function SendUpdateButton(props: { entityID: string }) { 15 - let publishLink = useReadOnlyShareLink(); 16 - let { permissions } = useEntitySetContext(); 17 - let { permission_token } = useReplicache(); 18 - let [input, setInput] = useState(""); 19 - let toaster = useToaster(); 20 - let [open, setOpen] = useState(false); 21 - let [checkedRecipients, setCheckedRecipients] = useState({ 22 - GOING: true, 23 - MAYBE: true, 24 - NOT_GOING: false, 25 - }); 26 - 27 - let { data, mutate } = useRSVPData(); 28 - let attendees = 29 - data?.rsvps?.filter((rsvp) => rsvp.entity === props.entityID) || []; 30 - let going = attendees.filter((rsvp) => rsvp.status === "GOING"); 31 - let maybe = attendees.filter((rsvp) => rsvp.status === "MAYBE"); 32 - let notGoing = attendees.filter((rsvp) => rsvp.status === "NOT_GOING"); 33 - 34 - let allRecipients = 35 - ((checkedRecipients.GOING && going.length) || 0) + 36 - ((checkedRecipients.MAYBE && maybe.length) || 0) + 37 - ((checkedRecipients.NOT_GOING && notGoing.length) || 0); 38 - 39 - if (!!!permissions.write) return; 40 - return ( 41 - <Popover 42 - asChild 43 - open={open} 44 - onOpenChange={(open) => setOpen(open)} 45 - trigger={ 46 - <ButtonPrimary fullWidth className="mb-2"> 47 - <UpdateSmall /> Send a Text Blast 48 - </ButtonPrimary> 49 - } 50 - > 51 - <div className="rsvpMessageComposer flex flex-col gap-2 w-[1000px] max-w-full sm:max-w-md"> 52 - <div className="flex flex-col font-bold text-secondary"> 53 - <h3>Send a Text Blast to</h3> 54 - <RecipientPicker 55 - checked={checkedRecipients} 56 - setChecked={setCheckedRecipients} 57 - /> 58 - 59 - <textarea 60 - id="rsvp-message-input" 61 - onKeyDown={(e) => { 62 - if (e.key === "Backspace" && !e.currentTarget.value) 63 - e.preventDefault(); 64 - }} 65 - value={input} 66 - placeholder={ 67 - allRecipients === 0 68 - ? "Send an event update…but first…share this Leaflet to invite people!" 69 - : "Send people an event update!" 70 - } 71 - onChange={(e) => { 72 - setInput(e.target.value); 73 - }} 74 - className="input-with-border w-full h-[150px] mt-3 pt-0.5 font-normal text-primary" 75 - /> 76 - </div> 77 - <div className="flex justify-between items-start"> 78 - <div 79 - className={`rsvpMessageCharCounter text-sm text-tertiary`} 80 - style={ 81 - input.length > 300 82 - ? { 83 - color: theme.colors["accent-1"], 84 - fontWeight: "bold", 85 - } 86 - : { 87 - color: theme.colors["tertiary"], 88 - } 89 - } 90 - > 91 - {input.length}/300 {input.length > 300 && " (too long!)"} 92 - </div> 93 - <ButtonPrimary 94 - disabled={ 95 - input.length > 300 || input.length === 0 || allRecipients === 0 96 - } 97 - className="place-self-end " 98 - onClick={async () => { 99 - if (!permission_token || !publishLink) return; 100 - await sendUpdateToRSVPS(permission_token, { 101 - entity: props.entityID, 102 - message: input, 103 - eventName: document.title, 104 - sendto: checkedRecipients, 105 - publicLeafletID: publishLink, 106 - }); 107 - toaster({ 108 - content: <div className="font-bold">Update sent!</div>, 109 - type: "success", 110 - }); 111 - setOpen(false); 112 - }} 113 - > 114 - Text {allRecipients} {allRecipients === 1 ? "Person" : "People"}! 115 - </ButtonPrimary> 116 - </div> 117 - </div> 118 - </Popover> 119 - ); 120 - } 121 - 122 - const RecipientPicker = (props: { 123 - checked: { GOING: boolean; MAYBE: boolean; NOT_GOING: boolean }; 124 - setChecked: (checked: { 125 - GOING: boolean; 126 - MAYBE: boolean; 127 - NOT_GOING: boolean; 128 - }) => void; 129 - }) => { 130 - return ( 131 - <div className="flex flex-col gap-0.5"> 132 - {/* <small className="font-normal"> 133 - Send a text to everyone who RSVP&apos;d: 134 - </small> */} 135 - <div className="flex gap-4 text-secondary"> 136 - <Checkbox 137 - className="w-fit!" 138 - checked={props.checked.GOING} 139 - onChange={() => { 140 - props.setChecked({ 141 - ...props.checked, // Spread the existing values 142 - GOING: !props.checked.GOING, 143 - }); 144 - }} 145 - > 146 - Going 147 - </Checkbox> 148 - <Checkbox 149 - className="w-fit!" 150 - checked={props.checked.MAYBE} 151 - onChange={() => { 152 - props.setChecked({ 153 - ...props.checked, // Spread the existing values 154 - MAYBE: !props.checked.MAYBE, 155 - }); 156 - }} 157 - > 158 - Maybe 159 - </Checkbox> 160 - <Checkbox 161 - className="w-fit!" 162 - checked={props.checked.NOT_GOING} 163 - onChange={() => { 164 - props.setChecked({ 165 - ...props.checked, // Spread the existing values 166 - NOT_GOING: !props.checked.NOT_GOING, 167 - }); 168 - }} 169 - > 170 - Can&apos;t Go 171 - </Checkbox> 172 - </div> 173 - </div> 174 - ); 175 - }; 176 - 177 - const UpdateSmall = () => { 178 - return ( 179 - <svg 180 - width="24" 181 - height="24" 182 - viewBox="0 0 24 24" 183 - fill="none" 184 - xmlns="http://www.w3.org/2000/svg" 185 - > 186 - <path 187 - fillRule="evenodd" 188 - clipRule="evenodd" 189 - 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" 190 - fill="currentColor" 191 - /> 192 - </svg> 193 - ); 194 - };
+4 -248
components/Blocks/RSVPBlock/index.tsx
··· 1 1 "use client"; 2 - import { Database } from "supabase/database.types"; 3 2 import { BlockProps, BlockLayout } from "components/Blocks/Block"; 4 - import { useState } from "react"; 5 - import { submitRSVP } from "actions/phone_rsvp_to_event"; 6 - import { useRSVPData } from "components/PageSWRDataProvider"; 7 - import { useEntitySetContext } from "components/EntitySetProvider"; 8 - import { ButtonSecondary } from "components/Buttons"; 9 - import { create } from "zustand"; 10 - import { combine, createJSONStorage, persist } from "zustand/middleware"; 11 3 import { useUIState } from "src/useUIState"; 12 - import { theme } from "tailwind.config"; 13 - import { useToaster } from "components/Toast"; 14 - import { ContactDetailsForm } from "./ContactDetailsForm"; 15 - import styles from "./RSVPBackground.module.css"; 16 - import { Attendees } from "./Atendees"; 17 - import { SendUpdateButton } from "./SendUpdate"; 18 4 19 - export type RSVP_Status = Database["public"]["Enums"]["rsvp_status"]; 20 - let Statuses = ["GOING", "NOT_GOING", "MAYBE"]; 21 - export type State = 22 - | { 23 - state: "default"; 24 - } 25 - | { state: "contact_details"; status: RSVP_Status }; 26 - 27 - export function RSVPBlock( 28 - props: BlockProps & { 29 - areYouSure?: boolean; 30 - setAreYouSure?: (value: boolean) => void; 31 - }, 32 - ) { 5 + export function RSVPBlock(props: BlockProps) { 33 6 let isSelected = useUIState((s) => 34 7 s.selectedBlocks.find((b) => b.value === props.entityID), 35 8 ); 36 9 return ( 37 10 <BlockLayout 38 11 isSelected={!!isSelected} 39 - hasBackground={"accent"} 40 - areYouSure={props.areYouSure} 41 - setAreYouSure={props.setAreYouSure} 42 12 className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center" 43 13 > 44 - <RSVPForm entityID={props.entityID} /> 14 + <p className="text-tertiary italic text-sm py-4"> 15 + The RSVP block has been deprecated. 16 + </p> 45 17 </BlockLayout> 46 18 ); 47 19 } 48 - 49 - function RSVPForm(props: { entityID: string }) { 50 - let [state, setState] = useState<State>({ state: "default" }); 51 - let { permissions } = useEntitySetContext(); 52 - let { data, mutate } = useRSVPData(); 53 - let setStatus = (status: RSVP_Status) => { 54 - setState({ status, state: "contact_details" }); 55 - }; 56 - let [editing, setEditting] = useState(false); 57 - 58 - let rsvpStatus = data?.rsvps?.find( 59 - (rsvp) => 60 - data.authToken && 61 - rsvp.entity === props.entityID && 62 - data.authToken.country_code === rsvp.country_code && 63 - data.authToken.phone_number === rsvp.phone_number, 64 - )?.status; 65 - 66 - // IF YOU HAVE ALREADY RSVP'D 67 - if (rsvpStatus && !editing) 68 - return ( 69 - <> 70 - {permissions.write && <SendUpdateButton entityID={props.entityID} />} 71 - 72 - <YourRSVPStatus 73 - entityID={props.entityID} 74 - setEditting={() => { 75 - setEditting(true); 76 - }} 77 - /> 78 - <div className="w-full flex justify-between"> 79 - <Attendees entityID={props.entityID} /> 80 - <button 81 - className="hover:underline text-accent-contrast text-sm" 82 - onClick={() => { 83 - setStatus(rsvpStatus); 84 - setEditting(true); 85 - }} 86 - > 87 - Change RSVP 88 - </button> 89 - </div> 90 - </> 91 - ); 92 - 93 - // IF YOU HAVEN'T RSVP'D 94 - if (state.state === "default") 95 - return ( 96 - <> 97 - {permissions.write && <SendUpdateButton entityID={props.entityID} />} 98 - <RSVPButtons setStatus={setStatus} status={undefined} /> 99 - <Attendees entityID={props.entityID} className="" /> 100 - </> 101 - ); 102 - 103 - // IF YOU ARE CURRENTLY CONFIRMING YOUR CONTACT DETAILS 104 - if (state.state === "contact_details") 105 - return ( 106 - <> 107 - <ContactDetailsForm 108 - status={state.status} 109 - setStatus={setStatus} 110 - setState={(newState) => { 111 - if (newState.state === "default" && editing) setEditting(false); 112 - setState(newState); 113 - }} 114 - entityID={props.entityID} 115 - /> 116 - </> 117 - ); 118 - } 119 - 120 - export const RSVPButtons = (props: { 121 - setStatus: (status: RSVP_Status) => void; 122 - status: RSVP_Status | undefined; 123 - }) => { 124 - return ( 125 - <div className="relative w-full sm:p-6 py-4 px-3 rounded-md border-[1.5px] border-accent-1"> 126 - <RSVPBackground /> 127 - <div className="relative flex flex-row gap-2 items-center mx-auto z-1 w-fit"> 128 - <ButtonSecondary 129 - type="button" 130 - className={ 131 - props.status === "MAYBE" 132 - ? "text-accent-2! bg-accent-1! text-lg" 133 - : "" 134 - } 135 - onClick={() => props.setStatus("MAYBE")} 136 - > 137 - Maybe 138 - </ButtonSecondary> 139 - <ButtonSecondary 140 - type="button" 141 - className={ 142 - props.status === "GOING" 143 - ? "text-accent-2! bg-accent-1! text-lg" 144 - : props.status === undefined 145 - ? "text-lg" 146 - : "" 147 - } 148 - onClick={() => props.setStatus("GOING")} 149 - > 150 - Going! 151 - </ButtonSecondary> 152 - 153 - <ButtonSecondary 154 - type="button" 155 - className={ 156 - props.status === "NOT_GOING" 157 - ? "text-accent-2! bg-accent-1! text-lg" 158 - : "" 159 - } 160 - onClick={() => props.setStatus("NOT_GOING")} 161 - > 162 - Can&apos;t Go 163 - </ButtonSecondary> 164 - </div> 165 - </div> 166 - ); 167 - }; 168 - 169 - function YourRSVPStatus(props: { 170 - entityID: string; 171 - compact?: boolean; 172 - setEditting: (e: boolean) => void; 173 - }) { 174 - let { data, mutate } = useRSVPData(); 175 - let { name } = useRSVPNameState(); 176 - let toaster = useToaster(); 177 - 178 - let existingRSVP = data?.rsvps?.find( 179 - (rsvp) => 180 - data.authToken && 181 - rsvp.entity === props.entityID && 182 - data.authToken.phone_number === rsvp.phone_number, 183 - ); 184 - let rsvpStatus = existingRSVP?.status; 185 - 186 - let updateStatus = async (status: RSVP_Status) => { 187 - if (!data?.authToken) return; 188 - await submitRSVP({ 189 - status, 190 - name: name, 191 - entity: props.entityID, 192 - plus_ones: existingRSVP?.plus_ones || 0, 193 - }); 194 - 195 - mutate({ 196 - authToken: data.authToken, 197 - rsvps: [ 198 - ...(data?.rsvps || []).filter((r) => r.entity !== props.entityID), 199 - { 200 - name: name, 201 - status, 202 - entity: props.entityID, 203 - phone_number: data.authToken.phone_number, 204 - country_code: data.authToken.country_code, 205 - plus_ones: existingRSVP?.plus_ones || 0, 206 - }, 207 - ], 208 - }); 209 - }; 210 - return ( 211 - <div 212 - className={`relative w-full p-4 pb-5 rounded-md border-[1.5px] border-accent-1 font-bold items-center`} 213 - > 214 - <RSVPBackground /> 215 - <div className=" relative flex flex-col gap-1 sm:gap-2 z-1 justify-center w-fit mx-auto"> 216 - <div 217 - className=" w-fit text-xl text-center text-accent-2" 218 - style={{ 219 - WebkitTextStroke: `3px ${theme.colors["accent-1"]}`, 220 - textShadow: `-4px 3px 0 ${theme.colors["accent-1"]}`, 221 - paintOrder: "stroke fill", 222 - }} 223 - > 224 - {rsvpStatus !== undefined && 225 - { 226 - GOING: `You're Going!`, 227 - MAYBE: "You're a Maybe", 228 - NOT_GOING: "Can't Make It", 229 - }[rsvpStatus]} 230 - </div> 231 - {existingRSVP?.plus_ones && existingRSVP?.plus_ones > 0 ? ( 232 - <div className="absolute -top-2 -right-6 rotate-12 h-fit w-10 bg-accent-1 font-bold text-accent-2 rounded-full -z-10"> 233 - <div className="w-full text-center pr-[4px] pb-px"> 234 - +{existingRSVP?.plus_ones} 235 - </div> 236 - </div> 237 - ) : null} 238 - </div> 239 - </div> 240 - ); 241 - } 242 - 243 - const RSVPBackground = () => { 244 - return ( 245 - <div className="overflow-hidden absolute top-0 bottom-0 left-0 right-0 "> 246 - <div 247 - className={`rsvp-background w-full h-full bg-accent-1 z-0 ${styles.RSVPWavyBG} `} 248 - /> 249 - </div> 250 - ); 251 - }; 252 - 253 - export let useRSVPNameState = create( 254 - persist( 255 - combine({ name: "" }, (set) => ({ 256 - setName: (name: string) => set({ name }), 257 - })), 258 - { 259 - name: "rsvp-name", 260 - storage: createJSONStorage(() => localStorage), 261 - }, 262 - ), 263 - );
+51 -5
components/Blocks/TextBlock/mountProsemirror.ts
··· 26 26 import { useEntitySetContext } from "components/EntitySetProvider"; 27 27 import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 28 28 import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; 29 + import { 30 + useLinkPopoverStore, 31 + scheduleLinkPopoverClose, 32 + cancelLinkPopoverClose, 33 + } from "components/LinkPopover"; 29 34 30 35 export function useMountProsemirror({ 31 36 props, ··· 142 147 window.open(url, "_blank", "noopener,noreferrer"); 143 148 return; 144 149 } 145 - if (node.nodeSize - 2 <= _pos) return; 146 150 147 - // Check for marks at the clicked position 151 + if (node.nodeSize - 2 <= _pos) return; 148 152 const nodeAt1 = node.nodeAt(_pos - 1); 149 153 const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 150 - 151 - // Check for link marks 152 154 let linkMark = 153 155 nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 154 156 nodeAt2?.marks.find((f) => f.type === schema.marks.link); 155 157 if (linkMark) { 156 - window.open(linkMark.attrs.href, "_blank", "noreferrer"); 158 + let anchor = (_event.target as HTMLElement).closest("a") as HTMLElement | null; 159 + if (anchor) { 160 + cancelLinkPopoverClose(); 161 + useLinkPopoverStore.getState().open( 162 + linkMark.attrs.href, 163 + anchor, 164 + entityID, 165 + ); 166 + } 157 167 return; 158 168 } 169 + }, 170 + handleDOMEvents: { 171 + mouseover: (() => { 172 + let activeTimer: number | null = null; 173 + let activeAnchor: HTMLAnchorElement | null = null; 174 + let activeLeaveHandler: (() => void) | null = null; 175 + return (_view: EditorView, event: MouseEvent) => { 176 + let target = event.target as HTMLElement; 177 + let anchor = target.closest("a[href]") as HTMLAnchorElement | null; 178 + if (!anchor) return false; 179 + if (anchor === activeAnchor) return false; 180 + if (activeTimer !== null) window.clearTimeout(activeTimer); 181 + if (activeAnchor && activeLeaveHandler) { 182 + activeAnchor.removeEventListener("mouseleave", activeLeaveHandler); 183 + } 184 + cancelLinkPopoverClose(); 185 + activeAnchor = anchor; 186 + activeTimer = window.setTimeout(() => { 187 + useLinkPopoverStore.getState().open( 188 + anchor.getAttribute("href") || "", 189 + anchor, 190 + entityID, 191 + ); 192 + activeTimer = null; 193 + }, 300); 194 + activeLeaveHandler = () => { 195 + if (activeTimer !== null) window.clearTimeout(activeTimer); 196 + activeTimer = null; 197 + activeAnchor = null; 198 + activeLeaveHandler = null; 199 + scheduleLinkPopoverClose(); 200 + }; 201 + anchor.addEventListener("mouseleave", activeLeaveHandler, { once: true }); 202 + return false; 203 + }; 204 + })(), 159 205 }, 160 206 dispatchTransaction, 161 207 },
+1 -1
components/Blocks/TextBlock/schema.ts
··· 105 105 ], 106 106 toDOM(node) { 107 107 let { href } = node.attrs; 108 - return ["a", { href, target: "_blank", referrerpolicy: "no-referrer" }, 0]; 108 + return ["a", { href, target: "_blank", referrerpolicy: "no-referrer", style: "cursor: text" }, 0]; 109 109 }, 110 110 } as MarkSpec, 111 111 },
+231
components/LinkPopover.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useMemo, useState, useCallback } from "react"; 4 + import { create } from "zustand"; 5 + import { Transaction } from "prosemirror-state"; 6 + import * as RadixPopover from "@radix-ui/react-popover"; 7 + import { schema } from "components/Blocks/TextBlock/schema"; 8 + import { useEditorStates, setEditorState } from "src/state/useEditorState"; 9 + import { useReplicache } from "src/replicache"; 10 + import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 11 + import { DeleteTiny } from "components/Icons/DeleteTiny"; 12 + import { CheckTiny } from "components/Icons/CheckTiny"; 13 + import { PopoverArrow } from "components/Icons/PopoverArrow"; 14 + import { theme } from "tailwind.config"; 15 + import { ensureProtocol } from "src/utils/ensureProtocol"; 16 + import { findMarkRange } from "src/utils/prosemirror/findMarkRange"; 17 + 18 + type LinkPopoverState = { 19 + href: string | null; 20 + anchorElement: HTMLElement | null; 21 + blockEntityID: string | null; 22 + open: (href: string, anchor: HTMLElement, blockEntityID: string) => void; 23 + close: () => void; 24 + }; 25 + 26 + export const useLinkPopoverStore = create<LinkPopoverState>((set) => ({ 27 + href: null, 28 + anchorElement: null, 29 + blockEntityID: null, 30 + open: (href, anchor, blockEntityID) => 31 + set({ href, anchorElement: anchor, blockEntityID }), 32 + close: () => set({ href: null, anchorElement: null, blockEntityID: null }), 33 + })); 34 + 35 + export function LinkPopover() { 36 + let { href, anchorElement, blockEntityID, close } = useLinkPopoverStore(); 37 + let { undoManager } = useReplicache(); 38 + let [linkValue, setLinkValue] = useState(""); 39 + 40 + let isOpen = href !== null && anchorElement !== null; 41 + let isDirty = href !== null && linkValue !== href; 42 + 43 + useEffect(() => { 44 + if (href) { 45 + setLinkValue(href); 46 + } 47 + }, [href]); 48 + 49 + // Close on scroll or resize, matching FootnotePopover behavior 50 + useEffect(() => { 51 + if (!isOpen || !anchorElement) return; 52 + let handleScroll = () => close(); 53 + let scrollWrapper = anchorElement.closest(".pageScrollWrapper"); 54 + scrollWrapper?.addEventListener("scroll", handleScroll); 55 + window.addEventListener("resize", close); 56 + return () => { 57 + scrollWrapper?.removeEventListener("scroll", handleScroll); 58 + window.removeEventListener("resize", close); 59 + }; 60 + }, [isOpen, anchorElement, close]); 61 + 62 + let applyLinkTransaction = useCallback( 63 + (buildTr: (tr: Transaction, linkStart: number, linkEnd: number) => void) => { 64 + if (!blockEntityID || !anchorElement) return; 65 + let editorEntry = useEditorStates.getState().editorStates[blockEntityID]; 66 + if (!editorEntry?.editor || !editorEntry.view) return; 67 + 68 + let editor = editorEntry.editor; 69 + let from = editorEntry.view.posAtDOM(anchorElement, 0); 70 + let to = editorEntry.view.posAtDOM( 71 + anchorElement, 72 + anchorElement.childNodes.length, 73 + ); 74 + let { start, end } = findMarkRange( 75 + editor.doc, 76 + schema.marks.link, 77 + from, 78 + to, 79 + ); 80 + 81 + let tr = editor.tr; 82 + buildTr(tr, start, end); 83 + 84 + let oldState = editor; 85 + let newState = editor.apply(tr); 86 + undoManager.add({ 87 + undo: () => setEditorState(blockEntityID, { editor: oldState }), 88 + redo: () => setEditorState(blockEntityID, { editor: newState }), 89 + }); 90 + setEditorState(blockEntityID, { editor: newState }); 91 + close(); 92 + }, 93 + [blockEntityID, anchorElement, undoManager, close], 94 + ); 95 + 96 + let saveLink = useCallback(() => { 97 + applyLinkTransaction((tr, linkStart, linkEnd) => { 98 + if (linkValue.trim() === "") { 99 + tr.removeMark(linkStart, linkEnd, schema.marks.link); 100 + } else { 101 + let newHref = ensureProtocol(linkValue); 102 + tr.addMark( 103 + linkStart, 104 + linkEnd, 105 + schema.marks.link.create({ href: newHref }), 106 + ); 107 + } 108 + }); 109 + }, [applyLinkTransaction, linkValue]); 110 + 111 + let deleteLink = useCallback(() => { 112 + applyLinkTransaction((tr, linkStart, linkEnd) => { 113 + tr.removeMark(linkStart, linkEnd, schema.marks.link); 114 + }); 115 + }, [applyLinkTransaction]); 116 + 117 + let anchorRect = useMemo( 118 + () => anchorElement?.getBoundingClientRect(), 119 + [anchorElement], 120 + ); 121 + 122 + return ( 123 + <RadixPopover.Root open={isOpen}> 124 + <RadixPopover.Anchor 125 + style={{ 126 + position: "fixed", 127 + top: anchorRect?.top ?? 0, 128 + left: anchorRect?.left ?? 0, 129 + width: anchorRect?.width ?? 0, 130 + height: anchorRect?.height ?? 0, 131 + pointerEvents: "none", 132 + }} 133 + /> 134 + <RadixPopover.Portal> 135 + <RadixPopover.Content 136 + side="top" 137 + align="center" 138 + sideOffset={4} 139 + collisionPadding={12} 140 + onOpenAutoFocus={(e) => e.preventDefault()} 141 + onMouseEnter={() => cancelLinkPopoverClose()} 142 + onMouseLeave={() => scheduleLinkPopoverClose()} 143 + className="link-popover z-50 bg-bg-page border border-border rounded-lg shadow-md px-2 py-1 w-[min(calc(100vw-24px),320px)]" 144 + > 145 + <div className="flex items-center gap-1"> 146 + <input 147 + type="text" 148 + spellCheck={false} 149 + value={linkValue} 150 + onChange={(e) => setLinkValue(e.target.value)} 151 + onKeyDown={(e) => { 152 + if (e.key === "Enter") { 153 + e.preventDefault(); 154 + saveLink(); 155 + } 156 + if (e.key === "Escape") { 157 + e.preventDefault(); 158 + close(); 159 + } 160 + }} 161 + className="flex-1 min-w-0 bg-transparent border-none outline-none text-sm text-primary placeholder:text-tertiary" 162 + placeholder="https://example.com" 163 + /> 164 + {isDirty ? ( 165 + <button 166 + className="shrink-0 text-tertiary hover:text-accent-contrast" 167 + onMouseDown={(e) => { 168 + e.preventDefault(); 169 + saveLink(); 170 + }} 171 + title="Save link" 172 + > 173 + <CheckTiny /> 174 + </button> 175 + ) : ( 176 + <div className="flex items-center shrink-0 gap-1"> 177 + <button 178 + className="text-tertiary hover:text-accent-contrast" 179 + onMouseDown={(e) => { 180 + e.preventDefault(); 181 + window.open( 182 + ensureProtocol(linkValue), 183 + "_blank", 184 + "noopener,noreferrer", 185 + ); 186 + }} 187 + title="Open link" 188 + > 189 + <ExternalLinkTiny /> 190 + </button> 191 + <button 192 + className="text-tertiary hover:text-accent-contrast" 193 + onMouseDown={(e) => { 194 + e.preventDefault(); 195 + deleteLink(); 196 + }} 197 + title="Remove link" 198 + > 199 + <DeleteTiny /> 200 + </button> 201 + </div> 202 + )} 203 + </div> 204 + <RadixPopover.Arrow asChild width={16} height={8}> 205 + <PopoverArrow 206 + arrowFill={theme.colors["bg-page"]} 207 + arrowStroke={theme.colors["border"]} 208 + /> 209 + </RadixPopover.Arrow> 210 + </RadixPopover.Content> 211 + </RadixPopover.Portal> 212 + </RadixPopover.Root> 213 + ); 214 + } 215 + 216 + let closeTimer: number | null = null; 217 + 218 + export function scheduleLinkPopoverClose() { 219 + cancelLinkPopoverClose(); 220 + closeTimer = window.setTimeout(() => { 221 + useLinkPopoverStore.getState().close(); 222 + closeTimer = null; 223 + }, 300); 224 + } 225 + 226 + export function cancelLinkPopoverClose() { 227 + if (closeTimer !== null) { 228 + window.clearTimeout(closeTimer); 229 + closeTimer = null; 230 + } 231 + }
+18 -17
components/PageSWRDataProvider.tsx
··· 1 1 "use client"; 2 - import { getRSVPData } from "actions/getRSVPData"; 3 2 import { SWRConfig } from "swr"; 4 3 import { useReplicache } from "src/replicache"; 5 4 import useSWR from "swr"; ··· 8 7 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 8 import { createContext, useContext, useMemo } from "react"; 10 9 import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 11 - import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL"; 10 + import { 11 + getPublicationURL, 12 + getDocumentURL, 13 + } from "app/lish/createPub/getPublicationURL"; 12 14 import { AtUri } from "@atproto/syntax"; 13 15 import { 14 16 normalizeDocumentRecord, ··· 23 25 export function PageSWRDataProvider(props: { 24 26 leaflet_id: string; 25 27 leaflet_data: GetLeafletDataReturnType["result"]; 26 - rsvp_data: Awaited<ReturnType<typeof getRSVPData>>; 27 28 poll_data: Awaited<ReturnType<typeof getPollData>>; 28 29 children: React.ReactNode; 29 30 }) { ··· 31 32 <SWRConfig 32 33 value={{ 33 34 fallback: { 34 - rsvp_data: props.rsvp_data, 35 35 poll_data: props.poll_data, 36 36 [`${props.leaflet_id}-leaflet_data`]: props.leaflet_data.data, 37 37 }, ··· 42 42 ); 43 43 } 44 44 45 - export function useRSVPData() { 46 - let { permission_token } = useReplicache(); 47 - return useSWR(`rsvp_data`, () => 48 - getRSVPData( 49 - permission_token.permission_token_rights.map((pr) => pr.entity_set), 50 - ), 51 - ); 52 - } 53 45 export function usePollData() { 54 46 let { permission_token } = useReplicache(); 55 47 return useSWR(`poll_data`, () => ··· 82 74 // Normalize records so consumers don't have to 83 75 const normalizedPublication = useMemo( 84 76 () => normalizePublicationRecord(pubData?.publications?.record), 85 - [pubData?.publications?.record] 77 + [pubData?.publications?.record], 86 78 ); 87 79 const normalizedDocument = useMemo( 88 80 () => normalizeDocumentRecord(pubData?.documents?.data), 89 - [pubData?.documents?.data] 81 + [pubData?.documents?.data], 90 82 ); 91 83 92 84 return { ··· 118 110 119 111 // Compute the full post URL for sharing 120 112 let postShareLink: string | undefined; 121 - if (publishedInPublication?.publications && publishedInPublication.documents) { 113 + if ( 114 + publishedInPublication?.publications && 115 + publishedInPublication.documents 116 + ) { 122 117 const normalizedDoc = normalizeDocumentRecord( 123 118 publishedInPublication.documents.data, 124 119 publishedInPublication.documents.uri, ··· 132 127 } 133 128 } else if (publishedStandalone?.document) { 134 129 const normalizedDoc = publishedStandalone.documents 135 - ? normalizeDocumentRecord(publishedStandalone.documents.data, publishedStandalone.document) 130 + ? normalizeDocumentRecord( 131 + publishedStandalone.documents.data, 132 + publishedStandalone.document, 133 + ) 136 134 : null; 137 135 if (normalizedDoc) { 138 - postShareLink = getDocumentURL(normalizedDoc, publishedStandalone.document); 136 + postShareLink = getDocumentURL( 137 + normalizedDoc, 138 + publishedStandalone.document, 139 + ); 139 140 } else { 140 141 const docUri = new AtUri(publishedStandalone.document); 141 142 postShareLink = `/p/${docUri.host}/${docUri.rkey}`;
+2
components/Pages/Page.tsx
··· 22 22 import { FootnoteSection } from "components/Footnotes/FootnoteSection"; 23 23 import { FootnoteSideColumn } from "components/Footnotes/FootnoteSideColumn"; 24 24 import { FootnotePopover } from "components/Footnotes/FootnotePopover"; 25 + import { LinkPopover } from "components/LinkPopover"; 25 26 26 27 export function Page(props: { 27 28 entityID: string; ··· 89 90 </PageWrapper> 90 91 <DesktopPageFooter pageID={props.entityID} /> 91 92 <FootnotePopover /> 93 + <LinkPopover /> 92 94 </FootnoteContext.Provider> 93 95 </CardThemeProvider> 94 96 );
-1
components/ThemeManager/PageThemeSetter.tsx
··· 11 11 import { ButtonPrimary } from "components/Buttons"; 12 12 import { PaintSmall } from "components/Icons/PaintSmall"; 13 13 import { AccentPickers } from "./Pickers/AccentPickers"; 14 - import Page from "twilio/lib/base/Page"; 15 14 16 15 export const PageThemeSetter = (props: { entityID: string }) => { 17 16 let { rootEntity } = useReplicache();
+9 -38
components/Toolbar/InlineLinkToolbar.tsx
··· 1 1 import { schema } from "components/Blocks/TextBlock/schema"; 2 - import { EditorState, TextSelection } from "prosemirror-state"; 2 + import { TextSelection } from "prosemirror-state"; 3 3 import { useUIState } from "src/useUIState"; 4 4 import { ToolbarButton } from "."; 5 5 import { useEffect, useState } from "react"; 6 6 import { Separator } from "components/Layout"; 7 - import { MarkType } from "prosemirror-model"; 8 7 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 9 8 import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 9 + import { findMarkRange } from "src/utils/prosemirror/findMarkRange"; 10 + import { ensureProtocol } from "src/utils/ensureProtocol"; 10 11 import { Input } from "components/Input"; 11 12 import { useReplicache } from "src/replicache"; 12 13 import { CheckTiny } from "components/Icons/CheckTiny"; ··· 81 82 start = from; 82 83 end = to; 83 84 } else { 84 - let markRange = findMarkRange(focusedEditor.editor, schema.marks.link); 85 + let markRange = findMarkRange( 86 + focusedEditor.editor.doc, 87 + schema.marks.link, 88 + from, 89 + ); 85 90 start = markRange.start; 86 91 end = markRange.end; 87 92 } ··· 94 99 } 95 100 let [linkValue, setLinkValue] = useState(content); 96 101 let setLink = () => { 97 - let href = 98 - !linkValue.startsWith("http") && 99 - !linkValue.startsWith("mailto") && 100 - !linkValue.startsWith("tel:") 101 - ? `https://${linkValue}` 102 - : linkValue; 102 + let href = ensureProtocol(linkValue); 103 103 104 104 let editor = focusedEditor?.editor; 105 105 if (!editor || start === null || !end || !focusedBlock) return; ··· 176 176 ); 177 177 } 178 178 179 - function findMarkRange(state: EditorState, markType: MarkType) { 180 - const { from, $from } = state.selection; 181 - 182 - // Find the start of the mark 183 - let start = from; 184 - let startPos = $from; 185 - while ( 186 - startPos.parent.inlineContent && 187 - startPos.nodeBefore && 188 - startPos.nodeBefore.marks.some((mark) => mark.type === markType) 189 - ) { 190 - start -= startPos.nodeBefore.nodeSize; 191 - startPos = state.doc.resolve(start); 192 - } 193 - 194 - // Find the end of the mark 195 - let end = from; 196 - let endPos = $from; 197 - while ( 198 - endPos.parent.inlineContent && 199 - endPos.nodeAfter && 200 - endPos.nodeAfter.marks.some((mark) => mark.type === markType) 201 - ) { 202 - end += endPos.nodeAfter.nodeSize; 203 - endPos = state.doc.resolve(end); 204 - } 205 - 206 - return { start, end }; 207 - }
+1 -1
components/ViewportSizeLayout.tsx
··· 11 11 height: 12 12 isIOS() && difference !== 0 13 13 ? `calc(${viewheight}px + 10px)` 14 - : "calc(100% + env(safe-area-inset-top)", 14 + : "calc(100% + env(safe-area-inset-top))", 15 15 }} 16 16 > 17 17 {props.children}
+5
lexicons/api/lexicons.ts
··· 1176 1176 type: 'ref', 1177 1177 ref: 'lex:pub.leaflet.blocks.image#aspectRatio', 1178 1178 }, 1179 + fullBleed: { 1180 + type: 'boolean', 1181 + description: 1182 + 'Whether the image should extend to the full width of the container, ignoring padding.', 1183 + }, 1179 1184 }, 1180 1185 }, 1181 1186 aspectRatio: {
+2
lexicons/api/types/pub/leaflet/blocks/image.ts
··· 20 20 /** Alt text description of the image, for accessibility. */ 21 21 alt?: string 22 22 aspectRatio: AspectRatio 23 + /** Whether the image should extend to the full width of the container, ignoring padding. */ 24 + fullBleed?: boolean 23 25 } 24 26 25 27 const hashMain = 'main'
+4
lexicons/pub/leaflet/blocks/image.json
··· 23 23 "aspectRatio": { 24 24 "type": "ref", 25 25 "ref": "#aspectRatio" 26 + }, 27 + "fullBleed": { 28 + "type": "boolean", 29 + "description": "Whether the image should extend to the full width of the container, ignoring padding." 26 30 } 27 31 } 28 32 },
+15 -5
lexicons/src/blocks.ts
··· 180 180 type: "ref", 181 181 ref: "#aspectRatio", 182 182 }, 183 + fullBleed: { 184 + type: "boolean", 185 + description: 186 + "Whether the image should extend to the full width of the container, ignoring padding.", 187 + }, 183 188 }, 184 189 }, 185 190 aspectRatio: { ··· 203 208 properties: { 204 209 startIndex: { 205 210 type: "integer", 206 - description: "The starting number for this ordered list. Defaults to 1 if not specified.", 211 + description: 212 + "The starting number for this ordered list. Defaults to 1 if not specified.", 207 213 }, 208 214 children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 209 215 }, ··· 227 233 }, 228 234 children: { 229 235 type: "array", 230 - description: "Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence.", 236 + description: 237 + "Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence.", 231 238 items: { type: "ref", ref: "#listItem" }, 232 239 }, 233 240 unorderedListChildren: { 234 241 type: "ref", 235 - description: "A nested unordered list. Mutually exclusive with children; if both are present, children takes precedence.", 242 + description: 243 + "A nested unordered list. Mutually exclusive with children; if both are present, children takes precedence.", 236 244 ref: "pub.leaflet.blocks.unorderedList", 237 245 }, 238 246 }, ··· 270 278 }, 271 279 children: { 272 280 type: "array", 273 - description: "Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.", 281 + description: 282 + "Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.", 274 283 items: { type: "ref", ref: "#listItem" }, 275 284 }, 276 285 orderedListChildren: { 277 286 type: "ref", 278 - description: "Nested ordered list items. Mutually exclusive with children; if both are present, children takes precedence.", 287 + description: 288 + "Nested ordered list items. Mutually exclusive with children; if both are present, children takes precedence.", 279 289 ref: "pub.leaflet.blocks.orderedList", 280 290 }, 281 291 },
+133 -251
package-lock.json
··· 83 83 "stripe": "^20.4.0", 84 84 "swr": "^2.3.3", 85 85 "thumbhash": "^0.1.1", 86 - "twilio": "^5.3.7", 87 86 "unified": "^11.0.5", 88 87 "unist-util-visit": "^5.0.0", 89 88 "uuid": "^10.0.0", ··· 936 935 "integrity": "sha512-vLu7SRY84CV/Dd+NUdgtidn2hS5hSMUC1vDBY0VcviTdgRYkU43vIz3vIFbmx14cX1r+mM7WjzE5Fl1fGEM0RQ==", 937 936 "license": "(Apache-2.0 AND BSD-3-Clause)" 938 937 }, 938 + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { 939 + "version": "2.2.0", 940 + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz", 941 + "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==", 942 + "cpu": [ 943 + "arm64" 944 + ], 945 + "license": "MIT", 946 + "optional": true, 947 + "os": [ 948 + "darwin" 949 + ] 950 + }, 951 + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { 952 + "version": "2.2.0", 953 + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz", 954 + "integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==", 955 + "cpu": [ 956 + "x64" 957 + ], 958 + "license": "MIT", 959 + "optional": true, 960 + "os": [ 961 + "darwin" 962 + ] 963 + }, 964 + "node_modules/@cbor-extract/cbor-extract-linux-arm": { 965 + "version": "2.2.0", 966 + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz", 967 + "integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==", 968 + "cpu": [ 969 + "arm" 970 + ], 971 + "license": "MIT", 972 + "optional": true, 973 + "os": [ 974 + "linux" 975 + ] 976 + }, 977 + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { 978 + "version": "2.2.0", 979 + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz", 980 + "integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==", 981 + "cpu": [ 982 + "arm64" 983 + ], 984 + "license": "MIT", 985 + "optional": true, 986 + "os": [ 987 + "linux" 988 + ] 989 + }, 939 990 "node_modules/@cbor-extract/cbor-extract-linux-x64": { 940 991 "version": "2.2.0", 941 992 "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz", ··· 947 998 "optional": true, 948 999 "os": [ 949 1000 "linux" 1001 + ] 1002 + }, 1003 + "node_modules/@cbor-extract/cbor-extract-win32-x64": { 1004 + "version": "2.2.0", 1005 + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz", 1006 + "integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==", 1007 + "cpu": [ 1008 + "x64" 1009 + ], 1010 + "license": "MIT", 1011 + "optional": true, 1012 + "os": [ 1013 + "win32" 950 1014 ] 951 1015 }, 952 1016 "node_modules/@clack/core": { ··· 982 1046 "node": ">=16.13" 983 1047 } 984 1048 }, 1049 + "node_modules/@cloudflare/workerd-darwin-64": { 1050 + "version": "1.20240524.0", 1051 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20240524.0.tgz", 1052 + "integrity": "sha512-ATaXjefbTsrv4mpn4Fdua114RRDXcX5Ky+Mv+f4JTUllgalmqC4CYMN4jxRz9IpJU/fNMN8IEfvUyuJBAcl9Iw==", 1053 + "cpu": [ 1054 + "x64" 1055 + ], 1056 + "dev": true, 1057 + "license": "Apache-2.0", 1058 + "optional": true, 1059 + "os": [ 1060 + "darwin" 1061 + ], 1062 + "engines": { 1063 + "node": ">=16" 1064 + } 1065 + }, 1066 + "node_modules/@cloudflare/workerd-darwin-arm64": { 1067 + "version": "1.20240524.0", 1068 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20240524.0.tgz", 1069 + "integrity": "sha512-wnbsZI4CS0QPCd+wnBHQ40C28A/2Qo4ESi1YhE2735G3UNcc876MWksZhsubd+XH0XPIra6eNFqyw6wRMpQOXA==", 1070 + "cpu": [ 1071 + "arm64" 1072 + ], 1073 + "dev": true, 1074 + "license": "Apache-2.0", 1075 + "optional": true, 1076 + "os": [ 1077 + "darwin" 1078 + ], 1079 + "engines": { 1080 + "node": ">=16" 1081 + } 1082 + }, 985 1083 "node_modules/@cloudflare/workerd-linux-64": { 986 1084 "version": "1.20240524.0", 987 1085 "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20240524.0.tgz", ··· 993 1091 "optional": true, 994 1092 "os": [ 995 1093 "linux" 1094 + ], 1095 + "engines": { 1096 + "node": ">=16" 1097 + } 1098 + }, 1099 + "node_modules/@cloudflare/workerd-linux-arm64": { 1100 + "version": "1.20240524.0", 1101 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20240524.0.tgz", 1102 + "integrity": "sha512-/Fr1W671t2triNCDCBWdStxngnbUfZunZ/2e4kaMLzJDJLYDtYdmvOUCBDzUD4ssqmIMbn9RCQQ0U+CLEoqBqw==", 1103 + "cpu": [ 1104 + "arm64" 1105 + ], 1106 + "dev": true, 1107 + "license": "Apache-2.0", 1108 + "optional": true, 1109 + "os": [ 1110 + "linux" 1111 + ], 1112 + "engines": { 1113 + "node": ">=16" 1114 + } 1115 + }, 1116 + "node_modules/@cloudflare/workerd-windows-64": { 1117 + "version": "1.20240524.0", 1118 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20240524.0.tgz", 1119 + "integrity": "sha512-G+ThDEx57g9mAEKqhWnHaaJgpeGYtyhkmwM/BDpLqPks/rAY5YEfZbY4YL1pNk1kkcZDXGrwIsY8xe9Apf5JdA==", 1120 + "cpu": [ 1121 + "x64" 1122 + ], 1123 + "dev": true, 1124 + "license": "Apache-2.0", 1125 + "optional": true, 1126 + "os": [ 1127 + "win32" 996 1128 ], 997 1129 "engines": { 998 1130 "node": ">=16" ··· 9428 9560 "node": ">= 0.4" 9429 9561 } 9430 9562 }, 9431 - "node_modules/asynckit": { 9432 - "version": "0.4.0", 9433 - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 9434 - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 9435 - }, 9436 9563 "node_modules/atomic-sleep": { 9437 9564 "version": "1.0.0", 9438 9565 "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", ··· 9472 9599 "license": "MPL-2.0", 9473 9600 "engines": { 9474 9601 "node": ">=4" 9475 - } 9476 - }, 9477 - "node_modules/axios": { 9478 - "version": "1.7.9", 9479 - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", 9480 - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", 9481 - "license": "MIT", 9482 - "dependencies": { 9483 - "follow-redirects": "^1.15.6", 9484 - "form-data": "^4.0.0", 9485 - "proxy-from-env": "^1.1.0" 9486 9602 } 9487 9603 }, 9488 9604 "node_modules/axobject-query": { ··· 9707 9823 "base64-js": "^1.3.1", 9708 9824 "ieee754": "^1.2.1" 9709 9825 } 9710 - }, 9711 - "node_modules/buffer-equal-constant-time": { 9712 - "version": "1.0.1", 9713 - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", 9714 - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", 9715 - "license": "BSD-3-Clause" 9716 9826 }, 9717 9827 "node_modules/buffer-from": { 9718 9828 "version": "1.1.2", ··· 10070 10180 "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", 10071 10181 "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==" 10072 10182 }, 10073 - "node_modules/combined-stream": { 10074 - "version": "1.0.8", 10075 - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 10076 - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 10077 - "dependencies": { 10078 - "delayed-stream": "~1.0.0" 10079 - }, 10080 - "engines": { 10081 - "node": ">= 0.8" 10082 - } 10083 - }, 10084 10183 "node_modules/comma-separated-tokens": { 10085 10184 "version": "2.0.3", 10086 10185 "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", ··· 10413 10512 "url": "https://github.com/sponsors/kossnocorp" 10414 10513 } 10415 10514 }, 10416 - "node_modules/dayjs": { 10417 - "version": "1.11.13", 10418 - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", 10419 - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", 10420 - "license": "MIT" 10421 - }, 10422 10515 "node_modules/debounce": { 10423 10516 "version": "1.2.1", 10424 10517 "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", ··· 10503 10596 }, 10504 10597 "funding": { 10505 10598 "url": "https://github.com/sponsors/ljharb" 10506 - } 10507 - }, 10508 - "node_modules/delayed-stream": { 10509 - "version": "1.0.0", 10510 - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 10511 - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 10512 - "engines": { 10513 - "node": ">=0.4.0" 10514 10599 } 10515 10600 }, 10516 10601 "node_modules/denque": { ··· 11180 11265 "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", 11181 11266 "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", 11182 11267 "license": "MIT" 11183 - }, 11184 - "node_modules/ecdsa-sig-formatter": { 11185 - "version": "1.0.11", 11186 - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", 11187 - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", 11188 - "license": "Apache-2.0", 11189 - "dependencies": { 11190 - "safe-buffer": "^5.0.1" 11191 - } 11192 11268 }, 11193 11269 "node_modules/ee-first": { 11194 11270 "version": "1.1.1", ··· 12485 12561 "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", 12486 12562 "dev": true 12487 12563 }, 12488 - "node_modules/follow-redirects": { 12489 - "version": "1.15.6", 12490 - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", 12491 - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", 12492 - "funding": [ 12493 - { 12494 - "type": "individual", 12495 - "url": "https://github.com/sponsors/RubenVerborgh" 12496 - } 12497 - ], 12498 - "engines": { 12499 - "node": ">=4.0" 12500 - }, 12501 - "peerDependenciesMeta": { 12502 - "debug": { 12503 - "optional": true 12504 - } 12505 - } 12506 - }, 12507 12564 "node_modules/for-each": { 12508 12565 "version": "0.3.5", 12509 12566 "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", ··· 12518 12575 }, 12519 12576 "funding": { 12520 12577 "url": "https://github.com/sponsors/ljharb" 12521 - } 12522 - }, 12523 - "node_modules/form-data": { 12524 - "version": "4.0.0", 12525 - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", 12526 - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", 12527 - "dependencies": { 12528 - "asynckit": "^0.4.0", 12529 - "combined-stream": "^1.0.8", 12530 - "mime-types": "^2.1.12" 12531 - }, 12532 - "engines": { 12533 - "node": ">= 6" 12534 12578 } 12535 12579 }, 12536 12580 "node_modules/formdata-polyfill": { ··· 14285 14329 "json5": "lib/cli.js" 14286 14330 } 14287 14331 }, 14288 - "node_modules/jsonwebtoken": { 14289 - "version": "9.0.2", 14290 - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", 14291 - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", 14292 - "license": "MIT", 14293 - "dependencies": { 14294 - "jws": "^3.2.2", 14295 - "lodash.includes": "^4.3.0", 14296 - "lodash.isboolean": "^3.0.3", 14297 - "lodash.isinteger": "^4.0.4", 14298 - "lodash.isnumber": "^3.0.3", 14299 - "lodash.isplainobject": "^4.0.6", 14300 - "lodash.isstring": "^4.0.1", 14301 - "lodash.once": "^4.0.0", 14302 - "ms": "^2.1.1", 14303 - "semver": "^7.5.4" 14304 - }, 14305 - "engines": { 14306 - "node": ">=12", 14307 - "npm": ">=6" 14308 - } 14309 - }, 14310 14332 "node_modules/jsx-ast-utils": { 14311 14333 "version": "3.3.5", 14312 14334 "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", ··· 14321 14343 }, 14322 14344 "engines": { 14323 14345 "node": ">=4.0" 14324 - } 14325 - }, 14326 - "node_modules/jwa": { 14327 - "version": "1.4.1", 14328 - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", 14329 - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", 14330 - "license": "MIT", 14331 - "dependencies": { 14332 - "buffer-equal-constant-time": "1.0.1", 14333 - "ecdsa-sig-formatter": "1.0.11", 14334 - "safe-buffer": "^5.0.1" 14335 - } 14336 - }, 14337 - "node_modules/jws": { 14338 - "version": "3.2.2", 14339 - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", 14340 - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", 14341 - "license": "MIT", 14342 - "dependencies": { 14343 - "jwa": "^1.4.1", 14344 - "safe-buffer": "^5.0.1" 14345 14346 } 14346 14347 }, 14347 14348 "node_modules/katex": { ··· 14719 14720 "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", 14720 14721 "license": "MIT" 14721 14722 }, 14722 - "node_modules/lodash.includes": { 14723 - "version": "4.3.0", 14724 - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", 14725 - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", 14726 - "license": "MIT" 14727 - }, 14728 14723 "node_modules/lodash.isarguments": { 14729 14724 "version": "3.1.0", 14730 14725 "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", 14731 14726 "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", 14732 14727 "license": "MIT" 14733 14728 }, 14734 - "node_modules/lodash.isboolean": { 14735 - "version": "3.0.3", 14736 - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", 14737 - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", 14738 - "license": "MIT" 14739 - }, 14740 - "node_modules/lodash.isinteger": { 14741 - "version": "4.0.4", 14742 - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", 14743 - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", 14744 - "license": "MIT" 14745 - }, 14746 - "node_modules/lodash.isnumber": { 14747 - "version": "3.0.3", 14748 - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", 14749 - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", 14750 - "license": "MIT" 14751 - }, 14752 - "node_modules/lodash.isplainobject": { 14753 - "version": "4.0.6", 14754 - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", 14755 - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", 14756 - "license": "MIT" 14757 - }, 14758 - "node_modules/lodash.isstring": { 14759 - "version": "4.0.1", 14760 - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", 14761 - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", 14762 - "license": "MIT" 14763 - }, 14764 14729 "node_modules/lodash.merge": { 14765 14730 "version": "4.6.2", 14766 14731 "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", 14767 14732 "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", 14768 14733 "dev": true 14769 - }, 14770 - "node_modules/lodash.once": { 14771 - "version": "4.1.1", 14772 - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", 14773 - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", 14774 - "license": "MIT" 14775 14734 }, 14776 14735 "node_modules/lodash.throttle": { 14777 14736 "version": "4.1.1", ··· 16861 16820 "node": "^10 || ^12 || >=14" 16862 16821 } 16863 16822 }, 16864 - "node_modules/postgres": { 16865 - "version": "3.4.4", 16866 - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.4.tgz", 16867 - "integrity": "sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==", 16868 - "optional": true, 16869 - "peer": true, 16870 - "engines": { 16871 - "node": ">=12" 16872 - }, 16873 - "funding": { 16874 - "type": "individual", 16875 - "url": "https://github.com/sponsors/porsager" 16876 - } 16877 - }, 16878 16823 "node_modules/postgres-array": { 16879 16824 "version": "2.0.0", 16880 16825 "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", ··· 17228 17173 "node": ">= 0.10" 17229 17174 } 17230 17175 }, 17231 - "node_modules/proxy-from-env": { 17232 - "version": "1.1.0", 17233 - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 17234 - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 17235 - }, 17236 17176 "node_modules/punycode": { 17237 17177 "version": "2.3.1", 17238 17178 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", ··· 18290 18230 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", 18291 18231 "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" 18292 18232 }, 18293 - "node_modules/scmp": { 18294 - "version": "2.1.0", 18295 - "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", 18296 - "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", 18297 - "license": "BSD-3-Clause" 18298 - }, 18299 18233 "node_modules/selfsigned": { 18300 18234 "version": "2.4.1", 18301 18235 "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", ··· 19304 19238 "fsevents": "~2.3.3" 19305 19239 } 19306 19240 }, 19307 - "node_modules/twilio": { 19308 - "version": "5.3.7", 19309 - "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.3.7.tgz", 19310 - "integrity": "sha512-9PaIXQ2CSfKKKjsQ7YpLRi7vo77ryevjoylR+7uSB0D/9t5VOYz+QNxNuR3YGk9UCt/f5pxzjVQraWVI5lQFSQ==", 19311 - "license": "MIT", 19312 - "dependencies": { 19313 - "axios": "^1.7.4", 19314 - "dayjs": "^1.11.9", 19315 - "https-proxy-agent": "^5.0.0", 19316 - "jsonwebtoken": "^9.0.2", 19317 - "qs": "^6.9.4", 19318 - "scmp": "^2.1.0", 19319 - "xmlbuilder": "^13.0.2" 19320 - }, 19321 - "engines": { 19322 - "node": ">=14.0" 19323 - } 19324 - }, 19325 - "node_modules/twilio/node_modules/agent-base": { 19326 - "version": "6.0.2", 19327 - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", 19328 - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", 19329 - "license": "MIT", 19330 - "dependencies": { 19331 - "debug": "4" 19332 - }, 19333 - "engines": { 19334 - "node": ">= 6.0.0" 19335 - } 19336 - }, 19337 - "node_modules/twilio/node_modules/https-proxy-agent": { 19338 - "version": "5.0.1", 19339 - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", 19340 - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", 19341 - "license": "MIT", 19342 - "dependencies": { 19343 - "agent-base": "6", 19344 - "debug": "4" 19345 - }, 19346 - "engines": { 19347 - "node": ">= 6" 19348 - } 19349 - }, 19350 19241 "node_modules/type": { 19351 19242 "version": "2.7.2", 19352 19243 "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", ··· 20594 20485 }, 20595 20486 "bin": { 20596 20487 "xml-js": "bin/cli.js" 20597 - } 20598 - }, 20599 - "node_modules/xmlbuilder": { 20600 - "version": "13.0.2", 20601 - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", 20602 - "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", 20603 - "license": "MIT", 20604 - "engines": { 20605 - "node": ">=6.0" 20606 20488 } 20607 20489 }, 20608 20490 "node_modules/xtend": {
-1
package.json
··· 94 94 "stripe": "^20.4.0", 95 95 "swr": "^2.3.3", 96 96 "thumbhash": "^0.1.1", 97 - "twilio": "^5.3.7", 98 97 "unified": "^11.0.5", 99 98 "unist-util-visit": "^5.0.0", 100 99 "uuid": "^10.0.0",
+9
src/utils/ensureProtocol.ts
··· 1 + export function ensureProtocol(url: string) { 2 + if ( 3 + url.startsWith("http") || 4 + url.startsWith("mailto") || 5 + url.startsWith("tel:") 6 + ) 7 + return url; 8 + return `https://${url}`; 9 + }
+28
src/utils/prosemirror/findMarkRange.ts
··· 1 + import { MarkType, Node } from "prosemirror-model"; 2 + 3 + export function findMarkRange( 4 + doc: Node, 5 + markType: MarkType, 6 + from: number, 7 + to?: number, 8 + ) { 9 + let start = from; 10 + let end = to ?? from; 11 + let $start = doc.resolve(start); 12 + let $end = doc.resolve(end); 13 + while ( 14 + $start.nodeBefore && 15 + $start.nodeBefore.marks.some((m) => m.type === markType) 16 + ) { 17 + start -= $start.nodeBefore.nodeSize; 18 + $start = doc.resolve(start); 19 + } 20 + while ( 21 + $end.nodeAfter && 22 + $end.nodeAfter.marks.some((m) => m.type === markType) 23 + ) { 24 + end += $end.nodeAfter.nodeSize; 25 + $end = doc.resolve(end); 26 + } 27 + return { start, end }; 28 + }