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 feature/email

celine (Apr 7, 2026, 4:47 PM EDT) 5b985ee9 e88b4388

+2859 -1833
+1
CLAUDE.md
··· 71 71 - **React contexts**: `DocumentProvider`, `LeafletContentProvider` for page-level data 72 72 - **Inngest functions**: Async jobs in `app/api/inngest/functions/` 73 73 - **Icons**: Icon components live in `components/Icons/`. Each icon is a named export in its own file (e.g. `RefreshSmall.tsx`), imports `Props` from `./Props`, spreads `{...props}` on the `<svg>` element, and uses `fill="currentColor"` instead of hardcoded colors like `fill="black"`. 74 + - **Popovers and menus**: Use the existing `Popover` (`components/Popover`), `Menu`, and `MenuItem` (`components/Menu`) components — do not create new popover/menu primitives
-1
actions/createNewLeaflet.ts
··· 12 12 email_auth_tokens, 13 13 } from "drizzle/schema"; 14 14 import { redirect } from "next/navigation"; 15 - import postgres from "postgres"; 16 15 import { v7 } from "uuid"; 17 16 import { sql, eq, and } from "drizzle-orm"; 18 17 import { cookies } from "next/headers";
+1 -1
actions/emailAuth.ts
··· 2 2 3 3 import { randomBytes } from "crypto"; 4 4 import { drizzle } from "drizzle-orm/node-postgres"; 5 - import postgres from "postgres"; 5 + 6 6 import { email_auth_tokens, identities } from "drizzle/schema"; 7 7 import { and, eq } from "drizzle-orm"; 8 8 import { cookies } from "next/headers";
+30 -2
actions/getIdentityData.ts
··· 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 5 import { cache } from "react"; 6 6 import { deduplicateByUri } from "src/utils/deduplicateRecords"; 7 + import { AtUri } from "@atproto/syntax"; 8 + import { TID } from "@atproto/common"; 7 9 export const getIdentityData = cache(uncachedGetIdentityData); 8 10 export async function uncachedGetIdentityData() { 9 11 let cookieStore = await cookies(); ··· 75 77 .from("publications") 76 78 .select("*") 77 79 .eq("identity_did", auth_res.data.identities.atp_did); 78 - // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 79 - const publications = deduplicateByUri(rawPublications || []); 80 + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces, 81 + // then filter to only publications created by Leaflet 82 + const publications = deduplicateByUri(rawPublications || []).filter( 83 + isLeafletPublication, 84 + ); 80 85 return { 81 86 ...auth_res.data.identities, 82 87 publications, ··· 92 97 subscription, 93 98 }; 94 99 } 100 + 101 + function isLeafletPublication(p: { uri: string; record: unknown }): boolean { 102 + try { 103 + const rkey = new AtUri(p.uri).rkey; 104 + if (!TID.is(rkey)) return false; 105 + } catch { 106 + return false; 107 + } 108 + 109 + const record = p.record as Record<string, any> | null; 110 + if (!record) return true; 111 + 112 + if (record.preferences?.greengale) return false; 113 + 114 + if ( 115 + record.theme && 116 + record.theme.$type && 117 + record.theme.$type !== "pub.leaflet.publication#theme" 118 + ) 119 + return false; 120 + 121 + return true; 122 + }
-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 - }
-1
actions/login.ts
··· 1 1 "use server"; 2 2 import { drizzle } from "drizzle-orm/node-postgres"; 3 - import postgres from "postgres"; 4 3 import { 5 4 email_auth_tokens, 6 5 identities,
-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 - }
+8
actions/publishToPublication.ts
··· 542 542 let record: PubLeafletBlocksUnorderedList.ListItem = { 543 543 $type: "pub.leaflet.blocks.unorderedList#listItem", 544 544 content, 545 + ...(child.block.listData?.checklist && { 546 + checked: child.block.listData.checked ?? false, 547 + }), 545 548 }; 546 549 let sameStyle = child.children.filter( 547 550 (c) => c.block.listData?.listStyle !== "ordered", ··· 576 579 let record: PubLeafletBlocksOrderedList.ListItem = { 577 580 $type: "pub.leaflet.blocks.orderedList#listItem", 578 581 content, 582 + ...(child.block.listData?.checklist && { 583 + checked: child.block.listData.checked ?? false, 584 + }), 579 585 }; 580 586 let sameStyle = child.children.filter( 581 587 (c) => c.block.listData?.listStyle === "ordered", ··· 718 724 let [image] = scan.eav(b.value, "block/image"); 719 725 if (!image) return; 720 726 let [altText] = scan.eav(b.value, "image/alt"); 727 + let [fullBleed] = scan.eav(b.value, "image/full-bleed"); 721 728 let blobref = await uploadImage(image.data.src); 722 729 if (!blobref) return; 723 730 let block: $Typed<PubLeafletBlocksImage.Main> = { ··· 728 735 width: Math.floor(image.data.width), 729 736 }, 730 737 alt: altText ? altText.data.value : undefined, 738 + fullBleed: fullBleed?.data.value || undefined, 731 739 }; 732 740 return block; 733 741 }
+1 -1
actions/removeLeafletFromHome.ts
··· 2 2 3 3 import { drizzle } from "drizzle-orm/node-postgres"; 4 4 import { permission_token_on_homepage } from "drizzle/schema"; 5 - import postgres from "postgres"; 5 + 6 6 import { v7 } from "uuid"; 7 7 import { sql, eq, inArray, and } from "drizzle-orm"; 8 8 import { cookies } from "next/headers";
-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 - }
+1 -1
actions/subscriptions/confirmEmailSubscription.ts
··· 8 8 facts, 9 9 permission_tokens, 10 10 } from "drizzle/schema"; 11 - import postgres from "postgres"; 11 + 12 12 import type { Fact } from "src/replicache"; 13 13 import { Database } from "supabase/database.types"; 14 14 import { pool } from "supabase/pool";
+1 -1
actions/subscriptions/deleteSubscription.ts
··· 2 2 3 3 import { drizzle } from "drizzle-orm/node-postgres"; 4 4 import { email_subscriptions_to_entity, facts } from "drizzle/schema"; 5 - import postgres from "postgres"; 5 + 6 6 import { eq, and, sql } from "drizzle-orm"; 7 7 import type { Fact } from "src/replicache"; 8 8 import { v7 } from "uuid";
+1 -1
actions/subscriptions/sendPostToSubscribers.ts
··· 5 5 import { and, eq } from "drizzle-orm"; 6 6 import { drizzle } from "drizzle-orm/node-postgres"; 7 7 import { email_subscriptions_to_entity, entities } from "drizzle/schema"; 8 - import postgres from "postgres"; 8 + 9 9 import type { PermissionToken } from "src/replicache"; 10 10 import { Database } from "supabase/database.types"; 11 11 import { pool } from "supabase/pool";
+1 -1
actions/subscriptions/subscribeToMailboxWithEmail.ts
··· 5 5 import { and, eq } from "drizzle-orm"; 6 6 import { drizzle } from "drizzle-orm/node-postgres"; 7 7 import { email_subscriptions_to_entity } from "drizzle/schema"; 8 - import postgres from "postgres"; 8 + 9 9 import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 10 10 import type { Fact, PermissionToken } from "src/replicache"; 11 11 import type { Attribute } from "src/replicache/attributes";
+1 -1
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
··· 57 57 > 58 58 <SpeedyLink 59 59 href={`/${tokenId}`} 60 - className={`absolute w-full h-full top-0 left-0 no-underline hover:no-underline! text-primary`} 60 + className={`absolute w-full h-full top-0 left-0 no-underline! hover:no-underline! text-primary`} 61 61 /> 62 62 {props.showPreview && <LeafletListPreview isVisible={isOnScreen} />} 63 63 <LeafletInfo
+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 + }
+7 -2
app/api/rpc/[command]/get_profile_data.ts
··· 52 52 }); 53 53 } 54 54 55 - let profileReq = agent.app.bsky.actor.getProfile({ actor: did }); 55 + let profileReq = agent.app.bsky.actor 56 + .getProfile({ actor: did }) 57 + .then( 58 + (res) => res.data, 59 + () => undefined, 60 + ); 56 61 57 62 let publicationsReq = supabase 58 63 .from("publications") 59 64 .select("*") 60 65 .eq("identity_did", did); 61 66 62 - let [{ data: profile }, { data: rawPublications }] = await Promise.all([ 67 + let [profile, { data: rawPublications }] = await Promise.all([ 63 68 profileReq, 64 69 publicationsReq, 65 70 ]);
+1 -1
app/emails/unsubscribe/route.ts
··· 1 1 import { NextRequest } from "next/server"; 2 2 import { drizzle } from "drizzle-orm/node-postgres"; 3 3 import { email_subscriptions_to_entity } from "drizzle/schema"; 4 - import postgres from "postgres"; 4 + 5 5 import { eq } from "drizzle-orm"; 6 6 import { pool } from "supabase/pool"; 7 7
+33 -8
app/globals.css
··· 96 96 --accent-2: 255, 255, 255; 97 97 --accent-contrast: 0, 0, 225; 98 98 --accent-1-is-contrast: "true"; 99 + --accent-contrast-similar-to-text: 0; 100 + --link-underline: none; 99 101 --accent-light: color-mix( 100 102 in oklab, 101 103 rgb(var(--accent-contrast)), ··· 189 191 a { 190 192 @apply text-accent-contrast; 191 193 @apply hover:cursor-pointer; 192 - @apply no-underline; 193 194 @apply hover:underline; 195 + } 196 + 197 + .postPageContent a { 198 + text-decoration-line: var(--link-underline, none); 194 199 } 195 200 196 201 p { ··· 357 362 } 358 363 359 364 .selection-highlight { 365 + background-color: transparent; 366 + @apply py-[1.5px]; 367 + } 368 + 369 + .block-focused .selection-highlight { 360 370 background-color: Highlight; 361 - @apply py-[1.5px]; 362 371 } 363 372 364 - .ProseMirror:focus-within .selection-highlight { 373 + .block-focused:focus-within .selection-highlight { 365 374 background-color: transparent; 366 375 } 367 376 ··· 387 396 white-space: normal; 388 397 } 389 398 390 - .multiselected:focus-within .selection-highlight { 399 + .multiselected .block-focused:focus-within .selection-highlight { 391 400 background-color: transparent; 392 401 } 393 402 ··· 594 603 } 595 604 596 605 .footnote-side-item { 597 - max-height: 4.5em; 598 606 overflow: hidden; 599 607 transition: 600 608 max-height 200ms ease, 609 + <<<<<<< HEAD 601 610 mask-image 200ms ease; 611 + ======= 612 + opacity 200ms ease; 613 + background: rgba(var(--bg-page), var(--bg-page-alpha)); 614 + border-radius: 4px; 615 + padding: 4px 6px; 616 + >>>>>>> 9980796e1a77eb0fcad4be7b11d60d4f14e78589 602 617 } 603 - .footnote-side-item.has-overflow { 604 - mask-image: linear-gradient(to bottom, white 50%, transparent 100%); 618 + .footnote-side-item:not(:hover):not(:focus-within):not(.footnote-side-focused) { 619 + max-height: calc(4.5em + 8px); 620 + opacity: calc(var(--bg-page-alpha) * 0.7); 621 + } 622 + .footnote-side-item:not(:hover):not(:focus-within):not(.footnote-side-focused) 623 + .footnote-item 624 + > div:last-of-type { 625 + display: -webkit-box; 626 + -webkit-line-clamp: 3; 627 + -webkit-box-orient: vertical; 628 + overflow: hidden; 629 + text-overflow: ellipsis; 605 630 } 606 631 .footnote-side-item:hover, 607 632 .footnote-side-item:focus-within, 608 633 .footnote-side-item.footnote-side-focused { 609 634 max-height: 40em; 610 - mask-image: none; 635 + opacity: var(--bg-page-alpha); 611 636 }
+2 -2
app/lish/[did]/[publication]/PublicationHomeLayout.tsx
··· 11 11 return ( 12 12 <div 13 13 ref={props.showPageBackground ? null : ref} 14 - className={`pubWrapper flex flex-col sm:py-6 h-full ${props.showPageBackground ? "max-w-prose mx-auto sm:px-0 px-[6px] py-2" : "w-full overflow-y-scroll"}`} 14 + className={`pubWrapper flex flex-col sm:py-6 h-full ${props.showPageBackground ? "max-w-(--page-width-units) mx-auto sm:px-0 px-[6px] py-2" : "w-full overflow-y-scroll"}`} 15 15 > 16 16 <div 17 17 ref={!props.showPageBackground ? null : ref} 18 - className={`pub sm:max-w-prose max-w-(--page-width-units) w-[1000px] mx-auto px-3 sm:px-4 py-5 ${props.showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`} 18 + className={`pub max-w-(--page-width-units) w-[1000px] mx-auto px-3 sm:px-4 py-5 ${props.showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`} 19 19 > 20 20 {props.children} 21 21 </div>
+1 -1
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 202 202 pageId: string | undefined; 203 203 isSubpage: boolean | undefined; 204 204 data: PostPageData; 205 - profile: ProfileViewDetailed; 205 + profile?: ProfileViewDetailed; 206 206 preferences: { 207 207 showComments?: boolean; 208 208 showMentions?: boolean;
+5 -2
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 46 46 47 47 let [document, profile] = await Promise.all([ 48 48 getPostPageData(did, rkey), 49 - agent.getProfile({ actor: did }), 49 + agent.getProfile({ actor: did }).then( 50 + (res) => res.data, 51 + () => undefined, 52 + ), 50 53 ]); 51 54 52 55 const record = document?.normalizedDocument; ··· 149 152 pubRecord?.preferences, 150 153 )} 151 154 pubRecord={pubRecord} 152 - profile={JSON.parse(JSON.stringify(profile.data))} 155 + profile={profile ? JSON.parse(JSON.stringify(profile)) : undefined} 153 156 document={document} 154 157 bskyPostData={JSON.parse(JSON.stringify(bskyPostData))} 155 158 did={did}
+17 -3
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 42 42 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 43 43 import { Mention, MentionAutocomplete } from "components/Mention"; 44 44 import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 45 + import { useIdentityData } from "components/IdentityProvider"; 46 + import { useRecordFromDid } from "src/utils/useRecordFromDid"; 47 + import { Avatar } from "components/Avatar"; 45 48 46 49 const addMentionToEditor = ( 47 50 mention: Mention, ··· 98 101 let [loading, setLoading] = useState(false); 99 102 let view = useRef<null | EditorView>(null); 100 103 let toaster = useToaster(); 104 + let { identity } = useIdentityData(); 105 + let { data: record } = useRecordFromDid(identity?.atp_did); 101 106 102 107 // Mention autocomplete state 103 108 const [mentionOpen, setMentionOpen] = useState(false); ··· 452 457 view={view} 453 458 /> 454 459 </div> 455 - <ButtonPrimary compact onClick={() => handleSubmitRef.current()}> 456 - {loading ? <DotLoader /> : <ShareSmall />} 457 - </ButtonPrimary> 460 + <div className="flex items-center gap-2"> 461 + {record && ( 462 + <Avatar 463 + src={record.avatar} 464 + displayName={record.displayName || record.handle} 465 + size="small" 466 + /> 467 + )} 468 + <ButtonPrimary compact onClick={() => handleSubmitRef.current()}> 469 + {loading ? <DotLoader /> : <ShareSmall />} 470 + </ButtonPrimary> 471 + </div> 458 472 </div> 459 473 </div> 460 474 );
+67 -6
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 36 36 import { blockTextSize } from "src/utils/blockTextSize"; 37 37 import { slugify } from "src/utils/slugify"; 38 38 import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 39 + import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 40 + import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 39 41 40 42 export function PostContent({ 41 43 blocks, ··· 75 77 did={did} 76 78 key={index} 77 79 previousBlock={blocks[index - 1]} 80 + nextBlock={blocks[index + 1]} 78 81 index={[index]} 79 82 preview={preview} 80 83 prerenderedCodeBlocks={prerenderedCodeBlocks} 81 84 pollData={pollData} 82 85 footnoteIndexMap={footnoteIndexMap} 86 + isFirst={index === 0} 87 + isLast={index === blocks.length - 1} 83 88 /> 84 89 ); 85 90 })} ··· 94 99 index, 95 100 preview, 96 101 previousBlock, 102 + nextBlock, 97 103 prerenderedCodeBlocks, 98 104 bskyPostData, 99 105 pageId, 100 106 pages, 101 107 pollData, 102 108 footnoteIndexMap, 109 + isFirst, 110 + isLast, 103 111 }: { 104 112 pageId?: string; 105 113 preview?: boolean; ··· 109 117 isList?: boolean; 110 118 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 111 119 previousBlock?: PubLeafletPagesLinearDocument.Block; 120 + nextBlock?: PubLeafletPagesLinearDocument.Block; 112 121 prerenderedCodeBlocks?: Map<string, string>; 113 122 bskyPostData: AppBskyFeedDefs.PostView[]; 114 123 pollData: PollData[]; 115 124 footnoteIndexMap?: Map<string, number>; 125 + isFirst?: boolean; 126 + isLast?: boolean; 116 127 }) => { 117 128 let b = block; 118 129 let blockProps = { ··· 327 338 ); 328 339 } 329 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 + 330 359 return ( 331 360 <div 332 - className={`imageBlock relative flex ${alignment}`} 361 + className={`imageBlock relative flex ${isFullBleed ? "" : alignment} ${fullBleedClassName}`} 333 362 {...blockProps} 334 363 > 335 364 <img 336 365 alt={b.block.alt} 337 366 height={b.block.aspectRatio?.height} 338 367 width={b.block.aspectRatio?.width} 339 - className={`rounded-lg border border-transparent ${className}`} 368 + className={`${isFullBleed ? "w-full border-none" : "rounded-lg border border-transparent "} ${className}`} 340 369 src={blobRefToSrc(b.block.image.ref, did)} 341 370 /> 342 371 {b.block.alt && ( ··· 415 444 ); 416 445 if (b.block.level === 1) 417 446 return ( 418 - <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 + > 419 452 {link(<TextBlock {...textBlockProps} />)} 420 453 </h1> 421 454 ); 422 455 if (b.block.level === 2) 423 456 return ( 424 - <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 + > 425 462 {link(<TextBlock {...textBlockProps} />)} 426 463 </h2> 427 464 ); 428 465 if (b.block.level === 3) 429 466 return ( 430 - <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 + > 431 472 {link(<TextBlock {...textBlockProps} />)} 432 473 </h3> 433 474 ); 434 475 return ( 435 - <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 + > 436 481 {link(<TextBlock {...textBlockProps} />)} 437 482 </h6> 438 483 ); ··· 487 532 ))} 488 533 </ol> 489 534 ) : null; 535 + let isChecklist = props.item.checked !== undefined; 490 536 return ( 491 537 <li className={`pb-0! flex flex-row gap-2`}> 492 538 <div 493 539 className={`listMarker shrink-0 mx-2 z-1 mt-[14px] h-[5px] w-[5px] ${props.item.content?.$type !== "null" ? "rounded-full bg-secondary" : ""}`} 494 540 /> 541 + {isChecklist && ( 542 + <div 543 + className={`pr-2 ${props.item.checked ? "text-accent-contrast" : "text-border"}`} 544 + > 545 + {props.item.checked ? <CheckboxChecked /> : <CheckboxEmpty />} 546 + </div> 547 + )} 495 548 <div className="flex flex-col w-full"> 496 549 <Block 497 550 pollData={props.pollData} ··· 558 611 ))} 559 612 </ul> 560 613 ) : null; 614 + let isChecklist = props.item.checked !== undefined; 561 615 return ( 562 616 <li className={`pb-0! flex flex-row gap-2`}> 563 617 <div className="listMarker shrink-0 mx-2 z-1 mt-[4px]"> 564 618 {calculatedIndex}. 565 619 </div> 620 + {isChecklist && ( 621 + <div 622 + className={`pr-2 ${props.item.checked ? "text-accent-contrast" : "text-border"}`} 623 + > 624 + {props.item.checked ? <CheckboxChecked /> : <CheckboxEmpty />} 625 + </div> 626 + )} 566 627 <div className="flex flex-col w-full"> 567 628 <Block 568 629 pollData={props.pollData}
+2 -2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 17 17 18 18 export function PostHeader(props: { 19 19 data: PostPageData; 20 - profile: ProfileViewDetailed; 20 + profile?: ProfileViewDetailed; 21 21 preferences: { 22 22 showComments?: boolean; 23 23 showMentions?: boolean; ··· 48 48 <> 49 49 {pub && ( 50 50 <SpeedyLink 51 - className="font-bold hover:no-underline text-accent-contrast" 51 + className="font-bold no-underline! text-accent-contrast" 52 52 href={document && getPublicationURL(pub)} 53 53 > 54 54 {pub?.name}
+2 -2
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 43 43 export type SharedPageProps = { 44 44 document: PostPageData; 45 45 did: string; 46 - profile: ProfileViewDetailed; 46 + profile?: ProfileViewDetailed; 47 47 preferences: { 48 48 showComments?: boolean; 49 49 showMentions?: boolean; ··· 104 104 }: { 105 105 document_uri: string; 106 106 document: PostPageData; 107 - profile: ProfileViewDetailed; 107 + profile?: ProfileViewDetailed; 108 108 pubRecord?: NormalizedPublication | null; 109 109 did: string; 110 110 prerenderedCodeBlocks?: Map<string, string>;
+8
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 12 12 PubLeafletPagesLinearDocument, 13 13 } from "lexicons/api"; 14 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 + import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 16 + import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 15 17 import { blockTextSize } from "src/utils/blockTextSize"; 16 18 import { TextBlockCore, TextBlockCoreProps } from "./Blocks/TextBlockCore"; 17 19 import { StaticMathBlock } from "./Blocks/StaticMathBlock"; ··· 165 167 did: string; 166 168 className?: string; 167 169 }) { 170 + let isChecklist = props.item.checked !== undefined; 168 171 return ( 169 172 <li className={`pb-0! flex flex-row gap-2`}> 170 173 <div 171 174 className={`listMarker shrink-0 mx-2 z-1 mt-[14px] h-[5px] w-[5px] rounded-full bg-secondary`} 172 175 /> 176 + {isChecklist && ( 177 + <div className={`pr-2 ${props.item.checked ? "text-accent-contrast" : "text-border"}`}> 178 + {props.item.checked ? <CheckboxChecked /> : <CheckboxEmpty />} 179 + </div> 180 + )} 173 181 <div className="flex flex-col"> 174 182 <Block block={{ block: props.item.content }} did={props.did} isList /> 175 183 {props.item.children?.length ? (
+380 -50
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 1 1 "use client"; 2 - import { useEffect, useRef } from "react"; 3 - import { AppBskyFeedDefs } from "@atproto/api"; 2 + import { useEffect, useMemo, useRef } from "react"; 3 + import { 4 + AppBskyFeedDefs, 5 + AppBskyFeedPost, 6 + AppBskyRichtextFacet, 7 + AppBskyEmbedExternal, 8 + } from "@atproto/api"; 9 + import { AtUri } from "@atproto/syntax"; 4 10 import useSWR from "swr"; 5 11 import { PageWrapper } from "components/Pages/Page"; 6 12 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; ··· 18 24 fetchThread, 19 25 prefetchThread, 20 26 } from "./PostLinks"; 27 + import { useDocument } from "contexts/DocumentContext"; 28 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 29 + import { QuoteContent } from "./Interactions/Quotes"; 30 + import { 31 + decodeQuotePosition, 32 + type QuotePosition, 33 + } from "./quotePosition"; 21 34 22 35 // Re-export for backwards compatibility 23 36 export { ThreadLink, getThreadKey, fetchThread, prefetchThread, ClientDate }; ··· 27 40 type BlockedPost = AppBskyFeedDefs.BlockedPost; 28 41 type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; 29 42 43 + // Walk a reply chain collecting consecutive same-author posts 44 + // where each post is the sole reply. Returns the flat chain. 45 + function flattenSameAuthorChain( 46 + post: ThreadViewPost, 47 + rootAuthorDid: string, 48 + ): ThreadViewPost[] { 49 + if (post.post.author.did !== rootAuthorDid) return [post]; 50 + 51 + const chain: ThreadViewPost[] = [post]; 52 + let current = post; 53 + 54 + while (current.replies && current.replies.length > 0) { 55 + const replies = current.replies as any[]; 56 + const sameAuthorReplies = replies.filter( 57 + (r) => 58 + AppBskyFeedDefs.isThreadViewPost(r) && 59 + (r as ThreadViewPost).post.author.did === rootAuthorDid, 60 + ) as ThreadViewPost[]; 61 + 62 + // Only flatten if there's exactly one reply and it's by the same author 63 + if (sameAuthorReplies.length !== 1 || replies.length !== 1) break; 64 + 65 + chain.push(sameAuthorReplies[0]); 66 + current = sameAuthorReplies[0]; 67 + } 68 + 69 + return chain; 70 + } 71 + 72 + // Check if a URL matches any of the document's known URLs, 73 + // and extract the quote position if present 74 + function matchDocumentUrl( 75 + uri: string, 76 + documentUrls: string[], 77 + ): { url: string; quotePosition: QuotePosition | null } | null { 78 + try { 79 + const url = new URL(uri); 80 + const parts = url.pathname.split("/l-quote/"); 81 + const pathWithoutQuote = parts[0]; 82 + const quoteParam = parts[1]; 83 + const fullUrlWithoutQuote = (url.origin + pathWithoutQuote).replace( 84 + /\/$/, 85 + "", 86 + ); 87 + 88 + for (const docUrl of documentUrls) { 89 + const normalized = docUrl.replace(/\/$/, ""); 90 + if (fullUrlWithoutQuote === normalized) { 91 + return { 92 + url: uri, 93 + quotePosition: quoteParam 94 + ? decodeQuotePosition(quoteParam) 95 + : null, 96 + }; 97 + } 98 + } 99 + } catch { 100 + return null; 101 + } 102 + return null; 103 + } 104 + 105 + // Scan a post's facets and embed for links to the current document 106 + function findDocumentQuoteLink( 107 + post: AppBskyFeedDefs.PostView, 108 + documentUrls: string[], 109 + ): { 110 + url: string; 111 + quotePosition: QuotePosition | null; 112 + isEmbed: boolean; 113 + } | null { 114 + if (documentUrls.length === 0) return null; 115 + 116 + const record = post.record as AppBskyFeedPost.Record; 117 + 118 + // Check facets for link URIs 119 + if (record.facets) { 120 + for (const facet of record.facets) { 121 + for (const feature of facet.features) { 122 + if (AppBskyRichtextFacet.isLink(feature)) { 123 + const match = matchDocumentUrl(feature.uri, documentUrls); 124 + if (match) return { ...match, isEmbed: false }; 125 + } 126 + } 127 + } 128 + } 129 + 130 + // Check external embed URI 131 + if (post.embed && AppBskyEmbedExternal.isView(post.embed)) { 132 + const match = matchDocumentUrl( 133 + post.embed.external.uri, 134 + documentUrls, 135 + ); 136 + if (match) return { ...match, isEmbed: true }; 137 + } 138 + 139 + return null; 140 + } 141 + 30 142 export function ThreadPage(props: { 31 143 parentUri: string; 32 144 pageId: string; ··· 75 187 const { post, parentUri } = props; 76 188 const mainPostRef = useRef<HTMLDivElement>(null); 77 189 190 + // Compute document URLs for leaflet link detection 191 + const { 192 + uri: docUri, 193 + normalizedDocument, 194 + normalizedPublication, 195 + } = useDocument(); 196 + const docAtUri = useMemo(() => new AtUri(docUri), [docUri]); 197 + const docDid = docAtUri.host; 198 + 199 + const documentUrls = useMemo(() => { 200 + const urls: string[] = []; 201 + const canonicalUrl = getDocumentURL( 202 + normalizedDocument, 203 + docUri, 204 + normalizedPublication, 205 + ); 206 + if (canonicalUrl.startsWith("http")) { 207 + urls.push(canonicalUrl); 208 + } else { 209 + urls.push(`https://leaflet.pub${canonicalUrl}`); 210 + } 211 + urls.push(`https://leaflet.pub/p/${docAtUri.host}/${docAtUri.rkey}`); 212 + if ( 213 + normalizedDocument.site && 214 + normalizedDocument.site.startsWith("http") 215 + ) { 216 + const path = normalizedDocument.path || "/" + docAtUri.rkey; 217 + urls.push(normalizedDocument.site + path); 218 + } 219 + return urls; 220 + }, [docUri, docAtUri, normalizedDocument, normalizedPublication]); 221 + 78 222 // Scroll the main post into view when the thread loads 79 223 useEffect(() => { 80 224 if (mainPostRef.current) { ··· 101 245 return <PostNotAvailable />; 102 246 } 103 247 248 + const rootAuthorDid = post.post.author.did; 249 + 104 250 // Collect all parent posts in order (oldest first) 105 251 const parents: ThreadViewPost[] = []; 106 252 let currentParent = post.parent; ··· 137 283 parentPostUri={post.post.uri} 138 284 depth={0} 139 285 parentAuthorDid={post.post.author.did} 286 + rootAuthorDid={rootAuthorDid} 287 + documentUrls={documentUrls} 288 + docDid={docDid} 140 289 /> 141 290 </div> 142 291 )} ··· 188 337 replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; 189 338 depth: number; 190 339 parentAuthorDid?: string; 340 + rootAuthorDid: string; 191 341 pageUri: string; 192 342 parentPostUri: string; 343 + documentUrls: string[]; 344 + docDid: string; 193 345 }) { 194 - const { replies, depth, parentAuthorDid, pageUri, parentPostUri } = props; 346 + const { 347 + replies, 348 + depth, 349 + parentAuthorDid, 350 + rootAuthorDid, 351 + pageUri, 352 + parentPostUri, 353 + documentUrls, 354 + docDid, 355 + } = props; 195 356 const collapsedThreads = useThreadState((s) => s.collapsedThreads); 196 357 const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 197 358 198 359 // Sort replies so that replies from the parent author come first 199 - const sortedReplies = parentAuthorDid 200 - ? [...replies].sort((a, b) => { 201 - const aIsAuthor = 202 - AppBskyFeedDefs.isThreadViewPost(a) && 203 - a.post.author.did === parentAuthorDid; 204 - const bIsAuthor = 205 - AppBskyFeedDefs.isThreadViewPost(b) && 206 - b.post.author.did === parentAuthorDid; 207 - if (aIsAuthor && !bIsAuthor) return -1; 208 - if (!aIsAuthor && bIsAuthor) return 1; 209 - return 0; 210 - }) 211 - : replies; 360 + const sortedReplies = useMemo( 361 + () => 362 + parentAuthorDid 363 + ? [...replies].sort((a, b) => { 364 + const aIsAuthor = 365 + AppBskyFeedDefs.isThreadViewPost(a) && 366 + a.post.author.did === parentAuthorDid; 367 + const bIsAuthor = 368 + AppBskyFeedDefs.isThreadViewPost(b) && 369 + b.post.author.did === parentAuthorDid; 370 + if (aIsAuthor && !bIsAuthor) return -1; 371 + if (!aIsAuthor && bIsAuthor) return 1; 372 + return 0; 373 + }) 374 + : replies, 375 + [replies, parentAuthorDid], 376 + ); 212 377 213 378 return ( 214 379 <div className="replies flex flex-col gap-0 w-full"> ··· 249 414 isLast={index === replies.length - 1 && !hasReplies} 250 415 pageUri={pageUri} 251 416 parentPostUri={parentPostUri} 252 - toggleCollapsed={(uri) => toggleCollapsed(uri)} 417 + toggleCollapsed={toggleCollapsed} 253 418 isCollapsed={isCollapsed} 254 419 depth={props.depth} 420 + rootAuthorDid={rootAuthorDid} 421 + documentUrls={documentUrls} 422 + docDid={docDid} 255 423 /> 256 424 ); 257 425 })} 258 - {pageUri && depth > 0 && replies.length > 3 && ( 259 - <ThreadLink 260 - postUri={pageUri} 261 - parent={{ type: "thread", uri: pageUri }} 262 - className="flex justify-start text-sm text-accent-contrast h-fit hover:underline" 263 - > 264 - <div className="mx-[19px] w-0.5 h-[24px] bg-border-light" /> 265 - View {replies.length - 3} more{" "} 266 - {replies.length === 4 ? "reply" : "replies"} 267 - </ThreadLink> 268 - )} 269 426 </div> 270 427 ); 271 428 } ··· 278 435 toggleCollapsed: (uri: string) => void; 279 436 isCollapsed: boolean; 280 437 depth: number; 438 + rootAuthorDid: string; 439 + documentUrls: string[]; 440 + docDid: string; 281 441 }) => { 282 - const { post, pageUri, parentPostUri } = props; 283 - const postView = post.post; 442 + const { post, pageUri, parentPostUri, rootAuthorDid, documentUrls, docDid } = props; 284 443 285 - const hasReplies = props.post.replies && props.post.replies.length > 0; 444 + // Flatten same-author chains 445 + const chain = flattenSameAuthorChain(post, rootAuthorDid); 446 + const lastInChain = chain[chain.length - 1]; 447 + const hasReplies = lastInChain.replies && lastInChain.replies.length > 0; 448 + const isTruncated = 449 + !hasReplies && 450 + lastInChain.post.replyCount != null && 451 + lastInChain.post.replyCount > 0; 286 452 287 453 return ( 288 454 <div className="flex h-fit relative"> ··· 296 462 onClick={(e) => { 297 463 e.preventDefault(); 298 464 e.stopPropagation(); 299 - 300 465 props.toggleCollapsed(parentPostUri); 301 466 }} 302 467 /> ··· 305 470 <div 306 471 className={`reply relative flex flex-col w-full ${props.depth === 0 && "mb-3"}`} 307 472 > 308 - <BskyPostContent 309 - post={postView} 310 - parent={{ type: "thread", uri: pageUri }} 311 - showEmbed={false} 312 - showBlueskyLink={false} 313 - quoteEnabled 314 - replyEnabled 315 - replyOnClick={(e) => { 316 - e.preventDefault(); 317 - props.toggleCollapsed(post.post.uri); 318 - }} 319 - className="text-sm" 320 - /> 321 - {hasReplies && props.depth < 3 && ( 322 - <div className="ml-[28px] flex grow "> 473 + {/* Render chain: intermediate posts compact, last post full */} 474 + {chain.length > 1 ? ( 475 + <> 476 + {chain.slice(0, -1).map((chainPost) => ( 477 + <div 478 + key={chainPost.post.uri} 479 + className="flex gap-2 relative w-full pl-[6px] pb-2" 480 + > 481 + <div className="absolute top-0 bottom-0 left-[6px] w-5"> 482 + <div className="bg-border-light w-[2px] h-full mx-auto" /> 483 + </div> 484 + <ReplyPostContent 485 + post={chainPost.post} 486 + pageUri={pageUri} 487 + documentUrls={documentUrls} 488 + docDid={docDid} 489 + compact 490 + /> 491 + </div> 492 + ))} 493 + <ReplyPostContent 494 + post={lastInChain.post} 495 + pageUri={pageUri} 496 + documentUrls={documentUrls} 497 + docDid={docDid} 498 + compact 499 + toggleCollapsed={() => 500 + props.toggleCollapsed(lastInChain.post.uri) 501 + } 502 + /> 503 + </> 504 + ) : ( 505 + <ReplyPostContent 506 + post={post.post} 507 + pageUri={pageUri} 508 + documentUrls={documentUrls} 509 + docDid={docDid} 510 + toggleCollapsed={() => props.toggleCollapsed(post.post.uri)} 511 + /> 512 + )} 513 + 514 + {/* Render child replies */} 515 + {hasReplies && props.depth < 10 && ( 516 + <div className="ml-[28px] flex grow"> 323 517 {!props.isCollapsed && ( 324 518 <Replies 325 519 pageUri={pageUri} 326 - parentPostUri={post.post.uri} 327 - replies={props.post.replies as any[]} 520 + parentPostUri={lastInChain.post.uri} 521 + replies={lastInChain.replies as any[]} 328 522 depth={props.depth + 1} 329 - parentAuthorDid={props.post.post.author.did} 523 + parentAuthorDid={lastInChain.post.author.did} 524 + rootAuthorDid={rootAuthorDid} 525 + documentUrls={documentUrls} 526 + docDid={docDid} 330 527 /> 331 528 )} 332 529 </div> 333 530 )} 531 + 532 + {/* Auto-load truncated replies */} 533 + {isTruncated && props.depth < 10 && !props.isCollapsed && ( 534 + <div className="ml-[28px] flex grow"> 535 + <SubThread 536 + postUri={lastInChain.post.uri} 537 + pageUri={pageUri} 538 + depth={props.depth} 539 + rootAuthorDid={rootAuthorDid} 540 + documentUrls={documentUrls} 541 + docDid={docDid} 542 + /> 543 + </div> 544 + )} 545 + 546 + {/* Safety fallback at extreme depth */} 547 + {(hasReplies || isTruncated) && props.depth >= 10 && ( 548 + <div className="ml-[28px]"> 549 + <ThreadLink 550 + postUri={lastInChain.post.uri} 551 + parent={{ type: "thread", uri: pageUri }} 552 + className="text-sm text-accent-contrast hover:underline" 553 + > 554 + Continue thread 555 + </ThreadLink> 556 + </div> 557 + )} 334 558 </div> 335 559 </div> 336 560 ); 337 561 }; 562 + 563 + // Renders a single post's content with optional inline quote detection 564 + function ReplyPostContent(props: { 565 + post: AppBskyFeedDefs.PostView; 566 + pageUri: string; 567 + documentUrls: string[]; 568 + docDid: string; 569 + compact?: boolean; 570 + toggleCollapsed?: () => void; 571 + }) { 572 + const { post, pageUri, documentUrls, docDid: did, compact } = props; 573 + 574 + // Detect leaflet links in this post 575 + const docLink = findDocumentQuoteLink(post, documentUrls); 576 + const page = { type: "thread" as const, uri: pageUri }; 577 + 578 + const quoteBlock = docLink?.quotePosition && ( 579 + <div className="mb-1 ml-[32px]"> 580 + <QuoteContent position={docLink.quotePosition} index={0} did={did} /> 581 + </div> 582 + ); 583 + 584 + if (compact) { 585 + return ( 586 + <div className="flex flex-col w-full"> 587 + {quoteBlock} 588 + <CompactBskyPostContent 589 + post={post} 590 + parent={page} 591 + quoteEnabled 592 + replyEnabled={!!props.toggleCollapsed} 593 + replyOnClick={ 594 + props.toggleCollapsed 595 + ? (e) => { 596 + e.preventDefault(); 597 + props.toggleCollapsed!(); 598 + } 599 + : undefined 600 + } 601 + /> 602 + </div> 603 + ); 604 + } 605 + 606 + return ( 607 + <div className="flex flex-col w-full"> 608 + {quoteBlock} 609 + <BskyPostContent 610 + post={post} 611 + parent={page} 612 + showEmbed={!docLink?.isEmbed} 613 + showBlueskyLink={false} 614 + quoteEnabled 615 + replyEnabled 616 + replyOnClick={ 617 + props.toggleCollapsed 618 + ? (e) => { 619 + e.preventDefault(); 620 + props.toggleCollapsed!(); 621 + } 622 + : undefined 623 + } 624 + className="text-sm" 625 + /> 626 + </div> 627 + ); 628 + } 629 + 630 + // Auto-loads a sub-thread when replies were truncated by the API depth limit 631 + function SubThread(props: { 632 + postUri: string; 633 + pageUri: string; 634 + depth: number; 635 + rootAuthorDid: string; 636 + documentUrls: string[]; 637 + docDid: string; 638 + }) { 639 + const { data: thread, isLoading } = useSWR( 640 + getThreadKey(props.postUri), 641 + () => fetchThread(props.postUri), 642 + ); 643 + 644 + if (isLoading) { 645 + return ( 646 + <div className="flex items-center gap-1 text-tertiary italic text-xs py-2"> 647 + <DotLoader /> 648 + </div> 649 + ); 650 + } 651 + 652 + if (!thread || !AppBskyFeedDefs.isThreadViewPost(thread)) return null; 653 + if (!thread.replies || thread.replies.length === 0) return null; 654 + 655 + return ( 656 + <Replies 657 + replies={thread.replies as any[]} 658 + pageUri={props.pageUri} 659 + parentPostUri={props.postUri} 660 + depth={props.depth + 1} 661 + parentAuthorDid={thread.post.author.did} 662 + rootAuthorDid={props.rootAuthorDid} 663 + documentUrls={props.documentUrls} 664 + docDid={props.docDid} 665 + /> 666 + ); 667 + }
+12 -12
app/lish/[did]/[publication]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 3 + import { 4 + getPublicationURL, 5 + getDocumentURL, 6 + } from "app/lish/createPub/getPublicationURL"; 4 7 import { BskyAgent } from "@atproto/api"; 5 8 import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 6 9 import React from "react"; ··· 102 105 handle={profile.handle} 103 106 /> 104 107 )} 105 - <div className="pt-4 w-full max-w-sm mx-auto"> 106 - <SubscribeInput {...dummy} /> 108 + <div className="sm:pt-4 pt-4"> 109 + <SubscribeWithBluesky 110 + base_url={getPublicationURL(publication)} 111 + pubName={publication.name} 112 + pub_uri={publication.uri} 113 + subscribers={publication.publication_subscriptions} 114 + /> 107 115 </div> 108 - {/*<div className="sm:pt-4 pt-4"> 109 - <SubscribeWithBluesky 110 - base_url={getPublicationURL(publication)} 111 - pubName={publication.name} 112 - pub_uri={publication.uri} 113 - subscribers={publication.publication_subscriptions} 114 - /> 115 - </div>*/} 116 116 </div> 117 117 <div className="publicationPostList w-full flex flex-col gap-4"> 118 118 {publication.documents_in_publications ··· 155 155 <div className="flex w-full grow flex-col "> 156 156 <SpeedyLink 157 157 href={docUrl} 158 - className="publishedPost hover:no-underline! flex flex-col" 158 + className="publishedPost no-underline! flex flex-col" 159 159 > 160 160 {doc_record.title && ( 161 161 <h3 className="text-primary">
+1 -13
components/ActionBar/Publications.tsx
··· 66 66 </> 67 67 )} 68 68 69 - {identity.publications 70 - ?.filter((p) => { 71 - let record = p.record as any; 72 - if (record.preferences?.greengale) return false; 73 - if ( 74 - record.theme && 75 - record.theme.$type && 76 - record.theme.$type !== "pub.leaflet.publication#theme" 77 - ) 78 - return false; 79 - return true; 80 - }) 81 - .map((d) => { 69 + {identity.publications?.map((d) => { 82 70 return ( 83 71 <PublicationOption 84 72 {...d}
+1
components/Blocks/Block.tsx
··· 48 48 type: Fact<"block/type">["data"]["value"]; 49 49 listData?: { 50 50 checklist?: boolean; 51 + checked?: boolean; 51 52 listStyle?: "ordered" | "unordered"; 52 53 listStart?: number; 53 54 displayNumber?: number;
+3
components/Blocks/BlueskyPostBlock/BlueskyRichText.tsx
··· 28 28 href={segment.link.uri} 29 29 className="text-accent-contrast hover:underline" 30 30 target="_blank" 31 + rel="noopener noreferrer" 31 32 > 32 33 {segment.text} 33 34 </a>, ··· 42 43 href={`https://bsky.app/profile/${segment.mention.did}`} 43 44 className="text-accent-contrast hover:underline" 44 45 target="_blank" 46 + rel="noopener noreferrer" 45 47 > 46 48 {segment.text} 47 49 </a>, ··· 56 58 href={`https://bsky.app/tag/${segment.tag.tag}`} 57 59 className="text-accent-contrast hover:underline" 58 60 target="_blank" 61 + rel="noopener noreferrer" 59 62 > 60 63 {segment.text} 61 64 </a>,
+1
components/Blocks/ButtonBlock.tsx
··· 41 41 <a 42 42 href={url?.data.value} 43 43 target="_blank" 44 + referrerPolicy="no-referrer" 44 45 className={` ${alignment === "justify" ? "w-full" : "w-fit"}`} 45 46 > 46 47 <ButtonPrimary
+1
components/Blocks/EmbedBlock.tsx
··· 94 94 src={url?.data.value} 95 95 allow="fullscreen" 96 96 loading="lazy" 97 + referrerPolicy="no-referrer" 97 98 ></iframe> 98 99 </BlockLayout> 99 100 {/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast">
+1
components/Blocks/ExternalLinkBlock.tsx
··· 73 73 <a 74 74 href={url?.data.value} 75 75 target="_blank" 76 + referrerPolicy="no-referrer" 76 77 className="flex w-full h-full text-primary hover:no-underline no-underline" 77 78 > 78 79 <div className="pt-2 pb-2 px-3 grow min-w-0">
+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 - );
+16 -4
components/Blocks/TextBlock/index.tsx
··· 170 170 <div 171 171 style={{ 172 172 wordBreak: "break-word", 173 - ...(props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : {}), 173 + ...(props.type === "heading" 174 + ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } 175 + : {}), 174 176 }} 175 177 onClick={(e) => { 176 178 let target = e.target as HTMLElement; ··· 290 292 // forces break if a single text string (e.g. a url) spans more than a full line 291 293 style={{ 292 294 wordBreak: "break-word", 293 - fontFamily: props.type === "heading" ? "var(--theme-heading-font)" : "var(--theme-font)", 294 - ...(props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : {}), 295 + fontFamily: 296 + props.type === "heading" 297 + ? "var(--theme-heading-font)" 298 + : "var(--theme-font)", 299 + ...(props.type === "heading" 300 + ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } 301 + : {}), 295 302 }} 296 303 className={` 297 304 ${alignmentClass} 298 305 grow resize-none align-top whitespace-pre-wrap bg-transparent 299 306 outline-hidden 307 + ${focused ? "block-focused" : ""} 300 308 301 309 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 302 310 ${props.className}`} ··· 316 324 props.nextBlock === null ? ( 317 325 // if this is the only block on the page and is empty or is a canvas, show placeholder 318 326 <div 319 - style={props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : undefined} 327 + style={ 328 + props.type === "heading" 329 + ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } 330 + : undefined 331 + } 320 332 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col 321 333 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 322 334 `}
+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"); 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" }, 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 + }
+36
components/Menu.tsx
··· 74 74 ); 75 75 }; 76 76 77 + export const RadioMenuGroup = (props: { 78 + value: string; 79 + onValueChange?: (value: string) => void; 80 + children: React.ReactNode; 81 + }) => { 82 + return ( 83 + <DropdownMenu.RadioGroup 84 + value={props.value} 85 + onValueChange={props.onValueChange} 86 + > 87 + {props.children} 88 + </DropdownMenu.RadioGroup> 89 + ); 90 + }; 91 + 92 + export const RadioMenuItem = (props: { 93 + value: string; 94 + children?: React.ReactNode; 95 + className?: string; 96 + onSelect?: (e: Event) => void; 97 + selected?: boolean; 98 + }) => { 99 + return ( 100 + <DropdownMenu.RadioItem 101 + value={props.value} 102 + onSelect={props.onSelect} 103 + className={` menuItem 104 + ${props.selected && "bg-accent-1! text-accent-2!"} 105 + ${props.className} 106 + `} 107 + > 108 + {props.children} 109 + </DropdownMenu.RadioItem> 110 + ); 111 + }; 112 + 77 113 export const MenuItem = (props: { 78 114 children?: React.ReactNode; 79 115 className?: string;
+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 -1
components/Pages/PublicationMetadata.tsx
··· 69 69 ? `${getBasePublicationURL(pub.publications)}/dashboard` 70 70 : getPublicationURL(pub.publications) 71 71 } 72 - className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 72 + className="leafletMetadata text-accent-contrast font-bold no-underline!" 73 73 > 74 74 {pub.publications?.name} 75 75 </Link>
+1
components/Popover/index.tsx
··· 64 64 width={16} 65 65 height={8} 66 66 viewBox="0 0 16 8" 67 + className="z-30" 67 68 > 68 69 <PopoverArrow 69 70 arrowFill={
+1 -1
components/PostListing.tsx
··· 195 195 <div className="flex justify-between gap-4 w-full "> 196 196 <Link 197 197 href={props.href} 198 - className="text-accent-contrast font-bold no-underline text-sm flex gap-[6px] items-center relative grow w-max shrink-0 min-w-0" 198 + className="text-accent-contrast font-bold no-underline! text-sm flex gap-[6px] items-center relative grow w-max shrink-0 min-w-0" 199 199 > 200 200 <PubIcon tiny record={props.pubRecord} uri={props.uri} /> 201 201 <div className="w-max min-w-0">{props.pubRecord.name}</div>
+1 -1
components/ProfilePopover.tsx
··· 58 58 > 59 59 {isLoading ? ( 60 60 <div className="text-secondary p-4">Loading...</div> 61 - ) : data ? ( 61 + ) : data?.profile ? ( 62 62 <div> 63 63 <ProfileHeader 64 64 profile={data.profile}
-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();
+98 -86
components/ThemeManager/Pickers/TextPickers.tsx
··· 1 1 "use client"; 2 2 3 3 import { Input } from "components/Input"; 4 - import { useState } from "react"; 5 - import { Menu } from "components/Menu"; 6 - import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 4 + import { useRef, useState } from "react"; 5 + import { Menu, MenuItem, RadioMenuGroup, RadioMenuItem } from "components/Menu"; 7 6 import { useIsMobile } from "src/hooks/isMobile"; 8 7 import { 9 8 fonts, ··· 14 13 createCustomFontId, 15 14 getFontConfig, 16 15 } from "src/fonts"; 16 + import { ButtonPrimary } from "components/Buttons"; 17 + import { useSmoker } from "components/Toast"; 18 + import { DotLoader } from "components/utils/DotLoader"; 17 19 18 20 export const FontPicker = (props: { 19 21 label: string; ··· 21 23 onChange: (fontId: string) => void; 22 24 }) => { 23 25 let isMobile = useIsMobile(); 26 + let smoker = useSmoker(); 27 + let inputWrapperRef = useRef<HTMLDivElement>(null); 24 28 let [showCustomInput, setShowCustomInput] = useState(false); 25 29 let [customFontValue, setCustomFontValue] = useState(""); 26 30 let fontId = props.value || defaultFontId; ··· 31 35 a.displayName.localeCompare(b.displayName), 32 36 ); 33 37 34 - const handleCustomSubmit = () => { 38 + let [loading, setLoading] = useState(false); 39 + 40 + const handleCustomSubmit = async () => { 35 41 const parsed = parseGoogleFontInput(customFontValue); 36 - if (parsed) { 42 + if (!parsed) return; 43 + 44 + setLoading(true); 45 + try { 46 + const url = `https://fonts.googleapis.com/css2?family=${parsed.googleFontsFamily}&display=swap`; 47 + const res = await fetch(url, { method: "HEAD" }); 48 + if (!res.ok) { 49 + const rect = inputWrapperRef.current?.getBoundingClientRect(); 50 + smoker({ 51 + error: true, 52 + position: { 53 + x: rect ? rect.left + rect.width / 2 : 0, 54 + y: rect ? rect.top - 8 : 0, 55 + }, 56 + text: "No font found!", 57 + }); 58 + return; 59 + } 37 60 const customId = createCustomFontId( 38 61 parsed.fontName, 39 62 parsed.googleFontsFamily, ··· 41 64 props.onChange(customId); 42 65 setShowCustomInput(false); 43 66 setCustomFontValue(""); 67 + } catch { 68 + const rect = inputWrapperRef.current?.getBoundingClientRect(); 69 + smoker({ 70 + error: true, 71 + position: { 72 + x: rect ? rect.left + rect.width / 2 : 0, 73 + y: rect ? rect.top - 8 : 0, 74 + }, 75 + text: "No font found!", 76 + }); 77 + } finally { 78 + setLoading(false); 44 79 } 45 80 }; 46 81 ··· 62 97 } 63 98 side={isMobile ? "bottom" : "right"} 64 99 align="start" 65 - className="w-[250px] !gap-0 !outline-none max-h-72 " 100 + className="w-fit !gap-0 !outline-none max-h-72 " 66 101 > 67 102 {showCustomInput ? ( 68 103 <div className="p-2 flex flex-col gap-2"> 69 - <div className="text-sm text-secondary"> 70 - Paste a Google Font name 104 + <div> 105 + <div className="font-bold text-secondary"> 106 + Paste any Google Font name 107 + </div> 108 + <div className="text-sm text-tertiary">This is case sensitive</div> 71 109 </div> 72 - <Input 73 - value={customFontValue} 74 - className="w-full" 75 - placeholder="e.g. Roboto, Open Sans, Playfair Display" 76 - autoFocus 77 - onChange={(e) => setCustomFontValue(e.currentTarget.value)} 78 - onKeyDown={(e) => { 79 - if (e.key === "Enter") { 80 - e.preventDefault(); 81 - handleCustomSubmit(); 82 - } else if (e.key === "Escape") { 83 - setShowCustomInput(false); 84 - setCustomFontValue(""); 85 - } 86 - }} 87 - /> 88 - <div className="flex gap-2"> 89 - <button 90 - className="flex-1 px-2 py-1 text-sm rounded-md bg-accent-1 text-accent-2 hover:opacity-80" 91 - onClick={handleCustomSubmit} 92 - > 93 - Add Font 94 - </button> 110 + <div ref={inputWrapperRef}> 111 + <Input 112 + value={customFontValue} 113 + className="w-full input-with-border" 114 + placeholder="e.g. Roboto, Open Sans" 115 + autoFocus 116 + onChange={(e) => setCustomFontValue(e.currentTarget.value)} 117 + onKeyDown={(e) => { 118 + if (e.key === "Enter") { 119 + e.preventDefault(); 120 + handleCustomSubmit(); 121 + } else if (e.key === "Escape") { 122 + setShowCustomInput(false); 123 + setCustomFontValue(""); 124 + } 125 + }} 126 + /> 127 + </div> 128 + <div className="flex gap-2 self-end items-center"> 95 129 <button 96 - className="px-2 py-1 text-sm rounded-md text-secondary hover:bg-border-light" 130 + className="px-1 py-0 rounded-md text-accent-contrast font-bold hover:bg-border-light" 97 131 onClick={() => { 98 132 setShowCustomInput(false); 99 133 setCustomFontValue(""); 100 134 }} 101 135 > 102 - Cancel 136 + Nevermind 103 137 </button> 138 + <ButtonPrimary 139 + compact 140 + className="" 141 + disabled={loading} 142 + onClick={handleCustomSubmit} 143 + > 144 + {loading ? <DotLoader /> : "Add Font"} 145 + </ButtonPrimary> 104 146 </div> 105 147 </div> 106 148 ) : ( 107 149 <div className="flex flex-col h-full overflow-auto gap-0 py-1"> 108 - {fontList.map((fontOption) => { 109 - return ( 110 - <FontOption 111 - key={fontOption.id} 112 - onSelect={() => { 113 - props.onChange(fontOption.id); 114 - }} 115 - font={fontOption} 116 - selected={fontOption.id === fontId} 117 - /> 118 - ); 119 - })} 120 - {isCustom && ( 121 - <FontOption 122 - key={fontId} 123 - onSelect={() => {}} 124 - font={font} 125 - selected={true} 126 - /> 127 - )} 150 + <RadioMenuGroup value={fontId} onValueChange={props.onChange}> 151 + {fontList.map((fontOption) => { 152 + return ( 153 + <FontOption 154 + key={fontOption.id} 155 + font={fontOption} 156 + selected={fontOption.id === fontId} 157 + /> 158 + ); 159 + })} 160 + {isCustom && ( 161 + <FontOption key={fontId} font={font} selected={true} /> 162 + )} 163 + </RadioMenuGroup> 128 164 <hr className="mx-2 my-1 border-border" /> 129 - <DropdownMenu.Item 165 + <MenuItem 130 166 onSelect={(e) => { 131 167 e.preventDefault(); 132 168 setShowCustomInput(true); 133 169 }} 134 - className={` 135 - fontOption 136 - z-10 px-1 py-0.5 137 - text-left text-secondary 138 - data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 139 - hover:bg-border-light hover:text-secondary 140 - outline-none 141 - cursor-pointer 142 - `} 143 170 > 144 - <div className="px-2 py-0 rounded-md">Custom Google Font...</div> 145 - </DropdownMenu.Item> 171 + Add a Custom Font 172 + </MenuItem> 146 173 </div> 147 174 )} 148 175 </Menu> 149 176 ); 150 177 }; 151 178 152 - const FontOption = (props: { 153 - onSelect: () => void; 154 - font: FontConfig; 155 - selected: boolean; 156 - }) => { 179 + const FontOption = (props: { font: FontConfig; selected: boolean }) => { 157 180 return ( 158 - <DropdownMenu.RadioItem 181 + <RadioMenuItem 159 182 value={props.font.id} 160 - onSelect={props.onSelect} 183 + selected={props.selected} 161 184 className={` 162 - fontOption 163 - z-10 px-1 py-0.5 164 - text-left text-secondary 165 - data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 166 - hover:bg-border-light hover:text-secondary 167 - outline-none 168 - cursor-pointer 169 - 185 + fontOption text-normal! 170 186 `} 171 187 > 172 - <div 173 - className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 174 - > 175 - {props.font.displayName} 176 - </div> 177 - </DropdownMenu.RadioItem> 188 + {props.font.displayName} 189 + </RadioMenuItem> 178 190 ); 179 191 };
-1
components/ThemeManager/PublicationThemeProvider.tsx
··· 2 2 import { useMemo, useState } from "react"; 3 3 import { parseColor } from "react-aria-components"; 4 4 import { useEntity } from "src/replicache"; 5 - import { getColorDifference } from "./themeUtils"; 6 5 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 6 import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 8 7 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
+82 -35
components/ThemeManager/ThemeProvider.tsx
··· 28 28 PublicationBackgroundProvider, 29 29 PublicationThemeProvider, 30 30 } from "./PublicationThemeProvider"; 31 - import { getColorDifference } from "./themeUtils"; 32 - import { getFontConfig, getGoogleFontsUrl, getFontFamilyValue, getFontBaseSize, defaultFontId } from "src/fonts"; 31 + import { compareColors } from "./themeUtils"; 32 + import { 33 + getFontConfig, 34 + getGoogleFontsUrl, 35 + getFontFamilyValue, 36 + getFontBaseSize, 37 + defaultFontId, 38 + } from "src/fonts"; 33 39 34 40 // define a function to set an Aria Color to a CSS Variable in RGB 35 41 function setCSSVariableToColor( ··· 89 95 90 96 let pageWidth = useEntity(props.entityID, "theme/page-width"); 91 97 // Use initial font IDs as fallback until Replicache syncs 92 - let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value ?? props.initialHeadingFontId; 93 - let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value ?? props.initialBodyFontId; 98 + let headingFontId = 99 + useEntity(props.entityID, "theme/heading-font")?.data.value ?? 100 + props.initialHeadingFontId; 101 + let bodyFontId = 102 + useEntity(props.entityID, "theme/body-font")?.data.value ?? 103 + props.initialBodyFontId; 94 104 95 105 return ( 96 106 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> ··· 160 170 !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp; 161 171 162 172 let accentContrast; 173 + let bgRef = colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"); 174 + let primaryStr = colorToString(primary, "rgb"); 175 + 163 176 let sortedAccents = [accent1, accent2].sort((a, b) => { 164 177 // sort accents by contrast against the background 165 178 return ( 166 - getColorDifference( 167 - colorToString(b, "rgb"), 168 - colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 169 - ) - 170 - getColorDifference( 171 - colorToString(a, "rgb"), 172 - colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 173 - ) 179 + compareColors(colorToString(b, "rgb"), bgRef).distance - 180 + compareColors(colorToString(a, "rgb"), bgRef).distance 174 181 ); 175 182 }); 183 + 184 + let bestVsText = compareColors( 185 + colorToString(sortedAccents[0], "rgb"), 186 + primaryStr, 187 + ); 188 + let altVsBg = compareColors(colorToString(sortedAccents[1], "rgb"), bgRef); 189 + 176 190 if ( 177 191 // if the contrast-y accent is too similar to text color 178 - getColorDifference( 179 - colorToString(sortedAccents[0], "rgb"), 180 - colorToString(primary, "rgb"), 181 - ) < 0.15 && 192 + // (close in distance AND not distinguishable by hue/chroma) 193 + bestVsText.distance < 0.15 && 194 + bestVsText.chromaDiff < 0.05 && 182 195 // and if the other accent is different enough from the background 183 - getColorDifference( 184 - colorToString(sortedAccents[1], "rgb"), 185 - colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"), 186 - ) > 0.31 196 + altVsBg.distance > 0.31 187 197 ) { 188 198 //then choose the less contrast-y accent 189 199 accentContrast = sortedAccents[1]; ··· 192 202 accentContrast = sortedAccents[0]; 193 203 } 194 204 205 + // Check if the accent contrast color is visually similar to the text color. 206 + // We check both overall OKLab distance AND chroma (hue/saturation) difference 207 + // because dark colors are compressed in OKLab lightness — a dark blue accent 208 + // vs black text can have a small OKLab distance yet be clearly distinguishable 209 + // by hue. If the chroma difference is significant, the colors are visually 210 + // distinct and don't need an underline to tell them apart. 211 + let accentVsText = compareColors( 212 + colorToString(accentContrast, "rgb"), 213 + primaryStr, 214 + ); 215 + let accentContrastSimilarToText = 216 + accentVsText.distance < 0.45 && accentVsText.chromaDiff < 0.05; 217 + 195 218 // Get font configs for CSS variables. 196 219 // When using the default font (Quattro), use var(--font-quattro) which is 197 220 // always available via next/font/local in layout.tsx, rather than the raw ··· 203 226 const headingFontConfig = getFontConfig(headingFontId); 204 227 const bodyFontConfig = getFontConfig(bodyFontId); 205 228 const headingFontValue = isDefaultHeading 206 - ? (isDefaultBody ? undefined : "var(--font-quattro)") 229 + ? isDefaultBody 230 + ? undefined 231 + : "var(--font-quattro)" 207 232 : getFontFamilyValue(headingFontConfig); 208 233 const bodyFontValue = isDefaultBody 209 - ? (isDefaultHeading ? undefined : "var(--font-quattro)") 234 + ? isDefaultHeading 235 + ? undefined 236 + : "var(--font-quattro)" 210 237 : getFontFamilyValue(bodyFontConfig); 211 - const bodyFontBaseSize = isDefaultBody ? undefined : getFontBaseSize(bodyFontConfig); 238 + const bodyFontBaseSize = isDefaultBody 239 + ? undefined 240 + : getFontBaseSize(bodyFontConfig); 212 241 const headingGoogleFontsUrl = getGoogleFontsUrl(headingFontConfig); 213 242 const bodyGoogleFontsUrl = getGoogleFontsUrl(bodyFontConfig); 214 243 ··· 222 251 if (existingLink) return; 223 252 224 253 // Add preconnect hints if not present 225 - if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) { 254 + if ( 255 + !document.querySelector('link[href="https://fonts.googleapis.com"]') 256 + ) { 226 257 const preconnect1 = document.createElement("link"); 227 258 preconnect1.rel = "preconnect"; 228 259 preconnect1.href = "https://fonts.googleapis.com"; ··· 249 280 250 281 loadGoogleFont(headingGoogleFontsUrl, headingFontConfig.fontFamily); 251 282 loadGoogleFont(bodyGoogleFontsUrl, bodyFontConfig.fontFamily); 252 - }, [headingGoogleFontsUrl, bodyGoogleFontsUrl, headingFontConfig.fontFamily, bodyFontConfig.fontFamily]); 283 + }, [ 284 + headingGoogleFontsUrl, 285 + bodyGoogleFontsUrl, 286 + headingFontConfig.fontFamily, 287 + bodyFontConfig.fontFamily, 288 + ]); 253 289 254 290 useEffect(() => { 255 291 if (local) return; ··· 293 329 "--accent-1-is-contrast", 294 330 accentContrast === accent1 ? "1" : "0", 295 331 ); 332 + el?.style.setProperty( 333 + "--accent-contrast-similar-to-text", 334 + accentContrastSimilarToText ? "1" : "0", 335 + ); 336 + el?.style.setProperty( 337 + "--link-underline", 338 + accentContrastSimilarToText ? "underline" : "none", 339 + ); 296 340 297 341 // Set page width CSS variable 298 342 el?.style.setProperty( 299 343 "--page-width-setting", 300 344 (pageWidth || 624).toString(), 301 345 ); 302 - 303 346 }, [ 304 347 local, 305 348 bgLeaflet, ··· 311 354 accent1, 312 355 accent2, 313 356 accentContrast, 357 + accentContrastSimilarToText, 314 358 pageWidth, 315 359 ]); 316 360 return ( ··· 326 370 "--accent-2": colorToString(accent2, "rgb"), 327 371 "--accent-contrast": colorToString(accentContrast, "rgb"), 328 372 "--accent-1-is-contrast": accentContrast === accent1 ? 1 : 0, 373 + "--accent-contrast-similar-to-text": accentContrastSimilarToText 374 + ? 1 375 + : 0, 376 + "--link-underline": accentContrastSimilarToText 377 + ? "underline" 378 + : "none", 329 379 "--highlight-1": highlight1 330 380 ? `rgb(${colorToString(parseColor(`hsba(${highlight1})`), "rgb")})` 331 381 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)", ··· 336 386 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 337 387 "--theme-heading-font": headingFontValue, 338 388 "--theme-font": bodyFontValue, 339 - "--theme-font-base-size": bodyFontBaseSize ? `${bodyFontBaseSize}px` : undefined, 389 + "--theme-font-base-size": bodyFontBaseSize 390 + ? `${bodyFontBaseSize}px` 391 + : undefined, 340 392 } as CSSProperties 341 393 } 342 394 > ··· 372 424 let accentContrast = 373 425 bgPage && accent1 && accent2 374 426 ? [accent1, accent2].sort((a, b) => { 427 + let bgStr = colorToString(bgPage, "rgb"); 375 428 return ( 376 - getColorDifference( 377 - colorToString(b, "rgb"), 378 - colorToString(bgPage, "rgb"), 379 - ) - 380 - getColorDifference( 381 - colorToString(a, "rgb"), 382 - colorToString(bgPage, "rgb"), 383 - ) 429 + compareColors(colorToString(b, "rgb"), bgStr).distance - 430 + compareColors(colorToString(a, "rgb"), bgStr).distance 384 431 ); 385 432 })[0] 386 433 : null;
+16 -4
components/ThemeManager/themeUtils.ts
··· 1 - import { parse, ColorSpace, sRGB, distance, OKLab } from "colorjs.io/fn"; 1 + import { parse, ColorSpace, sRGB, distance, OKLab, to } from "colorjs.io/fn"; 2 2 3 3 // define the color defaults for everything 4 4 export const ThemeDefaults = { ··· 16 16 "theme/accent-contrast": "#57822B", 17 17 }; 18 18 19 - // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast 20 - export function getColorDifference(color1: string, color2: string) { 19 + // Compares two RGB color strings in OKLab space and returns both the overall 20 + // perceptual distance and the chroma (hue/saturation) difference. 21 + // 22 + // Why both? Dark colors are compressed in OKLab lightness, so two colors can 23 + // have a small overall distance yet be clearly distinguishable by hue (e.g. 24 + // dark blue vs black). Checking chromaDiff lets callers tell apart "two 25 + // similar grays" from "a dark chromatic color next to a gray/black". 26 + export function compareColors(color1: string, color2: string) { 21 27 ColorSpace.register(sRGB); 22 28 ColorSpace.register(OKLab); 23 29 24 30 let parsedColor1 = parse(`rgb(${color1})`); 25 31 let parsedColor2 = parse(`rgb(${color2})`); 26 32 27 - return distance(parsedColor1, parsedColor2, "oklab"); 33 + let [, a1, b1] = to(parsedColor1, "oklab").coords; 34 + let [, a2, b2] = to(parsedColor2, "oklab").coords; 35 + 36 + return { 37 + distance: distance(parsedColor1, parsedColor2, "oklab"), 38 + chromaDiff: Math.sqrt((a1 - a2) ** 2 + (b1 - b2) ** 2), 39 + }; 28 40 }
+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}
+16 -1
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: { ··· 1233 1238 type: 'object', 1234 1239 required: ['content'], 1235 1240 properties: { 1241 + checked: { 1242 + type: 'boolean', 1243 + description: 1244 + 'If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item.', 1245 + }, 1236 1246 content: { 1237 1247 type: 'union', 1238 1248 refs: [ ··· 1338 1348 type: 'object', 1339 1349 required: ['content'], 1340 1350 properties: { 1351 + checked: { 1352 + type: 'boolean', 1353 + description: 1354 + 'If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item.', 1355 + }, 1341 1356 content: { 1342 1357 type: 'union', 1343 1358 refs: [ ··· 2152 2167 type: 'array', 2153 2168 items: { 2154 2169 type: 'ref', 2155 - ref: 'lex:pub.leaflet.richtext.facet', 2170 + ref: 'lex:pub.leaflet.richtext.facet#main', 2156 2171 }, 2157 2172 }, 2158 2173 },
+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'
+2
lexicons/api/types/pub/leaflet/blocks/orderedList.ts
··· 37 37 38 38 export interface ListItem { 39 39 $type?: 'pub.leaflet.blocks.orderedList#listItem' 40 + /** If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item. */ 41 + checked?: boolean 40 42 content: 41 43 | $Typed<PubLeafletBlocksText.Main> 42 44 | $Typed<PubLeafletBlocksHeader.Main>
+2
lexicons/api/types/pub/leaflet/blocks/unorderedList.ts
··· 35 35 36 36 export interface ListItem { 37 37 $type?: 'pub.leaflet.blocks.unorderedList#listItem' 38 + /** If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item. */ 39 + checked?: boolean 38 40 content: 39 41 | $Typed<PubLeafletBlocksText.Main> 40 42 | $Typed<PubLeafletBlocksHeader.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 },
+4
lexicons/pub/leaflet/blocks/orderedList.json
··· 27 27 "content" 28 28 ], 29 29 "properties": { 30 + "checked": { 31 + "type": "boolean", 32 + "description": "If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item." 33 + }, 30 34 "content": { 31 35 "type": "union", 32 36 "refs": [
+4
lexicons/pub/leaflet/blocks/unorderedList.json
··· 23 23 "content" 24 24 ], 25 25 "properties": { 26 + "checked": { 27 + "type": "boolean", 28 + "description": "If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item." 29 + }, 26 30 "content": { 27 31 "type": "union", 28 32 "refs": [
+4 -1
lexicons/pub/leaflet/richtext/facet.json
··· 140 140 "footnote": { 141 141 "type": "object", 142 142 "description": "Facet feature for a footnote reference", 143 - "required": ["footnoteId", "contentPlaintext"], 143 + "required": [ 144 + "footnoteId", 145 + "contentPlaintext" 146 + ], 144 147 "properties": { 145 148 "footnoteId": { 146 149 "type": "string"
+25 -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 }, ··· 212 218 type: "object", 213 219 required: ["content"], 214 220 properties: { 221 + checked: { 222 + type: "boolean", 223 + description: 224 + "If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item.", 225 + }, 215 226 content: { 216 227 type: "union", 217 228 refs: [ ··· 222 233 }, 223 234 children: { 224 235 type: "array", 225 - 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.", 226 238 items: { type: "ref", ref: "#listItem" }, 227 239 }, 228 240 unorderedListChildren: { 229 241 type: "ref", 230 - 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.", 231 244 ref: "pub.leaflet.blocks.unorderedList", 232 245 }, 233 246 }, ··· 250 263 type: "object", 251 264 required: ["content"], 252 265 properties: { 266 + checked: { 267 + type: "boolean", 268 + description: 269 + "If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item.", 270 + }, 253 271 content: { 254 272 type: "union", 255 273 refs: [ ··· 260 278 }, 261 279 children: { 262 280 type: "array", 263 - 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.", 264 283 items: { type: "ref", ref: "#listItem" }, 265 284 }, 266 285 orderedListChildren: { 267 286 type: "ref", 268 - 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.", 269 289 ref: "pub.leaflet.blocks.orderedList", 270 290 }, 271 291 },
+133 -251
package-lock.json
··· 84 84 "stripe": "^20.4.0", 85 85 "swr": "^2.3.3", 86 86 "thumbhash": "^0.1.1", 87 - "twilio": "^5.3.7", 88 87 "unified": "^11.0.5", 89 88 "unist-util-visit": "^5.0.0", 90 89 "uuid": "^10.0.0", ··· 937 936 "integrity": "sha512-vLu7SRY84CV/Dd+NUdgtidn2hS5hSMUC1vDBY0VcviTdgRYkU43vIz3vIFbmx14cX1r+mM7WjzE5Fl1fGEM0RQ==", 938 937 "license": "(Apache-2.0 AND BSD-3-Clause)" 939 938 }, 939 + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { 940 + "version": "2.2.0", 941 + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz", 942 + "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==", 943 + "cpu": [ 944 + "arm64" 945 + ], 946 + "license": "MIT", 947 + "optional": true, 948 + "os": [ 949 + "darwin" 950 + ] 951 + }, 952 + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { 953 + "version": "2.2.0", 954 + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz", 955 + "integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==", 956 + "cpu": [ 957 + "x64" 958 + ], 959 + "license": "MIT", 960 + "optional": true, 961 + "os": [ 962 + "darwin" 963 + ] 964 + }, 965 + "node_modules/@cbor-extract/cbor-extract-linux-arm": { 966 + "version": "2.2.0", 967 + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz", 968 + "integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==", 969 + "cpu": [ 970 + "arm" 971 + ], 972 + "license": "MIT", 973 + "optional": true, 974 + "os": [ 975 + "linux" 976 + ] 977 + }, 978 + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { 979 + "version": "2.2.0", 980 + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz", 981 + "integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==", 982 + "cpu": [ 983 + "arm64" 984 + ], 985 + "license": "MIT", 986 + "optional": true, 987 + "os": [ 988 + "linux" 989 + ] 990 + }, 940 991 "node_modules/@cbor-extract/cbor-extract-linux-x64": { 941 992 "version": "2.2.0", 942 993 "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz", ··· 948 999 "optional": true, 949 1000 "os": [ 950 1001 "linux" 1002 + ] 1003 + }, 1004 + "node_modules/@cbor-extract/cbor-extract-win32-x64": { 1005 + "version": "2.2.0", 1006 + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz", 1007 + "integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==", 1008 + "cpu": [ 1009 + "x64" 1010 + ], 1011 + "license": "MIT", 1012 + "optional": true, 1013 + "os": [ 1014 + "win32" 951 1015 ] 952 1016 }, 953 1017 "node_modules/@clack/core": { ··· 983 1047 "node": ">=16.13" 984 1048 } 985 1049 }, 1050 + "node_modules/@cloudflare/workerd-darwin-64": { 1051 + "version": "1.20240524.0", 1052 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20240524.0.tgz", 1053 + "integrity": "sha512-ATaXjefbTsrv4mpn4Fdua114RRDXcX5Ky+Mv+f4JTUllgalmqC4CYMN4jxRz9IpJU/fNMN8IEfvUyuJBAcl9Iw==", 1054 + "cpu": [ 1055 + "x64" 1056 + ], 1057 + "dev": true, 1058 + "license": "Apache-2.0", 1059 + "optional": true, 1060 + "os": [ 1061 + "darwin" 1062 + ], 1063 + "engines": { 1064 + "node": ">=16" 1065 + } 1066 + }, 1067 + "node_modules/@cloudflare/workerd-darwin-arm64": { 1068 + "version": "1.20240524.0", 1069 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20240524.0.tgz", 1070 + "integrity": "sha512-wnbsZI4CS0QPCd+wnBHQ40C28A/2Qo4ESi1YhE2735G3UNcc876MWksZhsubd+XH0XPIra6eNFqyw6wRMpQOXA==", 1071 + "cpu": [ 1072 + "arm64" 1073 + ], 1074 + "dev": true, 1075 + "license": "Apache-2.0", 1076 + "optional": true, 1077 + "os": [ 1078 + "darwin" 1079 + ], 1080 + "engines": { 1081 + "node": ">=16" 1082 + } 1083 + }, 986 1084 "node_modules/@cloudflare/workerd-linux-64": { 987 1085 "version": "1.20240524.0", 988 1086 "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20240524.0.tgz", ··· 994 1092 "optional": true, 995 1093 "os": [ 996 1094 "linux" 1095 + ], 1096 + "engines": { 1097 + "node": ">=16" 1098 + } 1099 + }, 1100 + "node_modules/@cloudflare/workerd-linux-arm64": { 1101 + "version": "1.20240524.0", 1102 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20240524.0.tgz", 1103 + "integrity": "sha512-/Fr1W671t2triNCDCBWdStxngnbUfZunZ/2e4kaMLzJDJLYDtYdmvOUCBDzUD4ssqmIMbn9RCQQ0U+CLEoqBqw==", 1104 + "cpu": [ 1105 + "arm64" 1106 + ], 1107 + "dev": true, 1108 + "license": "Apache-2.0", 1109 + "optional": true, 1110 + "os": [ 1111 + "linux" 1112 + ], 1113 + "engines": { 1114 + "node": ">=16" 1115 + } 1116 + }, 1117 + "node_modules/@cloudflare/workerd-windows-64": { 1118 + "version": "1.20240524.0", 1119 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20240524.0.tgz", 1120 + "integrity": "sha512-G+ThDEx57g9mAEKqhWnHaaJgpeGYtyhkmwM/BDpLqPks/rAY5YEfZbY4YL1pNk1kkcZDXGrwIsY8xe9Apf5JdA==", 1121 + "cpu": [ 1122 + "x64" 1123 + ], 1124 + "dev": true, 1125 + "license": "Apache-2.0", 1126 + "optional": true, 1127 + "os": [ 1128 + "win32" 997 1129 ], 998 1130 "engines": { 999 1131 "node": ">=16" ··· 9479 9611 "node": ">= 0.4" 9480 9612 } 9481 9613 }, 9482 - "node_modules/asynckit": { 9483 - "version": "0.4.0", 9484 - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 9485 - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 9486 - }, 9487 9614 "node_modules/atomic-sleep": { 9488 9615 "version": "1.0.0", 9489 9616 "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", ··· 9523 9650 "license": "MPL-2.0", 9524 9651 "engines": { 9525 9652 "node": ">=4" 9526 - } 9527 - }, 9528 - "node_modules/axios": { 9529 - "version": "1.7.9", 9530 - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", 9531 - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", 9532 - "license": "MIT", 9533 - "dependencies": { 9534 - "follow-redirects": "^1.15.6", 9535 - "form-data": "^4.0.0", 9536 - "proxy-from-env": "^1.1.0" 9537 9653 } 9538 9654 }, 9539 9655 "node_modules/axobject-query": { ··· 9758 9874 "base64-js": "^1.3.1", 9759 9875 "ieee754": "^1.2.1" 9760 9876 } 9761 - }, 9762 - "node_modules/buffer-equal-constant-time": { 9763 - "version": "1.0.1", 9764 - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", 9765 - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", 9766 - "license": "BSD-3-Clause" 9767 9877 }, 9768 9878 "node_modules/buffer-from": { 9769 9879 "version": "1.1.2", ··· 10121 10231 "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", 10122 10232 "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==" 10123 10233 }, 10124 - "node_modules/combined-stream": { 10125 - "version": "1.0.8", 10126 - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 10127 - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 10128 - "dependencies": { 10129 - "delayed-stream": "~1.0.0" 10130 - }, 10131 - "engines": { 10132 - "node": ">= 0.8" 10133 - } 10134 - }, 10135 10234 "node_modules/comma-separated-tokens": { 10136 10235 "version": "2.0.3", 10137 10236 "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", ··· 10464 10563 "url": "https://github.com/sponsors/kossnocorp" 10465 10564 } 10466 10565 }, 10467 - "node_modules/dayjs": { 10468 - "version": "1.11.13", 10469 - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", 10470 - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", 10471 - "license": "MIT" 10472 - }, 10473 10566 "node_modules/debounce": { 10474 10567 "version": "1.2.1", 10475 10568 "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", ··· 10554 10647 }, 10555 10648 "funding": { 10556 10649 "url": "https://github.com/sponsors/ljharb" 10557 - } 10558 - }, 10559 - "node_modules/delayed-stream": { 10560 - "version": "1.0.0", 10561 - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 10562 - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 10563 - "engines": { 10564 - "node": ">=0.4.0" 10565 10650 } 10566 10651 }, 10567 10652 "node_modules/denque": { ··· 11231 11316 "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", 11232 11317 "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", 11233 11318 "license": "MIT" 11234 - }, 11235 - "node_modules/ecdsa-sig-formatter": { 11236 - "version": "1.0.11", 11237 - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", 11238 - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", 11239 - "license": "Apache-2.0", 11240 - "dependencies": { 11241 - "safe-buffer": "^5.0.1" 11242 - } 11243 11319 }, 11244 11320 "node_modules/ee-first": { 11245 11321 "version": "1.1.1", ··· 12536 12612 "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", 12537 12613 "dev": true 12538 12614 }, 12539 - "node_modules/follow-redirects": { 12540 - "version": "1.15.6", 12541 - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", 12542 - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", 12543 - "funding": [ 12544 - { 12545 - "type": "individual", 12546 - "url": "https://github.com/sponsors/RubenVerborgh" 12547 - } 12548 - ], 12549 - "engines": { 12550 - "node": ">=4.0" 12551 - }, 12552 - "peerDependenciesMeta": { 12553 - "debug": { 12554 - "optional": true 12555 - } 12556 - } 12557 - }, 12558 12615 "node_modules/for-each": { 12559 12616 "version": "0.3.5", 12560 12617 "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", ··· 12569 12626 }, 12570 12627 "funding": { 12571 12628 "url": "https://github.com/sponsors/ljharb" 12572 - } 12573 - }, 12574 - "node_modules/form-data": { 12575 - "version": "4.0.0", 12576 - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", 12577 - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", 12578 - "dependencies": { 12579 - "asynckit": "^0.4.0", 12580 - "combined-stream": "^1.0.8", 12581 - "mime-types": "^2.1.12" 12582 - }, 12583 - "engines": { 12584 - "node": ">= 6" 12585 12629 } 12586 12630 }, 12587 12631 "node_modules/formdata-polyfill": { ··· 14336 14380 "json5": "lib/cli.js" 14337 14381 } 14338 14382 }, 14339 - "node_modules/jsonwebtoken": { 14340 - "version": "9.0.2", 14341 - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", 14342 - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", 14343 - "license": "MIT", 14344 - "dependencies": { 14345 - "jws": "^3.2.2", 14346 - "lodash.includes": "^4.3.0", 14347 - "lodash.isboolean": "^3.0.3", 14348 - "lodash.isinteger": "^4.0.4", 14349 - "lodash.isnumber": "^3.0.3", 14350 - "lodash.isplainobject": "^4.0.6", 14351 - "lodash.isstring": "^4.0.1", 14352 - "lodash.once": "^4.0.0", 14353 - "ms": "^2.1.1", 14354 - "semver": "^7.5.4" 14355 - }, 14356 - "engines": { 14357 - "node": ">=12", 14358 - "npm": ">=6" 14359 - } 14360 - }, 14361 14383 "node_modules/jsx-ast-utils": { 14362 14384 "version": "3.3.5", 14363 14385 "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", ··· 14372 14394 }, 14373 14395 "engines": { 14374 14396 "node": ">=4.0" 14375 - } 14376 - }, 14377 - "node_modules/jwa": { 14378 - "version": "1.4.1", 14379 - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", 14380 - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", 14381 - "license": "MIT", 14382 - "dependencies": { 14383 - "buffer-equal-constant-time": "1.0.1", 14384 - "ecdsa-sig-formatter": "1.0.11", 14385 - "safe-buffer": "^5.0.1" 14386 - } 14387 - }, 14388 - "node_modules/jws": { 14389 - "version": "3.2.2", 14390 - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", 14391 - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", 14392 - "license": "MIT", 14393 - "dependencies": { 14394 - "jwa": "^1.4.1", 14395 - "safe-buffer": "^5.0.1" 14396 14397 } 14397 14398 }, 14398 14399 "node_modules/katex": { ··· 14770 14771 "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", 14771 14772 "license": "MIT" 14772 14773 }, 14773 - "node_modules/lodash.includes": { 14774 - "version": "4.3.0", 14775 - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", 14776 - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", 14777 - "license": "MIT" 14778 - }, 14779 14774 "node_modules/lodash.isarguments": { 14780 14775 "version": "3.1.0", 14781 14776 "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", 14782 14777 "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", 14783 14778 "license": "MIT" 14784 14779 }, 14785 - "node_modules/lodash.isboolean": { 14786 - "version": "3.0.3", 14787 - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", 14788 - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", 14789 - "license": "MIT" 14790 - }, 14791 - "node_modules/lodash.isinteger": { 14792 - "version": "4.0.4", 14793 - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", 14794 - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", 14795 - "license": "MIT" 14796 - }, 14797 - "node_modules/lodash.isnumber": { 14798 - "version": "3.0.3", 14799 - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", 14800 - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", 14801 - "license": "MIT" 14802 - }, 14803 - "node_modules/lodash.isplainobject": { 14804 - "version": "4.0.6", 14805 - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", 14806 - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", 14807 - "license": "MIT" 14808 - }, 14809 - "node_modules/lodash.isstring": { 14810 - "version": "4.0.1", 14811 - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", 14812 - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", 14813 - "license": "MIT" 14814 - }, 14815 14780 "node_modules/lodash.merge": { 14816 14781 "version": "4.6.2", 14817 14782 "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", 14818 14783 "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", 14819 14784 "dev": true 14820 - }, 14821 - "node_modules/lodash.once": { 14822 - "version": "4.1.1", 14823 - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", 14824 - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", 14825 - "license": "MIT" 14826 14785 }, 14827 14786 "node_modules/lodash.throttle": { 14828 14787 "version": "4.1.1", ··· 16912 16871 "node": "^10 || ^12 || >=14" 16913 16872 } 16914 16873 }, 16915 - "node_modules/postgres": { 16916 - "version": "3.4.4", 16917 - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.4.tgz", 16918 - "integrity": "sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==", 16919 - "optional": true, 16920 - "peer": true, 16921 - "engines": { 16922 - "node": ">=12" 16923 - }, 16924 - "funding": { 16925 - "type": "individual", 16926 - "url": "https://github.com/sponsors/porsager" 16927 - } 16928 - }, 16929 16874 "node_modules/postgres-array": { 16930 16875 "version": "2.0.0", 16931 16876 "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", ··· 17279 17224 "node": ">= 0.10" 17280 17225 } 17281 17226 }, 17282 - "node_modules/proxy-from-env": { 17283 - "version": "1.1.0", 17284 - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 17285 - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 17286 - }, 17287 17227 "node_modules/punycode": { 17288 17228 "version": "2.3.1", 17289 17229 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", ··· 18341 18281 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", 18342 18282 "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" 18343 18283 }, 18344 - "node_modules/scmp": { 18345 - "version": "2.1.0", 18346 - "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", 18347 - "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", 18348 - "license": "BSD-3-Clause" 18349 - }, 18350 18284 "node_modules/selfsigned": { 18351 18285 "version": "2.4.1", 18352 18286 "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", ··· 19355 19289 "fsevents": "~2.3.3" 19356 19290 } 19357 19291 }, 19358 - "node_modules/twilio": { 19359 - "version": "5.3.7", 19360 - "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.3.7.tgz", 19361 - "integrity": "sha512-9PaIXQ2CSfKKKjsQ7YpLRi7vo77ryevjoylR+7uSB0D/9t5VOYz+QNxNuR3YGk9UCt/f5pxzjVQraWVI5lQFSQ==", 19362 - "license": "MIT", 19363 - "dependencies": { 19364 - "axios": "^1.7.4", 19365 - "dayjs": "^1.11.9", 19366 - "https-proxy-agent": "^5.0.0", 19367 - "jsonwebtoken": "^9.0.2", 19368 - "qs": "^6.9.4", 19369 - "scmp": "^2.1.0", 19370 - "xmlbuilder": "^13.0.2" 19371 - }, 19372 - "engines": { 19373 - "node": ">=14.0" 19374 - } 19375 - }, 19376 - "node_modules/twilio/node_modules/agent-base": { 19377 - "version": "6.0.2", 19378 - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", 19379 - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", 19380 - "license": "MIT", 19381 - "dependencies": { 19382 - "debug": "4" 19383 - }, 19384 - "engines": { 19385 - "node": ">= 6.0.0" 19386 - } 19387 - }, 19388 - "node_modules/twilio/node_modules/https-proxy-agent": { 19389 - "version": "5.0.1", 19390 - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", 19391 - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", 19392 - "license": "MIT", 19393 - "dependencies": { 19394 - "agent-base": "6", 19395 - "debug": "4" 19396 - }, 19397 - "engines": { 19398 - "node": ">= 6" 19399 - } 19400 - }, 19401 19292 "node_modules/type": { 19402 19293 "version": "2.7.2", 19403 19294 "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", ··· 20645 20536 }, 20646 20537 "bin": { 20647 20538 "xml-js": "bin/cli.js" 20648 - } 20649 - }, 20650 - "node_modules/xmlbuilder": { 20651 - "version": "13.0.2", 20652 - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", 20653 - "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", 20654 - "license": "MIT", 20655 - "engines": { 20656 - "node": ">=6.0" 20657 20539 } 20658 20540 }, 20659 20541 "node_modules/xtend": {
-1
package.json
··· 95 95 "stripe": "^20.4.0", 96 96 "swr": "^2.3.3", 97 97 "thumbhash": "^0.1.1", 98 - "twilio": "^5.3.7", 99 98 "unified": "^11.0.5", 100 99 "unist-util-visit": "^5.0.0", 101 100 "uuid": "^10.0.0",
+1
src/replicache/getBlocks.ts
··· 78 78 parent, 79 79 path: newPath, 80 80 checklist: !!checklist[0], 81 + checked: checklist[0]?.data.value, 81 82 listStyle: listStyle?.data.value, 82 83 listStart: listNumber?.data.value, 83 84 },
+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 + }
+2 -1
supabase/supabase-image-loader.js
··· 1 1 export default function supabaseLoader({ src, width, quality }) { 2 - return `${process.env.NEXT_PUBLIC_SUPABASE_API_URL}/storage/v1/render/image/public/${src}?width=${width}&quality=${quality || 75}`; 2 + const path = src.startsWith("/") ? src.slice(1) : src; 3 + return `${process.env.NEXT_PUBLIC_SUPABASE_API_URL}/storage/v1/render/image/public/${path}?width=${width}&quality=${quality || 75}`; 3 4 }