a tool for shared writing and social publishing

Feature/subscribe (#55)

* init mailbox

* reogranized file to be more componentized, moved around layout

* added a mailbox icon, componentized are you sure thingy, added selected state and delete confirm to mailbox

* fixed issue where delete card button on card block wasn't working, replaced x with a trash icon to make it more clear what's happening

* add basic subscription and confirm flow!

* split out mailbox components and fix backspace wierdness

* WIP

* more WIP

* add basic unsubscribe logic

* add unsubscribe headers

* display message card

* properly add sent message to mailbox archive

* added a little status area bar at top of card to house draft state status and send button

* add email tables migration

* add images and page titles to mail

* make draft header work!

* refactored Menu and MenuItem component to use Radix dropdown

* styled the channel selector in subscribeForm

* add subscriber count

* open archive from reader view

* - addded write post button state for creators so that it says 'edit' instead of 'wrte' if there is an ongoing draft
- styled confirm code state
- added error state is code is incorrect
- added way to reset to email form again from confirm state
- added empty state in more reader and writer box if there are no posts

* added a see past posts button to unsubscribed reader state

* styled subscriber count

* small style tweaks to the defat indicator

* wrapped the email and confirm inputs in a form element

* tweak copy for mailbox post status banner

* tweaks to email copy and formatting in sendPostToSubscribers

* correctly pluralize readers in draft post header

* tweak copy in MailboxInfo and add info button to unsubscribed form

* small things, copy on send button, form fields have bg transparent, rm info icons for readers to keep the UI cleaner

* mailbox isn't a textblock

* focus the first block when you open the draft post

* close drafts and archive pages when the mailbox is deleted

* rm a bunch of console logs

* set title in confirm code page

* sending draft closed the draft and opens the archive, adds the post to top of archive, focus first block

* rm channel selector for now

* menu items have a cursor pointer

* reorganized Toolbar

* combine pending and active subscriptions table and store unconfirmed subscriptions

* update migration to remove pending subscriptions table

---------

Co-authored-by: celine <celine@hyperlink.academy>
Co-authored-by: Brendan Schlagel <brendan.schlagel@gmail.com>

authored by awarm.space

celine
Brendan Schlagel
and committed by
GitHub
110d03cb a47a5968

+2691 -498
-1
actions/addLinkCard.ts
··· 8 ); 9 10 export async function addLinkCard(args: { link: string }) { 11 - console.log("addLinkCard"); 12 let result = await get_url_preview_data(args.link); 13 return result; 14 }
··· 8 ); 9 10 export async function addLinkCard(args: { link: string }) { 11 let result = await get_url_preview_data(args.link); 12 return result; 13 }
-1
actions/createNewDoc.ts
··· 14 import { sql } from "drizzle-orm"; 15 16 export async function createNewDoc() { 17 - console.log("Create new doc"); 18 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 19 const db = drizzle(client); 20 let { permissionToken } = await db.transaction(async (tx) => {
··· 14 import { sql } from "drizzle-orm"; 15 16 export async function createNewDoc() { 17 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 18 const db = drizzle(client); 19 let { permissionToken } = await db.transaction(async (tx) => {
-1
actions/deleteDoc.ts
··· 15 import { revalidatePath } from "next/cache"; 16 17 export async function deleteDoc(permission_token: PermissionToken) { 18 - console.log("Delete doc"); 19 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 20 const db = drizzle(client); 21 await db.transaction(async (tx) => {
··· 15 import { revalidatePath } from "next/cache"; 16 17 export async function deleteDoc(permission_token: PermissionToken) { 18 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 19 const db = drizzle(client); 20 await db.transaction(async (tx) => {
+87
actions/subscriptions/confirmEmailSubscription.ts
···
··· 1 + "use server"; 2 + 3 + import { createClient } from "@supabase/supabase-js"; 4 + import { createIdentity } from "actions/createIdentity"; 5 + import { and, eq, sql } from "drizzle-orm"; 6 + import { drizzle } from "drizzle-orm/postgres-js"; 7 + import { 8 + email_subscriptions_to_entity, 9 + facts, 10 + permission_tokens, 11 + } from "drizzle/schema"; 12 + import postgres from "postgres"; 13 + import { Fact, PermissionToken } from "src/replicache"; 14 + import { serverMutationContext } from "src/replicache/serverMutationContext"; 15 + import { Database } from "supabase/database.types"; 16 + import { v7 } from "uuid"; 17 + 18 + export async function confirmEmailSubscription( 19 + subscriptionID: string, 20 + code: string, 21 + ) { 22 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 23 + const db = drizzle(client); 24 + let subscription = await db.transaction(async (tx) => { 25 + let [{ email_subscriptions_to_entity: sub, permission_tokens: token }] = 26 + await db 27 + .select() 28 + .from(email_subscriptions_to_entity) 29 + .innerJoin( 30 + permission_tokens, 31 + eq(permission_tokens.id, email_subscriptions_to_entity.token), 32 + ) 33 + .where(and(eq(email_subscriptions_to_entity.id, subscriptionID))); 34 + if (sub.confirmed) return { subscription: sub, token }; 35 + if (code !== sub.confirmation_code) return null; 36 + let [fact] = (await db 37 + .select() 38 + .from(facts) 39 + .where( 40 + and( 41 + eq(facts.entity, sub.entity), 42 + 43 + eq(facts.attribute, "mailbox/subscriber-count"), 44 + ), 45 + )) as Fact<"mailbox/subscriber-count">[]; 46 + if (!fact) { 47 + await db.insert(facts).values({ 48 + id: v7(), 49 + entity: sub.entity, 50 + data: sql`${{ type: "number", value: 1 }}::jsonb`, 51 + attribute: "mailbox/subscriber-count", 52 + }); 53 + } else { 54 + await db 55 + .update(facts) 56 + .set({ 57 + data: sql`${{ type: "number", value: fact.data.value + 1 }}::jsonb`, 58 + }) 59 + .where(eq(facts.id, fact.id)); 60 + } 61 + let [subscription] = await db 62 + .update(email_subscriptions_to_entity) 63 + .set({ 64 + confirmed: true, 65 + }) 66 + .where(eq(email_subscriptions_to_entity.id, sub.id)) 67 + .returning(); 68 + 69 + return { subscription, token }; 70 + }); 71 + 72 + let supabase = createClient<Database>( 73 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 74 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 75 + ); 76 + let channel = supabase.channel( 77 + `rootEntity:${subscription?.token.root_entity}`, 78 + ); 79 + await channel.send({ 80 + type: "broadcast", 81 + event: "poke", 82 + payload: { message: "poke" }, 83 + }); 84 + supabase.removeChannel(channel); 85 + client.end(); 86 + return subscription; 87 + }
+51
actions/subscriptions/deleteSubscription.ts
···
··· 1 + "use server"; 2 + 3 + import { drizzle } from "drizzle-orm/postgres-js"; 4 + import { email_subscriptions_to_entity, facts } from "drizzle/schema"; 5 + import postgres from "postgres"; 6 + import { eq, and, sql } from "drizzle-orm"; 7 + import { Fact } from "src/replicache"; 8 + import { v7 } from "uuid"; 9 + 10 + export async function deleteSubscription(subscriptionID: string) { 11 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 12 + const db = drizzle(client); 13 + 14 + try { 15 + await db.transaction(async (db) => { 16 + let [subscription] = await db 17 + .select() 18 + .from(email_subscriptions_to_entity) 19 + .where(eq(email_subscriptions_to_entity.id, subscriptionID)); 20 + if (!subscription) return; 21 + let [fact] = (await db 22 + .select() 23 + .from(facts) 24 + .where( 25 + and( 26 + eq(facts.entity, subscription.entity), 27 + 28 + eq(facts.attribute, "mailbox/subscriber-count"), 29 + ), 30 + )) as Fact<"mailbox/subscriber-count">[]; 31 + if (fact) { 32 + await db 33 + .update(facts) 34 + .set({ 35 + data: sql`${{ type: "number", value: fact.data.value - 1 }}::jsonb`, 36 + }) 37 + .where(eq(facts.id, fact.id)); 38 + } 39 + await db 40 + .delete(email_subscriptions_to_entity) 41 + .where(eq(email_subscriptions_to_entity.id, subscriptionID)); 42 + }); 43 + 44 + client.end(); 45 + return { success: true }; 46 + } catch (error) { 47 + console.error("Error unsubscribing:", error); 48 + client.end(); 49 + return { success: false, error: "Failed to unsubscribe" }; 50 + } 51 + }
+100
actions/subscriptions/sendPostToSubscribers.ts
···
··· 1 + "use server"; 2 + 3 + import { getCurrentDeploymentDomain } from "src/utils/getCurrentDeploymentDomain"; 4 + import { createServerClient } from "@supabase/ssr"; 5 + import { and, eq } from "drizzle-orm"; 6 + import { drizzle } from "drizzle-orm/postgres-js"; 7 + import { email_subscriptions_to_entity, entities } from "drizzle/schema"; 8 + import postgres from "postgres"; 9 + import { PermissionToken } from "src/replicache"; 10 + import { Database } from "supabase/database.types"; 11 + 12 + let supabase = createServerClient<Database>( 13 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 14 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 15 + { cookies: {} } 16 + ); 17 + export async function sendPostToSubscribers({ 18 + title, 19 + permission_token, 20 + mailboxEntity, 21 + messageEntity, 22 + contents, 23 + }: { 24 + title: string; 25 + permission_token: PermissionToken; 26 + mailboxEntity: string; 27 + messageEntity: string; 28 + contents: { 29 + html: string; 30 + markdown: string; 31 + }; 32 + }) { 33 + let token_rights = await supabase 34 + .from("permission_tokens") 35 + .select("*, permission_token_rights(*)") 36 + .eq("id", permission_token.id) 37 + .single(); 38 + let rootEntity = token_rights.data?.root_entity; 39 + if (!rootEntity || !token_rights.data) return { title: "Doc not found" }; 40 + let { data } = await supabase.rpc("get_facts", { 41 + root: rootEntity, 42 + }); 43 + 44 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 45 + const db = drizzle(client); 46 + let subscribers = await db 47 + .select() 48 + .from(email_subscriptions_to_entity) 49 + .innerJoin(entities, eq(email_subscriptions_to_entity.entity, entities.id)) 50 + .where(eq(email_subscriptions_to_entity.entity, mailboxEntity)); 51 + let entity_set = subscribers[0]?.entities.set; 52 + if ( 53 + !token_rights.data.permission_token_rights.find( 54 + (r) => r.entity_set === entity_set 55 + ) 56 + ) { 57 + return; 58 + } 59 + let domain = getCurrentDeploymentDomain(); 60 + let res = await fetch("https://api.postmarkapp.com/email/batch", { 61 + method: "POST", 62 + headers: { 63 + "Content-Type": "application/json", 64 + "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, 65 + }, 66 + body: JSON.stringify( 67 + subscribers.map((sub) => ({ 68 + Headers: [ 69 + { 70 + Name: "List-Unsubscribe-Post", 71 + Value: "List-Unsubscribe=One-Click", 72 + }, 73 + { 74 + Name: "List-Unsubscribe", 75 + Value: `<${domain}/mail/unsubscribe?sub_id=${sub.email_subscriptions_to_entity.id}>`, 76 + }, 77 + ], 78 + MessageStream: "broadcast", 79 + From: "Leaflet Mailbox <mailbox@leaflet.pub>", 80 + Subject: `New Mail in: ${title}`, 81 + To: sub.email_subscriptions_to_entity.email, 82 + HtmlBody: ` 83 + You've got new mail from <a href="${domain}/${sub.email_subscriptions_to_entity.token}?sub_id=${sub.email_subscriptions_to_entity.id}&email=${sub.email_subscriptions_to_entity.email}&entity=${sub.email_subscriptions_to_entity.entity}&openCard=${messageEntity}"> 84 + ${title}! 85 + </a> 86 + <hr style="margin-top: 1em; margin-bottom: 1em;"> 87 + ${contents.html} 88 + <hr style="margin-top: 1em; margin-bottom: 1em;"> 89 + <em>Manage your subscription at 90 + <a href="${domain}/${sub.email_subscriptions_to_entity.token}?sub_id=${sub.email_subscriptions_to_entity.id}&email=${sub.email_subscriptions_to_entity.email}&entity=${sub.email_subscriptions_to_entity.entity}&openCard=${messageEntity}"> 91 + ${title} 92 + </a></em> 93 + `, 94 + TextBody: contents.markdown, 95 + })) 96 + ), 97 + }); 98 + client.end(); 99 + return; 100 + }
+107
actions/subscriptions/subscribeToMailboxWithEmail.ts
···
··· 1 + "use server"; 2 + 3 + import * as base64 from "base64-js"; 4 + import { createServerClient } from "@supabase/ssr"; 5 + import { and, eq } from "drizzle-orm"; 6 + import { drizzle } from "drizzle-orm/postgres-js"; 7 + import { email_subscriptions_to_entity } from "drizzle/schema"; 8 + import postgres from "postgres"; 9 + import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 10 + import { Fact, PermissionToken } from "src/replicache"; 11 + import { Attributes } from "src/replicache/attributes"; 12 + import { Database } from "supabase/database.types"; 13 + import * as Y from "yjs"; 14 + import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 15 + 16 + let supabase = createServerClient<Database>( 17 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 18 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 19 + { cookies: {} }, 20 + ); 21 + const generateCode = () => { 22 + // Generate a random 6 digit code 23 + let digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; 24 + const randomDigit = () => digits[Math.floor(Math.random() * digits.length)]; 25 + return [ 26 + randomDigit(), 27 + randomDigit(), 28 + randomDigit(), 29 + randomDigit(), 30 + randomDigit(), 31 + randomDigit(), 32 + ].join(""); 33 + }; 34 + 35 + export async function subscribeToMailboxWithEmail( 36 + entity: string, 37 + email: string, 38 + token: PermissionToken, 39 + ) { 40 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 41 + const db = drizzle(client); 42 + let newCode = generateCode(); 43 + let subscription = await db.transaction(async (tx) => { 44 + let existingEmail = await db 45 + .select() 46 + .from(email_subscriptions_to_entity) 47 + .where( 48 + and( 49 + eq(email_subscriptions_to_entity.entity, entity), 50 + eq(email_subscriptions_to_entity.email, email), 51 + ), 52 + ); 53 + if (existingEmail[0]) return existingEmail[0]; 54 + if (existingEmail.length === 0) { 55 + let newSubscription = await tx 56 + .insert(email_subscriptions_to_entity) 57 + .values({ 58 + token: token.id, 59 + entity, 60 + email, 61 + confirmation_code: newCode, 62 + }) 63 + .returning(); 64 + return newSubscription[0]; 65 + } 66 + }); 67 + if (!subscription) return; 68 + 69 + let res = await fetch("https://api.postmarkapp.com/email", { 70 + method: "POST", 71 + headers: { 72 + "Content-Type": "application/json", 73 + "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, 74 + }, 75 + body: JSON.stringify({ 76 + From: "Leaflet Subscriptions <subscriptions@leaflet.pub>", 77 + Subject: `Your confirmation code is ${subscription.confirmation_code}`, 78 + To: email, 79 + TextBody: `Paste this code to confirm your subscription to a mailbox in ${await getPageTitle(token.root_entity)}: 80 + 81 + ${subscription.confirmation_code} 82 + `, 83 + }), 84 + }); 85 + client.end(); 86 + return subscription; 87 + } 88 + 89 + async function getPageTitle(root_entity: string) { 90 + let { data } = await supabase.rpc("get_facts", { 91 + root: root_entity, 92 + }); 93 + let initialFacts = (data as unknown as Fact<keyof typeof Attributes>[]) || []; 94 + let blocks = getBlocksWithTypeLocal(initialFacts, root_entity); 95 + let title = blocks.filter( 96 + (f) => f.type === "text" || f.type === "heading", 97 + )[0]; 98 + let text = initialFacts.find( 99 + (f) => f.entity === title.value && f.attribute === "block/text", 100 + ) as Fact<"block/text"> | undefined; 101 + if (!text) return "Untitled Doc"; 102 + let doc = new Y.Doc(); 103 + const update = base64.toByteArray(text.data.value); 104 + Y.applyUpdate(doc, update); 105 + let nodes = doc.getXmlElement("prosemirror").toArray(); 106 + return YJSFragmentToString(nodes[0]) || "Untitled Leaflet"; 107 + }
+22
app/emails/unsubscribe/route.ts
···
··· 1 + import { NextRequest } from "next/server"; 2 + import { drizzle } from "drizzle-orm/postgres-js"; 3 + import { email_subscriptions_to_entity } from "drizzle/schema"; 4 + import postgres from "postgres"; 5 + import { eq } from "drizzle-orm"; 6 + 7 + export async function POST(request: NextRequest) { 8 + let sub_id = request.nextUrl.searchParams.get("sub_id"); 9 + if (!sub_id) return new Response(null, { status: 404 }); 10 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 11 + const db = drizzle(client); 12 + 13 + try { 14 + await db 15 + .delete(email_subscriptions_to_entity) 16 + .where(eq(email_subscriptions_to_entity.id, sub_id)); 17 + } catch (error) { 18 + console.log(error); 19 + } 20 + client.end(); 21 + return new Response(null, { status: 200 }); 22 + }
+9
app/globals.css
··· 122 display: none; 123 } 124 125 .highlight { 126 @apply px-[1px]; 127 @apply py-[1px];
··· 122 display: none; 123 } 124 125 + input::-webkit-outer-spin-button, 126 + input::-webkit-inner-spin-button { 127 + -webkit-appearance: none; 128 + margin: 0; 129 + } 130 + input[type="number"] { 131 + -moz-appearance: textfield; 132 + } 133 + 134 .highlight { 135 @apply px-[1px]; 136 @apply py-[1px];
+16 -35
app/home/DocOptions.tsx
··· 1 "use client"; 2 - import { PopoverArrow } from "components/Icons"; 3 import { DeleteSmall, MoreOptionsTiny } from "components/Icons"; 4 - import * as Popover from "@radix-ui/react-popover"; 5 import { Menu, MenuItem } from "components/Layout"; 6 - import { theme } from "tailwind.config"; 7 - import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 8 - import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 10 export const DocOptions = (props: { 11 doc_id: string; ··· 13 }) => { 14 return ( 15 <> 16 - <div className="absolute -bottom-6 sm:bottom-1 right-1 "> 17 - <Popover.Root> 18 - <Popover.Trigger className="bg-accent-1 text-accent-2 px-2 py-1 border border-accent-2 rounded-md w-fit place-self-end "> 19 - <MoreOptionsTiny className=" " /> 20 - </Popover.Trigger> 21 - <Popover.Anchor /> 22 - <Popover.Portal> 23 - <Popover.Content align="end"> 24 - <ThemeProvider entityID={props.doc_id} local> 25 - <Menu> 26 - <MenuItem 27 - onClick={(e) => { 28 - props.setState("deleting"); 29 - }} 30 - > 31 - <DeleteSmall /> 32 - Delete Doc 33 - </MenuItem> 34 - </Menu> 35 - <Popover.Arrow asChild width={16} height={8} viewBox="0 0 16 8"> 36 - <PopoverArrow 37 - arrowFill={theme.colors["bg-card"]} 38 - arrowStroke={theme.colors["border"]} 39 - /> 40 - </Popover.Arrow> 41 - </ThemeProvider> 42 - </Popover.Content> 43 - </Popover.Portal> 44 - </Popover.Root> 45 - </div> 46 </> 47 ); 48 };
··· 1 "use client"; 2 import { DeleteSmall, MoreOptionsTiny } from "components/Icons"; 3 import { Menu, MenuItem } from "components/Layout"; 4 5 export const DocOptions = (props: { 6 doc_id: string; ··· 8 }) => { 9 return ( 10 <> 11 + <Menu 12 + trigger={ 13 + <div className="bg-accent-1 text-accent-2 px-2 py-1 border border-accent-2 rounded-md"> 14 + <MoreOptionsTiny /> 15 + </div> 16 + } 17 + > 18 + <MenuItem 19 + onSelect={(e) => { 20 + props.setState("deleting"); 21 + }} 22 + > 23 + <DeleteSmall /> 24 + Delete Doc 25 + </MenuItem> 26 + </Menu> 27 </> 28 ); 29 };
+4 -2
app/home/DocPreview.tsx
··· 27 href={"/" + props.token.id} 28 className={`no-underline hover:no-underline text-primary h-full`} 29 > 30 - <div className="rounded-lg hover:border-primary hover:shadow-sm overflow-clip border border-border bg-bg-page grow w-full h-full"> 31 <ThemeBackgroundProvider entityID={props.doc_id}> 32 <div className="docPreview grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end"> 33 <div ··· 82 </div> 83 )} 84 {state === "normal" && ( 85 - <DocOptions doc_id={props.doc_id} setState={setState} /> 86 )} 87 </ThemeProvider> 88 </div>
··· 27 href={"/" + props.token.id} 28 className={`no-underline hover:no-underline text-primary h-full`} 29 > 30 + <div className="rounded-lg hover:shadow-sm overflow-clip border border-border outline outline-transparent hover:outline-border bg-bg-page grow w-full h-full"> 31 <ThemeBackgroundProvider entityID={props.doc_id}> 32 <div className="docPreview grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end"> 33 <div ··· 82 </div> 83 )} 84 {state === "normal" && ( 85 + <div className="flex justify-end pt-1"> 86 + <DocOptions doc_id={props.doc_id} setState={setState} /> 87 + </div> 88 )} 89 </ThemeProvider> 90 </div>
+44 -12
components/Blocks/BlockOptions.tsx
··· 11 Header3Small, 12 LinkSmall, 13 LinkTextToolbarSmall, 14 ParagraphSmall, 15 } from "components/Icons"; 16 import { generateKeyBetween } from "fractional-indexing"; ··· 26 import * as Tooltip from "@radix-ui/react-tooltip"; 27 import { 28 TextBlockTypeButton, 29 - TextBlockTypeButtons, 30 - } from "components/Toolbar/TextBlockTypeButtons"; 31 import { isUrl } from "src/utils/isURL"; 32 import { useSmoker, useToaster } from "components/Toast"; 33 ··· 110 </label> 111 </ToolbarButton> 112 <ToolbarButton 113 - tooltipContent="Add a Link" 114 - className="text-tertiary h-6" 115 - onClick={() => { 116 - setblockMenuState("link"); 117 - }} 118 - > 119 - <LinkTextToolbarSmall /> 120 - </ToolbarButton> 121 - <ToolbarButton 122 tooltipContent="Add a card" 123 className="text-tertiary h-6" 124 onClick={async () => { ··· 159 > 160 <BlockCardSmall /> 161 </ToolbarButton> 162 <Separator classname="h-6" /> 163 <TextBlockTypeButton 164 className="hover:text-primary text-tertiary h-6" ··· 168 )} 169 {blockMenuState === "heading" && ( 170 <> 171 - <TextBlockTypeButtons 172 className="bg-transparent hover:text-primary text-tertiary " 173 onClose={() => setblockMenuState("default")} 174 />
··· 11 Header3Small, 12 LinkSmall, 13 LinkTextToolbarSmall, 14 + MailboxSmall, 15 ParagraphSmall, 16 } from "components/Icons"; 17 import { generateKeyBetween } from "fractional-indexing"; ··· 27 import * as Tooltip from "@radix-ui/react-tooltip"; 28 import { 29 TextBlockTypeButton, 30 + TextBlockTypeToolbar, 31 + } from "components/Toolbar/TextBlockTypeToolbar"; 32 import { isUrl } from "src/utils/isURL"; 33 import { useSmoker, useToaster } from "components/Toast"; 34 ··· 111 </label> 112 </ToolbarButton> 113 <ToolbarButton 114 tooltipContent="Add a card" 115 className="text-tertiary h-6" 116 onClick={async () => { ··· 151 > 152 <BlockCardSmall /> 153 </ToolbarButton> 154 + <ToolbarButton 155 + tooltipContent="Add a Link" 156 + className="text-tertiary h-6" 157 + onClick={() => { 158 + setblockMenuState("link"); 159 + }} 160 + > 161 + <LinkTextToolbarSmall /> 162 + </ToolbarButton> 163 + 164 + <ToolbarButton 165 + tooltipContent="Add a Mailbox" 166 + className="text-tertiary h-6" 167 + onClick={async () => { 168 + let entity; 169 + if (!props.entityID) { 170 + entity = v7(); 171 + await rep?.mutate.addBlock({ 172 + parent: props.parent, 173 + factID: v7(), 174 + permission_set: entity_set.set, 175 + type: "mailbox", 176 + position: generateKeyBetween( 177 + props.position, 178 + props.nextPosition, 179 + ), 180 + newEntityID: entity, 181 + }); 182 + } else { 183 + entity = props.entityID; 184 + await rep?.mutate.assertFact({ 185 + entity, 186 + attribute: "block/type", 187 + data: { type: "block-type-union", value: "mailbox" }, 188 + }); 189 + } 190 + }} 191 + > 192 + <MailboxSmall /> 193 + </ToolbarButton> 194 <Separator classname="h-6" /> 195 <TextBlockTypeButton 196 className="hover:text-primary text-tertiary h-6" ··· 200 )} 201 {blockMenuState === "heading" && ( 202 <> 203 + <TextBlockTypeToolbar 204 className="bg-transparent hover:text-primary text-tertiary " 205 onClose={() => setblockMenuState("default")} 206 />
+8 -36
components/Blocks/CardBlock.tsx
··· 11 import { useUIState } from "src/useUIState"; 12 import { RenderedTextBlock } from "components/Blocks/TextBlock"; 13 import { useDocMetadata } from "src/hooks/queries/useDocMetadata"; 14 - import { CloseTiny } from "components/Icons"; 15 import { CSSProperties, useEffect, useRef, useState } from "react"; 16 import { useEntitySetContext } from "components/EntitySetProvider"; 17 import { useBlocks } from "src/hooks/queries/useBlocks"; 18 19 export function CardBlock(props: BlockProps & { renderPreview?: boolean }) { 20 let { rep } = useReplicache(); ··· 31 let isOpen = useUIState((s) => s.openCards).includes(cardEntity); 32 33 let [areYouSure, setAreYouSure] = useState(false); 34 - 35 useEffect(() => { 36 if (!isSelected) { 37 setAreYouSure(false); 38 - } 39 - }, [isSelected]); 40 - 41 - useEffect(() => { 42 - if (isSelected) { 43 } 44 }, [isSelected]); 45 ··· 98 } 99 }} 100 > 101 - {/* if the block is not focused, set are you sure to false*/} 102 - {areYouSure && isSelected ? ( 103 - <div className="flex flex-col gap-1 w-full h-full place-items-center items-center font-bold py-4 bg-border-light"> 104 - <div className="">Delete this Page?</div> 105 - <div className="flex gap-2"> 106 - <button 107 - className="bg-accent-1 text-accent-2 px-2 py-1 rounded-md " 108 - onClick={(e) => { 109 - e.stopPropagation(); 110 - useUIState.getState().closeCard(cardEntity); 111 - 112 - rep && 113 - rep.mutate.removeBlock({ 114 - blockEntity: props.entityID, 115 - }); 116 - }} 117 - > 118 - Delete 119 - </button> 120 - <button 121 - className="text-accent-1" 122 - onClick={() => setAreYouSure(false)} 123 - > 124 - Nevermind 125 - </button> 126 - </div> 127 - </div> 128 ) : ( 129 <> 130 <div ··· 171 {docMetadata[2].listData && ( 172 <ListMarker {...docMetadata[2]} className="!pt-[8px]" /> 173 )} 174 - 175 <RenderedTextBlock entityID={docMetadata[2].value} /> 176 </div> 177 )} ··· 186 setAreYouSure(true); 187 }} 188 > 189 - <CloseTiny /> 190 </button> 191 )} 192 </>
··· 11 import { useUIState } from "src/useUIState"; 12 import { RenderedTextBlock } from "components/Blocks/TextBlock"; 13 import { useDocMetadata } from "src/hooks/queries/useDocMetadata"; 14 + import { CloseTiny, TrashSmall } from "components/Icons"; 15 import { CSSProperties, useEffect, useRef, useState } from "react"; 16 import { useEntitySetContext } from "components/EntitySetProvider"; 17 import { useBlocks } from "src/hooks/queries/useBlocks"; 18 + import { AreYouSure } from "./DeleteBlock"; 19 20 export function CardBlock(props: BlockProps & { renderPreview?: boolean }) { 21 let { rep } = useReplicache(); ··· 32 let isOpen = useUIState((s) => s.openCards).includes(cardEntity); 33 34 let [areYouSure, setAreYouSure] = useState(false); 35 useEffect(() => { 36 if (!isSelected) { 37 setAreYouSure(false); 38 } 39 }, [isSelected]); 40 ··· 93 } 94 }} 95 > 96 + {areYouSure ? ( 97 + <AreYouSure 98 + closeAreYouSure={() => setAreYouSure(false)} 99 + entityID={props.entityID} 100 + /> 101 ) : ( 102 <> 103 <div ··· 144 {docMetadata[2].listData && ( 145 <ListMarker {...docMetadata[2]} className="!pt-[8px]" /> 146 )} 147 <RenderedTextBlock entityID={docMetadata[2].value} /> 148 </div> 149 )} ··· 158 setAreYouSure(true); 159 }} 160 > 161 + <TrashSmall /> 162 </button> 163 )} 164 </>
+51
components/Blocks/DeleteBlock.tsx
···
··· 1 + import { useEffect } from "react"; 2 + import { useEntity, useReplicache } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { focusBlock } from "."; 5 + 6 + export const AreYouSure = (props: { 7 + entityID: string; 8 + onClick?: () => void; 9 + closeAreYouSure: () => void; 10 + }) => { 11 + let { rep } = useReplicache(); 12 + let card = useEntity(props.entityID, "block/card"); 13 + let cardID = card ? card.data.value : props.entityID; 14 + let type = useEntity(props.entityID, "block/type")?.data.value; 15 + 16 + return ( 17 + <div className="flex flex-col gap-1 w-full h-full place-items-center items-center font-bold py-4 bg-border-light"> 18 + <div className=""> 19 + Delete this{" "} 20 + {type === "card" ? <span>Page</span> : <span>Mailbox and Posts</span>}?{" "} 21 + </div> 22 + <div className="flex gap-2"> 23 + <button 24 + className="bg-accent-1 text-accent-2 px-2 py-1 rounded-md " 25 + onClick={(e) => { 26 + e.stopPropagation(); 27 + // This only handles the case where the literal delete button is clicked. 28 + // In cases where the backspace button is pressed, each block that uses the AreYouSure 29 + // has an event listener that handles the backspace key press. 30 + useUIState.getState().closeCard(cardID); 31 + 32 + rep && 33 + rep.mutate.removeBlock({ 34 + blockEntity: props.entityID, 35 + }); 36 + 37 + props.onClick && props.onClick(); 38 + }} 39 + > 40 + Delete 41 + </button> 42 + <button 43 + className="text-accent-1" 44 + onClick={() => props.closeAreYouSure()} 45 + > 46 + Nevermind 47 + </button> 48 + </div> 49 + </div> 50 + ); 51 + };
+2 -2
components/Blocks/ExternalLinkBlock.tsx
··· 1 import { useEntity, useReplicache } from "src/replicache"; 2 - import { CloseTiny } from "components/Icons"; 3 import { useEntitySetContext } from "components/EntitySetProvider"; 4 import { useUIState } from "src/useUIState"; 5 ··· 68 }); 69 }} 70 > 71 - <CloseTiny /> 72 </button> 73 )} 74 </a>
··· 1 import { useEntity, useReplicache } from "src/replicache"; 2 + import { CloseTiny, TrashSmall } from "components/Icons"; 3 import { useEntitySetContext } from "components/EntitySetProvider"; 4 import { useUIState } from "src/useUIState"; 5 ··· 68 }); 69 }} 70 > 71 + <TrashSmall /> 72 </button> 73 )} 74 </a>
+587
components/Blocks/MailboxBlock.tsx
···
··· 1 + import { ButtonPrimary } from "components/Buttons"; 2 + import { ArrowDownTiny, InfoSmall } from "components/Icons"; 3 + import { Popover } from "components/Popover"; 4 + import { Menu, MenuItem, Separator } from "components/Layout"; 5 + import { useUIState } from "src/useUIState"; 6 + import { useEffect, useState } from "react"; 7 + import { useSmoker, useToaster } from "components/Toast"; 8 + import { BlockProps } from "."; 9 + import { useEntity, useReplicache } from "src/replicache"; 10 + import { AreYouSure } from "./DeleteBlock"; 11 + import { focusBlock } from "."; 12 + import { useEntitySetContext } from "components/EntitySetProvider"; 13 + import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 14 + import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; 15 + import { focusCard } from "components/Cards"; 16 + import { v7 } from "uuid"; 17 + import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 18 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 19 + import { getBlocksAsHTML } from "src/utils/getBlocksAsHTML"; 20 + import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 21 + import { 22 + addSubscription, 23 + removeSubscription, 24 + unsubscribe, 25 + useSubscriptionStatus, 26 + } from "src/hooks/useSubscriptionStatus"; 27 + import { scanIndex } from "src/replicache/utils"; 28 + import { usePageTitle } from "components/utils/UpdatePageTitle"; 29 + 30 + export const MailboxBlock = (props: BlockProps) => { 31 + let isSubscribed = useSubscriptionStatus(props.entityID); 32 + let [areYouSure, setAreYouSure] = useState(false); 33 + let isSelected = useUIState((s) => 34 + s.selectedBlock.find((b) => b.value === props.entityID), 35 + ); 36 + 37 + let card = useEntity(props.entityID, "block/card"); 38 + let cardEntity = card ? card.data.value : props.entityID; 39 + let permission = useEntitySetContext().permissions.write; 40 + 41 + let { rep } = useReplicache(); 42 + 43 + let smoke = useSmoker(); 44 + 45 + useEffect(() => { 46 + if (!isSelected) { 47 + setAreYouSure(false); 48 + } 49 + }, [isSelected]); 50 + let draft = useEntity(props.entityID, "mailbox/draft"); 51 + let entity_set = useEntitySetContext(); 52 + let archive = useEntity(props.entityID, "mailbox/archive"); 53 + 54 + useEffect(() => { 55 + if (!isSelected) return; 56 + let listener = (e: KeyboardEvent) => { 57 + let el = e.target as HTMLElement; 58 + if ( 59 + el.tagName === "INPUT" || 60 + el.tagName === "textarea" || 61 + el.contentEditable === "true" 62 + ) 63 + return; 64 + if (e.key === "Backspace" && permission) { 65 + if (e.defaultPrevented) return; 66 + if (areYouSure === false) { 67 + setAreYouSure(true); 68 + } else { 69 + e.preventDefault(); 70 + 71 + rep && 72 + rep.mutate.removeBlock({ 73 + blockEntity: props.entityID, 74 + }); 75 + 76 + props.previousBlock && 77 + focusBlock(props.previousBlock, { type: "end" }); 78 + 79 + draft && useUIState.getState().closeCard(draft.data.value); 80 + archive && useUIState.getState().closeCard(archive.data.value); 81 + } 82 + } 83 + }; 84 + window.addEventListener("keydown", listener); 85 + return () => window.removeEventListener("keydown", listener); 86 + }, [ 87 + draft, 88 + archive, 89 + areYouSure, 90 + cardEntity, 91 + isSelected, 92 + permission, 93 + props.entityID, 94 + props.previousBlock, 95 + rep, 96 + ]); 97 + 98 + let subscriber_count = useEntity(props.entityID, "mailbox/subscriber-count"); 99 + if (!permission) 100 + return ( 101 + <MailboxReaderView entityID={props.entityID} parent={props.parent} /> 102 + ); 103 + 104 + return ( 105 + <div className={`mailboxContent relative w-full flex flex-col gap-1`}> 106 + <div 107 + className={`flex flex-col gap-2 items-center justify-center w-full rounded-md border outline ${ 108 + isSelected 109 + ? "border-border outline-border" 110 + : "border-border-light outline-transparent" 111 + }`} 112 + style={{ 113 + backgroundColor: 114 + "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-card)) 85%)", 115 + }} 116 + > 117 + {!areYouSure ? ( 118 + <div className="flex gap-2 p-4"> 119 + <ButtonPrimary 120 + onClick={async () => { 121 + let entity; 122 + if (draft) { 123 + entity = draft.data.value; 124 + } else { 125 + entity = v7(); 126 + await rep?.mutate.createDraft({ 127 + mailboxEntity: props.entityID, 128 + permission_set: entity_set.set, 129 + newEntity: entity, 130 + firstBlockEntity: v7(), 131 + firstBlockFactID: v7(), 132 + }); 133 + } 134 + useUIState.getState().openCard(props.parent, entity); 135 + if (rep) focusCard(entity, rep, "focusFirstBlock"); 136 + return; 137 + }} 138 + > 139 + {draft ? "Edit Draft" : "Write a Post"} 140 + </ButtonPrimary> 141 + <MailboxInfo /> 142 + </div> 143 + ) : ( 144 + <AreYouSure 145 + entityID={props.entityID} 146 + closeAreYouSure={() => setAreYouSure(false)} 147 + onClick={() => { 148 + draft && useUIState.getState().closeCard(draft.data.value); 149 + archive && useUIState.getState().closeCard(archive.data.value); 150 + }} 151 + /> 152 + )} 153 + </div> 154 + <div className="flex gap-3 items-center justify-between"> 155 + { 156 + <> 157 + {!isSubscribed?.confirmed ? ( 158 + <SubscribePopover 159 + entityID={props.entityID} 160 + unconfirmed={!!isSubscribed && !isSubscribed.confirmed} 161 + parent={props.parent} 162 + /> 163 + ) : ( 164 + <button 165 + className="text-tertiary hover:text-accent-contrast" 166 + onClick={(e) => { 167 + let rect = e.currentTarget.getBoundingClientRect(); 168 + unsubscribe(isSubscribed); 169 + smoke({ 170 + text: "unsubscribed!", 171 + position: { x: rect.left, y: rect.top - 8 }, 172 + }); 173 + }} 174 + > 175 + Unsubscribe 176 + </button> 177 + )} 178 + <div className="flex gap-2 place-items-center"> 179 + <span className="text-tertiary"> 180 + {!subscriber_count || 181 + subscriber_count?.data.value === undefined || 182 + subscriber_count?.data.value === 0 183 + ? "no" 184 + : subscriber_count?.data.value}{" "} 185 + reader 186 + {subscriber_count?.data.value === 1 ? "" : "s"} 187 + </span> 188 + <Separator classname="h-5" /> 189 + 190 + <GoToArchive entityID={props.entityID} parent={props.parent} /> 191 + </div> 192 + </> 193 + } 194 + </div> 195 + </div> 196 + ); 197 + }; 198 + 199 + const MailboxReaderView = (props: { entityID: string; parent: string }) => { 200 + let isSubscribed = useSubscriptionStatus(props.entityID); 201 + let isSelected = useUIState((s) => 202 + s.selectedBlock.find((b) => b.value === props.entityID), 203 + ); 204 + let archive = useEntity(props.entityID, "mailbox/archive"); 205 + let smoke = useSmoker(); 206 + let { rep } = useReplicache(); 207 + return ( 208 + <div className={`mailboxContent relative w-full flex flex-col gap-1 h-32`}> 209 + <div 210 + className={`h-full flex flex-col gap-2 items-center justify-center w-full rounded-md border outline ${ 211 + isSelected 212 + ? "border-border outline-border" 213 + : "border-border-light outline-transparent" 214 + }`} 215 + style={{ 216 + backgroundColor: 217 + "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-card)) 85%)", 218 + }} 219 + > 220 + <div className="flex flex-col w-full gap-2 p-4"> 221 + {!isSubscribed ? ( 222 + <> 223 + <SubscribeForm 224 + entityID={props.entityID} 225 + role={"reader"} 226 + parent={props.parent} 227 + /> 228 + </> 229 + ) : ( 230 + <div className="flex flex-col gap-2 items-center place-self-center"> 231 + <div className=" font-bold text-secondary "> 232 + You&apos;re Subscribed! 233 + </div> 234 + <div className="flex flex-col gap-1 items-center place-self-center"> 235 + {archive ? ( 236 + <ButtonPrimary 237 + onMouseDown={(e) => { 238 + e.preventDefault(); 239 + if (rep) { 240 + useUIState 241 + .getState() 242 + .openCard(props.parent, archive.data.value); 243 + focusCard(archive.data.value, rep); 244 + } 245 + }} 246 + > 247 + See All Posts 248 + </ButtonPrimary> 249 + ) : ( 250 + <div className="text-tertiary"> 251 + Nothing has been posted yet 252 + </div> 253 + )} 254 + <button 255 + className="text-accent-contrast hover:underline text-sm" 256 + onClick={(e) => { 257 + let rect = e.currentTarget.getBoundingClientRect(); 258 + unsubscribe(isSubscribed); 259 + smoke({ 260 + text: "unsubscribed!", 261 + position: { x: rect.left, y: rect.top - 8 }, 262 + }); 263 + }} 264 + > 265 + unsubscribe 266 + </button> 267 + </div> 268 + </div> 269 + )} 270 + </div> 271 + </div> 272 + </div> 273 + ); 274 + }; 275 + 276 + const MailboxInfo = (props: { subscriber?: boolean }) => { 277 + return ( 278 + <Popover 279 + className="max-w-xs" 280 + trigger={<InfoSmall className="shrink-0 text-accent-contrast" />} 281 + content={ 282 + <div className="text-sm text-secondary flex flex-col gap-2"> 283 + {props.subscriber ? ( 284 + <> 285 + <p className="font-bold"> 286 + Get a notification whenever the creator posts to this mailbox! 287 + </p> 288 + <p> 289 + Your contact info will be kept private, and you can unsubscribe 290 + anytime. 291 + </p> 292 + </> 293 + ) : ( 294 + <> 295 + <p className="font-bold"> 296 + When you post to this mailbox, subscribers will be notified! 297 + </p> 298 + <p>Reader contact info is kept private.</p> 299 + <p>You can have one draft post at a time.</p> 300 + </> 301 + )} 302 + </div> 303 + } 304 + /> 305 + ); 306 + }; 307 + 308 + const SubscribePopover = (props: { 309 + entityID: string; 310 + parent: string; 311 + unconfirmed: boolean; 312 + }) => { 313 + return ( 314 + <Popover 315 + className="max-w-sm" 316 + trigger={ 317 + <div className="font-bold text-accent-contrast"> 318 + {props.unconfirmed ? "Confirm" : "Subscribe"} 319 + </div> 320 + } 321 + content={ 322 + <div className="text-secondary flex flex-col gap-2 py-1"> 323 + <SubscribeForm 324 + compact 325 + entityID={props.entityID} 326 + role="author" 327 + parent={props.parent} 328 + /> 329 + </div> 330 + } 331 + /> 332 + ); 333 + }; 334 + 335 + const SubscribeForm = (props: { 336 + entityID: string; 337 + parent: string; 338 + role: "author" | "reader"; 339 + compact?: boolean; 340 + }) => { 341 + let smoke = useSmoker(); 342 + let [channel, setChannel] = useState<"email" | "sms">("email"); 343 + let [email, setEmail] = useState(""); 344 + let [sms, setSMS] = useState(""); 345 + 346 + let subscription = useSubscriptionStatus(props.entityID); 347 + let [code, setCode] = useState(""); 348 + let { permission_token } = useReplicache(); 349 + if (subscription && !subscription.confirmed) { 350 + return ( 351 + <div className="flex flex-col gap-3 justify-center text-center "> 352 + <div className="font-bold text-secondary "> 353 + Enter the code we sent to{" "} 354 + <code 355 + className="italic" 356 + style={{ fontFamily: "var(--font-quattro)" }} 357 + > 358 + {subscription.email} 359 + </code>{" "} 360 + here! 361 + </div> 362 + <div className="flex flex-col gap-1"> 363 + <form 364 + onSubmit={async (e) => { 365 + e.preventDefault(); 366 + let result = await confirmEmailSubscription( 367 + subscription.id, 368 + code, 369 + ); 370 + 371 + let rect = document 372 + .getElementById("confirm-code-button") 373 + ?.getBoundingClientRect(); 374 + 375 + if (!result) { 376 + smoke({ 377 + error: true, 378 + text: "oops, incorrect code", 379 + position: { 380 + x: rect ? rect.left + 45 : 0, 381 + y: rect ? rect.top + 15 : 0, 382 + }, 383 + }); 384 + return; 385 + } 386 + addSubscription(result.subscription); 387 + }} 388 + className="mailboxConfirmCodeInput flex gap-2 items-center mx-auto" 389 + > 390 + <input 391 + type="number" 392 + value={code} 393 + className="appearance-none focus:outline-none focus:border-border w-20 border border-border-light bg-bg-card rounded-md p-1" 394 + onChange={(e) => setCode(e.currentTarget.value)} 395 + /> 396 + 397 + <ButtonPrimary type="submit" id="confirm-code-button"> 398 + Confirm! 399 + </ButtonPrimary> 400 + </form> 401 + 402 + <button 403 + onMouseDown={() => { 404 + removeSubscription(subscription); 405 + setEmail(""); 406 + }} 407 + className="text-accent-contrast hover:underline text-sm" 408 + > 409 + use another contact 410 + </button> 411 + </div> 412 + </div> 413 + ); 414 + } 415 + return ( 416 + <> 417 + <div className="flex flex-col gap-1"> 418 + <form 419 + onSubmit={async (e) => { 420 + e.preventDefault(); 421 + let subscriptionID = await subscribeToMailboxWithEmail( 422 + props.entityID, 423 + email, 424 + permission_token, 425 + ); 426 + if (subscriptionID) addSubscription(subscriptionID); 427 + }} 428 + className={`mailboxSubscribeForm flex sm:flex-row flex-col ${props.compact && "sm:flex-col sm:gap-2"} gap-2 sm:gap-3 items-center place-self-center mx-auto`} 429 + > 430 + <div className="mailboxChannelInput flex gap-2 border border-border-light bg-bg-card rounded-md py-1 px-2 grow max-w-72 "> 431 + <ChannelSelector 432 + channel={channel} 433 + setChannel={(channel) => { 434 + setChannel(channel); 435 + }} 436 + /> 437 + <Separator classname="h-6" /> 438 + {channel === "email" ? ( 439 + <input 440 + value={email} 441 + type="email" 442 + onChange={(e) => setEmail(e.target.value)} 443 + className="w-full appearance-none focus:outline-none bg-transparent" 444 + placeholder="youremail@email.com" 445 + /> 446 + ) : ( 447 + <input 448 + value={sms} 449 + type="tel" 450 + onChange={(e) => setSMS(e.target.value)} 451 + className="w-full appearance-none focus:outline-none bg-transparent" 452 + placeholder="123-456-7890" 453 + /> 454 + )} 455 + </div> 456 + <ButtonPrimary type="submit">Subscribe!</ButtonPrimary> 457 + </form> 458 + {props.role === "reader" && ( 459 + <GoToArchive entityID={props.entityID} parent={props.parent} small /> 460 + )} 461 + </div> 462 + </> 463 + ); 464 + }; 465 + 466 + const ChannelSelector = (props: { 467 + channel: "email" | "sms"; 468 + setChannel: (channel: "email" | "sms") => void; 469 + }) => { 470 + return ( 471 + <Menu 472 + className="w-20" 473 + trigger={ 474 + <div className="flex gap-2 w-16 items-center justify-between text-secondary"> 475 + {props.channel === "email" ? "Email" : "SMS"}{" "} 476 + <ArrowDownTiny className="shrink-0 text-accent-contrast" /> 477 + </div> 478 + } 479 + > 480 + <MenuItem 481 + className="font-normal" 482 + onSelect={() => { 483 + props.setChannel("email"); 484 + }} 485 + > 486 + Email 487 + </MenuItem> 488 + <MenuItem 489 + className="font-normal" 490 + onSelect={() => { 491 + props.setChannel("sms"); 492 + }} 493 + > 494 + SMS 495 + </MenuItem> 496 + </Menu> 497 + ); 498 + }; 499 + export const DraftPostOptions = (props: { mailboxEntity: string }) => { 500 + let toaster = useToaster(); 501 + let draft = useEntity(props.mailboxEntity, "mailbox/draft"); 502 + let { rep, permission_token } = useReplicache(); 503 + let entity_set = useEntitySetContext(); 504 + let archive = useEntity(props.mailboxEntity, "mailbox/archive"); 505 + let pagetitle = usePageTitle(permission_token.root_entity); 506 + let subscriber_count = useEntity( 507 + props.mailboxEntity, 508 + "mailbox/subscriber-count", 509 + ); 510 + if (!draft) return null; 511 + 512 + // once the send button is clicked, close the card and show a toast. 513 + return ( 514 + <div className="flex justify-between items-center text-sm"> 515 + <div className="flex gap-2"> 516 + <em>Draft</em> 517 + </div> 518 + <button 519 + className="font-bold text-accent-2 bg-accent-1 border hover:bg-accent-2 hover:text-accent-1 rounded-md px-2" 520 + onClick={async () => { 521 + if (!rep) return; 522 + let blocks = 523 + (await rep?.query((tx) => 524 + getBlocksWithType(tx, draft.data.value), 525 + )) || []; 526 + let html = (await getBlocksAsHTML(rep, blocks))?.join("\n"); 527 + await sendPostToSubscribers({ 528 + title: pagetitle, 529 + permission_token, 530 + mailboxEntity: props.mailboxEntity, 531 + messageEntity: draft.data.value, 532 + contents: { 533 + html, 534 + markdown: htmlToMarkdown(html), 535 + }, 536 + }); 537 + 538 + rep?.mutate.archiveDraft({ 539 + entity_set: entity_set.set, 540 + mailboxEntity: props.mailboxEntity, 541 + newBlockEntity: v7(), 542 + archiveEntity: v7(), 543 + }); 544 + 545 + toaster({ 546 + content: <div className="font-bold">Sent Post to Readers!</div>, 547 + type: "success", 548 + }); 549 + }} 550 + > 551 + Send 552 + {!subscriber_count || 553 + (subscriber_count.data.value !== 0 && 554 + ` to ${subscriber_count.data.value} Reader${subscriber_count.data.value === 1 ? "" : "s"}`)} 555 + ! 556 + </button> 557 + </div> 558 + ); 559 + }; 560 + 561 + const GoToArchive = (props: { 562 + entityID: string; 563 + parent: string; 564 + small?: boolean; 565 + }) => { 566 + let archive = useEntity(props.entityID, "mailbox/archive"); 567 + let { rep } = useReplicache(); 568 + 569 + return archive ? ( 570 + <button 571 + className={`text-tertiary hover:text-accent-contrast ${props.small && "text-sm"}`} 572 + onMouseDown={(e) => { 573 + e.preventDefault(); 574 + if (rep) { 575 + useUIState.getState().openCard(props.parent, archive.data.value); 576 + focusCard(archive.data.value, rep); 577 + } 578 + }} 579 + > 580 + past posts 581 + </button> 582 + ) : ( 583 + <div className={`text-tertiary text-center ${props.small && "text-sm"}`}> 584 + no posts yet 585 + </div> 586 + ); 587 + };
-1
components/Blocks/TextBlock/keymap.ts
··· 326 propsRef.current.nextBlock?.listData && 327 propsRef.current.nextBlock.listData.depth > 328 propsRef.current.listData.depth; 329 - console.log(propsRef); 330 position = generateKeyBetween( 331 hasChild ? null : propsRef.current.position, 332 propsRef.current.nextPosition,
··· 326 propsRef.current.nextBlock?.listData && 327 propsRef.current.nextBlock.listData.depth > 328 propsRef.current.listData.depth; 329 position = generateKeyBetween( 330 hasChild ? null : propsRef.current.position, 331 propsRef.current.nextPosition,
+7 -1
components/Blocks/index.tsx
··· 11 import { CardBlock } from "./CardBlock"; 12 import { ExternalLinkBlock } from "./ExternalLinkBlock"; 13 import { BlockOptions } from "./BlockOptions"; 14 import { useBlocks } from "src/hooks/queries/useBlocks"; 15 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 16 import { useEntitySetContext } from "components/EntitySetProvider"; ··· 296 if (e.key === "Backspace") { 297 if (!entity_set.permissions.write) return; 298 if (textBlocks[props.type]) return; 299 - if (props.type === "card") return; 300 e.preventDefault(); 301 r.mutate.removeBlock({ blockEntity: props.entityID }); 302 useUIState.getState().closeCard(props.entityID); ··· 406 <ImageBlock {...props} /> 407 ) : props.type === "link" ? ( 408 <ExternalLinkBlock {...props} /> 409 ) : null} 410 </div> 411 );
··· 11 import { CardBlock } from "./CardBlock"; 12 import { ExternalLinkBlock } from "./ExternalLinkBlock"; 13 import { BlockOptions } from "./BlockOptions"; 14 + import { MailboxBlock } from "./MailboxBlock"; 15 + 16 import { useBlocks } from "src/hooks/queries/useBlocks"; 17 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 18 import { useEntitySetContext } from "components/EntitySetProvider"; ··· 298 if (e.key === "Backspace") { 299 if (!entity_set.permissions.write) return; 300 if (textBlocks[props.type]) return; 301 + if (props.type === "card" || props.type === "mailbox") return; 302 e.preventDefault(); 303 r.mutate.removeBlock({ blockEntity: props.entityID }); 304 useUIState.getState().closeCard(props.entityID); ··· 408 <ImageBlock {...props} /> 409 ) : props.type === "link" ? ( 410 <ExternalLinkBlock {...props} /> 411 + ) : props.type === "mailbox" ? ( 412 + <div className="flex flex-col gap-4 w-full"> 413 + <MailboxBlock {...props} /> 414 + </div> 415 ) : null} 416 </div> 417 );
+1 -1
components/Buttons.tsx
··· 9 return ( 10 <button 11 {...props} 12 - className={`m-0 px-2 py-0.5 w-max 13 bg-accent-1 outline-offset-[-2px] active:outline active:outline-2 14 border border-accent-1 rounded-md 15 text-base font-bold text-accent-2
··· 9 return ( 10 <button 11 {...props} 12 + className={`m-0 px-2 py-0.5 w-max h-max 13 bg-accent-1 outline-offset-[-2px] active:outline active:outline-2 14 border border-accent-1 rounded-md 15 text-base font-bold text-accent-2
+53 -28
components/Cards.tsx
··· 7 import { Media } from "./Media"; 8 import { DesktopCardFooter } from "./DesktopFooter"; 9 import { Replicache } from "replicache"; 10 - import { Fact, ReplicacheMutators, useReplicache } from "src/replicache"; 11 import * as Popover from "@radix-ui/react-popover"; 12 import { MoreOptionsTiny, DeleteSmall, CloseTiny, PopoverArrow } from "./Icons"; 13 import { useToaster } from "./Toast"; ··· 15 import { MenuItem, Menu } from "./Layout"; 16 import { useEntitySetContext } from "./EntitySetProvider"; 17 import { HomeButton } from "./HomeButton"; 18 19 export function Cards(props: { rootCard: string }) { 20 let openCards = useUIState((s) => s.openCards); 21 22 return ( 23 <div ··· 48 <div className="flex items-stretch"> 49 <Card entityID={props.rootCard} first /> 50 </div> 51 - {openCards.map((card) => ( 52 <div className="flex items-stretch" key={card}> 53 <Card entityID={card} /> 54 </div> ··· 74 75 function Card(props: { entityID: string; first?: boolean }) { 76 let { rep } = useReplicache(); 77 78 let focusedElement = useUIState((s) => s.focusedBlock); 79 let focusedCardID = ··· 118 {!props.first && <CardOptions entityID={props.entityID} />} 119 </Media> 120 <DesktopCardFooter cardID={props.entityID} /> 121 <Blocks entityID={props.entityID} /> 122 </div> 123 <Media mobile={false}> ··· 135 return ( 136 <div className=" z-10 w-fit absolute sm:top-2 sm:-right-[18px] top-0 right-3 flex sm:flex-col flex-row-reverse gap-1 items-start"> 137 <button 138 - className="p-1 sm:p-0.5 sm:pl-0 bg-border text-bg-card sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:bg-accent-1 hover:text-accent-2" 139 onClick={() => { 140 useUIState.getState().closeCard(props.entityID); 141 }} ··· 150 const OptionsMenu = () => { 151 let toaster = useToaster(); 152 return ( 153 - <Popover.Root> 154 - <Popover.Trigger 155 - className={`cardOptionsTrigger 156 - shrink-0 sm:h-8 sm:w-5 h-5 w-8 157 - bg-bg-card text-border 158 - border sm:border-l-0 border-t-1 border-border sm:rounded-r-md sm:rounded-l-none rounded-b-md 159 - sm:hover:border-r-2 hover:border-b-2 hover:border-y-2 hover:border-t-1 160 - flex items-center justify-center`} 161 > 162 - <MoreOptionsTiny className="sm:rotate-90" /> 163 - </Popover.Trigger> 164 - <Popover.Portal> 165 - <Popover.Content align="end" sideOffset={6} className="cardOptionsMenu"> 166 - <Menu> 167 - <MenuItem 168 - onClick={(e) => { 169 - // TODO: Wire up delete card 170 - toaster(DeleteCardToast); 171 - }} 172 - > 173 - Delete Page <DeleteSmall /> 174 - </MenuItem> 175 - </Menu> 176 - </Popover.Content> 177 - </Popover.Portal> 178 - </Popover.Root> 179 ); 180 }; 181
··· 7 import { Media } from "./Media"; 8 import { DesktopCardFooter } from "./DesktopFooter"; 9 import { Replicache } from "replicache"; 10 + import { 11 + Fact, 12 + ReplicacheMutators, 13 + useReferenceToEntity, 14 + useReplicache, 15 + } from "src/replicache"; 16 import * as Popover from "@radix-ui/react-popover"; 17 import { MoreOptionsTiny, DeleteSmall, CloseTiny, PopoverArrow } from "./Icons"; 18 import { useToaster } from "./Toast"; ··· 20 import { MenuItem, Menu } from "./Layout"; 21 import { useEntitySetContext } from "./EntitySetProvider"; 22 import { HomeButton } from "./HomeButton"; 23 + import { useSearchParams } from "next/navigation"; 24 + import { useEffect } from "react"; 25 + import { DraftPostOptions } from "./Blocks/MailboxBlock"; 26 27 export function Cards(props: { rootCard: string }) { 28 let openCards = useUIState((s) => s.openCards); 29 + let params = useSearchParams(); 30 + let openCard = params.get("openCard"); 31 + useEffect(() => { 32 + if (openCard) { 33 + } 34 + }, [openCard, props.rootCard]); 35 + let cards = [...openCards]; 36 + if (openCard && !cards.includes(openCard)) cards.push(openCard); 37 38 return ( 39 <div ··· 64 <div className="flex items-stretch"> 65 <Card entityID={props.rootCard} first /> 66 </div> 67 + {cards.map((card) => ( 68 <div className="flex items-stretch" key={card}> 69 <Card entityID={card} /> 70 </div> ··· 90 91 function Card(props: { entityID: string; first?: boolean }) { 92 let { rep } = useReplicache(); 93 + let isDraft = useReferenceToEntity("mailbox/draft", props.entityID); 94 95 let focusedElement = useUIState((s) => s.focusedBlock); 96 let focusedCardID = ··· 135 {!props.first && <CardOptions entityID={props.entityID} />} 136 </Media> 137 <DesktopCardFooter cardID={props.entityID} /> 138 + {isDraft.length > 0 && ( 139 + <div 140 + className={`cardStatus pt-[6px] pb-1 ${!props.first ? "pr-10 pl-3 sm:px-4" : "px-3 sm:px-4"} border-b border-border text-tertiary`} 141 + style={{ 142 + backgroundColor: 143 + "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-card)) 85%)", 144 + }} 145 + > 146 + <DraftPostOptions mailboxEntity={isDraft[0].entity} /> 147 + </div> 148 + )} 149 <Blocks entityID={props.entityID} /> 150 </div> 151 <Media mobile={false}> ··· 163 return ( 164 <div className=" z-10 w-fit absolute sm:top-2 sm:-right-[18px] top-0 right-3 flex sm:flex-col flex-row-reverse gap-1 items-start"> 165 <button 166 + className="p-1 pt-[10px] sm:p-0.5 sm:pl-0 bg-border text-bg-card sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:bg-accent-1 hover:text-accent-2" 167 onClick={() => { 168 useUIState.getState().closeCard(props.entityID); 169 }} ··· 178 const OptionsMenu = () => { 179 let toaster = useToaster(); 180 return ( 181 + <Menu 182 + trigger={ 183 + <div 184 + className={`cardOptionsTrigger 185 + shrink-0 sm:h-8 sm:w-5 h-5 w-8 186 + bg-bg-card text-border 187 + border sm:border-l-0 border-t-1 border-border sm:rounded-r-md sm:rounded-l-none rounded-b-md 188 + sm:hover:border-r-2 hover:border-b-2 hover:border-y-2 hover:border-t-1 189 + flex items-center justify-center`} 190 + > 191 + <MoreOptionsTiny className="sm:rotate-90" /> 192 + </div> 193 + } 194 + > 195 + <MenuItem 196 + onSelect={(e) => { 197 + // TODO: Wire up delete card 198 + toaster(DeleteCardToast); 199 + }} 200 > 201 + Delete Page <DeleteSmall /> 202 + </MenuItem> 203 + </Menu> 204 ); 205 }; 206
+2 -2
components/DesktopFooter.tsx
··· 1 "use client"; 2 import { useUIState } from "src/useUIState"; 3 import { Media } from "./Media"; 4 - import { TextToolbar } from "./Toolbar"; 5 6 export function DesktopCardFooter(props: { cardID: string }) { 7 let focusedBlock = useUIState((s) => s.focusedBlock); ··· 23 if (e.currentTarget === e.target) e.preventDefault(); 24 }} 25 > 26 - <TextToolbar 27 cardID={focusedBlockParentID} 28 blockID={focusedBlock.entityID} 29 />
··· 1 "use client"; 2 import { useUIState } from "src/useUIState"; 3 import { Media } from "./Media"; 4 + import { Toolbar } from "./Toolbar"; 5 6 export function DesktopCardFooter(props: { cardID: string }) { 7 let focusedBlock = useUIState((s) => s.focusedBlock); ··· 23 if (e.currentTarget === e.target) e.preventDefault(); 24 }} 25 > 26 + <Toolbar 27 cardID={focusedBlockParentID} 28 blockID={focusedBlock.entityID} 29 />
+60
components/Icons.tsx
··· 230 ); 231 }; 232 233 export const PaintSmall = (props: Props) => { 234 return ( 235 <svg ··· 270 ); 271 }; 272 273 // TINY ICONS 16x16 274 275 export const AddTiny = (props: Props) => { ··· 305 fillRule="evenodd" 306 clipRule="evenodd" 307 d="M7.05487 2.30299L12.4568 8L7.05487 13.697C6.6718 14.101 6.05073 14.101 5.66767 13.697C5.2846 13.293 5.2846 12.638 5.66767 12.234L9.68238 8L5.66767 3.76597C5.2846 3.36198 5.2846 2.70698 5.66767 2.30299C6.05073 1.899 6.6718 1.899 7.05487 2.30299Z" 308 fill="currentColor" 309 /> 310 </svg>
··· 230 ); 231 }; 232 233 + export const MailboxSmall = (props: Props) => { 234 + return ( 235 + <svg 236 + width="24" 237 + height="24" 238 + viewBox="0 0 24 24" 239 + fill="none" 240 + xmlns="http://www.w3.org/2000/svg" 241 + {...props} 242 + > 243 + <path 244 + fillRule="evenodd" 245 + clipRule="evenodd" 246 + d="M18.1405 4.10733V14.9176C18.1405 15.2887 18.5305 15.5304 18.8628 15.3655L19.2479 15.1743C19.418 15.0899 19.5256 14.9163 19.5256 14.7264L19.5249 7.23625C19.5249 7.05424 19.6238 6.8866 19.7831 6.79857L22.2488 5.43608C22.4081 5.34806 22.507 5.18044 22.507 4.99845V2.50061C22.507 2.13292 22.1235 1.891 21.7916 2.04937L18.4251 3.65608C18.2512 3.73908 18.1405 3.91463 18.1405 4.10733ZM13.2665 5.20447L13.2658 5.20481L9.16584 7.18112C10.9757 7.42867 12.2115 8.34096 13.0005 9.50354C13.9051 10.8366 14.1943 12.4518 14.1943 13.6609V19.7119L20.874 16.5746V9.8999C20.874 9.43611 20.8228 8.90363 20.6975 8.35957C20.62 8.02319 20.8299 7.68772 21.1663 7.61026C21.5027 7.53281 21.8381 7.7427 21.9156 8.07908C22.0635 8.72117 22.124 9.34811 22.124 9.8999V16.654C22.124 17.0851 21.8776 17.4783 21.4897 17.6664L21.4828 17.6697L21.4828 17.6697L13.835 21.2617C13.7519 21.3007 13.6611 21.321 13.5693 21.321H11.8129C11.4677 21.321 11.1879 21.0412 11.1879 20.696C11.1879 20.3508 11.4677 20.071 11.8129 20.071H12.9443V18.4761L6.13761 21.6453C5.21494 22.0749 4.14744 21.5593 3.8857 20.6072L3.22811 20.87C2.16076 21.2966 1 20.5105 1 19.3611V13.6342C1 13.0109 1.35661 12.4424 1.91788 12.1712L9.49069 8.51114C9.09212 8.41656 8.64806 8.36444 8.15318 8.36444C7.22369 8.36444 6.45813 8.52476 5.82706 8.7905L5.40702 8.99297C4.84121 9.30264 4.40116 9.70358 4.05806 10.1421C3.84535 10.4139 3.45254 10.4619 3.18068 10.2492C2.90883 10.0365 2.86088 9.64367 3.07359 9.37181C3.52707 8.79224 4.10319 8.27899 4.82065 7.88892L4.82464 7.88675C4.97994 7.80248 5.14184 7.72398 5.31055 7.65182L12.7248 4.07797L12.7265 4.07714C13.6982 3.6124 14.7434 3.38306 16.0829 3.38306C16.2492 3.38306 16.4118 3.38805 16.5707 3.39786C16.9153 3.41914 17.1773 3.71568 17.156 4.0602C17.1347 4.40472 16.8382 4.66676 16.4937 4.64549C16.3611 4.6373 16.2242 4.63306 16.0829 4.63306C14.9016 4.63306 14.0456 4.83202 13.2665 5.20447ZM12.9443 13.9836V17.0972L5.61 20.5121C5.38343 20.6176 5.11901 20.4761 5.08106 20.229L4.27726 14.9961C4.25197 14.8314 4.33833 14.6698 4.48927 14.5993L12.3675 10.921C12.5404 11.297 12.6688 11.6895 12.7601 12.0787C12.747 12.094 12.7345 12.1101 12.7226 12.1269L10.2306 15.6673L5.96792 14.9839C5.62709 14.9293 5.30651 15.1613 5.25187 15.5021C5.19723 15.8429 5.42923 16.1635 5.77006 16.2182L10.4181 16.9633C10.6539 17.0011 10.8906 16.9012 11.0281 16.7059L12.9443 13.9836ZM10.9801 9.17961C11.2561 9.37907 11.4945 9.60675 11.7005 9.85293L3.96045 13.4667C3.30638 13.7721 2.93216 14.4723 3.04175 15.1858L3.68032 19.3431L2.76418 19.7093C2.51787 19.8077 2.25 19.6263 2.25 19.3611V13.6342C2.25 13.4904 2.3323 13.3592 2.46182 13.2966L10.9801 9.17961Z" 247 + fill="currentColor" 248 + /> 249 + </svg> 250 + ); 251 + }; 252 + 253 export const PaintSmall = (props: Props) => { 254 return ( 255 <svg ··· 290 ); 291 }; 292 293 + export const TrashSmall = (props: Props) => { 294 + return ( 295 + <svg 296 + width="24" 297 + height="24" 298 + viewBox="0 0 24 24" 299 + fill="none" 300 + xmlns="http://www.w3.org/2000/svg" 301 + {...props} 302 + > 303 + <path 304 + fillRule="evenodd" 305 + clipRule="evenodd" 306 + d="M10.682 3.11103C9.4724 3.23448 8.89352 3.67017 8.73295 4.09426C8.57457 4.51256 8.71273 5.20871 9.51183 6.08877C9.86249 6.47497 10.3151 6.86398 10.8567 7.2317C11.521 7.68272 12.3093 8.09493 13.1874 8.42742C14.7679 9.02584 16.286 9.23267 17.4457 9.11431C18.6553 8.99087 19.2342 8.55517 19.3948 8.13109C19.5553 7.707 19.4102 6.99716 18.5856 6.10354C17.7951 5.24678 16.5208 4.39634 14.9403 3.79793C13.3598 3.19951 11.8417 2.99268 10.682 3.11103ZM10.5297 1.61879C11.9666 1.47215 13.7204 1.73212 15.4714 2.39511C17.2225 3.0581 18.7086 4.02485 19.6881 5.08636C20.6335 6.11101 21.2677 7.42054 20.7976 8.66223C20.4763 9.51081 19.7476 10.0415 18.8756 10.3377L18.8512 10.5864C18.8085 11.0201 18.7488 11.6195 18.6804 12.2826C18.5439 13.6052 18.3721 15.193 18.2332 16.2206C18.0958 17.2367 17.9216 18.1654 17.782 18.8394C17.7065 19.2037 17.6277 19.5677 17.5385 19.9289C17.3496 20.8304 16.5864 21.4845 15.7038 21.895C14.7878 22.321 13.5933 22.5626 12.2528 22.5626C9.70265 22.5626 7.61297 21.8776 6.98728 20.0005C6.8862 19.6168 6.80407 19.2278 6.72362 18.8394C6.58403 18.1654 6.40982 17.2367 6.27245 16.2206C6.13295 15.1888 5.96088 13.4661 5.82457 12.0139C5.75617 11.2852 5.69637 10.6201 5.65367 10.1372C5.63217 9.89407 5.61097 9.65088 5.58999 9.40767C5.57932 9.28304 5.58998 9.16406 5.60549 9.03837C5.71664 8.13769 6.61126 7.27662 8.06548 6.69616C7.34029 5.75314 6.92513 4.63277 7.33013 3.56312C7.80026 2.32143 9.14275 1.76033 10.5297 1.61879ZM17.1289 10.6396C17.1998 10.6367 17.2702 10.633 17.3398 10.6283C17.2993 11.0382 17.2469 11.56 17.1883 12.1287C17.0517 13.4526 16.882 15.0185 16.7467 16.0196C16.5497 17.4769 16.2709 18.7602 16.145 19.3048C16.121 19.4087 16.0961 19.5125 16.0714 19.6162C16.0259 19.8437 15.774 20.2081 15.0713 20.5349C14.3961 20.8489 13.4274 21.0626 12.2528 21.0626C9.79771 21.0626 8.71725 20.3951 8.41916 19.552C8.39914 19.4697 8.37966 19.3873 8.36059 19.3048C8.31867 19.1234 8.25973 18.86 8.19244 18.5352C8.05767 17.8845 7.89033 16.9916 7.75893 16.0196C7.62416 15.0228 7.45468 13.3299 7.31801 11.8738C7.28819 11.5561 7.26001 11.2505 7.23417 10.9669C7.3779 11.0411 7.52304 11.1123 7.66588 11.1824C7.78804 11.2424 7.90851 11.3016 8.02495 11.361C8.50725 11.6069 9.07475 11.811 9.70356 11.9612C10.4756 12.1457 11.3401 12.249 12.2528 12.249C14.3262 12.249 16.1049 11.5603 17.1289 10.6396ZM13.8382 5.4146C13.6815 5.78286 13.2605 5.9648 12.8828 5.82179C12.4954 5.67512 12.3002 5.24219 12.4469 4.85481C12.5697 4.53047 12.8562 4.18935 13.3052 4.0074C13.7683 3.8197 14.3267 3.83032 14.949 4.06593C15.5712 4.30154 15.9966 4.66339 16.2193 5.11078C16.4352 5.54447 16.4239 5.98976 16.3011 6.31411C16.1544 6.70148 15.7215 6.89661 15.3341 6.74994C14.9564 6.60693 14.7615 6.19178 14.8879 5.81206C14.8869 5.8057 14.8841 5.79446 14.8765 5.77925C14.8604 5.74695 14.7761 5.60439 14.4178 5.46874C14.0596 5.33309 13.9019 5.38404 13.8685 5.39759C13.8528 5.40397 13.8432 5.41054 13.8382 5.4146ZM9.032 12.9912C9.39109 12.9721 9.67224 13.2642 9.69072 13.6111C9.71588 14.0747 9.7415 14.5382 9.76856 15.0016C9.81412 15.782 9.87062 16.6895 9.91555 17.2239C9.98125 18.0053 10.0742 18.693 10.1161 18.9847C10.1701 19.3602 10.074 19.7752 9.6166 19.8464C9.17393 19.9154 8.93368 19.5441 8.87883 19.1625C8.83468 18.8554 8.73864 18.1456 8.66995 17.3287C8.62347 16.7759 8.56612 15.8528 8.52068 15.0745C8.4935 14.609 8.46777 14.1434 8.44249 13.6778C8.42412 13.333 8.67585 13.01 9.032 12.9912ZM16.0647 13.6483C16.083 13.3036 15.8183 13.0094 15.4736 12.9912C15.1289 12.9729 14.8347 13.238 14.8164 13.5827L14.8164 13.584C14.7907 14.0566 14.7647 14.5292 14.7371 15.0016C14.6915 15.782 14.635 16.6895 14.5901 17.2239C14.5463 17.7451 14.4905 18.2237 14.4456 18.5723C14.4215 18.7593 14.3965 18.9464 14.3676 19.1328C14.3145 19.4738 14.548 19.7933 14.889 19.8464C15.2301 19.8995 15.5496 19.666 15.6027 19.325L15.6029 19.324C15.6333 19.1271 15.6599 18.9296 15.6853 18.732C15.7318 18.3708 15.7899 17.8731 15.8357 17.3287C15.8822 16.7759 15.9395 15.8528 15.9849 15.0745C16.0127 14.5998 16.0389 14.1251 16.0646 13.6503L16.0647 13.6483ZM12.9954 14.4678C12.9954 14.1226 12.7156 13.8428 12.3704 13.8428C12.0253 13.8428 11.7454 14.1226 11.7454 14.4678L11.7454 19.3657C11.7454 19.7109 12.0253 19.9907 12.3704 19.9907C12.7156 19.9907 12.9954 19.7109 12.9954 19.3657L12.9954 14.4678ZM8.34005 10.25C8.61619 10.25 8.84005 10.0261 8.84005 9.75C8.84005 9.47386 8.61619 9.25 8.34005 9.25C8.06391 9.25 7.84005 9.47386 7.84005 9.75C7.84005 10.0261 8.06391 10.25 8.34005 10.25ZM8.34005 10.75C8.89233 10.75 9.34005 10.3023 9.34005 9.75C9.34005 9.19772 8.89233 8.75 8.34005 8.75C7.78777 8.75 7.34005 9.19772 7.34005 9.75C7.34005 10.3023 7.78777 10.75 8.34005 10.75ZM11.4369 10.75C11.713 10.75 11.9369 10.5261 11.9369 10.25C11.9369 9.97386 11.713 9.75 11.4369 9.75C11.1607 9.75 10.9369 9.97386 10.9369 10.25C10.9369 10.5261 11.1607 10.75 11.4369 10.75ZM11.4369 11.25C11.9891 11.25 12.4369 10.8023 12.4369 10.25C12.4369 9.69772 11.9891 9.25 11.4369 9.25C10.8846 9.25 10.4369 9.69772 10.4369 10.25C10.4369 10.8023 10.8846 11.25 11.4369 11.25Z" 307 + fill="currentColor" 308 + /> 309 + </svg> 310 + ); 311 + }; 312 + 313 // TINY ICONS 16x16 314 315 export const AddTiny = (props: Props) => { ··· 345 fillRule="evenodd" 346 clipRule="evenodd" 347 d="M7.05487 2.30299L12.4568 8L7.05487 13.697C6.6718 14.101 6.05073 14.101 5.66767 13.697C5.2846 13.293 5.2846 12.638 5.66767 12.234L9.68238 8L5.66767 3.76597C5.2846 3.36198 5.2846 2.70698 5.66767 2.30299C6.05073 1.899 6.6718 1.899 7.05487 2.30299Z" 348 + fill="currentColor" 349 + /> 350 + </svg> 351 + ); 352 + }; 353 + 354 + export const ArrowDownTiny = (props: Props) => { 355 + return ( 356 + <svg 357 + width="16" 358 + height="16" 359 + viewBox="0 0 16 16" 360 + fill="none" 361 + xmlns="http://www.w3.org/2000/svg" 362 + {...props} 363 + > 364 + <path 365 + fillRule="evenodd" 366 + clipRule="evenodd" 367 + d="M13.697 7.05487L8 12.4568L2.30299 7.05487C1.899 6.6718 1.899 6.05073 2.30299 5.66767C2.70698 5.2846 3.36198 5.2846 3.76597 5.66767L8 9.68238L12.234 5.66767C12.638 5.2846 13.293 5.2846 13.697 5.66767C14.101 6.05073 14.101 6.6718 13.697 7.05487Z" 368 fill="currentColor" 369 /> 370 </svg>
+53 -9
components/Layout.tsx
··· 1 export const Separator = (props: { classname?: string }) => { 2 return ( 3 <div className={`min-h-full border-r border-border ${props.classname}`} /> 4 ); 5 }; 6 7 - export const Menu = (props: { children?: React.ReactNode }) => { 8 return ( 9 - <div className="dropdownMenu bg-bg-card flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md"> 10 - {props.children} 11 - </div> 12 ); 13 }; 14 15 export const MenuItem = (props: { 16 children?: React.ReactNode; 17 - onClick: (e: React.MouseEvent) => void; 18 }) => { 19 return ( 20 - <button 21 - onClick={(e) => props.onClick(e)} 22 - className="MenuItem font-bold z-10 text-left text-secondary py-1 px-3 flex gap-2 hover:bg-border-light hover:text-secondary " 23 > 24 {props.children} 25 - </button> 26 ); 27 }; 28
··· 1 + import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 2 + import { theme } from "tailwind.config"; 3 + import { PopoverArrow } from "./Icons"; 4 + 5 export const Separator = (props: { classname?: string }) => { 6 return ( 7 <div className={`min-h-full border-r border-border ${props.classname}`} /> 8 ); 9 }; 10 11 + export const Menu = (props: { 12 + trigger: React.ReactNode; 13 + children: React.ReactNode; 14 + align?: "start" | "end" | "center"; 15 + background?: string; 16 + border?: string; 17 + className?: string; 18 + }) => { 19 return ( 20 + <DropdownMenu.Root> 21 + <DropdownMenu.Trigger>{props.trigger}</DropdownMenu.Trigger> 22 + <DropdownMenu.Portal> 23 + <DropdownMenu.Content 24 + align={props.align ? props.align : "center"} 25 + sideOffset={4} 26 + collisionPadding={16} 27 + className={`dropdownMenu z-20 bg-bg-card flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`} 28 + > 29 + {props.children} 30 + <DropdownMenu.Arrow asChild width={16} height={8} viewBox="0 0 16 8"> 31 + <PopoverArrow 32 + arrowFill={ 33 + props.background ? props.background : theme.colors["bg-card"] 34 + } 35 + arrowStroke={props.border ? props.border : theme.colors["border"]} 36 + /> 37 + </DropdownMenu.Arrow> 38 + </DropdownMenu.Content> 39 + </DropdownMenu.Portal> 40 + </DropdownMenu.Root> 41 ); 42 }; 43 44 export const MenuItem = (props: { 45 children?: React.ReactNode; 46 + className?: string; 47 + onSelect: (e: Event) => void; 48 + id?: string; 49 }) => { 50 return ( 51 + <DropdownMenu.Item 52 + id={props.id} 53 + onSelect={(event) => { 54 + props.onSelect(event); 55 + }} 56 + className={` 57 + MenuItem 58 + font-bold z-10 py-1 px-3 59 + text-left text-secondary 60 + flex gap-2 61 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 62 + hover:bg-border-light hover:text-secondary 63 + outline-none 64 + cursor-pointer 65 + ${props.className} 66 + `} 67 > 68 {props.children} 69 + </DropdownMenu.Item> 70 ); 71 }; 72
+2 -2
components/MobileFooter.tsx
··· 2 import { useUIState } from "src/useUIState"; 3 import { Media } from "./Media"; 4 import { ThemePopover } from "./ThemeManager/ThemeSetter"; 5 - import { TextToolbar } from "components/Toolbar"; 6 import { ShareOptions } from "./ShareOptions"; 7 import { HomeButton } from "./HomeButton"; 8 ··· 18 if (e.currentTarget === e.target) e.preventDefault(); 19 }} 20 > 21 - <TextToolbar 22 cardID={focusedBlock.parent} 23 blockID={focusedBlock.entityID} 24 />
··· 2 import { useUIState } from "src/useUIState"; 3 import { Media } from "./Media"; 4 import { ThemePopover } from "./ThemeManager/ThemeSetter"; 5 + import { Toolbar } from "components/Toolbar"; 6 import { ShareOptions } from "./ShareOptions"; 7 import { HomeButton } from "./HomeButton"; 8 ··· 18 if (e.currentTarget === e.target) e.preventDefault(); 19 }} 20 > 21 + <Toolbar 22 cardID={focusedBlock.parent} 23 blockID={focusedBlock.entityID} 24 />
+36
components/Popover.tsx
···
··· 1 + import * as RadixPopover from "@radix-ui/react-popover"; 2 + import { PopoverArrow } from "./Icons"; 3 + import { theme } from "tailwind.config"; 4 + 5 + export const Popover = (props: { 6 + trigger: React.ReactNode; 7 + content: React.ReactNode; 8 + align?: "start" | "end" | "center"; 9 + background?: string; 10 + border?: string; 11 + className?: string; 12 + }) => { 13 + return ( 14 + <RadixPopover.Root> 15 + <RadixPopover.Trigger>{props.trigger}</RadixPopover.Trigger> 16 + <RadixPopover.Portal> 17 + <RadixPopover.Content 18 + className={`z-20 bg-bg-card border border-border rounded-md px-3 py-2 ${props.className}`} 19 + align={props.align ? props.align : "center"} 20 + sideOffset={4} 21 + collisionPadding={16} 22 + > 23 + {props.content} 24 + <RadixPopover.Arrow asChild width={16} height={8} viewBox="0 0 16 8"> 25 + <PopoverArrow 26 + arrowFill={ 27 + props.background ? props.background : theme.colors["bg-card"] 28 + } 29 + arrowStroke={props.border ? props.border : theme.colors["border"]} 30 + /> 31 + </RadixPopover.Arrow> 32 + </RadixPopover.Content> 33 + </RadixPopover.Portal> 34 + </RadixPopover.Root> 35 + ); 36 + };
-1
components/SelectionManager.tsx
··· 86 let [sortedBlocks, siblings] = await getSortedSelection(); 87 for (let block of sortedBlocks) { 88 if (!block.listData) { 89 - console.log("yo?"); 90 await rep?.mutate.assertFact({ 91 entity: block.value, 92 attribute: "block/is-list",
··· 86 let [sortedBlocks, siblings] = await getSortedSelection(); 87 for (let block of sortedBlocks) { 88 if (!block.listData) { 89 await rep?.mutate.assertFact({ 90 entity: block.value, 91 attribute: "block/is-list",
+64 -66
components/ShareOptions/index.tsx
··· 1 import { useReplicache } from "src/replicache"; 2 - import { PopoverArrow, ShareSmall } from "components/Icons"; 3 import { useEffect, useState } from "react"; 4 import { getShareLink } from "./getShareLink"; 5 import { useEntitySetContext } from "components/EntitySetProvider"; 6 import { useSmoker } from "components/Toast"; 7 - import * as Popover from "@radix-ui/react-popover"; 8 import { Menu, MenuItem } from "components/Layout"; 9 - import { theme } from "tailwind.config"; 10 import { HoverButton } from "components/Buttons"; 11 12 export function ShareOptions(props: { rootEntity: string }) { ··· 37 return null; 38 39 return ( 40 - <Popover.Root> 41 - <Popover.Trigger> 42 <HoverButton 43 icon=<ShareSmall /> 44 label="Share" 45 background="bg-accent-1" 46 text="text-accent-2" 47 /> 48 - </Popover.Trigger> 49 - <Popover.Portal> 50 - <Popover.Content 51 - className="z-20" 52 - align="center" 53 - sideOffset={4} 54 - collisionPadding={16} 55 - > 56 - <Menu> 57 - <MenuItem 58 - onClick={(e) => { 59 - if (link) { 60 - navigator.clipboard.writeText( 61 - `${location.protocol}//${location.host}/${link}`, 62 - ); 63 - smoker({ 64 - position: { x: e.clientX, y: e.clientY }, 65 - text: "Publish link copied!", 66 - }); 67 - } 68 - }} 69 - > 70 - <div className="group/publish"> 71 - <div className=" group-hover/publish:text-accent-contrast"> 72 - Publish 73 - </div> 74 - <div className="text-sm font-normal text-tertiary group-hover/publish:text-accent-contrast"> 75 - Share a read only version of this doc 76 - </div> 77 - </div> 78 - </MenuItem> 79 - <MenuItem 80 - onClick={(e) => { 81 - if (link) { 82 - navigator.clipboard.writeText(`${window.location.href}`); 83 - smoker({ 84 - position: { x: e.clientX, y: e.clientY }, 85 - text: "Collab link copied!", 86 - }); 87 - } 88 - }} 89 - > 90 - <div className="group/collab"> 91 - <div className="group-hover/collab:text-accent-contrast"> 92 - Collaborate 93 - </div> 94 - <div className="text-sm font-normal text-tertiary group-hover/collab:text-accent-contrast"> 95 - Invite people to work together 96 - </div> 97 - </div> 98 - </MenuItem> 99 - </Menu> 100 - <Popover.Arrow asChild width={16} height={8} viewBox="0 0 16 8"> 101 - <PopoverArrow 102 - arrowFill={theme.colors["bg-card"]} 103 - arrowStroke={theme.colors["border"]} 104 - /> 105 - </Popover.Arrow> 106 - </Popover.Content> 107 - </Popover.Portal> 108 - </Popover.Root> 109 ); 110 }
··· 1 import { useReplicache } from "src/replicache"; 2 + import { ShareSmall } from "components/Icons"; 3 import { useEffect, useState } from "react"; 4 import { getShareLink } from "./getShareLink"; 5 import { useEntitySetContext } from "components/EntitySetProvider"; 6 import { useSmoker } from "components/Toast"; 7 import { Menu, MenuItem } from "components/Layout"; 8 import { HoverButton } from "components/Buttons"; 9 10 export function ShareOptions(props: { rootEntity: string }) { ··· 35 return null; 36 37 return ( 38 + <Menu 39 + trigger={ 40 <HoverButton 41 icon=<ShareSmall /> 42 label="Share" 43 background="bg-accent-1" 44 text="text-accent-2" 45 /> 46 + } 47 + > 48 + <MenuItem 49 + id="get-publish-link" 50 + onSelect={(event) => { 51 + event.preventDefault(); 52 + let rect = document 53 + .getElementById("get-publish-link") 54 + ?.getBoundingClientRect(); 55 + if (link) { 56 + navigator.clipboard.writeText( 57 + `${location.protocol}//${location.host}/${link}`, 58 + ); 59 + smoker({ 60 + position: { 61 + x: rect ? rect.left + 80 : 0, 62 + y: rect ? rect.top + 26 : 0, 63 + }, 64 + text: "Publish link copied!", 65 + }); 66 + } 67 + }} 68 + > 69 + <div className="group/publish"> 70 + <div className=" group-hover/publish:text-accent-contrast"> 71 + Publish 72 + </div> 73 + <div className="text-sm font-normal text-tertiary group-hover/publish:text-accent-contrast"> 74 + Share a read only version of this doc 75 + </div> 76 + </div> 77 + </MenuItem> 78 + <MenuItem 79 + id="get-collab-link" 80 + onSelect={(event) => { 81 + event.preventDefault(); 82 + let rect = document 83 + .getElementById("get-collab-link") 84 + ?.getBoundingClientRect(); 85 + if (link) { 86 + navigator.clipboard.writeText(`${window.location.href}`); 87 + smoker({ 88 + position: { 89 + x: rect ? rect.left + 80 : 0, 90 + y: rect ? rect.top + 26 : 0, 91 + }, 92 + text: "Collab link copied!", 93 + }); 94 + } 95 + }} 96 + > 97 + <div className="group/collab"> 98 + <div className="group-hover/collab:text-accent-contrast"> 99 + Collaborate 100 + </div> 101 + <div className="text-sm font-normal text-tertiary group-hover/collab:text-accent-contrast"> 102 + Invite people to work together 103 + </div> 104 + </div> 105 + </MenuItem>{" "} 106 + </Menu> 107 ); 108 }
+7 -7
components/Toast.tsx
··· 59 () => { 60 setToastState(null); 61 }, 62 - toast?.duration ? toast.duration : 3000, 63 ); 64 }, 65 [setToastState], ··· 84 setToast: (t: Toast | null) => void; 85 }) => { 86 let transitions = useTransition(props.toast ? [props.toast] : [], { 87 - from: { top: -30 }, 88 enter: { top: 8 }, 89 - leave: { top: -30 }, 90 config: { 91 mass: 8, 92 friction: 150, ··· 101 className={`toastAnimationWrapper fixed bottom-0 right-0 left-0 z-50 h-fit`} 102 > 103 <div 104 - className={`toast absolute right-2 w-max px-3 py-1 flex flex-row gap-2 rounded-full border text-center ${ 105 props.toast?.type === "error" 106 - ? "bg-accent-red text-white" 107 : props.toast?.type === "success" 108 - ? "bg-accent-green text-white" 109 - : "bg-accent-1 text-accent-2 shadow-md border border-border" 110 }`} 111 > 112 <div className="flex gap-2 grow justify-center">{item.content}</div>
··· 59 () => { 60 setToastState(null); 61 }, 62 + toast?.duration ? toast.duration : 6000, 63 ); 64 }, 65 [setToastState], ··· 84 setToast: (t: Toast | null) => void; 85 }) => { 86 let transitions = useTransition(props.toast ? [props.toast] : [], { 87 + from: { top: -40 }, 88 enter: { top: 8 }, 89 + leave: { top: -40 }, 90 config: { 91 mass: 8, 92 friction: 150, ··· 101 className={`toastAnimationWrapper fixed bottom-0 right-0 left-0 z-50 h-fit`} 102 > 103 <div 104 + className={`toast absolute right-2 w-max shadow-md px-3 py-1 flex flex-row gap-2 rounded-full border text-center ${ 105 props.toast?.type === "error" 106 + ? "bg-accent-red text-white border-white" 107 : props.toast?.type === "success" 108 + ? "bg-accent-1 text-accent-2 border border-accent-2" 109 + : "bg-accent-1 text-accent-2 border border-accent-2" 110 }`} 111 > 112 <div className="flex gap-2 grow justify-center">{item.content}</div>
+117 -18
components/Toolbar/HighlightButton.tsx components/Toolbar/HighlightToolbar.tsx
··· 1 - import { setEditorState, useEditorStates } from "src/state/useEditorState"; 2 import { useUIState } from "src/useUIState"; 3 import { schema } from "components/Blocks/TextBlock/schema"; 4 import { TextSelection } from "prosemirror-state"; 5 - import { toggleMarkInFocusedBlock } from "./TextDecorationButton"; 6 import * as Popover from "@radix-ui/react-popover"; 7 import * as Tooltip from "@radix-ui/react-tooltip"; 8 - 9 import { theme } from "../../tailwind.config"; 10 - 11 - import { 12 - ColorPicker as SpectrumColorPicker, 13 - parseColor, 14 - Color, 15 - ColorArea, 16 - ColorThumb, 17 - ColorSlider, 18 - Input, 19 - ColorField, 20 - SliderTrack, 21 - ColorSwatch, 22 - } from "react-aria-components"; 23 import { 24 ColorPicker, 25 pickers, ··· 27 setColorAttribute, 28 } from "components/ThemeManager/ThemeSetter"; 29 import { useEntity, useReplicache } from "src/replicache"; 30 - import { useMemo, useState } from "react"; 31 import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 32 import { useParams } from "next/navigation"; 33 import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 34 - import { PaintSmall, PopoverArrow } from "components/Icons"; 35 import { ToolbarButton } from "."; 36 37 export const HighlightColorButton = (props: { 38 color: "1" | "2" | "3";
··· 1 + import { useEditorStates } from "src/state/useEditorState"; 2 import { useUIState } from "src/useUIState"; 3 import { schema } from "components/Blocks/TextBlock/schema"; 4 import { TextSelection } from "prosemirror-state"; 5 + import { 6 + TextDecorationButton, 7 + toggleMarkInFocusedBlock, 8 + } from "./TextDecorationButton"; 9 import * as Popover from "@radix-ui/react-popover"; 10 import * as Tooltip from "@radix-ui/react-tooltip"; 11 import { theme } from "../../tailwind.config"; 12 import { 13 ColorPicker, 14 pickers, ··· 16 setColorAttribute, 17 } from "components/ThemeManager/ThemeSetter"; 18 import { useEntity, useReplicache } from "src/replicache"; 19 + import { useEffect, useMemo, useState } from "react"; 20 import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 21 import { useParams } from "next/navigation"; 22 import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 23 + import { 24 + ArrowRightTiny, 25 + HighlightSmall, 26 + PaintSmall, 27 + PopoverArrow, 28 + } from "components/Icons"; 29 + import { Separator, ShortcutKey } from "components/Layout"; 30 + import { isMac } from "@react-aria/utils"; 31 import { ToolbarButton } from "."; 32 + 33 + export const HighlightButton = (props: { 34 + lastUsedHighlight: string; 35 + setToolbarState: (s: "highlight") => void; 36 + }) => { 37 + return ( 38 + <div className="flex items-center gap-1"> 39 + <TextDecorationButton 40 + tooltipContent={ 41 + <div className="flex flex-col gap-1 justify-center"> 42 + <div className="text-center bg-border-light w-fit rounded-md px-0.5 mx-auto "> 43 + Highlight 44 + </div> 45 + <div className="flex gap-1"> 46 + {isMac() ? ( 47 + <> 48 + <ShortcutKey>⌘</ShortcutKey> +{" "} 49 + <ShortcutKey> Ctrl </ShortcutKey> +{" "} 50 + <ShortcutKey> H </ShortcutKey> 51 + </> 52 + ) : ( 53 + <> 54 + <ShortcutKey> Ctrl </ShortcutKey> +{" "} 55 + <ShortcutKey> Meta </ShortcutKey> +{" "} 56 + <ShortcutKey> H </ShortcutKey> 57 + </> 58 + )} 59 + </div> 60 + </div> 61 + } 62 + attrs={{ color: props.lastUsedHighlight }} 63 + mark={schema.marks.highlight} 64 + icon={ 65 + <HighlightSmall 66 + highlightColor={ 67 + props.lastUsedHighlight === "1" 68 + ? theme.colors["highlight-1"] 69 + : props.lastUsedHighlight === "2" 70 + ? theme.colors["highlight-2"] 71 + : theme.colors["highlight-3"] 72 + } 73 + /> 74 + } 75 + /> 76 + 77 + <ToolbarButton 78 + tooltipContent="Change Highlight Color" 79 + onClick={() => { 80 + props.setToolbarState("highlight"); 81 + }} 82 + className="-ml-1" 83 + > 84 + <ArrowRightTiny /> 85 + </ToolbarButton> 86 + </div> 87 + ); 88 + }; 89 + 90 + export const HighlightToolbar = (props: { 91 + onClose: () => void; 92 + lastUsedHighlight: "1" | "2" | "3"; 93 + setLastUsedHighlight: (color: "1" | "2" | "3") => void; 94 + }) => { 95 + let focusedBlock = useUIState((s) => s.focusedBlock); 96 + let focusedEditor = useEditorStates((s) => 97 + focusedBlock ? s.editorStates[focusedBlock.entityID] : null, 98 + ); 99 + let [initialRender, setInitialRender] = useState(true); 100 + useEffect(() => { 101 + setInitialRender(false); 102 + }, []); 103 + 104 + useEffect(() => { 105 + // we're not returning initialRender in the dependancy array on purpose! although admittedly, can't remember why not... 106 + if (initialRender) return; 107 + if (focusedEditor) props.onClose(); 108 + }, [focusedEditor, props]); 109 + 110 + return ( 111 + <div className="flex w-full justify-between items-center gap-4 text-secondary"> 112 + <div className="flex items-center gap-[6px]"> 113 + <HighlightColorButton 114 + color="1" 115 + lastUsedHighlight={props.lastUsedHighlight} 116 + setLastUsedHightlight={props.setLastUsedHighlight} 117 + /> 118 + <HighlightColorButton 119 + color="2" 120 + lastUsedHighlight={props.lastUsedHighlight} 121 + setLastUsedHightlight={props.setLastUsedHighlight} 122 + /> 123 + <HighlightColorButton 124 + color="3" 125 + lastUsedHighlight={props.lastUsedHighlight} 126 + setLastUsedHightlight={props.setLastUsedHighlight} 127 + /> 128 + 129 + <Separator classname="h-6" /> 130 + <HighlightColorSettings /> 131 + </div> 132 + </div> 133 + ); 134 + }; 135 136 export const HighlightColorButton = (props: { 137 color: "1" | "2" | "3";
+1 -1
components/Toolbar/LinkButton.tsx components/Toolbar/InlineLinkToolbar.tsx
··· 45 ); 46 } 47 48 - export function LinkEditor(props: { onClose: () => void }) { 49 let focusedBlock = useUIState((s) => s.focusedBlock); 50 let focusedEditor = useEditorStates((s) => 51 focusedBlock ? s.editorStates[focusedBlock.entityID] : null,
··· 45 ); 46 } 47 48 + export function InlineLinkToolbar(props: { onClose: () => void }) { 49 let focusedBlock = useUIState((s) => s.focusedBlock); 50 let focusedEditor = useEditorStates((s) => 51 focusedBlock ? s.editorStates[focusedBlock.entityID] : null,
+2 -1
components/Toolbar/ListButton.tsx components/Toolbar/ListToolbar.tsx
··· 38 </div> 39 </div> 40 } 41 - onClick={() => { 42 if (!focusedBlock) return; 43 if (!isList?.data.value) { 44 rep?.mutate.assertFact({
··· 38 </div> 39 </div> 40 } 41 + onClick={(e) => { 42 + e.preventDefault(); 43 if (!focusedBlock) return; 44 if (!isList?.data.value) { 45 rep?.mutate.assertFact({
+2 -2
components/Toolbar/TextBlockTypeButtons.tsx components/Toolbar/TextBlockTypeToolbar.tsx
··· 4 Header3Small, 5 ParagraphSmall, 6 } from "components/Icons"; 7 - import { Separator, ShortcutKey } from "components/Layout"; 8 import { ToolbarButton } from "components/Toolbar"; 9 import { TextSelection } from "prosemirror-state"; 10 import { useCallback } from "react"; ··· 12 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 13 import { useUIState } from "src/useUIState"; 14 15 - export const TextBlockTypeButtons = (props: { 16 onClose: () => void; 17 className?: string; 18 }) => {
··· 4 Header3Small, 5 ParagraphSmall, 6 } from "components/Icons"; 7 + import { ShortcutKey } from "components/Layout"; 8 import { ToolbarButton } from "components/Toolbar"; 9 import { TextSelection } from "prosemirror-state"; 10 import { useCallback } from "react"; ··· 12 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 13 import { useUIState } from "src/useUIState"; 14 15 + export const TextBlockTypeToolbar = (props: { 16 onClose: () => void; 17 className?: string; 18 }) => {
+81
components/Toolbar/TextToolbar.tsx
···
··· 1 + import { isMac } from "@react-aria/utils"; 2 + import { BoldSmall, ItalicSmall, StrikethroughSmall } from "components/Icons"; 3 + import { Separator, ShortcutKey } from "components/Layout"; 4 + import { metaKey } from "src/utils/metaKey"; 5 + import { LinkButton } from "./InlineLinkToolbar"; 6 + import { ListButton } from "./ListToolbar"; 7 + import { TextBlockTypeButton } from "./TextBlockTypeToolbar"; 8 + import { TextDecorationButton } from "./TextDecorationButton"; 9 + import { HighlightButton } from "./HighlightToolbar"; 10 + import { ToolbarTypes } from "."; 11 + import { schema } from "components/Blocks/TextBlock/schema"; 12 + 13 + export const TextToolbar = (props: { 14 + lastUsedHighlight: string; 15 + setToolbarState: (s: ToolbarTypes) => void; 16 + }) => { 17 + return ( 18 + <> 19 + <TextDecorationButton 20 + tooltipContent={ 21 + <div className="flex flex-col gap-1 justify-center"> 22 + <div className="text-center">Bold </div> 23 + <div className="flex gap-1"> 24 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 25 + <ShortcutKey> B </ShortcutKey> 26 + </div> 27 + </div> 28 + } 29 + mark={schema.marks.strong} 30 + icon={<BoldSmall />} 31 + /> 32 + <TextDecorationButton 33 + tooltipContent=<div className="flex flex-col gap-1 justify-center"> 34 + <div className="italic font-normal text-center">Italic</div> 35 + <div className="flex gap-1"> 36 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 37 + <ShortcutKey> I </ShortcutKey> 38 + </div> 39 + </div> 40 + mark={schema.marks.em} 41 + icon={<ItalicSmall />} 42 + /> 43 + <TextDecorationButton 44 + tooltipContent={ 45 + <div className="flex flex-col gap-1 justify-center"> 46 + <div className="text-center font-normal line-through"> 47 + Strikethrough 48 + </div> 49 + <div className="flex gap-1"> 50 + {isMac() ? ( 51 + <> 52 + <ShortcutKey>⌘</ShortcutKey> +{" "} 53 + <ShortcutKey> Ctrl </ShortcutKey> +{" "} 54 + <ShortcutKey> X </ShortcutKey> 55 + </> 56 + ) : ( 57 + <> 58 + <ShortcutKey> Ctrl </ShortcutKey> +{" "} 59 + <ShortcutKey> Meta </ShortcutKey> +{" "} 60 + <ShortcutKey> X </ShortcutKey> 61 + </> 62 + )} 63 + </div> 64 + </div> 65 + } 66 + mark={schema.marks.strikethrough} 67 + icon={<StrikethroughSmall />} 68 + /> 69 + <HighlightButton 70 + lastUsedHighlight={props.lastUsedHighlight} 71 + setToolbarState={props.setToolbarState} 72 + /> 73 + <Separator classname="h-6" /> 74 + <ListButton setToolbarState={props.setToolbarState} /> 75 + <Separator classname="h-6" /> 76 + <LinkButton setToolbarState={props.setToolbarState} /> 77 + <Separator classname="h-6" /> 78 + <TextBlockTypeButton setToolbarState={props.setToolbarState} /> 79 + </> 80 + ); 81 + };
+25 -169
components/Toolbar/index.tsx
··· 5 BoldSmall, 6 CloseTiny, 7 ItalicSmall, 8 - ListUnorderedSmall, 9 - ListIndentDecreaseSmall, 10 - ListIndentIncreaseSmall, 11 StrikethroughSmall, 12 HighlightSmall, 13 PopoverArrow, ··· 18 import { 19 keepFocus, 20 TextBlockTypeButton, 21 - TextBlockTypeButtons, 22 - } from "./TextBlockTypeButtons"; 23 - import { LinkButton, LinkEditor } from "./LinkButton"; 24 - import { 25 - HighlightColorButton, 26 - HighlightColorSettings, 27 - } from "./HighlightButton"; 28 import { theme } from "../../tailwind.config"; 29 import { useEditorStates } from "src/state/useEditorState"; 30 import { useUIState } from "src/useUIState"; 31 - import { useEntity, useReplicache } from "src/replicache"; 32 import * as Tooltip from "@radix-ui/react-tooltip"; 33 import { Separator, ShortcutKey } from "components/Layout"; 34 import { metaKey } from "src/utils/metaKey"; 35 import { isMac } from "@react-aria/utils"; 36 import { addShortcut } from "src/shortcuts"; 37 - import { useBlocks } from "src/hooks/queries/useBlocks"; 38 - import { indent, outdent } from "src/utils/list-operations"; 39 - import { ListButton, ListToolbar } from "./ListButton"; 40 41 - export const TextToolbar = (props: { cardID: string; blockID: string }) => { 42 let { rep } = useReplicache(); 43 let focusedBlock = useUIState((s) => s.focusedBlock); 44 45 - let [toolbarState, setToolbarState] = useState< 46 - "default" | "highlight" | "link" | "header" | "list" | "linkBlock" 47 - >("default"); 48 49 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); 50 let setLastUsedHighlight = (color: "1" | "2" | "3") => ··· 73 <div className="flex items-center justify-between w-full gap-6"> 74 <div className="flex gap-[6px] items-center grow"> 75 {toolbarState === "default" ? ( 76 - <> 77 - <TextDecorationButton 78 - tooltipContent={ 79 - <div className="flex flex-col gap-1 justify-center"> 80 - <div className="text-center">Bold </div> 81 - <div className="flex gap-1"> 82 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 83 - <ShortcutKey> B </ShortcutKey> 84 - </div> 85 - </div> 86 - } 87 - mark={schema.marks.strong} 88 - icon={<BoldSmall />} 89 - /> 90 - <TextDecorationButton 91 - tooltipContent=<div className="flex flex-col gap-1 justify-center"> 92 - <div className="italic font-normal text-center">Italic</div> 93 - <div className="flex gap-1"> 94 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 95 - <ShortcutKey> I </ShortcutKey> 96 - </div> 97 - </div> 98 - mark={schema.marks.em} 99 - icon={<ItalicSmall />} 100 - /> 101 - <TextDecorationButton 102 - tooltipContent={ 103 - <div className="flex flex-col gap-1 justify-center"> 104 - <div className="text-center font-normal line-through"> 105 - Strikethrough 106 - </div> 107 - <div className="flex gap-1"> 108 - {isMac() ? ( 109 - <> 110 - <ShortcutKey>⌘</ShortcutKey> +{" "} 111 - <ShortcutKey> Ctrl </ShortcutKey> +{" "} 112 - <ShortcutKey> X </ShortcutKey> 113 - </> 114 - ) : ( 115 - <> 116 - <ShortcutKey> Ctrl </ShortcutKey> +{" "} 117 - <ShortcutKey> Meta </ShortcutKey> +{" "} 118 - <ShortcutKey> X </ShortcutKey> 119 - </> 120 - )} 121 - </div> 122 - </div> 123 - } 124 - mark={schema.marks.strikethrough} 125 - icon={<StrikethroughSmall />} 126 - /> 127 - <div className="flex items-center gap-1"> 128 - <TextDecorationButton 129 - tooltipContent={ 130 - <div className="flex flex-col gap-1 justify-center"> 131 - <div className="text-center bg-border-light w-fit rounded-md px-0.5 mx-auto "> 132 - Highlight 133 - </div> 134 - <div className="flex gap-1"> 135 - {isMac() ? ( 136 - <> 137 - <ShortcutKey>⌘</ShortcutKey> +{" "} 138 - <ShortcutKey> Ctrl </ShortcutKey> +{" "} 139 - <ShortcutKey> H </ShortcutKey> 140 - </> 141 - ) : ( 142 - <> 143 - <ShortcutKey> Ctrl </ShortcutKey> +{" "} 144 - <ShortcutKey> Meta </ShortcutKey> +{" "} 145 - <ShortcutKey> H </ShortcutKey> 146 - </> 147 - )} 148 - </div> 149 - </div> 150 - } 151 - attrs={{ color: lastUsedHighlight }} 152 - mark={schema.marks.highlight} 153 - icon={ 154 - <HighlightSmall 155 - highlightColor={ 156 - lastUsedHighlight === "1" 157 - ? theme.colors["highlight-1"] 158 - : lastUsedHighlight === "2" 159 - ? theme.colors["highlight-2"] 160 - : theme.colors["highlight-3"] 161 - } 162 - /> 163 - } 164 - /> 165 - 166 - <ToolbarButton 167 - tooltipContent="Change Highlight Color" 168 - onClick={() => { 169 - setToolbarState("highlight"); 170 - }} 171 - className="-ml-1" 172 - > 173 - <ArrowRightTiny /> 174 - </ToolbarButton> 175 - </div> 176 - <Separator classname="h-6" /> 177 - <ListButton setToolbarState={setToolbarState} /> 178 - <Separator classname="h-6" /> 179 - <LinkButton setToolbarState={setToolbarState} /> 180 - <Separator classname="h-6" /> 181 - <TextBlockTypeButton setToolbarState={setToolbarState} /> 182 - </> 183 ) : toolbarState === "highlight" ? ( 184 <HighlightToolbar 185 onClose={() => setToolbarState("default")} ··· 191 ) : toolbarState === "list" ? ( 192 <ListToolbar onClose={() => setToolbarState("default")} /> 193 ) : toolbarState === "link" ? ( 194 - <LinkEditor 195 onClose={() => { 196 activeEditor?.view?.focus(); 197 setToolbarState("default"); 198 }} 199 /> 200 ) : toolbarState === "header" ? ( 201 - <TextBlockTypeButtons onClose={() => setToolbarState("default")} /> 202 ) : null} 203 </div> 204 <button ··· 222 </button> 223 </div> 224 </Tooltip.Provider> 225 - ); 226 - }; 227 - 228 - const HighlightToolbar = (props: { 229 - onClose: () => void; 230 - lastUsedHighlight: "1" | "2" | "3"; 231 - setLastUsedHighlight: (color: "1" | "2" | "3") => void; 232 - }) => { 233 - let focusedBlock = useUIState((s) => s.focusedBlock); 234 - let focusedEditor = useEditorStates((s) => 235 - focusedBlock ? s.editorStates[focusedBlock.entityID] : null, 236 - ); 237 - let [initialRender, setInitialRender] = useState(true); 238 - useEffect(() => { 239 - setInitialRender(false); 240 - }, []); 241 - useEffect(() => { 242 - if (initialRender) return; 243 - if (focusedEditor) props.onClose(); 244 - }, [focusedEditor, props]); 245 - return ( 246 - <div className="flex w-full justify-between items-center gap-4 text-secondary"> 247 - <div className="flex items-center gap-[6px]"> 248 - <HighlightColorButton 249 - color="1" 250 - lastUsedHighlight={props.lastUsedHighlight} 251 - setLastUsedHightlight={props.setLastUsedHighlight} 252 - /> 253 - <HighlightColorButton 254 - color="2" 255 - lastUsedHighlight={props.lastUsedHighlight} 256 - setLastUsedHightlight={props.setLastUsedHighlight} 257 - /> 258 - <HighlightColorButton 259 - color="3" 260 - lastUsedHighlight={props.lastUsedHighlight} 261 - setLastUsedHightlight={props.setLastUsedHighlight} 262 - /> 263 - <Separator classname="h-6" /> 264 - <HighlightColorSettings /> 265 - </div> 266 - </div> 267 ); 268 }; 269
··· 5 BoldSmall, 6 CloseTiny, 7 ItalicSmall, 8 StrikethroughSmall, 9 HighlightSmall, 10 PopoverArrow, ··· 15 import { 16 keepFocus, 17 TextBlockTypeButton, 18 + TextBlockTypeToolbar, 19 + } from "./TextBlockTypeToolbar"; 20 + import { LinkButton, InlineLinkToolbar } from "./InlineLinkToolbar"; 21 import { theme } from "../../tailwind.config"; 22 import { useEditorStates } from "src/state/useEditorState"; 23 import { useUIState } from "src/useUIState"; 24 + import { useReplicache } from "src/replicache"; 25 import * as Tooltip from "@radix-ui/react-tooltip"; 26 import { Separator, ShortcutKey } from "components/Layout"; 27 import { metaKey } from "src/utils/metaKey"; 28 import { isMac } from "@react-aria/utils"; 29 import { addShortcut } from "src/shortcuts"; 30 + import { ListButton, ListToolbar } from "./ListToolbar"; 31 + import { HighlightToolbar } from "./HighlightToolbar"; 32 + import { TextToolbar } from "./TextToolbar"; 33 + 34 + export type ToolbarTypes = 35 + | "default" 36 + | "highlight" 37 + | "link" 38 + | "header" 39 + | "list" 40 + | "linkBlock"; 41 42 + export const Toolbar = (props: { cardID: string; blockID: string }) => { 43 let { rep } = useReplicache(); 44 let focusedBlock = useUIState((s) => s.focusedBlock); 45 46 + let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 47 48 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); 49 let setLastUsedHighlight = (color: "1" | "2" | "3") => ··· 72 <div className="flex items-center justify-between w-full gap-6"> 73 <div className="flex gap-[6px] items-center grow"> 74 {toolbarState === "default" ? ( 75 + <TextToolbar 76 + lastUsedHighlight={lastUsedHighlight} 77 + setToolbarState={(s) => { 78 + setToolbarState(s); 79 + }} 80 + /> 81 ) : toolbarState === "highlight" ? ( 82 <HighlightToolbar 83 onClose={() => setToolbarState("default")} ··· 89 ) : toolbarState === "list" ? ( 90 <ListToolbar onClose={() => setToolbarState("default")} /> 91 ) : toolbarState === "link" ? ( 92 + <InlineLinkToolbar 93 onClose={() => { 94 activeEditor?.view?.focus(); 95 setToolbarState("default"); 96 }} 97 /> 98 ) : toolbarState === "header" ? ( 99 + <TextBlockTypeToolbar onClose={() => setToolbarState("default")} /> 100 ) : null} 101 </div> 102 <button ··· 120 </button> 121 </div> 122 </Tooltip.Provider> 123 ); 124 }; 125
+24 -9
components/utils/UpdatePageTitle.tsx
··· 1 "use client"; 2 3 - import { useEffect } from "react"; 4 import { useBlocks } from "src/hooks/queries/useBlocks"; 5 import { useEntity } from "src/replicache"; 6 import * as Y from "yjs"; ··· 15 (b) => b.type === "text" || b.type === "heading", 16 ); 17 let firstBlock = blocks[0]; 18 - let content = useEntity(firstBlock?.value, "block/text"); 19 useEffect(() => { 20 - if (content) { 21 - let doc = new Y.Doc(); 22 - const update = base64.toByteArray(content.data.value); 23 - Y.applyUpdate(doc, update); 24 - let nodes = doc.getXmlElement("prosemirror").toArray(); 25 - document.title = YJSFragmentToString(nodes[0]) || "Untitled Leaflet"; 26 } 27 - }, [content]); 28 let params = useSearchParams(); 29 let focusFirstBlock = params.get("focusFirstBlock"); 30 let router = useRouter(); ··· 42 43 return null; 44 }
··· 1 "use client"; 2 3 + import { useEffect, useState } from "react"; 4 import { useBlocks } from "src/hooks/queries/useBlocks"; 5 import { useEntity } from "src/replicache"; 6 import * as Y from "yjs"; ··· 15 (b) => b.type === "text" || b.type === "heading", 16 ); 17 let firstBlock = blocks[0]; 18 + let title = usePageTitle(props.entityID); 19 useEffect(() => { 20 + if (title) { 21 + document.title = title; 22 } 23 + }, [title]); 24 let params = useSearchParams(); 25 let focusFirstBlock = params.get("focusFirstBlock"); 26 let router = useRouter(); ··· 38 39 return null; 40 } 41 + 42 + export const usePageTitle = (entityID: string) => { 43 + let [title, setTitle] = useState(""); 44 + let blocks = useBlocks(entityID).filter( 45 + (b) => b.type === "text" || b.type === "heading", 46 + ); 47 + let firstBlock = blocks[0]; 48 + let content = useEntity(firstBlock?.value, "block/text"); 49 + useEffect(() => { 50 + if (content) { 51 + let doc = new Y.Doc(); 52 + const update = base64.toByteArray(content.data.value); 53 + Y.applyUpdate(doc, update); 54 + let nodes = doc.getXmlElement("prosemirror").toArray(); 55 + setTitle(YJSFragmentToString(nodes[0]) || "Untitled Leaflet"); 56 + } 57 + }, [content]); 58 + return title; 59 + };
+16 -3
drizzle/relations.ts
··· 1 import { relations } from "drizzle-orm/relations"; 2 - import { entity_sets, entities, permission_tokens, identities, facts, permission_token_rights } from "./schema"; 3 4 export const entitiesRelations = relations(entities, ({one, many}) => ({ 5 entity_set: one(entity_sets, { 6 fields: [entities.set], 7 references: [entity_sets.id] 8 }), 9 permission_tokens: many(permission_tokens), 10 facts: many(facts), 11 })); ··· 15 permission_token_rights: many(permission_token_rights), 16 })); 17 18 - export const identitiesRelations = relations(identities, ({one}) => ({ 19 permission_token: one(permission_tokens, { 20 - fields: [identities.home_page], 21 references: [permission_tokens.id] 22 }), 23 })); 24 25 export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ 26 identities: many(identities), 27 entity: one(entities, { 28 fields: [permission_tokens.root_entity], 29 references: [entities.id] 30 }), 31 permission_token_rights: many(permission_token_rights), 32 })); 33 34 export const factsRelations = relations(facts, ({one}) => ({
··· 1 import { relations } from "drizzle-orm/relations"; 2 + import { entity_sets, entities, email_subscriptions_to_entity, permission_tokens, identities, facts, permission_token_rights } from "./schema"; 3 4 export const entitiesRelations = relations(entities, ({one, many}) => ({ 5 entity_set: one(entity_sets, { 6 fields: [entities.set], 7 references: [entity_sets.id] 8 }), 9 + email_subscriptions_to_entities: many(email_subscriptions_to_entity), 10 permission_tokens: many(permission_tokens), 11 facts: many(facts), 12 })); ··· 16 permission_token_rights: many(permission_token_rights), 17 })); 18 19 + export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ 20 + entity: one(entities, { 21 + fields: [email_subscriptions_to_entity.entity], 22 + references: [entities.id] 23 + }), 24 permission_token: one(permission_tokens, { 25 + fields: [email_subscriptions_to_entity.token], 26 references: [permission_tokens.id] 27 }), 28 })); 29 30 export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ 31 + email_subscriptions_to_entities: many(email_subscriptions_to_entity), 32 identities: many(identities), 33 entity: one(entities, { 34 fields: [permission_tokens.root_entity], 35 references: [entities.id] 36 }), 37 permission_token_rights: many(permission_token_rights), 38 + })); 39 + 40 + export const identitiesRelations = relations(identities, ({one}) => ({ 41 + permission_token: one(permission_tokens, { 42 + fields: [identities.home_page], 43 + references: [permission_tokens.id] 44 + }), 45 })); 46 47 export const factsRelations = relations(facts, ({one}) => ({
+11 -1
drizzle/schema.ts
··· 1 - import { pgTable, pgEnum, text, bigint, foreignKey, uuid, timestamp, jsonb, primaryKey, boolean } from "drizzle-orm/pg-core" 2 import { sql } from "drizzle-orm" 3 4 export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) ··· 29 export const entity_sets = pgTable("entity_sets", { 30 id: uuid("id").defaultRandom().primaryKey().notNull(), 31 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 32 }); 33 34 export const identities = pgTable("identities", {
··· 1 + import { pgTable, pgEnum, text, bigint, foreignKey, uuid, timestamp, boolean, jsonb, primaryKey } from "drizzle-orm/pg-core" 2 import { sql } from "drizzle-orm" 3 4 export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) ··· 29 export const entity_sets = pgTable("entity_sets", { 30 id: uuid("id").defaultRandom().primaryKey().notNull(), 31 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 32 + }); 33 + 34 + export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_entity", { 35 + id: uuid("id").defaultRandom().primaryKey().notNull(), 36 + entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade" } ), 37 + email: text("email").notNull(), 38 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 39 + token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 40 + confirmed: boolean("confirmed").default(false).notNull(), 41 + confirmation_code: text("confirmation_code").notNull(), 42 }); 43 44 export const identities = pgTable("identities", {
+681 -84
package-lock.json
··· 11 "dependencies": { 12 "@baselime/node-opentelemetry": "^0.5.8", 13 "@nytimes/react-prosemirror": "^0.6.1", 14 "@radix-ui/react-popover": "^1.0.7", 15 "@radix-ui/react-slider": "^1.1.2", 16 "@radix-ui/react-tooltip": "^1.1.2", ··· 2691 } 2692 } 2693 }, 2694 "node_modules/@radix-ui/react-id": { 2695 "version": "1.1.0", 2696 "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", ··· 2722 } 2723 } 2724 }, 2725 "node_modules/@radix-ui/react-popover": { 2726 "version": "1.0.7", 2727 "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", ··· 3008 } 3009 } 3010 }, 3011 - "node_modules/@radix-ui/react-popover/node_modules/react-remove-scroll/node_modules/react-remove-scroll-bar": { 3012 - "version": "2.3.6", 3013 - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", 3014 - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", 3015 - "dependencies": { 3016 - "react-style-singleton": "^2.2.1", 3017 - "tslib": "^2.0.0" 3018 - }, 3019 - "engines": { 3020 - "node": ">=10" 3021 - }, 3022 - "peerDependencies": { 3023 - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", 3024 - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 3025 - }, 3026 - "peerDependenciesMeta": { 3027 - "@types/react": { 3028 - "optional": true 3029 - } 3030 - } 3031 - }, 3032 - "node_modules/@radix-ui/react-popover/node_modules/react-remove-scroll/node_modules/react-style-singleton": { 3033 - "version": "2.2.1", 3034 - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", 3035 - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", 3036 - "dependencies": { 3037 - "get-nonce": "^1.0.0", 3038 - "invariant": "^2.2.4", 3039 - "tslib": "^2.0.0" 3040 - }, 3041 - "engines": { 3042 - "node": ">=10" 3043 - }, 3044 - "peerDependencies": { 3045 - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", 3046 - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 3047 - }, 3048 - "peerDependenciesMeta": { 3049 - "@types/react": { 3050 - "optional": true 3051 - } 3052 - } 3053 - }, 3054 - "node_modules/@radix-ui/react-popover/node_modules/react-remove-scroll/node_modules/use-callback-ref": { 3055 - "version": "1.3.2", 3056 - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", 3057 - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", 3058 - "dependencies": { 3059 - "tslib": "^2.0.0" 3060 - }, 3061 - "engines": { 3062 - "node": ">=10" 3063 - }, 3064 - "peerDependencies": { 3065 - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", 3066 - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 3067 - }, 3068 - "peerDependenciesMeta": { 3069 - "@types/react": { 3070 - "optional": true 3071 - } 3072 - } 3073 - }, 3074 - "node_modules/@radix-ui/react-popover/node_modules/react-remove-scroll/node_modules/use-sidecar": { 3075 - "version": "1.1.2", 3076 - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", 3077 - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", 3078 - "dependencies": { 3079 - "detect-node-es": "^1.1.0", 3080 - "tslib": "^2.0.0" 3081 - }, 3082 - "engines": { 3083 - "node": ">=10" 3084 - }, 3085 - "peerDependencies": { 3086 - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", 3087 - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 3088 - }, 3089 - "peerDependenciesMeta": { 3090 - "@types/react": { 3091 - "optional": true 3092 - } 3093 - } 3094 - }, 3095 "node_modules/@radix-ui/react-popper": { 3096 "version": "1.2.0", 3097 "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", ··· 3400 "optional": true 3401 }, 3402 "@types/react-dom": { 3403 "optional": true 3404 } 3405 } ··· 11505 "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", 11506 "dev": true 11507 }, 11508 "node_modules/react-stately": { 11509 "version": "3.31.1", 11510 "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.31.1.tgz", ··· 11536 }, 11537 "peerDependencies": { 11538 "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" 11539 } 11540 }, 11541 "node_modules/react-use-measure": { ··· 12962 "dev": true, 12963 "dependencies": { 12964 "punycode": "^2.1.0" 12965 } 12966 }, 12967 "node_modules/use-sync-external-store": {
··· 11 "dependencies": { 12 "@baselime/node-opentelemetry": "^0.5.8", 13 "@nytimes/react-prosemirror": "^0.6.1", 14 + "@radix-ui/react-dropdown-menu": "^2.1.1", 15 "@radix-ui/react-popover": "^1.0.7", 16 "@radix-ui/react-slider": "^1.1.2", 17 "@radix-ui/react-tooltip": "^1.1.2", ··· 2692 } 2693 } 2694 }, 2695 + "node_modules/@radix-ui/react-dropdown-menu": { 2696 + "version": "2.1.1", 2697 + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.1.tgz", 2698 + "integrity": "sha512-y8E+x9fBq9qvteD2Zwa4397pUVhYsh9iq44b5RD5qu1GMJWBCBuVg1hMyItbc6+zH00TxGRqd9Iot4wzf3OoBQ==", 2699 + "dependencies": { 2700 + "@radix-ui/primitive": "1.1.0", 2701 + "@radix-ui/react-compose-refs": "1.1.0", 2702 + "@radix-ui/react-context": "1.1.0", 2703 + "@radix-ui/react-id": "1.1.0", 2704 + "@radix-ui/react-menu": "2.1.1", 2705 + "@radix-ui/react-primitive": "2.0.0", 2706 + "@radix-ui/react-use-controllable-state": "1.1.0" 2707 + }, 2708 + "peerDependencies": { 2709 + "@types/react": "*", 2710 + "@types/react-dom": "*", 2711 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2712 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2713 + }, 2714 + "peerDependenciesMeta": { 2715 + "@types/react": { 2716 + "optional": true 2717 + }, 2718 + "@types/react-dom": { 2719 + "optional": true 2720 + } 2721 + } 2722 + }, 2723 + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": { 2724 + "version": "1.1.0", 2725 + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", 2726 + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" 2727 + }, 2728 + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-compose-refs": { 2729 + "version": "1.1.0", 2730 + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", 2731 + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", 2732 + "peerDependencies": { 2733 + "@types/react": "*", 2734 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2735 + }, 2736 + "peerDependenciesMeta": { 2737 + "@types/react": { 2738 + "optional": true 2739 + } 2740 + } 2741 + }, 2742 + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { 2743 + "version": "1.1.0", 2744 + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", 2745 + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", 2746 + "peerDependencies": { 2747 + "@types/react": "*", 2748 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2749 + }, 2750 + "peerDependenciesMeta": { 2751 + "@types/react": { 2752 + "optional": true 2753 + } 2754 + } 2755 + }, 2756 + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { 2757 + "version": "2.0.0", 2758 + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", 2759 + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", 2760 + "dependencies": { 2761 + "@radix-ui/react-slot": "1.1.0" 2762 + }, 2763 + "peerDependencies": { 2764 + "@types/react": "*", 2765 + "@types/react-dom": "*", 2766 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2767 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2768 + }, 2769 + "peerDependenciesMeta": { 2770 + "@types/react": { 2771 + "optional": true 2772 + }, 2773 + "@types/react-dom": { 2774 + "optional": true 2775 + } 2776 + } 2777 + }, 2778 + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { 2779 + "version": "1.1.0", 2780 + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", 2781 + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", 2782 + "dependencies": { 2783 + "@radix-ui/react-compose-refs": "1.1.0" 2784 + }, 2785 + "peerDependencies": { 2786 + "@types/react": "*", 2787 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2788 + }, 2789 + "peerDependenciesMeta": { 2790 + "@types/react": { 2791 + "optional": true 2792 + } 2793 + } 2794 + }, 2795 + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-callback-ref": { 2796 + "version": "1.1.0", 2797 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", 2798 + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", 2799 + "peerDependencies": { 2800 + "@types/react": "*", 2801 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2802 + }, 2803 + "peerDependenciesMeta": { 2804 + "@types/react": { 2805 + "optional": true 2806 + } 2807 + } 2808 + }, 2809 + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": { 2810 + "version": "1.1.0", 2811 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", 2812 + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", 2813 + "dependencies": { 2814 + "@radix-ui/react-use-callback-ref": "1.1.0" 2815 + }, 2816 + "peerDependencies": { 2817 + "@types/react": "*", 2818 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2819 + }, 2820 + "peerDependenciesMeta": { 2821 + "@types/react": { 2822 + "optional": true 2823 + } 2824 + } 2825 + }, 2826 + "node_modules/@radix-ui/react-focus-guards": { 2827 + "version": "1.1.0", 2828 + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz", 2829 + "integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==", 2830 + "peerDependencies": { 2831 + "@types/react": "*", 2832 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2833 + }, 2834 + "peerDependenciesMeta": { 2835 + "@types/react": { 2836 + "optional": true 2837 + } 2838 + } 2839 + }, 2840 + "node_modules/@radix-ui/react-focus-scope": { 2841 + "version": "1.1.0", 2842 + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", 2843 + "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", 2844 + "dependencies": { 2845 + "@radix-ui/react-compose-refs": "1.1.0", 2846 + "@radix-ui/react-primitive": "2.0.0", 2847 + "@radix-ui/react-use-callback-ref": "1.1.0" 2848 + }, 2849 + "peerDependencies": { 2850 + "@types/react": "*", 2851 + "@types/react-dom": "*", 2852 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2853 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2854 + }, 2855 + "peerDependenciesMeta": { 2856 + "@types/react": { 2857 + "optional": true 2858 + }, 2859 + "@types/react-dom": { 2860 + "optional": true 2861 + } 2862 + } 2863 + }, 2864 + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": { 2865 + "version": "1.1.0", 2866 + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", 2867 + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", 2868 + "peerDependencies": { 2869 + "@types/react": "*", 2870 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2871 + }, 2872 + "peerDependenciesMeta": { 2873 + "@types/react": { 2874 + "optional": true 2875 + } 2876 + } 2877 + }, 2878 + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { 2879 + "version": "2.0.0", 2880 + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", 2881 + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", 2882 + "dependencies": { 2883 + "@radix-ui/react-slot": "1.1.0" 2884 + }, 2885 + "peerDependencies": { 2886 + "@types/react": "*", 2887 + "@types/react-dom": "*", 2888 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2889 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2890 + }, 2891 + "peerDependenciesMeta": { 2892 + "@types/react": { 2893 + "optional": true 2894 + }, 2895 + "@types/react-dom": { 2896 + "optional": true 2897 + } 2898 + } 2899 + }, 2900 + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { 2901 + "version": "1.1.0", 2902 + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", 2903 + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", 2904 + "dependencies": { 2905 + "@radix-ui/react-compose-refs": "1.1.0" 2906 + }, 2907 + "peerDependencies": { 2908 + "@types/react": "*", 2909 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2910 + }, 2911 + "peerDependenciesMeta": { 2912 + "@types/react": { 2913 + "optional": true 2914 + } 2915 + } 2916 + }, 2917 + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { 2918 + "version": "1.1.0", 2919 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", 2920 + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", 2921 + "peerDependencies": { 2922 + "@types/react": "*", 2923 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2924 + }, 2925 + "peerDependenciesMeta": { 2926 + "@types/react": { 2927 + "optional": true 2928 + } 2929 + } 2930 + }, 2931 "node_modules/@radix-ui/react-id": { 2932 "version": "1.1.0", 2933 "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", ··· 2959 } 2960 } 2961 }, 2962 + "node_modules/@radix-ui/react-menu": { 2963 + "version": "2.1.1", 2964 + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.1.tgz", 2965 + "integrity": "sha512-oa3mXRRVjHi6DZu/ghuzdylyjaMXLymx83irM7hTxutQbD+7IhPKdMdRHD26Rm+kHRrWcrUkkRPv5pd47a2xFQ==", 2966 + "dependencies": { 2967 + "@radix-ui/primitive": "1.1.0", 2968 + "@radix-ui/react-collection": "1.1.0", 2969 + "@radix-ui/react-compose-refs": "1.1.0", 2970 + "@radix-ui/react-context": "1.1.0", 2971 + "@radix-ui/react-direction": "1.1.0", 2972 + "@radix-ui/react-dismissable-layer": "1.1.0", 2973 + "@radix-ui/react-focus-guards": "1.1.0", 2974 + "@radix-ui/react-focus-scope": "1.1.0", 2975 + "@radix-ui/react-id": "1.1.0", 2976 + "@radix-ui/react-popper": "1.2.0", 2977 + "@radix-ui/react-portal": "1.1.1", 2978 + "@radix-ui/react-presence": "1.1.0", 2979 + "@radix-ui/react-primitive": "2.0.0", 2980 + "@radix-ui/react-roving-focus": "1.1.0", 2981 + "@radix-ui/react-slot": "1.1.0", 2982 + "@radix-ui/react-use-callback-ref": "1.1.0", 2983 + "aria-hidden": "^1.1.1", 2984 + "react-remove-scroll": "2.5.7" 2985 + }, 2986 + "peerDependencies": { 2987 + "@types/react": "*", 2988 + "@types/react-dom": "*", 2989 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2990 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2991 + }, 2992 + "peerDependenciesMeta": { 2993 + "@types/react": { 2994 + "optional": true 2995 + }, 2996 + "@types/react-dom": { 2997 + "optional": true 2998 + } 2999 + } 3000 + }, 3001 + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": { 3002 + "version": "1.1.0", 3003 + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", 3004 + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" 3005 + }, 3006 + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": { 3007 + "version": "1.1.0", 3008 + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", 3009 + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", 3010 + "dependencies": { 3011 + "@radix-ui/react-compose-refs": "1.1.0", 3012 + "@radix-ui/react-context": "1.1.0", 3013 + "@radix-ui/react-primitive": "2.0.0", 3014 + "@radix-ui/react-slot": "1.1.0" 3015 + }, 3016 + "peerDependencies": { 3017 + "@types/react": "*", 3018 + "@types/react-dom": "*", 3019 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3020 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3021 + }, 3022 + "peerDependenciesMeta": { 3023 + "@types/react": { 3024 + "optional": true 3025 + }, 3026 + "@types/react-dom": { 3027 + "optional": true 3028 + } 3029 + } 3030 + }, 3031 + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-compose-refs": { 3032 + "version": "1.1.0", 3033 + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", 3034 + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", 3035 + "peerDependencies": { 3036 + "@types/react": "*", 3037 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3038 + }, 3039 + "peerDependenciesMeta": { 3040 + "@types/react": { 3041 + "optional": true 3042 + } 3043 + } 3044 + }, 3045 + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { 3046 + "version": "1.1.0", 3047 + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", 3048 + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", 3049 + "peerDependencies": { 3050 + "@types/react": "*", 3051 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3052 + }, 3053 + "peerDependenciesMeta": { 3054 + "@types/react": { 3055 + "optional": true 3056 + } 3057 + } 3058 + }, 3059 + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-direction": { 3060 + "version": "1.1.0", 3061 + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", 3062 + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", 3063 + "peerDependencies": { 3064 + "@types/react": "*", 3065 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3066 + }, 3067 + "peerDependenciesMeta": { 3068 + "@types/react": { 3069 + "optional": true 3070 + } 3071 + } 3072 + }, 3073 + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { 3074 + "version": "2.0.0", 3075 + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", 3076 + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", 3077 + "dependencies": { 3078 + "@radix-ui/react-slot": "1.1.0" 3079 + }, 3080 + "peerDependencies": { 3081 + "@types/react": "*", 3082 + "@types/react-dom": "*", 3083 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3084 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3085 + }, 3086 + "peerDependenciesMeta": { 3087 + "@types/react": { 3088 + "optional": true 3089 + }, 3090 + "@types/react-dom": { 3091 + "optional": true 3092 + } 3093 + } 3094 + }, 3095 + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { 3096 + "version": "1.1.0", 3097 + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", 3098 + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", 3099 + "dependencies": { 3100 + "@radix-ui/react-compose-refs": "1.1.0" 3101 + }, 3102 + "peerDependencies": { 3103 + "@types/react": "*", 3104 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3105 + }, 3106 + "peerDependenciesMeta": { 3107 + "@types/react": { 3108 + "optional": true 3109 + } 3110 + } 3111 + }, 3112 + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": { 3113 + "version": "1.1.0", 3114 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", 3115 + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", 3116 + "peerDependencies": { 3117 + "@types/react": "*", 3118 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3119 + }, 3120 + "peerDependenciesMeta": { 3121 + "@types/react": { 3122 + "optional": true 3123 + } 3124 + } 3125 + }, 3126 "node_modules/@radix-ui/react-popover": { 3127 "version": "1.0.7", 3128 "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", ··· 3409 } 3410 } 3411 }, 3412 "node_modules/@radix-ui/react-popper": { 3413 "version": "1.2.0", 3414 "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", ··· 3717 "optional": true 3718 }, 3719 "@types/react-dom": { 3720 + "optional": true 3721 + } 3722 + } 3723 + }, 3724 + "node_modules/@radix-ui/react-roving-focus": { 3725 + "version": "1.1.0", 3726 + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", 3727 + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", 3728 + "dependencies": { 3729 + "@radix-ui/primitive": "1.1.0", 3730 + "@radix-ui/react-collection": "1.1.0", 3731 + "@radix-ui/react-compose-refs": "1.1.0", 3732 + "@radix-ui/react-context": "1.1.0", 3733 + "@radix-ui/react-direction": "1.1.0", 3734 + "@radix-ui/react-id": "1.1.0", 3735 + "@radix-ui/react-primitive": "2.0.0", 3736 + "@radix-ui/react-use-callback-ref": "1.1.0", 3737 + "@radix-ui/react-use-controllable-state": "1.1.0" 3738 + }, 3739 + "peerDependencies": { 3740 + "@types/react": "*", 3741 + "@types/react-dom": "*", 3742 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3743 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3744 + }, 3745 + "peerDependenciesMeta": { 3746 + "@types/react": { 3747 + "optional": true 3748 + }, 3749 + "@types/react-dom": { 3750 + "optional": true 3751 + } 3752 + } 3753 + }, 3754 + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": { 3755 + "version": "1.1.0", 3756 + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", 3757 + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" 3758 + }, 3759 + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection": { 3760 + "version": "1.1.0", 3761 + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", 3762 + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", 3763 + "dependencies": { 3764 + "@radix-ui/react-compose-refs": "1.1.0", 3765 + "@radix-ui/react-context": "1.1.0", 3766 + "@radix-ui/react-primitive": "2.0.0", 3767 + "@radix-ui/react-slot": "1.1.0" 3768 + }, 3769 + "peerDependencies": { 3770 + "@types/react": "*", 3771 + "@types/react-dom": "*", 3772 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3773 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3774 + }, 3775 + "peerDependenciesMeta": { 3776 + "@types/react": { 3777 + "optional": true 3778 + }, 3779 + "@types/react-dom": { 3780 + "optional": true 3781 + } 3782 + } 3783 + }, 3784 + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { 3785 + "version": "1.1.0", 3786 + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", 3787 + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", 3788 + "peerDependencies": { 3789 + "@types/react": "*", 3790 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3791 + }, 3792 + "peerDependenciesMeta": { 3793 + "@types/react": { 3794 + "optional": true 3795 + } 3796 + } 3797 + }, 3798 + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { 3799 + "version": "1.1.0", 3800 + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", 3801 + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", 3802 + "peerDependencies": { 3803 + "@types/react": "*", 3804 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3805 + }, 3806 + "peerDependenciesMeta": { 3807 + "@types/react": { 3808 + "optional": true 3809 + } 3810 + } 3811 + }, 3812 + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-direction": { 3813 + "version": "1.1.0", 3814 + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", 3815 + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", 3816 + "peerDependencies": { 3817 + "@types/react": "*", 3818 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3819 + }, 3820 + "peerDependenciesMeta": { 3821 + "@types/react": { 3822 + "optional": true 3823 + } 3824 + } 3825 + }, 3826 + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { 3827 + "version": "2.0.0", 3828 + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", 3829 + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", 3830 + "dependencies": { 3831 + "@radix-ui/react-slot": "1.1.0" 3832 + }, 3833 + "peerDependencies": { 3834 + "@types/react": "*", 3835 + "@types/react-dom": "*", 3836 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3837 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3838 + }, 3839 + "peerDependenciesMeta": { 3840 + "@types/react": { 3841 + "optional": true 3842 + }, 3843 + "@types/react-dom": { 3844 + "optional": true 3845 + } 3846 + } 3847 + }, 3848 + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { 3849 + "version": "1.1.0", 3850 + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", 3851 + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", 3852 + "dependencies": { 3853 + "@radix-ui/react-compose-refs": "1.1.0" 3854 + }, 3855 + "peerDependencies": { 3856 + "@types/react": "*", 3857 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3858 + }, 3859 + "peerDependenciesMeta": { 3860 + "@types/react": { 3861 + "optional": true 3862 + } 3863 + } 3864 + }, 3865 + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { 3866 + "version": "1.1.0", 3867 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", 3868 + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", 3869 + "peerDependencies": { 3870 + "@types/react": "*", 3871 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3872 + }, 3873 + "peerDependenciesMeta": { 3874 + "@types/react": { 3875 + "optional": true 3876 + } 3877 + } 3878 + }, 3879 + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-controllable-state": { 3880 + "version": "1.1.0", 3881 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", 3882 + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", 3883 + "dependencies": { 3884 + "@radix-ui/react-use-callback-ref": "1.1.0" 3885 + }, 3886 + "peerDependencies": { 3887 + "@types/react": "*", 3888 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3889 + }, 3890 + "peerDependenciesMeta": { 3891 + "@types/react": { 3892 "optional": true 3893 } 3894 } ··· 11994 "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", 11995 "dev": true 11996 }, 11997 + "node_modules/react-remove-scroll": { 11998 + "version": "2.5.7", 11999 + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", 12000 + "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", 12001 + "dependencies": { 12002 + "react-remove-scroll-bar": "^2.3.4", 12003 + "react-style-singleton": "^2.2.1", 12004 + "tslib": "^2.1.0", 12005 + "use-callback-ref": "^1.3.0", 12006 + "use-sidecar": "^1.1.2" 12007 + }, 12008 + "engines": { 12009 + "node": ">=10" 12010 + }, 12011 + "peerDependencies": { 12012 + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", 12013 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 12014 + }, 12015 + "peerDependenciesMeta": { 12016 + "@types/react": { 12017 + "optional": true 12018 + } 12019 + } 12020 + }, 12021 + "node_modules/react-remove-scroll-bar": { 12022 + "version": "2.3.6", 12023 + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", 12024 + "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", 12025 + "dependencies": { 12026 + "react-style-singleton": "^2.2.1", 12027 + "tslib": "^2.0.0" 12028 + }, 12029 + "engines": { 12030 + "node": ">=10" 12031 + }, 12032 + "peerDependencies": { 12033 + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", 12034 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 12035 + }, 12036 + "peerDependenciesMeta": { 12037 + "@types/react": { 12038 + "optional": true 12039 + } 12040 + } 12041 + }, 12042 "node_modules/react-stately": { 12043 "version": "3.31.1", 12044 "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.31.1.tgz", ··· 12070 }, 12071 "peerDependencies": { 12072 "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" 12073 + } 12074 + }, 12075 + "node_modules/react-style-singleton": { 12076 + "version": "2.2.1", 12077 + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", 12078 + "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", 12079 + "dependencies": { 12080 + "get-nonce": "^1.0.0", 12081 + "invariant": "^2.2.4", 12082 + "tslib": "^2.0.0" 12083 + }, 12084 + "engines": { 12085 + "node": ">=10" 12086 + }, 12087 + "peerDependencies": { 12088 + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", 12089 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 12090 + }, 12091 + "peerDependenciesMeta": { 12092 + "@types/react": { 12093 + "optional": true 12094 + } 12095 } 12096 }, 12097 "node_modules/react-use-measure": { ··· 13518 "dev": true, 13519 "dependencies": { 13520 "punycode": "^2.1.0" 13521 + } 13522 + }, 13523 + "node_modules/use-callback-ref": { 13524 + "version": "1.3.2", 13525 + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", 13526 + "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", 13527 + "dependencies": { 13528 + "tslib": "^2.0.0" 13529 + }, 13530 + "engines": { 13531 + "node": ">=10" 13532 + }, 13533 + "peerDependencies": { 13534 + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", 13535 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 13536 + }, 13537 + "peerDependenciesMeta": { 13538 + "@types/react": { 13539 + "optional": true 13540 + } 13541 + } 13542 + }, 13543 + "node_modules/use-sidecar": { 13544 + "version": "1.1.2", 13545 + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", 13546 + "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", 13547 + "dependencies": { 13548 + "detect-node-es": "^1.1.0", 13549 + "tslib": "^2.0.0" 13550 + }, 13551 + "engines": { 13552 + "node": ">=10" 13553 + }, 13554 + "peerDependencies": { 13555 + "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", 13556 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 13557 + }, 13558 + "peerDependenciesMeta": { 13559 + "@types/react": { 13560 + "optional": true 13561 + } 13562 } 13563 }, 13564 "node_modules/use-sync-external-store": {
+1
package.json
··· 13 "dependencies": { 14 "@baselime/node-opentelemetry": "^0.5.8", 15 "@nytimes/react-prosemirror": "^0.6.1", 16 "@radix-ui/react-popover": "^1.0.7", 17 "@radix-ui/react-slider": "^1.1.2", 18 "@radix-ui/react-tooltip": "^1.1.2",
··· 13 "dependencies": { 14 "@baselime/node-opentelemetry": "^0.5.8", 15 "@nytimes/react-prosemirror": "^0.6.1", 16 + "@radix-ui/react-dropdown-menu": "^2.1.1", 17 "@radix-ui/react-popover": "^1.0.7", 18 "@radix-ui/react-slider": "^1.1.2", 19 "@radix-ui/react-tooltip": "^1.1.2",
+85
src/hooks/useSubscriptionStatus.ts
···
··· 1 + import { deleteSubscription } from "actions/subscriptions/deleteSubscription"; 2 + import { useSearchParams } from "next/navigation"; 3 + import { useEffect } from "react"; 4 + import useSWR, { mutate } from "swr"; 5 + 6 + type Subscription = { 7 + id: string; 8 + email: string; 9 + entity: string; 10 + confirmed: boolean; 11 + }; 12 + type SubscriptionStorage = { 13 + version: number; 14 + subscriptions: Array<Subscription>; 15 + }; 16 + 17 + let defaultValue: SubscriptionStorage = { 18 + version: 1, 19 + subscriptions: [], 20 + }; 21 + 22 + const key = "subscriptions-v1"; 23 + export function useSubscriptionStatus(entityID: string) { 24 + let params = useSearchParams(); 25 + let sub_id = params.get("sub_id"); 26 + useEffect(() => { 27 + if (!sub_id) return; 28 + let entity = params.get("entity"); 29 + let email = params.get("email"); 30 + if (!entity || !email) return; 31 + //How do I get subscribed state here huh? 32 + addSubscription({ 33 + id: sub_id, 34 + email: email, 35 + entity: entity, 36 + confirmed: true, 37 + }); 38 + 39 + const url = new URL(window.location.href); 40 + url.searchParams.delete("sub_id"); 41 + url.searchParams.delete("entity"); 42 + url.searchParams.delete("email"); 43 + window.history.replaceState({}, "", url.toString()); 44 + }, [sub_id, params]); 45 + let { data: docs } = useSWR("subscriptions", () => getSubscriptions(), {}); 46 + if (!docs) return null; 47 + return docs.find((d) => d.entity === entityID); 48 + } 49 + 50 + export function getSubscriptions() { 51 + let homepageDocs: SubscriptionStorage = JSON.parse( 52 + window.localStorage.getItem(key) || JSON.stringify(defaultValue), 53 + ); 54 + return homepageDocs.subscriptions; 55 + } 56 + 57 + export function addSubscription(s: Subscription) { 58 + let subscriptions = getSubscriptions(); 59 + let newSubscriptions = subscriptions.filter((d) => d.id !== s.id); 60 + newSubscriptions.push(s); 61 + let newValue: SubscriptionStorage = { 62 + version: 1, 63 + subscriptions: newSubscriptions, 64 + }; 65 + window.localStorage.setItem(key, JSON.stringify(newValue)); 66 + mutate("subscriptions", newSubscriptions, false); 67 + } 68 + 69 + export function removeSubscription(s: Subscription) { 70 + let subscriptions = getSubscriptions(); 71 + let newDocs = subscriptions.filter((d) => d.id !== s.id); 72 + let newValue: SubscriptionStorage = { 73 + version: 1, 74 + subscriptions: newDocs, 75 + }; 76 + // Call the unsubscribe action 77 + 78 + window.localStorage.setItem(key, JSON.stringify(newValue)); 79 + mutate("subscriptions", newDocs, false); 80 + } 81 + 82 + export async function unsubscribe(s: Subscription) { 83 + removeSubscription(s); 84 + await deleteSubscription(s.id); 85 + }
+17 -1
src/replicache/attributes.ts
··· 37 }, 38 } as const; 39 40 const LinkBlockAttributes = { 41 "link/preview": { 42 type: "image", ··· 104 ...BlockAttributes, 105 ...LinkBlockAttributes, 106 ...ThemeAttributes, 107 }; 108 type Attribute = typeof Attributes; 109 export type Data<A extends keyof typeof Attributes> = { ··· 137 reference: { type: "reference"; value: string }; 138 "block-type-union": { 139 type: "block-type-union"; 140 - value: "text" | "image" | "card" | "heading" | "link"; 141 }; 142 color: { type: "color"; value: string }; 143 }[(typeof Attributes)[A]["type"]];
··· 37 }, 38 } as const; 39 40 + const MailboxAttributes = { 41 + "mailbox/draft": { 42 + type: "reference", 43 + cardinality: "one", 44 + }, 45 + "mailbox/archive": { 46 + type: "reference", 47 + cardinality: "one", 48 + }, 49 + "mailbox/subscriber-count": { 50 + type: "number", 51 + cardinality: "one", 52 + }, 53 + } as const; 54 + 55 const LinkBlockAttributes = { 56 "link/preview": { 57 type: "image", ··· 119 ...BlockAttributes, 120 ...LinkBlockAttributes, 121 ...ThemeAttributes, 122 + ...MailboxAttributes, 123 }; 124 type Attribute = typeof Attributes; 125 export type Data<A extends keyof typeof Attributes> = { ··· 153 reference: { type: "reference"; value: string }; 154 "block-type-union": { 155 type: "block-type-union"; 156 + value: "text" | "image" | "card" | "heading" | "link" | "mailbox"; 157 }; 158 color: { type: "color"; value: string }; 159 }[(typeof Attributes)[A]["type"]];
+33 -1
src/replicache/index.tsx
··· 10 } from "replicache"; 11 import { Pull } from "./pull"; 12 import { mutations } from "./mutations"; 13 - import { Attributes, Data } from "./attributes"; 14 import { Push } from "./push"; 15 import { clientMutationContext } from "./clientMutationContext"; 16 import { supabaseBrowserClient } from "supabase/browserClient"; ··· 162 ? (d as CardinalityResult<A>) 163 : (d[0] as CardinalityResult<A>); 164 }
··· 10 } from "replicache"; 11 import { Pull } from "./pull"; 12 import { mutations } from "./mutations"; 13 + import { Attributes, Data, FilterAttributes } from "./attributes"; 14 import { Push } from "./push"; 15 import { clientMutationContext } from "./clientMutationContext"; 16 import { supabaseBrowserClient } from "supabase/browserClient"; ··· 162 ? (d as CardinalityResult<A>) 163 : (d[0] as CardinalityResult<A>); 164 } 165 + 166 + export function useReferenceToEntity< 167 + A extends keyof FilterAttributes<{ type: "reference" }>, 168 + >(attribute: A, entity: string) { 169 + let { rep, initialFacts } = useReplicache(); 170 + let fallbackData = useMemo( 171 + () => 172 + initialFacts.filter( 173 + (f) => 174 + (f as Fact<A>).data.value === entity && f.attribute === attribute, 175 + ), 176 + [entity, attribute, initialFacts], 177 + ); 178 + let data = useSubscribe( 179 + rep, 180 + async (tx) => { 181 + if (entity === null) return null; 182 + let initialized = await tx.get("initialized"); 183 + if (!initialized) return null; 184 + return ( 185 + await tx 186 + .scan<Fact<A>>({ indexName: "vae", prefix: `${entity}-${attribute}` }) 187 + .toArray() 188 + ).filter((f) => f.attribute === attribute); 189 + }, 190 + { 191 + default: null, 192 + dependencies: [entity, attribute], 193 + }, 194 + ); 195 + return data || (fallbackData as Fact<A>[]); 196 + }
+100
src/replicache/mutations.ts
··· 362 }); 363 }; 364 365 const retractAttribute: Mutation<{ 366 entity: string; 367 attribute: keyof FilterAttributes<{ cardinality: "one" }>; ··· 403 removeBlock, 404 moveChildren, 405 increaseHeadingLevel, 406 toggleTodoState, 407 };
··· 362 }); 363 }; 364 365 + const createDraft: Mutation<{ 366 + mailboxEntity: string; 367 + newEntity: string; 368 + permission_set: string; 369 + firstBlockEntity: string; 370 + firstBlockFactID: string; 371 + }> = async (args, ctx) => { 372 + let [existingDraft] = await ctx.scanIndex.eav( 373 + args.mailboxEntity, 374 + "mailbox/draft", 375 + ); 376 + if (existingDraft) return; 377 + await ctx.createEntity({ 378 + entityID: args.newEntity, 379 + permission_set: args.permission_set, 380 + }); 381 + await ctx.assertFact({ 382 + entity: args.mailboxEntity, 383 + attribute: "mailbox/draft", 384 + data: { type: "reference", value: args.newEntity }, 385 + }); 386 + await addBlock( 387 + { 388 + factID: args.firstBlockFactID, 389 + permission_set: args.permission_set, 390 + newEntityID: args.firstBlockEntity, 391 + type: "text", 392 + parent: args.newEntity, 393 + position: "a0", 394 + }, 395 + ctx, 396 + ); 397 + }; 398 + 399 + const archiveDraft: Mutation<{ 400 + mailboxEntity: string; 401 + archiveEntity: string; 402 + newBlockEntity: string; 403 + entity_set: string; 404 + }> = async (args, ctx) => { 405 + let [existingDraft] = await ctx.scanIndex.eav( 406 + args.mailboxEntity, 407 + "mailbox/draft", 408 + ); 409 + if (!existingDraft) return; 410 + 411 + let [archive] = await ctx.scanIndex.eav( 412 + args.mailboxEntity, 413 + "mailbox/archive", 414 + ); 415 + let archiveEntity = archive?.data.value; 416 + if (!archive) { 417 + archiveEntity = args.archiveEntity; 418 + await ctx.createEntity({ 419 + entityID: archiveEntity, 420 + permission_set: args.entity_set, 421 + }); 422 + await ctx.assertFact({ 423 + entity: args.mailboxEntity, 424 + attribute: "mailbox/archive", 425 + data: { type: "reference", value: archiveEntity }, 426 + }); 427 + } 428 + 429 + let archiveChildren = await ctx.scanIndex.eav(archiveEntity, "card/block"); 430 + let firstChild = archiveChildren.toSorted((a, b) => 431 + a.data.position > b.data.position ? 1 : -1, 432 + )[0]; 433 + 434 + await ctx.createEntity({ 435 + entityID: args.newBlockEntity, 436 + permission_set: args.entity_set, 437 + }); 438 + await ctx.assertFact({ 439 + entity: args.newBlockEntity, 440 + attribute: "block/type", 441 + data: { type: "block-type-union", value: "card" }, 442 + }); 443 + 444 + await ctx.assertFact({ 445 + entity: args.newBlockEntity, 446 + attribute: "block/card", 447 + data: { type: "reference", value: existingDraft.data.value }, 448 + }); 449 + 450 + await ctx.assertFact({ 451 + entity: archiveEntity, 452 + attribute: "card/block", 453 + data: { 454 + type: "ordered-reference", 455 + value: args.newBlockEntity, 456 + position: generateKeyBetween(null, firstChild?.data.position), 457 + }, 458 + }); 459 + 460 + await ctx.retractFact(existingDraft.id); 461 + }; 462 + 463 const retractAttribute: Mutation<{ 464 entity: string; 465 attribute: keyof FilterAttributes<{ cardinality: "one" }>; ··· 501 removeBlock, 502 moveChildren, 503 increaseHeadingLevel, 504 + archiveDraft, 505 toggleTodoState, 506 + createDraft, 507 };
+5
src/utils/getBlocksAsHTML.tsx
··· 50 ignoreWrapper?: boolean, 51 ) { 52 let wrapper: undefined | "h1" | "h2" | "h3"; 53 if (b.type === "heading") { 54 let headingLevel = await scanIndex(tx).eav(b.value, "block/heading-level"); 55 wrapper = "h" + headingLevel[0].data.value;
··· 50 ignoreWrapper?: boolean, 51 ) { 52 let wrapper: undefined | "h1" | "h2" | "h3"; 53 + if (b.type === "image") { 54 + let [src] = await scanIndex(tx).eav(b.value, "block/image"); 55 + if (!src) return ""; 56 + return renderToStaticMarkup(<img src={src.data.src} />); 57 + } 58 if (b.type === "heading") { 59 let headingLevel = await scanIndex(tx).eav(b.value, "block/heading-level"); 60 wrapper = "h" + headingLevel[0].data.value;
+7
src/utils/getCurrentDeploymentDomain.ts
···
··· 1 + import { headers } from "next/headers"; 2 + export function getCurrentDeploymentDomain() { 3 + const headersList = headers(); 4 + const hostname = headersList.get("x-forwarded-host"); 5 + let protocol = headersList.get("x-forwarded-proto"); 6 + return `${protocol}://${hostname}/`; 7 + }
+45
supabase/database.types.ts
··· 34 } 35 public: { 36 Tables: { 37 entities: { 38 Row: { 39 created_at: string
··· 34 } 35 public: { 36 Tables: { 37 + email_subscriptions_to_entity: { 38 + Row: { 39 + confirmation_code: string 40 + confirmed: boolean 41 + created_at: string 42 + email: string 43 + entity: string 44 + id: string 45 + token: string 46 + } 47 + Insert: { 48 + confirmation_code: string 49 + confirmed?: boolean 50 + created_at?: string 51 + email: string 52 + entity: string 53 + id?: string 54 + token: string 55 + } 56 + Update: { 57 + confirmation_code?: string 58 + confirmed?: boolean 59 + created_at?: string 60 + email?: string 61 + entity?: string 62 + id?: string 63 + token?: string 64 + } 65 + Relationships: [ 66 + { 67 + foreignKeyName: "email_subscriptions_to_entity_entity_fkey" 68 + columns: ["entity"] 69 + isOneToOne: false 70 + referencedRelation: "entities" 71 + referencedColumns: ["id"] 72 + }, 73 + { 74 + foreignKeyName: "email_subscriptions_to_entity_token_fkey" 75 + columns: ["token"] 76 + isOneToOne: false 77 + referencedRelation: "permission_tokens" 78 + referencedColumns: ["id"] 79 + }, 80 + ] 81 + } 82 entities: { 83 Row: { 84 created_at: string
+65
supabase/migrations/20240821203026_add_email_subscription_tables.sql
···
··· 1 + create table "public"."email_subscriptions_to_entity" ( 2 + "id" uuid not null default gen_random_uuid(), 3 + "entity" uuid not null, 4 + "email" text not null, 5 + "created_at" timestamp with time zone not null default now(), 6 + "token" uuid not null, 7 + "confirmed" boolean not null default false, 8 + "confirmation_code" text not null 9 + ); 10 + 11 + alter table "public"."email_subscriptions_to_entity" enable row level security; 12 + 13 + CREATE UNIQUE INDEX email_subscriptions_to_entity_pkey ON public.email_subscriptions_to_entity USING btree (id); 14 + 15 + alter table "public"."email_subscriptions_to_entity" add constraint "email_subscriptions_to_entity_pkey" PRIMARY KEY using index "email_subscriptions_to_entity_pkey"; 16 + 17 + alter table "public"."email_subscriptions_to_entity" add constraint "email_subscriptions_to_entity_entity_fkey" FOREIGN KEY (entity) REFERENCES entities(id) ON DELETE CASCADE not valid; 18 + 19 + alter table "public"."email_subscriptions_to_entity" validate constraint "email_subscriptions_to_entity_entity_fkey"; 20 + 21 + alter table "public"."email_subscriptions_to_entity" add constraint "email_subscriptions_to_entity_token_fkey" FOREIGN KEY (token) REFERENCES permission_tokens(id) ON DELETE CASCADE not valid; 22 + 23 + alter table "public"."email_subscriptions_to_entity" validate constraint "email_subscriptions_to_entity_token_fkey"; 24 + 25 + grant delete on table "public"."email_subscriptions_to_entity" to "anon"; 26 + 27 + grant insert on table "public"."email_subscriptions_to_entity" to "anon"; 28 + 29 + grant references on table "public"."email_subscriptions_to_entity" to "anon"; 30 + 31 + grant select on table "public"."email_subscriptions_to_entity" to "anon"; 32 + 33 + grant trigger on table "public"."email_subscriptions_to_entity" to "anon"; 34 + 35 + grant truncate on table "public"."email_subscriptions_to_entity" to "anon"; 36 + 37 + grant update on table "public"."email_subscriptions_to_entity" to "anon"; 38 + 39 + grant delete on table "public"."email_subscriptions_to_entity" to "authenticated"; 40 + 41 + grant insert on table "public"."email_subscriptions_to_entity" to "authenticated"; 42 + 43 + grant references on table "public"."email_subscriptions_to_entity" to "authenticated"; 44 + 45 + grant select on table "public"."email_subscriptions_to_entity" to "authenticated"; 46 + 47 + grant trigger on table "public"."email_subscriptions_to_entity" to "authenticated"; 48 + 49 + grant truncate on table "public"."email_subscriptions_to_entity" to "authenticated"; 50 + 51 + grant update on table "public"."email_subscriptions_to_entity" to "authenticated"; 52 + 53 + grant delete on table "public"."email_subscriptions_to_entity" to "service_role"; 54 + 55 + grant insert on table "public"."email_subscriptions_to_entity" to "service_role"; 56 + 57 + grant references on table "public"."email_subscriptions_to_entity" to "service_role"; 58 + 59 + grant select on table "public"."email_subscriptions_to_entity" to "service_role"; 60 + 61 + grant trigger on table "public"."email_subscriptions_to_entity" to "service_role"; 62 + 63 + grant truncate on table "public"."email_subscriptions_to_entity" to "service_role"; 64 + 65 + grant update on table "public"."email_subscriptions_to_entity" to "service_role";