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 8 ); 9 9 10 10 export async function addLinkCard(args: { link: string }) { 11 - console.log("addLinkCard"); 12 11 let result = await get_url_preview_data(args.link); 13 12 return result; 14 13 }
-1
actions/createNewDoc.ts
··· 14 14 import { sql } from "drizzle-orm"; 15 15 16 16 export async function createNewDoc() { 17 - console.log("Create new doc"); 18 17 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 19 18 const db = drizzle(client); 20 19 let { permissionToken } = await db.transaction(async (tx) => {
-1
actions/deleteDoc.ts
··· 15 15 import { revalidatePath } from "next/cache"; 16 16 17 17 export async function deleteDoc(permission_token: PermissionToken) { 18 - console.log("Delete doc"); 19 18 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 20 19 const db = drizzle(client); 21 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 122 display: none; 123 123 } 124 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 + 125 134 .highlight { 126 135 @apply px-[1px]; 127 136 @apply py-[1px];
+16 -35
app/home/DocOptions.tsx
··· 1 1 "use client"; 2 - import { PopoverArrow } from "components/Icons"; 3 2 import { DeleteSmall, MoreOptionsTiny } from "components/Icons"; 4 - import * as Popover from "@radix-ui/react-popover"; 5 3 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 4 10 5 export const DocOptions = (props: { 11 6 doc_id: string; ··· 13 8 }) => { 14 9 return ( 15 10 <> 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> 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> 46 27 </> 47 28 ); 48 29 };
+4 -2
app/home/DocPreview.tsx
··· 27 27 href={"/" + props.token.id} 28 28 className={`no-underline hover:no-underline text-primary h-full`} 29 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"> 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 31 <ThemeBackgroundProvider entityID={props.doc_id}> 32 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 33 <div ··· 82 82 </div> 83 83 )} 84 84 {state === "normal" && ( 85 - <DocOptions doc_id={props.doc_id} setState={setState} /> 85 + <div className="flex justify-end pt-1"> 86 + <DocOptions doc_id={props.doc_id} setState={setState} /> 87 + </div> 86 88 )} 87 89 </ThemeProvider> 88 90 </div>
+44 -12
components/Blocks/BlockOptions.tsx
··· 11 11 Header3Small, 12 12 LinkSmall, 13 13 LinkTextToolbarSmall, 14 + MailboxSmall, 14 15 ParagraphSmall, 15 16 } from "components/Icons"; 16 17 import { generateKeyBetween } from "fractional-indexing"; ··· 26 27 import * as Tooltip from "@radix-ui/react-tooltip"; 27 28 import { 28 29 TextBlockTypeButton, 29 - TextBlockTypeButtons, 30 - } from "components/Toolbar/TextBlockTypeButtons"; 30 + TextBlockTypeToolbar, 31 + } from "components/Toolbar/TextBlockTypeToolbar"; 31 32 import { isUrl } from "src/utils/isURL"; 32 33 import { useSmoker, useToaster } from "components/Toast"; 33 34 ··· 110 111 </label> 111 112 </ToolbarButton> 112 113 <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 114 tooltipContent="Add a card" 123 115 className="text-tertiary h-6" 124 116 onClick={async () => { ··· 159 151 > 160 152 <BlockCardSmall /> 161 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> 162 194 <Separator classname="h-6" /> 163 195 <TextBlockTypeButton 164 196 className="hover:text-primary text-tertiary h-6" ··· 168 200 )} 169 201 {blockMenuState === "heading" && ( 170 202 <> 171 - <TextBlockTypeButtons 203 + <TextBlockTypeToolbar 172 204 className="bg-transparent hover:text-primary text-tertiary " 173 205 onClose={() => setblockMenuState("default")} 174 206 />
+8 -36
components/Blocks/CardBlock.tsx
··· 11 11 import { useUIState } from "src/useUIState"; 12 12 import { RenderedTextBlock } from "components/Blocks/TextBlock"; 13 13 import { useDocMetadata } from "src/hooks/queries/useDocMetadata"; 14 - import { CloseTiny } from "components/Icons"; 14 + import { CloseTiny, TrashSmall } from "components/Icons"; 15 15 import { CSSProperties, useEffect, useRef, useState } from "react"; 16 16 import { useEntitySetContext } from "components/EntitySetProvider"; 17 17 import { useBlocks } from "src/hooks/queries/useBlocks"; 18 + import { AreYouSure } from "./DeleteBlock"; 18 19 19 20 export function CardBlock(props: BlockProps & { renderPreview?: boolean }) { 20 21 let { rep } = useReplicache(); ··· 31 32 let isOpen = useUIState((s) => s.openCards).includes(cardEntity); 32 33 33 34 let [areYouSure, setAreYouSure] = useState(false); 34 - 35 35 useEffect(() => { 36 36 if (!isSelected) { 37 37 setAreYouSure(false); 38 - } 39 - }, [isSelected]); 40 - 41 - useEffect(() => { 42 - if (isSelected) { 43 38 } 44 39 }, [isSelected]); 45 40 ··· 98 93 } 99 94 }} 100 95 > 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> 96 + {areYouSure ? ( 97 + <AreYouSure 98 + closeAreYouSure={() => setAreYouSure(false)} 99 + entityID={props.entityID} 100 + /> 128 101 ) : ( 129 102 <> 130 103 <div ··· 171 144 {docMetadata[2].listData && ( 172 145 <ListMarker {...docMetadata[2]} className="!pt-[8px]" /> 173 146 )} 174 - 175 147 <RenderedTextBlock entityID={docMetadata[2].value} /> 176 148 </div> 177 149 )} ··· 186 158 setAreYouSure(true); 187 159 }} 188 160 > 189 - <CloseTiny /> 161 + <TrashSmall /> 190 162 </button> 191 163 )} 192 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 1 import { useEntity, useReplicache } from "src/replicache"; 2 - import { CloseTiny } from "components/Icons"; 2 + import { CloseTiny, TrashSmall } from "components/Icons"; 3 3 import { useEntitySetContext } from "components/EntitySetProvider"; 4 4 import { useUIState } from "src/useUIState"; 5 5 ··· 68 68 }); 69 69 }} 70 70 > 71 - <CloseTiny /> 71 + <TrashSmall /> 72 72 </button> 73 73 )} 74 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 326 propsRef.current.nextBlock?.listData && 327 327 propsRef.current.nextBlock.listData.depth > 328 328 propsRef.current.listData.depth; 329 - console.log(propsRef); 330 329 position = generateKeyBetween( 331 330 hasChild ? null : propsRef.current.position, 332 331 propsRef.current.nextPosition,
+7 -1
components/Blocks/index.tsx
··· 11 11 import { CardBlock } from "./CardBlock"; 12 12 import { ExternalLinkBlock } from "./ExternalLinkBlock"; 13 13 import { BlockOptions } from "./BlockOptions"; 14 + import { MailboxBlock } from "./MailboxBlock"; 15 + 14 16 import { useBlocks } from "src/hooks/queries/useBlocks"; 15 17 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 16 18 import { useEntitySetContext } from "components/EntitySetProvider"; ··· 296 298 if (e.key === "Backspace") { 297 299 if (!entity_set.permissions.write) return; 298 300 if (textBlocks[props.type]) return; 299 - if (props.type === "card") return; 301 + if (props.type === "card" || props.type === "mailbox") return; 300 302 e.preventDefault(); 301 303 r.mutate.removeBlock({ blockEntity: props.entityID }); 302 304 useUIState.getState().closeCard(props.entityID); ··· 406 408 <ImageBlock {...props} /> 407 409 ) : props.type === "link" ? ( 408 410 <ExternalLinkBlock {...props} /> 411 + ) : props.type === "mailbox" ? ( 412 + <div className="flex flex-col gap-4 w-full"> 413 + <MailboxBlock {...props} /> 414 + </div> 409 415 ) : null} 410 416 </div> 411 417 );
+1 -1
components/Buttons.tsx
··· 9 9 return ( 10 10 <button 11 11 {...props} 12 - className={`m-0 px-2 py-0.5 w-max 12 + className={`m-0 px-2 py-0.5 w-max h-max 13 13 bg-accent-1 outline-offset-[-2px] active:outline active:outline-2 14 14 border border-accent-1 rounded-md 15 15 text-base font-bold text-accent-2
+53 -28
components/Cards.tsx
··· 7 7 import { Media } from "./Media"; 8 8 import { DesktopCardFooter } from "./DesktopFooter"; 9 9 import { Replicache } from "replicache"; 10 - import { Fact, ReplicacheMutators, useReplicache } from "src/replicache"; 10 + import { 11 + Fact, 12 + ReplicacheMutators, 13 + useReferenceToEntity, 14 + useReplicache, 15 + } from "src/replicache"; 11 16 import * as Popover from "@radix-ui/react-popover"; 12 17 import { MoreOptionsTiny, DeleteSmall, CloseTiny, PopoverArrow } from "./Icons"; 13 18 import { useToaster } from "./Toast"; ··· 15 20 import { MenuItem, Menu } from "./Layout"; 16 21 import { useEntitySetContext } from "./EntitySetProvider"; 17 22 import { HomeButton } from "./HomeButton"; 23 + import { useSearchParams } from "next/navigation"; 24 + import { useEffect } from "react"; 25 + import { DraftPostOptions } from "./Blocks/MailboxBlock"; 18 26 19 27 export function Cards(props: { rootCard: string }) { 20 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); 21 37 22 38 return ( 23 39 <div ··· 48 64 <div className="flex items-stretch"> 49 65 <Card entityID={props.rootCard} first /> 50 66 </div> 51 - {openCards.map((card) => ( 67 + {cards.map((card) => ( 52 68 <div className="flex items-stretch" key={card}> 53 69 <Card entityID={card} /> 54 70 </div> ··· 74 90 75 91 function Card(props: { entityID: string; first?: boolean }) { 76 92 let { rep } = useReplicache(); 93 + let isDraft = useReferenceToEntity("mailbox/draft", props.entityID); 77 94 78 95 let focusedElement = useUIState((s) => s.focusedBlock); 79 96 let focusedCardID = ··· 118 135 {!props.first && <CardOptions entityID={props.entityID} />} 119 136 </Media> 120 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 + )} 121 149 <Blocks entityID={props.entityID} /> 122 150 </div> 123 151 <Media mobile={false}> ··· 135 163 return ( 136 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"> 137 165 <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" 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" 139 167 onClick={() => { 140 168 useUIState.getState().closeCard(props.entityID); 141 169 }} ··· 150 178 const OptionsMenu = () => { 151 179 let toaster = useToaster(); 152 180 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`} 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 + }} 161 200 > 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> 201 + Delete Page <DeleteSmall /> 202 + </MenuItem> 203 + </Menu> 179 204 ); 180 205 }; 181 206
+2 -2
components/DesktopFooter.tsx
··· 1 1 "use client"; 2 2 import { useUIState } from "src/useUIState"; 3 3 import { Media } from "./Media"; 4 - import { TextToolbar } from "./Toolbar"; 4 + import { Toolbar } from "./Toolbar"; 5 5 6 6 export function DesktopCardFooter(props: { cardID: string }) { 7 7 let focusedBlock = useUIState((s) => s.focusedBlock); ··· 23 23 if (e.currentTarget === e.target) e.preventDefault(); 24 24 }} 25 25 > 26 - <TextToolbar 26 + <Toolbar 27 27 cardID={focusedBlockParentID} 28 28 blockID={focusedBlock.entityID} 29 29 />
+60
components/Icons.tsx
··· 230 230 ); 231 231 }; 232 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 + 233 253 export const PaintSmall = (props: Props) => { 234 254 return ( 235 255 <svg ··· 270 290 ); 271 291 }; 272 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 + 273 313 // TINY ICONS 16x16 274 314 275 315 export const AddTiny = (props: Props) => { ··· 305 345 fillRule="evenodd" 306 346 clipRule="evenodd" 307 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" 308 368 fill="currentColor" 309 369 /> 310 370 </svg>
+53 -9
components/Layout.tsx
··· 1 + import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 2 + import { theme } from "tailwind.config"; 3 + import { PopoverArrow } from "./Icons"; 4 + 1 5 export const Separator = (props: { classname?: string }) => { 2 6 return ( 3 7 <div className={`min-h-full border-r border-border ${props.classname}`} /> 4 8 ); 5 9 }; 6 10 7 - export const Menu = (props: { children?: React.ReactNode }) => { 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 + }) => { 8 19 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> 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> 12 41 ); 13 42 }; 14 43 15 44 export const MenuItem = (props: { 16 45 children?: React.ReactNode; 17 - onClick: (e: React.MouseEvent) => void; 46 + className?: string; 47 + onSelect: (e: Event) => void; 48 + id?: string; 18 49 }) => { 19 50 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 " 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 + `} 23 67 > 24 68 {props.children} 25 - </button> 69 + </DropdownMenu.Item> 26 70 ); 27 71 }; 28 72
+2 -2
components/MobileFooter.tsx
··· 2 2 import { useUIState } from "src/useUIState"; 3 3 import { Media } from "./Media"; 4 4 import { ThemePopover } from "./ThemeManager/ThemeSetter"; 5 - import { TextToolbar } from "components/Toolbar"; 5 + import { Toolbar } from "components/Toolbar"; 6 6 import { ShareOptions } from "./ShareOptions"; 7 7 import { HomeButton } from "./HomeButton"; 8 8 ··· 18 18 if (e.currentTarget === e.target) e.preventDefault(); 19 19 }} 20 20 > 21 - <TextToolbar 21 + <Toolbar 22 22 cardID={focusedBlock.parent} 23 23 blockID={focusedBlock.entityID} 24 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 86 let [sortedBlocks, siblings] = await getSortedSelection(); 87 87 for (let block of sortedBlocks) { 88 88 if (!block.listData) { 89 - console.log("yo?"); 90 89 await rep?.mutate.assertFact({ 91 90 entity: block.value, 92 91 attribute: "block/is-list",
+64 -66
components/ShareOptions/index.tsx
··· 1 1 import { useReplicache } from "src/replicache"; 2 - import { PopoverArrow, ShareSmall } from "components/Icons"; 2 + import { ShareSmall } from "components/Icons"; 3 3 import { useEffect, useState } from "react"; 4 4 import { getShareLink } from "./getShareLink"; 5 5 import { useEntitySetContext } from "components/EntitySetProvider"; 6 6 import { useSmoker } from "components/Toast"; 7 - import * as Popover from "@radix-ui/react-popover"; 8 7 import { Menu, MenuItem } from "components/Layout"; 9 - import { theme } from "tailwind.config"; 10 8 import { HoverButton } from "components/Buttons"; 11 9 12 10 export function ShareOptions(props: { rootEntity: string }) { ··· 37 35 return null; 38 36 39 37 return ( 40 - <Popover.Root> 41 - <Popover.Trigger> 38 + <Menu 39 + trigger={ 42 40 <HoverButton 43 41 icon=<ShareSmall /> 44 42 label="Share" 45 43 background="bg-accent-1" 46 44 text="text-accent-2" 47 45 /> 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> 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> 109 107 ); 110 108 }
+7 -7
components/Toast.tsx
··· 59 59 () => { 60 60 setToastState(null); 61 61 }, 62 - toast?.duration ? toast.duration : 3000, 62 + toast?.duration ? toast.duration : 6000, 63 63 ); 64 64 }, 65 65 [setToastState], ··· 84 84 setToast: (t: Toast | null) => void; 85 85 }) => { 86 86 let transitions = useTransition(props.toast ? [props.toast] : [], { 87 - from: { top: -30 }, 87 + from: { top: -40 }, 88 88 enter: { top: 8 }, 89 - leave: { top: -30 }, 89 + leave: { top: -40 }, 90 90 config: { 91 91 mass: 8, 92 92 friction: 150, ··· 101 101 className={`toastAnimationWrapper fixed bottom-0 right-0 left-0 z-50 h-fit`} 102 102 > 103 103 <div 104 - className={`toast absolute right-2 w-max px-3 py-1 flex flex-row gap-2 rounded-full border text-center ${ 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 105 props.toast?.type === "error" 106 - ? "bg-accent-red text-white" 106 + ? "bg-accent-red text-white border-white" 107 107 : props.toast?.type === "success" 108 - ? "bg-accent-green text-white" 109 - : "bg-accent-1 text-accent-2 shadow-md border border-border" 108 + ? "bg-accent-1 text-accent-2 border border-accent-2" 109 + : "bg-accent-1 text-accent-2 border border-accent-2" 110 110 }`} 111 111 > 112 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"; 1 + import { useEditorStates } from "src/state/useEditorState"; 2 2 import { useUIState } from "src/useUIState"; 3 3 import { schema } from "components/Blocks/TextBlock/schema"; 4 4 import { TextSelection } from "prosemirror-state"; 5 - import { toggleMarkInFocusedBlock } from "./TextDecorationButton"; 5 + import { 6 + TextDecorationButton, 7 + toggleMarkInFocusedBlock, 8 + } from "./TextDecorationButton"; 6 9 import * as Popover from "@radix-ui/react-popover"; 7 10 import * as Tooltip from "@radix-ui/react-tooltip"; 8 - 9 11 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 12 import { 24 13 ColorPicker, 25 14 pickers, ··· 27 16 setColorAttribute, 28 17 } from "components/ThemeManager/ThemeSetter"; 29 18 import { useEntity, useReplicache } from "src/replicache"; 30 - import { useMemo, useState } from "react"; 19 + import { useEffect, useMemo, useState } from "react"; 31 20 import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 32 21 import { useParams } from "next/navigation"; 33 22 import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 34 - import { PaintSmall, PopoverArrow } from "components/Icons"; 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"; 35 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 + }; 36 135 37 136 export const HighlightColorButton = (props: { 38 137 color: "1" | "2" | "3";
+1 -1
components/Toolbar/LinkButton.tsx components/Toolbar/InlineLinkToolbar.tsx
··· 45 45 ); 46 46 } 47 47 48 - export function LinkEditor(props: { onClose: () => void }) { 48 + export function InlineLinkToolbar(props: { onClose: () => void }) { 49 49 let focusedBlock = useUIState((s) => s.focusedBlock); 50 50 let focusedEditor = useEditorStates((s) => 51 51 focusedBlock ? s.editorStates[focusedBlock.entityID] : null,
+2 -1
components/Toolbar/ListButton.tsx components/Toolbar/ListToolbar.tsx
··· 38 38 </div> 39 39 </div> 40 40 } 41 - onClick={() => { 41 + onClick={(e) => { 42 + e.preventDefault(); 42 43 if (!focusedBlock) return; 43 44 if (!isList?.data.value) { 44 45 rep?.mutate.assertFact({
+2 -2
components/Toolbar/TextBlockTypeButtons.tsx components/Toolbar/TextBlockTypeToolbar.tsx
··· 4 4 Header3Small, 5 5 ParagraphSmall, 6 6 } from "components/Icons"; 7 - import { Separator, ShortcutKey } from "components/Layout"; 7 + import { ShortcutKey } from "components/Layout"; 8 8 import { ToolbarButton } from "components/Toolbar"; 9 9 import { TextSelection } from "prosemirror-state"; 10 10 import { useCallback } from "react"; ··· 12 12 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 13 13 import { useUIState } from "src/useUIState"; 14 14 15 - export const TextBlockTypeButtons = (props: { 15 + export const TextBlockTypeToolbar = (props: { 16 16 onClose: () => void; 17 17 className?: string; 18 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 5 BoldSmall, 6 6 CloseTiny, 7 7 ItalicSmall, 8 - ListUnorderedSmall, 9 - ListIndentDecreaseSmall, 10 - ListIndentIncreaseSmall, 11 8 StrikethroughSmall, 12 9 HighlightSmall, 13 10 PopoverArrow, ··· 18 15 import { 19 16 keepFocus, 20 17 TextBlockTypeButton, 21 - TextBlockTypeButtons, 22 - } from "./TextBlockTypeButtons"; 23 - import { LinkButton, LinkEditor } from "./LinkButton"; 24 - import { 25 - HighlightColorButton, 26 - HighlightColorSettings, 27 - } from "./HighlightButton"; 18 + TextBlockTypeToolbar, 19 + } from "./TextBlockTypeToolbar"; 20 + import { LinkButton, InlineLinkToolbar } from "./InlineLinkToolbar"; 28 21 import { theme } from "../../tailwind.config"; 29 22 import { useEditorStates } from "src/state/useEditorState"; 30 23 import { useUIState } from "src/useUIState"; 31 - import { useEntity, useReplicache } from "src/replicache"; 24 + import { useReplicache } from "src/replicache"; 32 25 import * as Tooltip from "@radix-ui/react-tooltip"; 33 26 import { Separator, ShortcutKey } from "components/Layout"; 34 27 import { metaKey } from "src/utils/metaKey"; 35 28 import { isMac } from "@react-aria/utils"; 36 29 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"; 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"; 40 41 41 - export const TextToolbar = (props: { cardID: string; blockID: string }) => { 42 + export const Toolbar = (props: { cardID: string; blockID: string }) => { 42 43 let { rep } = useReplicache(); 43 44 let focusedBlock = useUIState((s) => s.focusedBlock); 44 45 45 - let [toolbarState, setToolbarState] = useState< 46 - "default" | "highlight" | "link" | "header" | "list" | "linkBlock" 47 - >("default"); 46 + let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 48 47 49 48 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); 50 49 let setLastUsedHighlight = (color: "1" | "2" | "3") => ··· 73 72 <div className="flex items-center justify-between w-full gap-6"> 74 73 <div className="flex gap-[6px] items-center grow"> 75 74 {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 - </> 75 + <TextToolbar 76 + lastUsedHighlight={lastUsedHighlight} 77 + setToolbarState={(s) => { 78 + setToolbarState(s); 79 + }} 80 + /> 183 81 ) : toolbarState === "highlight" ? ( 184 82 <HighlightToolbar 185 83 onClose={() => setToolbarState("default")} ··· 191 89 ) : toolbarState === "list" ? ( 192 90 <ListToolbar onClose={() => setToolbarState("default")} /> 193 91 ) : toolbarState === "link" ? ( 194 - <LinkEditor 92 + <InlineLinkToolbar 195 93 onClose={() => { 196 94 activeEditor?.view?.focus(); 197 95 setToolbarState("default"); 198 96 }} 199 97 /> 200 98 ) : toolbarState === "header" ? ( 201 - <TextBlockTypeButtons onClose={() => setToolbarState("default")} /> 99 + <TextBlockTypeToolbar onClose={() => setToolbarState("default")} /> 202 100 ) : null} 203 101 </div> 204 102 <button ··· 222 120 </button> 223 121 </div> 224 122 </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 123 ); 268 124 }; 269 125
+24 -9
components/utils/UpdatePageTitle.tsx
··· 1 1 "use client"; 2 2 3 - import { useEffect } from "react"; 3 + import { useEffect, useState } from "react"; 4 4 import { useBlocks } from "src/hooks/queries/useBlocks"; 5 5 import { useEntity } from "src/replicache"; 6 6 import * as Y from "yjs"; ··· 15 15 (b) => b.type === "text" || b.type === "heading", 16 16 ); 17 17 let firstBlock = blocks[0]; 18 - let content = useEntity(firstBlock?.value, "block/text"); 18 + let title = usePageTitle(props.entityID); 19 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"; 20 + if (title) { 21 + document.title = title; 26 22 } 27 - }, [content]); 23 + }, [title]); 28 24 let params = useSearchParams(); 29 25 let focusFirstBlock = params.get("focusFirstBlock"); 30 26 let router = useRouter(); ··· 42 38 43 39 return null; 44 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 1 import { relations } from "drizzle-orm/relations"; 2 - import { entity_sets, entities, permission_tokens, identities, facts, permission_token_rights } from "./schema"; 2 + import { entity_sets, entities, email_subscriptions_to_entity, permission_tokens, identities, facts, permission_token_rights } from "./schema"; 3 3 4 4 export const entitiesRelations = relations(entities, ({one, many}) => ({ 5 5 entity_set: one(entity_sets, { 6 6 fields: [entities.set], 7 7 references: [entity_sets.id] 8 8 }), 9 + email_subscriptions_to_entities: many(email_subscriptions_to_entity), 9 10 permission_tokens: many(permission_tokens), 10 11 facts: many(facts), 11 12 })); ··· 15 16 permission_token_rights: many(permission_token_rights), 16 17 })); 17 18 18 - export const identitiesRelations = relations(identities, ({one}) => ({ 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 + }), 19 24 permission_token: one(permission_tokens, { 20 - fields: [identities.home_page], 25 + fields: [email_subscriptions_to_entity.token], 21 26 references: [permission_tokens.id] 22 27 }), 23 28 })); 24 29 25 30 export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ 31 + email_subscriptions_to_entities: many(email_subscriptions_to_entity), 26 32 identities: many(identities), 27 33 entity: one(entities, { 28 34 fields: [permission_tokens.root_entity], 29 35 references: [entities.id] 30 36 }), 31 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 + }), 32 45 })); 33 46 34 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" 1 + import { pgTable, pgEnum, text, bigint, foreignKey, uuid, timestamp, boolean, jsonb, primaryKey } from "drizzle-orm/pg-core" 2 2 import { sql } from "drizzle-orm" 3 3 4 4 export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) ··· 29 29 export const entity_sets = pgTable("entity_sets", { 30 30 id: uuid("id").defaultRandom().primaryKey().notNull(), 31 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(), 32 42 }); 33 43 34 44 export const identities = pgTable("identities", {
+681 -84
package-lock.json
··· 11 11 "dependencies": { 12 12 "@baselime/node-opentelemetry": "^0.5.8", 13 13 "@nytimes/react-prosemirror": "^0.6.1", 14 + "@radix-ui/react-dropdown-menu": "^2.1.1", 14 15 "@radix-ui/react-popover": "^1.0.7", 15 16 "@radix-ui/react-slider": "^1.1.2", 16 17 "@radix-ui/react-tooltip": "^1.1.2", ··· 2691 2692 } 2692 2693 } 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 + }, 2694 2931 "node_modules/@radix-ui/react-id": { 2695 2932 "version": "1.1.0", 2696 2933 "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", ··· 2722 2959 } 2723 2960 } 2724 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 + }, 2725 3126 "node_modules/@radix-ui/react-popover": { 2726 3127 "version": "1.0.7", 2727 3128 "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", ··· 3008 3409 } 3009 3410 } 3010 3411 }, 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 3412 "node_modules/@radix-ui/react-popper": { 3096 3413 "version": "1.2.0", 3097 3414 "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", ··· 3400 3717 "optional": true 3401 3718 }, 3402 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": { 3403 3892 "optional": true 3404 3893 } 3405 3894 } ··· 11505 11994 "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", 11506 11995 "dev": true 11507 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 + }, 11508 12042 "node_modules/react-stately": { 11509 12043 "version": "3.31.1", 11510 12044 "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.31.1.tgz", ··· 11536 12070 }, 11537 12071 "peerDependencies": { 11538 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 + } 11539 12095 } 11540 12096 }, 11541 12097 "node_modules/react-use-measure": { ··· 12962 13518 "dev": true, 12963 13519 "dependencies": { 12964 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 + } 12965 13562 } 12966 13563 }, 12967 13564 "node_modules/use-sync-external-store": {
+1
package.json
··· 13 13 "dependencies": { 14 14 "@baselime/node-opentelemetry": "^0.5.8", 15 15 "@nytimes/react-prosemirror": "^0.6.1", 16 + "@radix-ui/react-dropdown-menu": "^2.1.1", 16 17 "@radix-ui/react-popover": "^1.0.7", 17 18 "@radix-ui/react-slider": "^1.1.2", 18 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 37 }, 38 38 } as const; 39 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 + 40 55 const LinkBlockAttributes = { 41 56 "link/preview": { 42 57 type: "image", ··· 104 119 ...BlockAttributes, 105 120 ...LinkBlockAttributes, 106 121 ...ThemeAttributes, 122 + ...MailboxAttributes, 107 123 }; 108 124 type Attribute = typeof Attributes; 109 125 export type Data<A extends keyof typeof Attributes> = { ··· 137 153 reference: { type: "reference"; value: string }; 138 154 "block-type-union": { 139 155 type: "block-type-union"; 140 - value: "text" | "image" | "card" | "heading" | "link"; 156 + value: "text" | "image" | "card" | "heading" | "link" | "mailbox"; 141 157 }; 142 158 color: { type: "color"; value: string }; 143 159 }[(typeof Attributes)[A]["type"]];
+33 -1
src/replicache/index.tsx
··· 10 10 } from "replicache"; 11 11 import { Pull } from "./pull"; 12 12 import { mutations } from "./mutations"; 13 - import { Attributes, Data } from "./attributes"; 13 + import { Attributes, Data, FilterAttributes } from "./attributes"; 14 14 import { Push } from "./push"; 15 15 import { clientMutationContext } from "./clientMutationContext"; 16 16 import { supabaseBrowserClient } from "supabase/browserClient"; ··· 162 162 ? (d as CardinalityResult<A>) 163 163 : (d[0] as CardinalityResult<A>); 164 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 362 }); 363 363 }; 364 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 + 365 463 const retractAttribute: Mutation<{ 366 464 entity: string; 367 465 attribute: keyof FilterAttributes<{ cardinality: "one" }>; ··· 403 501 removeBlock, 404 502 moveChildren, 405 503 increaseHeadingLevel, 504 + archiveDraft, 406 505 toggleTodoState, 506 + createDraft, 407 507 };
+5
src/utils/getBlocksAsHTML.tsx
··· 50 50 ignoreWrapper?: boolean, 51 51 ) { 52 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 + } 53 58 if (b.type === "heading") { 54 59 let headingLevel = await scanIndex(tx).eav(b.value, "block/heading-level"); 55 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 34 } 35 35 public: { 36 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 + } 37 82 entities: { 38 83 Row: { 39 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";