a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

+1396 -1624
+9
.claude/settings.local.json
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "mcp__acp__Edit", 5 + "mcp__acp__Write", 6 + "mcp__acp__Bash" 7 + ] 8 + } 9 + }
-1
.github/pull_request_template.md
··· 2 2 - it looks good on both mobile and desktop 3 3 - it undo's like it ought to 4 4 - it handles keyboard interactions reasonably well 5 - - it behaves as you would expect if you lock it 6 5 - no build errors!!!
+115 -54
actions/publishToPublication.ts
··· 199 199 } 200 200 201 201 // Determine the collection to use - preserve existing schema if updating 202 - const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined; 202 + const existingCollection = existingDocUri 203 + ? new AtUri(existingDocUri).collection 204 + : undefined; 203 205 const documentType = getDocumentType(existingCollection); 204 206 205 207 // Build the pages array (used by both formats) ··· 228 230 if (documentType === "site.standard.document") { 229 231 // site.standard.document format 230 232 // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI 231 - const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 233 + const siteUri = 234 + publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 232 235 233 236 record = { 234 237 $type: "site.standard.document", 235 238 title: title || "Untitled", 236 239 site: siteUri, 237 - path: rkey, 240 + path: "/" + rkey, 238 241 publishedAt: 239 242 publishedAt || existingRecord.publishedAt || new Date().toISOString(), 240 243 ...(description && { description }), ··· 903 906 const mentionedDids = new Set<string>(); 904 907 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 905 908 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 909 + const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI 906 910 907 911 // Extract pages from either format 908 912 let pages: PubLeafletContent.Main["pages"] | undefined; ··· 917 921 918 922 if (!pages) return; 919 923 920 - // Extract mentions from all text blocks in all pages 921 - for (const page of pages) { 922 - if (page.$type === "pub.leaflet.pages.linearDocument") { 923 - const linearPage = page as PubLeafletPagesLinearDocument.Main; 924 - for (const blockWrapper of linearPage.blocks) { 925 - const block = blockWrapper.block; 926 - if (block.$type === "pub.leaflet.blocks.text") { 927 - const textBlock = block as PubLeafletBlocksText.Main; 928 - if (textBlock.facets) { 929 - for (const facet of textBlock.facets) { 930 - for (const feature of facet.features) { 931 - // Check for DID mentions 932 - if (PubLeafletRichtextFacet.isDidMention(feature)) { 933 - if (feature.did !== authorDid) { 934 - mentionedDids.add(feature.did); 935 - } 936 - } 937 - // Check for AT URI mentions (publications and documents) 938 - if (PubLeafletRichtextFacet.isAtMention(feature)) { 939 - const uri = new AtUri(feature.atURI); 924 + // Helper to extract blocks from all pages (both linear and canvas) 925 + function getAllBlocks(pages: PubLeafletContent.Main["pages"]) { 926 + const blocks: ( 927 + | PubLeafletPagesLinearDocument.Block["block"] 928 + | PubLeafletPagesCanvas.Block["block"] 929 + )[] = []; 930 + for (const page of pages) { 931 + if (page.$type === "pub.leaflet.pages.linearDocument") { 932 + const linearPage = page as PubLeafletPagesLinearDocument.Main; 933 + for (const blockWrapper of linearPage.blocks) { 934 + blocks.push(blockWrapper.block); 935 + } 936 + } else if (page.$type === "pub.leaflet.pages.canvas") { 937 + const canvasPage = page as PubLeafletPagesCanvas.Main; 938 + for (const blockWrapper of canvasPage.blocks) { 939 + blocks.push(blockWrapper.block); 940 + } 941 + } 942 + } 943 + return blocks; 944 + } 940 945 941 - if (isPublicationCollection(uri.collection)) { 942 - // Get the publication owner's DID 943 - const { data: publication } = await supabaseServerClient 944 - .from("publications") 945 - .select("identity_did") 946 - .eq("uri", feature.atURI) 947 - .single(); 946 + const allBlocks = getAllBlocks(pages); 948 947 949 - if (publication && publication.identity_did !== authorDid) { 950 - mentionedPublications.set( 951 - publication.identity_did, 952 - feature.atURI, 953 - ); 954 - } 955 - } else if (isDocumentCollection(uri.collection)) { 956 - // Get the document owner's DID 957 - const { data: document } = await supabaseServerClient 958 - .from("documents") 959 - .select("uri, data") 960 - .eq("uri", feature.atURI) 961 - .single(); 948 + // Extract mentions from all text blocks and embedded Bluesky posts 949 + for (const block of allBlocks) { 950 + // Check for embedded Bluesky posts 951 + if (PubLeafletBlocksBskyPost.isMain(block)) { 952 + const bskyPostUri = block.postRef.uri; 953 + // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx) 954 + const postAuthorDid = new AtUri(bskyPostUri).host; 955 + if (postAuthorDid !== authorDid) { 956 + embeddedBskyPosts.set(postAuthorDid, bskyPostUri); 957 + } 958 + } 962 959 963 - if (document) { 964 - const normalizedMentionedDoc = normalizeDocumentRecord( 965 - document.data, 966 - ); 967 - // Get the author from the document URI (the DID is the host part) 968 - const mentionedUri = new AtUri(feature.atURI); 969 - const docAuthor = mentionedUri.host; 970 - if (normalizedMentionedDoc && docAuthor !== authorDid) { 971 - mentionedDocuments.set(docAuthor, feature.atURI); 972 - } 973 - } 960 + // Check for text blocks with mentions 961 + if (block.$type === "pub.leaflet.blocks.text") { 962 + const textBlock = block as PubLeafletBlocksText.Main; 963 + if (textBlock.facets) { 964 + for (const facet of textBlock.facets) { 965 + for (const feature of facet.features) { 966 + // Check for DID mentions 967 + if (PubLeafletRichtextFacet.isDidMention(feature)) { 968 + if (feature.did !== authorDid) { 969 + mentionedDids.add(feature.did); 970 + } 971 + } 972 + // Check for AT URI mentions (publications and documents) 973 + if (PubLeafletRichtextFacet.isAtMention(feature)) { 974 + const uri = new AtUri(feature.atURI); 975 + 976 + if (isPublicationCollection(uri.collection)) { 977 + // Get the publication owner's DID 978 + const { data: publication } = await supabaseServerClient 979 + .from("publications") 980 + .select("identity_did") 981 + .eq("uri", feature.atURI) 982 + .single(); 983 + 984 + if (publication && publication.identity_did !== authorDid) { 985 + mentionedPublications.set( 986 + publication.identity_did, 987 + feature.atURI, 988 + ); 989 + } 990 + } else if (isDocumentCollection(uri.collection)) { 991 + // Get the document owner's DID 992 + const { data: document } = await supabaseServerClient 993 + .from("documents") 994 + .select("uri, data") 995 + .eq("uri", feature.atURI) 996 + .single(); 997 + 998 + if (document) { 999 + const normalizedMentionedDoc = normalizeDocumentRecord( 1000 + document.data, 1001 + ); 1002 + // Get the author from the document URI (the DID is the host part) 1003 + const mentionedUri = new AtUri(feature.atURI); 1004 + const docAuthor = mentionedUri.host; 1005 + if (normalizedMentionedDoc && docAuthor !== authorDid) { 1006 + mentionedDocuments.set(docAuthor, feature.atURI); 974 1007 } 975 1008 } 976 1009 } ··· 1026 1059 }; 1027 1060 await supabaseServerClient.from("notifications").insert(notification); 1028 1061 await pingIdentityToUpdateNotification(recipientDid); 1062 + } 1063 + 1064 + // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account) 1065 + if (embeddedBskyPosts.size > 0) { 1066 + // Check which of the Bluesky post authors have Leaflet accounts 1067 + const { data: identities } = await supabaseServerClient 1068 + .from("identities") 1069 + .select("atp_did") 1070 + .in("atp_did", Array.from(embeddedBskyPosts.keys())); 1071 + 1072 + const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []); 1073 + 1074 + for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) { 1075 + // Only notify if the post author has a Leaflet account 1076 + if (leafletUserDids.has(postAuthorDid)) { 1077 + const notification: Notification = { 1078 + id: v7(), 1079 + recipient: postAuthorDid, 1080 + data: { 1081 + type: "bsky_post_embed", 1082 + document_uri: documentUri, 1083 + bsky_post_uri: bskyPostUri, 1084 + }, 1085 + }; 1086 + await supabaseServerClient.from("notifications").insert(notification); 1087 + await pingIdentityToUpdateNotification(postAuthorDid); 1088 + } 1089 + } 1029 1090 } 1030 1091 }
+44
app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
··· 1 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 2 + import { ContentLayout, Notification } from "./Notification"; 3 + import { HydratedBskyPostEmbedNotification } from "src/notifications"; 4 + import { AtUri } from "@atproto/api"; 5 + 6 + export const BskyPostEmbedNotification = ( 7 + props: HydratedBskyPostEmbedNotification, 8 + ) => { 9 + const docRecord = props.normalizedDocument; 10 + const pubRecord = props.normalizedPublication; 11 + 12 + if (!docRecord) return null; 13 + 14 + const docUri = new AtUri(props.document.uri); 15 + const rkey = docUri.rkey; 16 + const did = docUri.host; 17 + 18 + const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; 19 + 20 + const embedder = props.documentCreatorHandle 21 + ? `@${props.documentCreatorHandle}` 22 + : "Someone"; 23 + 24 + return ( 25 + <Notification 26 + timestamp={props.created_at} 27 + href={href} 28 + icon={<BlueskyTiny />} 29 + actionText={<>{embedder} embedded your Bluesky post</>} 30 + content={ 31 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 32 + {props.bskyPostText && ( 33 + <pre 34 + style={{ wordBreak: "break-word" }} 35 + className="whitespace-pre-wrap text-secondary line-clamp-3 text-sm" 36 + > 37 + {props.bskyPostText} 38 + </pre> 39 + )} 40 + </ContentLayout> 41 + } 42 + /> 43 + ); 44 + };
+4
app/(home-pages)/notifications/NotificationList.tsx
··· 8 8 import { useIdentityData } from "components/IdentityProvider"; 9 9 import { FollowNotification } from "./FollowNotification"; 10 10 import { QuoteNotification } from "./QuoteNotification"; 11 + import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification"; 11 12 import { MentionNotification } from "./MentionNotification"; 12 13 import { CommentMentionNotification } from "./CommentMentionNotification"; 13 14 ··· 47 48 } 48 49 if (n.type === "quote") { 49 50 return <QuoteNotification key={n.id} {...n} />; 51 + } 52 + if (n.type === "bsky_post_embed") { 53 + return <BskyPostEmbedNotification key={n.id} {...n} />; 50 54 } 51 55 if (n.type === "mention") { 52 56 return <MentionNotification key={n.id} {...n} />;
+1 -1
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 16 16 popover?: boolean; 17 17 }) => { 18 18 let profileRecord = props.profile; 19 - const profileUrl = `/p/${props.profile.handle}`; 19 + const profileUrl = `https://leaflet.pub/p/${props.profile.handle}`; 20 20 21 21 const avatarElement = ( 22 22 <Avatar
+17 -1
app/[leaflet_id]/Footer.tsx
··· 8 8 import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 9 import { PublishButton } from "./actions/PublishButton"; 10 10 import { useEntitySetContext } from "components/EntitySetProvider"; 11 - import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; 12 11 import { Watermark } from "components/Watermark"; 13 12 import { BackToPubButton } from "./actions/BackToPubButton"; 14 13 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 15 14 import { useIdentityData } from "components/IdentityProvider"; 15 + import { useEntity } from "src/replicache"; 16 + import { block } from "sharp"; 16 17 18 + export function hasBlockToolbar(blockType: string | null | undefined) { 19 + return ( 20 + blockType === "text" || 21 + blockType === "heading" || 22 + blockType === "blockquote" || 23 + blockType === "button" || 24 + blockType === "datetime" || 25 + blockType === "image" 26 + ); 27 + } 17 28 export function LeafletFooter(props: { entityID: string }) { 18 29 let focusedBlock = useUIState((s) => s.focusedEntity); 30 + 19 31 let entity_set = useEntitySetContext(); 20 32 let { identity } = useIdentityData(); 21 33 let { data: pub } = useLeafletPublicationData(); 34 + let blockType = useEntity(focusedBlock?.entityID || null, "block/type")?.data 35 + .value; 22 36 23 37 return ( 24 38 <Media mobile className="mobileFooter w-full z-10 touch-none -mt-[54px] "> 25 39 {focusedBlock && 26 40 focusedBlock.entityType == "block" && 41 + hasBlockToolbar(blockType) && 27 42 entity_set.permissions.write ? ( 28 43 <div 29 44 className="w-full z-10 p-2 flex bg-bg-page pwa-padding-bottom" ··· 34 49 <Toolbar 35 50 pageID={focusedBlock.parent} 36 51 blockID={focusedBlock.entityID} 52 + blockType={blockType} 37 53 /> 38 54 </div> 39 55 ) : entity_set.permissions.write ? (
+1 -7
app/[leaflet_id]/Leaflet.tsx
··· 18 18 token: PermissionToken; 19 19 initialFacts: Fact<Attribute>[]; 20 20 leaflet_id: string; 21 - initialHeadingFontId?: string; 22 - initialBodyFontId?: string; 23 21 }) { 24 22 return ( 25 23 <ReplicacheProvider ··· 31 29 <EntitySetProvider 32 30 set={props.token.permission_token_rights[0].entity_set} 33 31 > 34 - <ThemeProvider 35 - entityID={props.leaflet_id} 36 - initialHeadingFontId={props.initialHeadingFontId} 37 - initialBodyFontId={props.initialBodyFontId} 38 - > 32 + <ThemeProvider entityID={props.leaflet_id}> 39 33 <ThemeBackgroundProvider entityID={props.leaflet_id}> 40 34 <UpdateLeafletTitle entityID={props.leaflet_id} /> 41 35 <AddLeafletToHomepage />
+12 -23
app/[leaflet_id]/page.tsx
··· 14 14 import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 15 15 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 16 16 import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 17 - import { FontLoader, extractFontsFromFacts } from "components/FontLoader"; 18 17 19 18 export const preferredRegion = ["sfo1"]; 20 19 export const dynamic = "force-dynamic"; ··· 49 48 getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 50 49 ]); 51 50 let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 52 - 53 - // Extract font settings from facts for server-side font loading 54 - const { headingFontId, bodyFontId } = extractFontsFromFacts(initialFacts as any, rootEntity); 55 - 56 51 return ( 57 - <> 58 - {/* Server-side font loading with preload and @font-face */} 59 - <FontLoader headingFontId={headingFontId} bodyFontId={bodyFontId} /> 60 - <PageSWRDataProvider 61 - rsvp_data={rsvp_data} 62 - poll_data={poll_data} 63 - leaflet_id={res.data.id} 64 - leaflet_data={res} 65 - > 66 - <Leaflet 67 - initialFacts={initialFacts} 68 - leaflet_id={rootEntity} 69 - token={res.data} 70 - initialHeadingFontId={headingFontId} 71 - initialBodyFontId={bodyFontId} 72 - /> 73 - </PageSWRDataProvider> 74 - </> 52 + <PageSWRDataProvider 53 + rsvp_data={rsvp_data} 54 + poll_data={poll_data} 55 + leaflet_id={res.data.id} 56 + leaflet_data={res} 57 + > 58 + <Leaflet 59 + initialFacts={initialFacts} 60 + leaflet_id={rootEntity} 61 + token={res.data} 62 + /> 63 + </PageSWRDataProvider> 75 64 ); 76 65 } 77 66
+10
app/api/inngest/client.ts
··· 26 26 did: string; 27 27 }; 28 28 }; 29 + "user/cleanup-expired-oauth-sessions": { 30 + data: {}; 31 + }; 32 + "user/check-oauth-session": { 33 + data: { 34 + identityId: string; 35 + did: string; 36 + tokenCount: number; 37 + }; 38 + }; 29 39 }; 30 40 31 41 // Create a client to send and receive events
+123
app/api/inngest/functions/cleanup_expired_oauth_sessions.ts
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { inngest } from "../client"; 3 + import { restoreOAuthSession } from "src/atproto-oauth"; 4 + 5 + // Main function that fetches identities and publishes events for each one 6 + export const cleanup_expired_oauth_sessions = inngest.createFunction( 7 + { id: "cleanup_expired_oauth_sessions" }, 8 + { event: "user/cleanup-expired-oauth-sessions" }, 9 + async ({ step }) => { 10 + // Get all identities with an atp_did (OAuth users) that have at least one auth token 11 + const identities = await step.run("fetch-oauth-identities", async () => { 12 + const { data, error } = await supabaseServerClient 13 + .from("identities") 14 + .select("id, atp_did, email_auth_tokens(count)") 15 + .not("atp_did", "is", null); 16 + 17 + if (error) { 18 + throw new Error(`Failed to fetch identities: ${error.message}`); 19 + } 20 + 21 + // Filter to only include identities with at least one auth token 22 + return (data || []) 23 + .filter((identity) => { 24 + const tokenCount = identity.email_auth_tokens?.[0]?.count ?? 0; 25 + return tokenCount > 0; 26 + }) 27 + .map((identity) => ({ 28 + id: identity.id, 29 + atp_did: identity.atp_did!, 30 + tokenCount: identity.email_auth_tokens?.[0]?.count ?? 0, 31 + })); 32 + }); 33 + 34 + console.log( 35 + `Found ${identities.length} OAuth identities with active sessions to check`, 36 + ); 37 + 38 + // Publish events for each identity in batches 39 + const BATCH_SIZE = 100; 40 + let totalSent = 0; 41 + 42 + for (let i = 0; i < identities.length; i += BATCH_SIZE) { 43 + const batch = identities.slice(i, i + BATCH_SIZE); 44 + 45 + await step.run(`send-events-batch-${i}`, async () => { 46 + const events = batch.map((identity) => ({ 47 + name: "user/check-oauth-session" as const, 48 + data: { 49 + identityId: identity.id, 50 + did: identity.atp_did, 51 + tokenCount: identity.tokenCount, 52 + }, 53 + })); 54 + 55 + await inngest.send(events); 56 + return events.length; 57 + }); 58 + 59 + totalSent += batch.length; 60 + } 61 + 62 + console.log(`Published ${totalSent} check-oauth-session events`); 63 + 64 + return { 65 + success: true, 66 + identitiesQueued: totalSent, 67 + }; 68 + }, 69 + ); 70 + 71 + // Function that checks a single identity's OAuth session and cleans up if expired 72 + export const check_oauth_session = inngest.createFunction( 73 + { id: "check_oauth_session" }, 74 + { event: "user/check-oauth-session" }, 75 + async ({ event, step }) => { 76 + const { identityId, did, tokenCount } = event.data; 77 + 78 + const result = await step.run("check-and-cleanup", async () => { 79 + console.log(`Checking OAuth session for DID: ${did} (${tokenCount} tokens)`); 80 + 81 + const sessionResult = await restoreOAuthSession(did); 82 + 83 + if (sessionResult.ok) { 84 + console.log(` Session valid for ${did}`); 85 + return { valid: true, tokensDeleted: 0 }; 86 + } 87 + 88 + // Session is expired/invalid - delete associated auth tokens 89 + console.log( 90 + ` Session expired for ${did}: ${sessionResult.error.message}`, 91 + ); 92 + 93 + const { error: deleteError } = await supabaseServerClient 94 + .from("email_auth_tokens") 95 + .delete() 96 + .eq("identity", identityId); 97 + 98 + if (deleteError) { 99 + console.error( 100 + ` Error deleting tokens for identity ${identityId}: ${deleteError.message}`, 101 + ); 102 + return { 103 + valid: false, 104 + tokensDeleted: 0, 105 + error: deleteError.message, 106 + }; 107 + } 108 + 109 + console.log(` Deleted ${tokenCount} auth tokens for identity ${identityId}`); 110 + 111 + return { 112 + valid: false, 113 + tokensDeleted: tokenCount, 114 + }; 115 + }); 116 + 117 + return { 118 + identityId, 119 + did, 120 + ...result, 121 + }; 122 + }, 123 + );
+61 -52
app/api/inngest/functions/migrate_user_to_standard.ts
··· 44 44 }; 45 45 46 46 // Step 1: Verify OAuth session is valid 47 - await step.run("verify-oauth-session", async () => { 47 + const oauthValid = await step.run("verify-oauth-session", async () => { 48 48 const result = await restoreOAuthSession(did); 49 49 if (!result.ok) { 50 - throw new Error( 51 - `Failed to restore OAuth session: ${result.error.message}`, 52 - ); 50 + // Mark identity as needing migration so we can retry later 51 + await supabaseServerClient 52 + .from("identities") 53 + .update({ 54 + metadata: { needsStandardSiteMigration: true }, 55 + }) 56 + .eq("atp_did", did); 57 + 58 + return { success: false, error: result.error.message }; 53 59 } 54 60 return { success: true }; 55 61 }); 62 + 63 + if (!oauthValid.success) { 64 + return { 65 + success: false, 66 + error: `Failed to restore OAuth session`, 67 + stats, 68 + publicationUriMap: {}, 69 + documentUriMap: {}, 70 + userSubscriptionUriMap: {}, 71 + }; 72 + } 56 73 57 74 // Step 2: Get user's pub.leaflet.publication records 58 75 const oldPublications = await step.run( ··· 109 126 }) 110 127 .filter((x) => x !== null); 111 128 112 - // Run all PDS writes in parallel 113 - const pubPdsResults = await Promise.all( 114 - publicationsToMigrate.map(({ pub, rkey, newRecord }) => 115 - step.run(`pds-write-publication-${pub.uri}`, async () => { 129 + // Run PDS + DB writes together for each publication 130 + const pubResults = await Promise.all( 131 + publicationsToMigrate.map(({ pub, rkey, normalized, newRecord }) => 132 + step.run(`migrate-publication-${pub.uri}`, async () => { 133 + // PDS write 116 134 const agent = await createAuthenticatedAgent(did); 117 135 const putResult = await agent.com.atproto.repo.putRecord({ 118 136 repo: did, ··· 121 139 record: newRecord, 122 140 validate: false, 123 141 }); 124 - return { oldUri: pub.uri, newUri: putResult.data.uri }; 125 - }), 126 - ), 127 - ); 142 + const newUri = putResult.data.uri; 128 143 129 - // Run all DB writes in parallel 130 - const pubDbResults = await Promise.all( 131 - publicationsToMigrate.map(({ pub, normalized, newRecord }, index) => { 132 - const newUri = pubPdsResults[index].newUri; 133 - return step.run(`db-write-publication-${pub.uri}`, async () => { 144 + // DB write 134 145 const { error: dbError } = await supabaseServerClient 135 146 .from("publications") 136 147 .upsert({ ··· 149 160 }; 150 161 } 151 162 return { success: true as const, oldUri: pub.uri, newUri }; 152 - }); 153 - }), 163 + }), 164 + ), 154 165 ); 155 166 156 167 // Process results 157 - for (const result of pubDbResults) { 168 + for (const result of pubResults) { 158 169 if (result.success) { 159 170 publicationUriMap[result.oldUri] = result.newUri; 160 171 stats.publicationsMigrated++; ··· 239 250 $type: "site.standard.document", 240 251 title: normalized.title || "Untitled", 241 252 site: siteValue, 242 - path: rkey, 253 + path: "/" + rkey, 243 254 publishedAt: normalized.publishedAt || new Date().toISOString(), 244 255 description: normalized.description, 245 256 content: normalized.content, ··· 252 263 }) 253 264 .filter((x) => x !== null); 254 265 255 - // Run all PDS writes in parallel 256 - const docPdsResults = await Promise.all( 257 - documentsToMigrate.map(({ doc, rkey, newRecord }) => 258 - step.run(`pds-write-document-${doc.uri}`, async () => { 266 + // Run PDS + DB writes together for each document 267 + const docResults = await Promise.all( 268 + documentsToMigrate.map(({ doc, rkey, newRecord, oldPubUri }) => 269 + step.run(`migrate-document-${doc.uri}`, async () => { 270 + // PDS write 259 271 const agent = await createAuthenticatedAgent(did); 260 272 const putResult = await agent.com.atproto.repo.putRecord({ 261 273 repo: did, ··· 264 276 record: newRecord, 265 277 validate: false, 266 278 }); 267 - return { oldUri: doc.uri, newUri: putResult.data.uri }; 268 - }), 269 - ), 270 - ); 279 + const newUri = putResult.data.uri; 271 280 272 - // Run all DB writes in parallel 273 - const docDbResults = await Promise.all( 274 - documentsToMigrate.map(({ doc, newRecord, oldPubUri }, index) => { 275 - const newUri = docPdsResults[index].newUri; 276 - return step.run(`db-write-document-${doc.uri}`, async () => { 281 + // DB write 277 282 const { error: dbError } = await supabaseServerClient 278 283 .from("documents") 279 284 .upsert({ ··· 302 307 } 303 308 304 309 return { success: true as const, oldUri: doc.uri, newUri }; 305 - }); 306 - }), 310 + }), 311 + ), 307 312 ); 308 313 309 314 // Process results 310 - for (const result of docDbResults) { 315 + for (const result of docResults) { 311 316 if (result.success) { 312 317 documentUriMap[result.oldUri] = result.newUri; 313 318 stats.documentsMigrated++; ··· 428 433 }) 429 434 .filter((x) => x !== null); 430 435 431 - // Run all PDS writes in parallel 432 - const subPdsResults = await Promise.all( 436 + // Run PDS + DB writes together for each subscription 437 + const subResults = await Promise.all( 433 438 subscriptionsToMigrate.map(({ sub, rkey, newRecord }) => 434 - step.run(`pds-write-subscription-${sub.uri}`, async () => { 439 + step.run(`migrate-subscription-${sub.uri}`, async () => { 440 + // PDS write 435 441 const agent = await createAuthenticatedAgent(did); 436 442 const putResult = await agent.com.atproto.repo.putRecord({ 437 443 repo: did, ··· 440 446 record: newRecord, 441 447 validate: false, 442 448 }); 443 - return { oldUri: sub.uri, newUri: putResult.data.uri }; 444 - }), 445 - ), 446 - ); 449 + const newUri = putResult.data.uri; 447 450 448 - // Run all DB writes in parallel 449 - const subDbResults = await Promise.all( 450 - subscriptionsToMigrate.map(({ sub, newRecord }, index) => { 451 - const newUri = subPdsResults[index].newUri; 452 - return step.run(`db-write-subscription-${sub.uri}`, async () => { 451 + // DB write 453 452 const { error: dbError } = await supabaseServerClient 454 453 .from("publication_subscriptions") 455 454 .update({ ··· 467 466 }; 468 467 } 469 468 return { success: true as const, oldUri: sub.uri, newUri }; 470 - }); 471 - }), 469 + }), 470 + ), 472 471 ); 473 472 474 473 // Process results 475 - for (const result of subDbResults) { 474 + for (const result of subResults) { 476 475 if (result.success) { 477 476 userSubscriptionUriMap[result.oldUri] = result.newUri; 478 477 stats.userSubscriptionsMigrated++; ··· 489 488 // 2. External references (e.g., from other AT Proto apps) to old URIs continue to work 490 489 // 3. The normalization layer handles both schemas transparently for reads 491 490 // Old records are also kept on the user's PDS so existing AT-URI references remain valid. 491 + 492 + // Clear the migration flag on success 493 + if (stats.errors.length === 0) { 494 + await step.run("clear-migration-flag", async () => { 495 + await supabaseServerClient 496 + .from("identities") 497 + .update({ metadata: null }) 498 + .eq("atp_did", did); 499 + }); 500 + } 492 501 493 502 return { 494 503 success: stats.errors.length === 0,
+6
app/api/inngest/route.tsx
··· 5 5 import { batched_update_profiles } from "./functions/batched_update_profiles"; 6 6 import { index_follows } from "./functions/index_follows"; 7 7 import { migrate_user_to_standard } from "./functions/migrate_user_to_standard"; 8 + import { 9 + cleanup_expired_oauth_sessions, 10 + check_oauth_session, 11 + } from "./functions/cleanup_expired_oauth_sessions"; 8 12 9 13 export const { GET, POST, PUT } = serve({ 10 14 client: inngest, ··· 14 18 batched_update_profiles, 15 19 index_follows, 16 20 migrate_user_to_standard, 21 + cleanup_expired_oauth_sessions, 22 + check_oauth_session, 17 23 ], 18 24 });
+11
app/api/oauth/[route]/route.ts
··· 11 11 ActionAfterSignIn, 12 12 parseActionFromSearchParam, 13 13 } from "./afterSignInActions"; 14 + import { inngest } from "app/api/inngest/client"; 14 15 15 16 type OauthRequestClientState = { 16 17 redirect: string | null; ··· 84 85 .single(); 85 86 identity = data; 86 87 } 88 + 89 + // Trigger migration if identity needs it 90 + const metadata = identity?.metadata as Record<string, unknown> | null; 91 + if (metadata?.needsStandardSiteMigration) { 92 + await inngest.send({ 93 + name: "user/migrate-to-standard", 94 + data: { did: session.did }, 95 + }); 96 + } 97 + 87 98 let { data: token } = await supabaseServerClient 88 99 .from("email_auth_tokens") 89 100 .insert({
+4 -9
app/globals.css
··· 62 62 --shadow-md: 1.2px 2.5px 2.7px -1.8px rgba(var(--primary), 0.1), 63 63 5.6px 11.6px 12.5px -3.5px rgba(var(--primary), 0.15); 64 64 65 - --font-sans: var(--theme-font, var(--font-quattro)); 65 + --font-sans: var(--font-quattro); 66 66 --font-serif: Garamond; 67 67 } 68 68 ··· 158 158 } 159 159 160 160 /* START FONT STYLING */ 161 - h1, 162 - h2, 163 - h3, 164 - h4 { 165 - font-family: var(--theme-heading-font, var(--theme-font)); 166 - } 167 - 168 161 h1 { 169 162 @apply text-2xl; 170 163 @apply font-bold; ··· 201 194 } 202 195 203 196 pre { 204 - font-family: var(--theme-font, --font-quattro); 197 + font-family: var(--font-quattro); 205 198 } 206 199 207 200 p { ··· 281 274 @apply p-2; 282 275 @apply rounded-md; 283 276 @apply overflow-auto; 277 + @apply sm:min-h-12; 278 + @apply min-h-10; 284 279 285 280 @media (min-width: 640px) { 286 281 @apply p-3;
+10 -12
app/layout.tsx
··· 36 36 const quattro = localFont({ 37 37 src: [ 38 38 { 39 - path: "../public/fonts/iaw-quattro-vf.woff2", 39 + path: "../public/fonts/iAWriterQuattroV.ttf", 40 40 style: "normal", 41 41 }, 42 42 { 43 - path: "../public/fonts/iaw-quattro-vf-Italic.woff2", 43 + path: "../public/fonts/iAWriterQuattroV-Italic.ttf", 44 44 style: "italic", 45 45 }, 46 46 ], ··· 48 48 variable: "--font-quattro", 49 49 }); 50 50 51 - export default async function RootLayout({ 52 - children, 53 - }: { 54 - children: React.ReactNode; 55 - }) { 51 + export default async function RootLayout( 52 + { 53 + children, 54 + }: { 55 + children: React.ReactNode; 56 + } 57 + ) { 56 58 let headersList = await headers(); 57 59 let ipLocation = headersList.get("X-Vercel-IP-Country"); 58 60 let acceptLanguage = headersList.get("accept-language"); ··· 78 80 <InitialPageLoad> 79 81 <PopUpProvider> 80 82 <IdentityProviderServer> 81 - <RequestHeadersProvider 82 - country={ipLocation} 83 - language={acceptLanguage} 84 - timezone={ipTimezone} 85 - > 83 + <RequestHeadersProvider country={ipLocation} language={acceptLanguage} timezone={ipTimezone}> 86 84 <ViewportSizeLayout>{children}</ViewportSizeLayout> 87 85 <RouteUIStateManager /> 88 86 </RequestHeadersProvider>
+34
app/lish/[did]/[publication]/.well-known/site.standard.publication/route.ts
··· 1 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + 4 + export async function GET( 5 + req: Request, 6 + props: { 7 + params: Promise<{ publication: string; did: string }>; 8 + }, 9 + ) { 10 + let params = await props.params; 11 + let did = decodeURIComponent(params.did); 12 + let publication_name = decodeURIComponent(params.publication); 13 + let [{ data: publications }] = await Promise.all([ 14 + supabaseServerClient 15 + .from("publications") 16 + .select( 17 + `*, 18 + publication_subscriptions(*), 19 + documents_in_publications(documents( 20 + *, 21 + comments_on_documents(count), 22 + document_mentions_in_bsky(count) 23 + )) 24 + `, 25 + ) 26 + .eq("identity_did", did) 27 + .or(publicationNameOrUriFilter(did, publication_name)) 28 + .order("uri", { ascending: false }) 29 + .limit(1), 30 + ]); 31 + let publication = publications?.[0]; 32 + if (!did || !publication) return new Response(null, { status: 404 }); 33 + return new Response(publication.uri); 34 + }
+7 -4
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 35 35 sizes: "32x32", 36 36 type: "image/png", 37 37 }, 38 - other: { 39 - rel: "alternate", 40 - url: document.uri, 41 - }, 38 + other: [ 39 + { 40 + rel: "alternate", 41 + url: document.uri, 42 + }, 43 + { rel: "site.standard.document", url: document.uri }, 44 + ], 42 45 }, 43 46 title: 44 47 docRecord.title +
-1
app/lish/[did]/[publication]/icon/route.ts
··· 14 14 request: NextRequest, 15 15 props: { params: Promise<{ did: string; publication: string }> }, 16 16 ) { 17 - console.log("are we getting here?"); 18 17 const params = await props.params; 19 18 try { 20 19 let did = decodeURIComponent(params.did);
-4
app/lish/createPub/updatePublication.ts
··· 273 273 showPageBackground: boolean; 274 274 accentBackground: Color; 275 275 accentText: Color; 276 - headingFont?: string; 277 - bodyFont?: string; 278 276 }; 279 277 }): Promise<UpdatePublicationResult> { 280 278 return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { ··· 314 312 accentText: { 315 313 ...theme.accentText, 316 314 }, 317 - headingFont: theme.headingFont, 318 - bodyFont: theme.bodyFont, 319 315 }; 320 316 321 317 // Derive basicTheme from the theme colors for site.standard.publication
+7 -4
app/p/[didOrHandle]/[rkey]/page.tsx
··· 38 38 39 39 return { 40 40 icons: { 41 - other: { 42 - rel: "alternate", 43 - url: document.uri, 44 - }, 41 + other: [ 42 + { 43 + rel: "alternate", 44 + url: document.uri, 45 + }, 46 + { rel: "site.standard.document", url: document.uri }, 47 + ], 45 48 }, 46 49 title: docRecord.title, 47 50 description: docRecord?.description || "",
+141 -67
components/Blocks/Block.tsx
··· 33 33 import { deepEquals } from "src/utils/deepEquals"; 34 34 import { isTextBlock } from "src/utils/isTextBlock"; 35 35 import { focusPage } from "src/utils/focusPage"; 36 + import { DeleteTiny } from "components/Icons/DeleteTiny"; 37 + import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 38 + import { Separator } from "components/Layout"; 39 + import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 40 + import { deleteBlock } from "src/utils/deleteBlock"; 36 41 37 42 export type Block = { 38 43 factID: string; ··· 63 68 // Block handles all block level events like 64 69 // mouse events, keyboard events and longPress, and setting AreYouSure state 65 70 // and shared styling like padding and flex for list layouting 66 - let { rep } = useReplicache(); 67 71 let mouseHandlers = useBlockMouseHandlers(props); 68 72 let handleDrop = useHandleDrop({ 69 73 parent: props.parent, ··· 72 76 }); 73 77 let entity_set = useEntitySetContext(); 74 78 75 - let { isLongPress, handlers } = useLongPress(() => { 79 + let { isLongPress, longPressHandlers } = useLongPress(() => { 76 80 if (isTextBlock[props.type]) return; 77 81 if (isLongPress.current) { 78 82 focusBlock( ··· 85 89 let selected = useUIState( 86 90 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 87 91 ); 92 + let alignment = useEntity(props.value, "block/text-alignment")?.data.value; 93 + 94 + let alignmentStyle = 95 + props.type === "button" || props.type === "image" 96 + ? "justify-center" 97 + : "justify-start"; 98 + 99 + if (alignment) 100 + alignmentStyle = { 101 + left: "justify-start", 102 + right: "justify-end", 103 + center: "justify-center", 104 + justify: "justify-start", 105 + }[alignment]; 88 106 89 107 let [areYouSure, setAreYouSure] = useState(false); 90 108 useEffect(() => { ··· 98 116 99 117 return ( 100 118 <div 101 - {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})} 119 + {...(!props.preview ? { ...mouseHandlers, ...longPressHandlers } : {})} 102 120 id={ 103 121 !props.preview ? elementId.block(props.entityID).container : undefined 104 122 } ··· 117 135 blockWrapper relative 118 136 flex flex-row gap-2 119 137 px-3 sm:px-4 138 + z-1 w-full 139 + ${alignmentStyle} 120 140 ${ 121 141 !props.nextBlock 122 142 ? "pb-3 sm:pb-4" ··· 255 275 ) => { 256 276 // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers 257 277 let BlockTypeComponent = BlockTypeComponents[props.type]; 258 - let alignment = useEntity(props.value, "block/text-alignment")?.data.value; 259 - 260 - let alignmentStyle = 261 - props.type === "button" || props.type === "image" 262 - ? "justify-center" 263 - : "justify-start"; 264 - 265 - if (alignment) 266 - alignmentStyle = { 267 - left: "justify-start", 268 - right: "justify-end", 269 - center: "justify-center", 270 - justify: "justify-start", 271 - }[alignment]; 272 278 273 279 if (!BlockTypeComponent) return <div>unknown block</div>; 274 280 return ( 275 - <div 276 - className={`blockContentWrapper w-full grow flex gap-2 z-1 ${alignmentStyle}`} 277 - > 281 + <> 278 282 {props.listData && <ListMarker {...props} />} 279 283 {props.areYouSure ? ( 280 284 <AreYouSure ··· 287 291 ) : ( 288 292 <BlockTypeComponent {...props} preview={props.preview} /> 289 293 )} 290 - </div> 294 + </> 291 295 ); 292 296 }; 293 297 ··· 326 330 s.selectedBlocks.length > 1, 327 331 ); 328 332 329 - let isSelected = useUIState((s) => 330 - s.selectedBlocks.find((b) => b.value === props.entityID), 331 - ); 332 - let isLocked = useEntity(props.value, "block/is-locked"); 333 - 334 333 let nextBlockSelected = useUIState((s) => 335 334 s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), 336 335 ); ··· 338 337 s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), 339 338 ); 340 339 341 - if (isMultiselected || (isLocked?.data.value && isSelected)) 342 - // not sure what multiselected and selected classes are doing (?) 343 - // use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected 344 - 340 + if (isMultiselected) 345 341 return ( 346 342 <> 347 343 <div ··· 354 350 ${!prevBlockSelected && "rounded-t-md"} 355 351 ${!nextBlockSelected && "rounded-b-md"} 356 352 `} 357 - style={ 358 - isLocked?.data.value 359 - ? { 360 - maskImage: "var(--hatchSVG)", 361 - maskRepeat: "repeat repeat", 362 - } 363 - : {} 364 - } 365 - ></div> 366 - {isLocked?.data.value && ( 367 - <div 368 - className={` 369 - blockSelectionLockIndicator z-10 370 - flex items-center 371 - text-border rounded-full 372 - absolute right-3 373 - 374 - ${ 375 - props.type === "heading" || props.type === "text" 376 - ? "top-[6px]" 377 - : "top-0" 378 - }`} 379 - > 380 - <LockTiny className="bg-bg-page p-0.5 rounded-full w-5 h-5" /> 381 - </div> 382 - )} 353 + /> 383 354 </> 384 355 ); 385 356 }; 386 357 387 358 export const BlockLayout = (props: { 388 - isSelected?: boolean; 359 + isSelected: boolean; 389 360 children: React.ReactNode; 390 361 className?: string; 362 + optionsClassName?: string; 391 363 hasBackground?: "accent" | "page"; 392 364 borderOnHover?: boolean; 365 + hasAlignment?: boolean; 366 + areYouSure?: boolean; 367 + setAreYouSure?: (value: boolean) => void; 393 368 }) => { 369 + // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete 394 370 return ( 395 371 <div 396 - className={`block ${props.className} p-2 sm:p-3 w-full overflow-hidden 372 + className={`nonTextBlockAndControls relative ${props.hasAlignment ? "w-fit" : "w-full"}`} 373 + > 374 + <div 375 + className={`nonTextBlock ${props.className} p-2 sm:p-3 overflow-hidden 376 + ${props.hasAlignment ? "w-fit" : "w-full"} 397 377 ${props.isSelected ? "block-border-selected " : "block-border"} 398 378 ${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`} 399 - style={{ 400 - backgroundColor: 401 - props.hasBackground === "accent" 402 - ? "var(--accent-light)" 403 - : props.hasBackground === "page" 404 - ? "rgb(var(--bg-page))" 405 - : "transparent", 406 - }} 379 + style={{ 380 + backgroundColor: 381 + props.hasBackground === "accent" 382 + ? "var(--accent-light)" 383 + : props.hasBackground === "page" 384 + ? "rgb(var(--bg-page))" 385 + : "transparent", 386 + }} 387 + > 388 + {props.children} 389 + </div> 390 + {props.isSelected && ( 391 + <NonTextBlockOptions 392 + optionsClassName={props.optionsClassName} 393 + areYouSure={props.areYouSure} 394 + setAreYouSure={props.setAreYouSure} 395 + /> 396 + )} 397 + </div> 398 + ); 399 + }; 400 + 401 + let debounced: null | number = null; 402 + 403 + const NonTextBlockOptions = (props: { 404 + areYouSure?: boolean; 405 + setAreYouSure?: (value: boolean) => void; 406 + optionsClassName?: string; 407 + }) => { 408 + let { rep } = useReplicache(); 409 + let entity_set = useEntitySetContext(); 410 + let focusedEntity = useUIState((s) => s.focusedEntity); 411 + let focusedEntityType = useEntity( 412 + focusedEntity?.entityType === "page" 413 + ? focusedEntity.entityID 414 + : focusedEntity?.parent || null, 415 + "page/type", 416 + ); 417 + 418 + let isMultiselected = useUIState((s) => s.selectedBlocks.length > 1); 419 + if (focusedEntity?.entityType === "page") return; 420 + 421 + if (isMultiselected) return; 422 + 423 + return ( 424 + <div 425 + className={`flex gap-1 absolute -top-[25px] right-2 pb-0.5 pt-1 px-1 rounded-t-md bg-border text-bg-page ${props.optionsClassName}`} 407 426 > 408 - {props.children} 427 + {focusedEntityType?.data.value !== "canvas" && ( 428 + <> 429 + <button 430 + onClick={async (e) => { 431 + e.stopPropagation(); 432 + 433 + if (!rep) return; 434 + await moveBlockDown(rep, entity_set.set); 435 + }} 436 + > 437 + <ArrowDownTiny /> 438 + </button> 439 + <button 440 + onClick={async (e) => { 441 + e.stopPropagation(); 442 + 443 + if (!rep) return; 444 + await moveBlockUp(rep); 445 + }} 446 + > 447 + <ArrowDownTiny className="rotate-180" /> 448 + </button> 449 + <Separator classname="border-bg-page! h-4! mx-0.5" /> 450 + </> 451 + )} 452 + <button 453 + onClick={async (e) => { 454 + e.stopPropagation(); 455 + if (!rep || !focusedEntity) return; 456 + 457 + if (props.areYouSure !== undefined && props.setAreYouSure) { 458 + if (!props.areYouSure) { 459 + props.setAreYouSure(true); 460 + debounced = window.setTimeout(() => { 461 + debounced = null; 462 + }, 300); 463 + return; 464 + } 465 + 466 + if (props.areYouSure) { 467 + if (debounced) { 468 + window.clearTimeout(debounced); 469 + debounced = window.setTimeout(() => { 470 + debounced = null; 471 + }, 300); 472 + return; 473 + } 474 + await deleteBlock([focusedEntity.entityID], rep); 475 + } 476 + } else { 477 + await deleteBlock([focusedEntity.entityID], rep); 478 + } 479 + }} 480 + > 481 + <DeleteTiny /> 482 + </button> 409 483 </div> 410 484 ); 411 485 };
+1 -3
components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx
··· 18 18 let isSelected = useUIState((s) => 19 19 s.selectedBlocks.find((b) => b.value === props.entityID), 20 20 ); 21 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 22 21 23 22 let entity_set = useEntitySetContext(); 24 23 let [urlValue, setUrlValue] = useState(""); ··· 91 90 className="w-full grow border-none outline-hidden bg-transparent " 92 91 placeholder="bsky.app/post-url" 93 92 value={urlValue} 94 - disabled={isLocked} 95 93 onChange={(e) => setUrlValue(e.target.value)} 96 94 onKeyDown={(e) => { 97 95 if (e.key === "Enter") { ··· 109 107 <button 110 108 type="submit" 111 109 id="bluesky-post-block-submit" 112 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 110 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 113 111 onMouseDown={(e) => { 114 112 e.preventDefault(); 115 113 errorSmokers(e.clientX + 12, e.clientY);
+38 -15
components/Blocks/ButtonBlock.tsx
··· 24 24 let isSelected = useUIState((s) => 25 25 s.selectedBlocks.find((b) => b.value === props.entityID), 26 26 ); 27 + let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 27 28 28 29 if (!url) { 29 30 if (!permissions.write) return null; ··· 31 32 } 32 33 33 34 return ( 34 - <a 35 - href={url?.data.value} 36 - target="_blank" 37 - className={`hover:outline-accent-contrast rounded-md! ${isSelected ? "block-border-selected border-0!" : "block-border border-transparent! border-0!"}`} 35 + <BlockLayout 36 + isSelected={!!isSelected} 37 + borderOnHover 38 + hasAlignment={alignment !== "justify"} 39 + className={`p-0! rounded-md! border-none!`} 38 40 > 39 - <ButtonPrimary role="link" type="submit"> 40 - {text?.data.value} 41 - </ButtonPrimary> 42 - </a> 41 + <a 42 + href={url?.data.value} 43 + target="_blank" 44 + className={` ${alignment === "justify" ? "w-full" : "w-fit"}`} 45 + > 46 + <ButtonPrimary 47 + role="link" 48 + type="submit" 49 + fullWidth={alignment === "justify"} 50 + > 51 + {text?.data.value} 52 + </ButtonPrimary> 53 + </a> 54 + </BlockLayout> 43 55 ); 44 56 }; 45 57 ··· 51 63 let isSelected = useUIState((s) => 52 64 s.selectedBlocks.find((b) => b.value === props.entityID), 53 65 ); 54 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 55 66 56 67 let [textValue, setTextValue] = useState(""); 57 68 let [urlValue, setUrlValue] = useState(""); 58 69 let text = textValue; 59 70 let url = urlValue; 71 + let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 60 72 61 73 let submit = async () => { 62 74 let entity = props.entityID; ··· 106 118 }; 107 119 108 120 return ( 109 - <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full "> 110 - <ButtonPrimary className="mx-auto"> 121 + <div 122 + className={`buttonBlockSettingsWrapper flex flex-col gap-2 w-full 123 + `} 124 + > 125 + <ButtonPrimary 126 + className={`relative ${ 127 + alignment === "center" 128 + ? "place-self-center" 129 + : alignment === "left" 130 + ? "place-self-start" 131 + : alignment === "right" 132 + ? "place-self-end" 133 + : "place-self-center" 134 + }`} 135 + fullWidth={alignment === "justify"} 136 + > 111 137 {text !== "" ? text : "Button"} 112 138 </ButtonPrimary> 113 139 <BlockLayout ··· 167 193 <Separator /> 168 194 <Input 169 195 type="text" 170 - autoFocus 171 196 className="w-full grow border-none outline-hidden bg-transparent" 172 197 placeholder="button text" 173 198 value={textValue} 174 - disabled={isLocked} 175 199 onChange={(e) => setTextValue(e.target.value)} 176 200 onKeyDown={(e) => { 177 201 if ( ··· 194 218 className="w-full grow border-none outline-hidden bg-transparent" 195 219 placeholder="www.example.com" 196 220 value={urlValue} 197 - disabled={isLocked} 198 221 onChange={(e) => setUrlValue(e.target.value)} 199 222 onKeyDown={(e) => { 200 223 if (e.key === "Backspace" && !e.currentTarget.value) ··· 205 228 <button 206 229 id="button-block-settings" 207 230 type="submit" 208 - className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 231 + className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 209 232 > 210 233 <div className="sm:hidden block">Save</div> 211 234 <CheckTiny />
+57 -76
components/Blocks/CodeBlock.tsx
··· 14 14 import { flushSync } from "react-dom"; 15 15 import { elementId } from "src/utils/elementId"; 16 16 import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 17 + import { focusBlock } from "src/utils/focusBlock"; 17 18 18 19 export function CodeBlock(props: BlockProps) { 19 20 let { rep, rootEntity } = useReplicache(); ··· 42 43 }, [content, lang, theme]); 43 44 44 45 const onClick = useCallback((e: React.MouseEvent<HTMLElement>) => { 45 - let selection = window.getSelection(); 46 - if (!selection || selection.rangeCount === 0) return; 47 - let range = selection.getRangeAt(0); 48 - if (!range) return; 49 - let length = range.toString().length; 50 - range.setStart(e.currentTarget, 0); 51 - let end = range.toString().length; 52 - let start = end - length; 53 - 54 - flushSync(() => { 55 - useUIState.getState().setSelectedBlock(props); 56 - useUIState.getState().setFocusedBlock({ 57 - entityType: "block", 58 - entityID: props.value, 59 - parent: props.parent, 60 - }); 61 - }); 62 - let el = document.getElementById( 63 - elementId.block(props.entityID).input, 64 - ) as HTMLTextAreaElement; 65 - if (!el) return; 66 - el.focus(); 67 - el.setSelectionRange(start, end); 46 + focusBlock( 47 + { parent: props.parent, value: props.value, type: "code" }, 48 + { type: "end" }, 49 + ); 68 50 }, []); 69 51 return ( 70 52 <div className="codeBlock w-full flex flex-col rounded-md gap-0.5 "> 71 - {permissions.write && ( 72 - <div className="text-sm text-tertiary flex justify-between"> 73 - <div className="flex gap-1"> 74 - Theme:{" "} 75 - <select 76 - className="codeBlockLang text-left bg-transparent pr-1 sm:max-w-none max-w-24" 77 - onClick={(e) => { 78 - e.preventDefault(); 79 - e.stopPropagation(); 80 - }} 81 - value={theme} 82 - onChange={async (e) => { 83 - await rep?.mutate.assertFact({ 84 - attribute: "theme/code-theme", 85 - entity: rootEntity, 86 - data: { type: "string", value: e.target.value }, 87 - }); 88 - }} 89 - > 90 - {bundledThemesInfo.map((t) => ( 91 - <option key={t.id} value={t.id}> 92 - {t.displayName} 93 - </option> 94 - ))} 95 - </select> 96 - </div> 97 - <select 98 - className="codeBlockLang text-right bg-transparent pr-1 sm:max-w-none max-w-24" 99 - onClick={(e) => { 100 - e.preventDefault(); 101 - e.stopPropagation(); 102 - }} 103 - value={lang} 104 - onChange={async (e) => { 105 - localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value); 106 - await rep?.mutate.assertFact({ 107 - attribute: "block/code-language", 108 - entity: props.entityID, 109 - data: { type: "string", value: e.target.value }, 110 - }); 111 - }} 112 - > 113 - <option value="plaintext">Plaintext</option> 114 - {bundledLanguagesInfo.map((l) => ( 115 - <option key={l.id} value={l.id}> 116 - {l.name} 117 - </option> 118 - ))} 119 - </select> 120 - </div> 121 - )} 122 - 123 53 <BlockLayout 124 54 isSelected={focusedBlock} 125 55 hasBackground="accent" 126 56 borderOnHover 127 - className="p-0! min-h-[48px]" 57 + className="p-0! min-h-10 sm:min-h-12" 128 58 > 129 59 {focusedBlock && permissions.write ? ( 130 60 <BaseTextareaBlock ··· 171 101 /> 172 102 )} 173 103 </BlockLayout> 104 + {permissions.write && ( 105 + <div className="text-sm text-tertiary flex w-full justify-between"> 106 + <div className="codeBlockTheme grow flex gap-1"> 107 + Theme:{" "} 108 + <select 109 + className="codeBlockThemeSelect text-left bg-transparent pr-1 sm:max-w-none max-w-24 w-full" 110 + onClick={(e) => { 111 + e.preventDefault(); 112 + e.stopPropagation(); 113 + }} 114 + value={theme} 115 + onChange={async (e) => { 116 + await rep?.mutate.assertFact({ 117 + attribute: "theme/code-theme", 118 + entity: rootEntity, 119 + data: { type: "string", value: e.target.value }, 120 + }); 121 + }} 122 + > 123 + {bundledThemesInfo.map((t) => ( 124 + <option key={t.id} value={t.id}> 125 + {t.displayName} 126 + </option> 127 + ))} 128 + </select> 129 + </div> 130 + <select 131 + className="codeBlockLang grow text-right bg-transparent pr-1 sm:max-w-none max-w-24 w-full" 132 + onClick={(e) => { 133 + e.preventDefault(); 134 + e.stopPropagation(); 135 + }} 136 + value={lang} 137 + onChange={async (e) => { 138 + localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value); 139 + await rep?.mutate.assertFact({ 140 + attribute: "block/code-language", 141 + entity: props.entityID, 142 + data: { type: "string", value: e.target.value }, 143 + }); 144 + }} 145 + > 146 + <option value="plaintext">Plaintext</option> 147 + {bundledLanguagesInfo.map((l) => ( 148 + <option key={l.id} value={l.id}> 149 + {l.name} 150 + </option> 151 + ))} 152 + </select> 153 + </div> 154 + )} 174 155 </div> 175 156 ); 176 157 }
+2 -3
components/Blocks/DateTimeBlock.tsx
··· 53 53 s.selectedBlocks.find((b) => b.value === props.entityID), 54 54 ); 55 55 56 - let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 57 56 let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 58 57 59 58 const handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { ··· 117 116 118 117 return ( 119 118 <Popover 120 - disabled={isLocked || !permissions.write} 119 + disabled={!permissions.write} 121 120 className="w-64 z-10 px-2!" 122 121 trigger={ 123 122 <BlockLayout ··· 133 132 {dateFact ? ( 134 133 <div 135 134 className={`font-bold 136 - ${!permissions.write || isLocked ? "" : "group-hover/date:underline"} 135 + ${!permissions.write ? "" : "group-hover/date:underline"} 137 136 `} 138 137 > 139 138 {selectedDate.toLocaleDateString(undefined, {
+4 -4
components/Blocks/EmbedBlock.tsx
··· 111 111 <div 112 112 data-draggable 113 113 className={`resizeHandle 114 + 115 + 114 116 cursor-ns-resize shrink-0 z-10 w-6 h-[5px] 115 - absolute bottom-2 right-1/2 translate-x-1/2 translate-y-[2px] 117 + absolute bottom-[3px] right-1/2 translate-x-1/2 116 118 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 117 119 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`} 118 120 {...heightHandle.handlers} ··· 129 131 let isSelected = useUIState((s) => 130 132 s.selectedBlocks.find((b) => b.value === props.entityID), 131 133 ); 132 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 133 134 134 135 let entity_set = useEntitySetContext(); 135 136 let [linkValue, setLinkValue] = useState(""); ··· 250 251 className="w-full grow border-none outline-hidden bg-transparent " 251 252 placeholder="www.example.com" 252 253 value={linkValue} 253 - disabled={isLocked} 254 254 onChange={(e) => setLinkValue(e.target.value)} 255 255 /> 256 256 <button 257 257 type="submit" 258 258 id="embed-block-submit" 259 259 disabled={loading} 260 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 260 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 261 261 onMouseDown={(e) => { 262 262 e.preventDefault(); 263 263 if (loading) return;
+1 -3
components/Blocks/ExternalLinkBlock.tsx
··· 118 118 let isSelected = useUIState((s) => 119 119 s.selectedBlocks.find((b) => b.value === props.entityID), 120 120 ); 121 - let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 122 121 let entity_set = useEntitySetContext(); 123 122 let [linkValue, setLinkValue] = useState(""); 124 123 let { rep } = useReplicache(); ··· 173 172 !props.preview ? elementId.block(props.entityID).input : undefined 174 173 } 175 174 type="url" 176 - disabled={isLocked} 177 175 className="w-full grow border-none outline-hidden bg-transparent " 178 176 placeholder="www.example.com" 179 177 value={linkValue} ··· 199 197 <div className="flex items-center gap-3 "> 200 198 <button 201 199 autoFocus={false} 202 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 200 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 203 201 onMouseDown={(e) => { 204 202 e.preventDefault(); 205 203 if (!linkValue || linkValue === "") {
+43 -34
components/Blocks/ImageBlock.tsx
··· 19 19 import { ImageAltSmall } from "components/Icons/ImageAlt"; 20 20 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 21 21 import { useSubscribe } from "src/replicache/useSubscribe"; 22 - import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 22 + import { 23 + ImageCoverImage, 24 + ImageCoverImageRemove, 25 + } from "components/Icons/ImageCoverImage"; 26 + import { 27 + ButtonPrimary, 28 + ButtonSecondary, 29 + ButtonTertiary, 30 + } from "components/Buttons"; 31 + import { CheckTiny } from "components/Icons/CheckTiny"; 23 32 24 33 export function ImageBlock(props: BlockProps & { preview?: boolean }) { 25 34 let { rep } = useReplicache(); ··· 28 37 let isSelected = useUIState((s) => 29 38 s.selectedBlocks.find((b) => b.value === props.value), 30 39 ); 31 - let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 32 40 let isFullBleed = useEntity(props.value, "image/full-bleed")?.data.value; 33 41 let isFirst = props.previousBlock === null; 34 42 let isLast = props.nextBlock === null; ··· 84 92 return ( 85 93 <BlockLayout 86 94 hasBackground="accent" 87 - isSelected={!!isSelected && !isLocked} 95 + isSelected={!!isSelected} 88 96 borderOnHover 89 97 className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 90 98 > 91 99 <label 92 100 className={` 93 - 94 101 w-full h-full hover:cursor-pointer 95 102 flex flex-col items-center justify-center 96 - ${props.pageType === "canvas" && "bg-bg-page"}`} 103 + `} 97 104 onMouseDown={(e) => e.preventDefault()} 98 105 onDragOver={(e) => { 99 106 e.preventDefault(); ··· 102 109 onDrop={async (e) => { 103 110 e.preventDefault(); 104 111 e.stopPropagation(); 105 - if (isLocked) return; 106 112 const files = e.dataTransfer.files; 107 113 if (files && files.length > 0) { 108 114 const file = files[0]; ··· 119 125 Upload An Image 120 126 </div> 121 127 <input 122 - disabled={isLocked} 123 128 className="h-0 w-0 hidden" 124 129 type="file" 125 130 accept="image/*" ··· 134 139 ); 135 140 } 136 141 137 - let imageClassName = isFullBleed 138 - ? "" 139 - : isSelected 140 - ? "block-border-selected border-transparent! " 141 - : "block-border border-transparent!"; 142 - 143 142 let isLocalUpload = localImages.get(image.data.src); 144 143 145 144 let blockClassName = ` 146 145 relative group/image border-transparent! p-0! w-fit! 147 - ${isFullBleed && "-mx-3 sm:-mx-4"} 146 + ${isFullBleed && "-mx-[14px] sm:-mx-[18px] rounded-[0px]! sm:outline-offset-[-16px]! -outline-offset[-12px]!"} 148 147 ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 149 148 ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} 150 149 `; 151 150 152 151 return ( 153 - <BlockLayout isSelected={!!isSelected} className={blockClassName}> 152 + <BlockLayout 153 + hasAlignment 154 + isSelected={!!isSelected} 155 + className={blockClassName} 156 + optionsClassName={isFullBleed ? "top-[-8px]!" : ""} 157 + > 154 158 {isLocalUpload || image.data.local ? ( 155 159 <img 156 160 loading="lazy" ··· 168 172 } 169 173 height={image?.data.height} 170 174 width={image?.data.width} 171 - className={imageClassName} 172 175 /> 173 176 )} 174 177 {altText !== undefined && !props.preview ? ( ··· 204 207 ); 205 208 206 209 // Only show if focused, in a publication, has write permissions, and no cover image is set 207 - if ( 208 - !isFocused || 209 - !pubData?.publications || 210 - !entity_set.permissions.write || 211 - coverImage 212 - ) 210 + if (!isFocused || !pubData?.publications || !entity_set.permissions.write) 213 211 return null; 214 - 215 - return ( 216 - <div className="absolute top-2 left-2"> 217 - <button 218 - className="flex items-center gap-1 text-xs bg-bg-page/80 hover:bg-bg-page text-secondary hover:text-primary px-2 py-1 rounded-md border border-border hover:border-primary transition-colors" 212 + if (coverImage) 213 + return ( 214 + <ButtonSecondary 215 + className="absolute top-2 right-2" 219 216 onClick={async (e) => { 220 217 e.preventDefault(); 221 218 e.stopPropagation(); 222 219 await rep?.mutate.updatePublicationDraft({ 223 - cover_image: props.entityID, 220 + cover_image: null, 224 221 }); 225 222 }} 226 223 > 227 - <span className="w-4 h-4 flex items-center justify-center"> 228 - <ImageCoverImage /> 229 - </span> 230 - Set as Cover 231 - </button> 232 - </div> 224 + Remove Cover Image 225 + <ImageCoverImageRemove /> 226 + </ButtonSecondary> 227 + ); 228 + return ( 229 + <ButtonPrimary 230 + className="absolute top-2 right-2" 231 + onClick={async (e) => { 232 + e.preventDefault(); 233 + e.stopPropagation(); 234 + await rep?.mutate.updatePublicationDraft({ 235 + cover_image: props.entityID, 236 + }); 237 + }} 238 + > 239 + Use as Cover Image 240 + <ImageCoverImage /> 241 + </ButtonPrimary> 233 242 ); 234 243 }; 235 244
+18 -3
components/Blocks/MailboxBlock.tsx
··· 26 26 import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 27 27 import { InfoSmall } from "components/Icons/InfoSmall"; 28 28 29 - export const MailboxBlock = (props: BlockProps) => { 29 + export const MailboxBlock = ( 30 + props: BlockProps & { 31 + areYouSure?: boolean; 32 + setAreYouSure?: (value: boolean) => void; 33 + }, 34 + ) => { 30 35 let isSubscribed = useSubscriptionStatus(props.entityID); 31 36 let isSelected = useUIState((s) => 32 37 s.selectedBlocks.find((b) => b.value === props.entityID), ··· 41 46 let subscriber_count = useEntity(props.entityID, "mailbox/subscriber-count"); 42 47 if (!permission) 43 48 return ( 44 - <MailboxReaderView entityID={props.entityID} parent={props.parent} /> 49 + <MailboxReaderView 50 + entityID={props.entityID} 51 + parent={props.parent} 52 + /> 45 53 ); 46 54 47 55 return ( ··· 49 57 <BlockLayout 50 58 isSelected={!!isSelected} 51 59 hasBackground={"accent"} 60 + areYouSure={props.areYouSure} 61 + setAreYouSure={props.setAreYouSure} 52 62 className="flex gap-2 items-center justify-center" 53 63 > 54 64 <ButtonPrimary ··· 120 130 ); 121 131 }; 122 132 123 - const MailboxReaderView = (props: { entityID: string; parent: string }) => { 133 + const MailboxReaderView = (props: { 134 + entityID: string; 135 + parent: string; 136 + 137 + }) => { 124 138 let isSubscribed = useSubscriptionStatus(props.entityID); 125 139 let isSelected = useUIState((s) => 126 140 s.selectedBlocks.find((b) => b.value === props.entityID), ··· 133 147 <BlockLayout 134 148 isSelected={!!isSelected} 135 149 hasBackground={"accent"} 150 + 136 151 className="`h-full flex flex-col gap-2 items-center justify-center" 137 152 > 138 153 {!isSubscribed?.confirmed ? (
+9 -1
components/Blocks/PageLinkBlock.tsx
··· 13 13 import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; 14 14 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 15 15 16 - export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { 16 + export function PageLinkBlock( 17 + props: BlockProps & { 18 + preview?: boolean; 19 + areYouSure?: boolean; 20 + setAreYouSure?: (value: boolean) => void; 21 + }, 22 + ) { 17 23 let page = useEntity(props.entityID, "block/card"); 18 24 let type = 19 25 useEntity(page?.data.value || null, "page/type")?.data.value || "doc"; ··· 32 38 <BlockLayout 33 39 hasBackground="page" 34 40 isSelected={!!isSelected} 41 + areYouSure={props.areYouSure} 42 + setAreYouSure={props.setAreYouSure} 35 43 className={`cursor-pointer 36 44 pageLinkBlockWrapper relative group/pageLinkBlock 37 45 flex overflow-clip p-0!
+14 -2
components/Blocks/PollBlock/index.tsx
··· 20 20 import { PublicationPollBlock } from "../PublicationPollBlock"; 21 21 import { usePollBlockUIState } from "./pollBlockState"; 22 22 23 - export const PollBlock = (props: BlockProps) => { 23 + export const PollBlock = ( 24 + props: BlockProps & { 25 + areYouSure?: boolean; 26 + setAreYouSure?: (value: boolean) => void; 27 + }, 28 + ) => { 24 29 let { data: pub } = useLeafletPublicationData(); 25 30 if (!pub) return <LeafletPollBlock {...props} />; 26 31 return <PublicationPollBlock {...props} />; 27 32 }; 28 33 29 - export const LeafletPollBlock = (props: BlockProps) => { 34 + export const LeafletPollBlock = ( 35 + props: BlockProps & { 36 + areYouSure?: boolean; 37 + setAreYouSure?: (value: boolean) => void; 38 + }, 39 + ) => { 30 40 let isSelected = useUIState((s) => 31 41 s.selectedBlocks.find((b) => b.value === props.entityID), 32 42 ); ··· 64 74 <BlockLayout 65 75 isSelected={!!isSelected} 66 76 hasBackground={"accent"} 77 + areYouSure={props.areYouSure} 78 + setAreYouSure={props.setAreYouSure} 67 79 className="poll flex flex-col gap-2 w-full" 68 80 > 69 81 {pollState === "editing" ? (
+10 -2
components/Blocks/PublicationPollBlock.tsx
··· 21 21 * It allows adding/editing options when the poll hasn't been published yet, 22 22 * but disables adding new options once the poll record exists (indicated by pollUri). 23 23 */ 24 - export const PublicationPollBlock = (props: BlockProps) => { 25 - let { data: publicationData, normalizedDocument } = useLeafletPublicationData(); 24 + export const PublicationPollBlock = ( 25 + props: BlockProps & { 26 + areYouSure?: boolean; 27 + setAreYouSure?: (value: boolean) => void; 28 + }, 29 + ) => { 30 + let { data: publicationData, normalizedDocument } = 31 + useLeafletPublicationData(); 26 32 let isSelected = useUIState((s) => 27 33 s.selectedBlocks.find((b) => b.value === props.entityID), 28 34 ); ··· 57 63 className="poll flex flex-col gap-2" 58 64 hasBackground={"accent"} 59 65 isSelected={!!isSelected} 66 + areYouSure={props.areYouSure} 67 + setAreYouSure={props.setAreYouSure} 60 68 > 61 69 <EditPollForPublication 62 70 entityID={props.entityID}
+8 -1
components/Blocks/RSVPBlock/index.tsx
··· 24 24 } 25 25 | { state: "contact_details"; status: RSVP_Status }; 26 26 27 - export function RSVPBlock(props: BlockProps) { 27 + export function RSVPBlock( 28 + props: BlockProps & { 29 + areYouSure?: boolean; 30 + setAreYouSure?: (value: boolean) => void; 31 + }, 32 + ) { 28 33 let isSelected = useUIState((s) => 29 34 s.selectedBlocks.find((b) => b.value === props.entityID), 30 35 ); ··· 32 37 <BlockLayout 33 38 isSelected={!!isSelected} 34 39 hasBackground={"accent"} 40 + areYouSure={props.areYouSure} 41 + setAreYouSure={props.setAreYouSure} 35 42 className="rsvp relative flex flex-col gap-1 w-full rounded-lg place-items-center justify-center" 36 43 > 37 44 <RSVPForm entityID={props.entityID} />
+5 -11
components/Blocks/TextBlock/index.tsx
··· 30 30 import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 31 31 32 32 const HeadingStyle = { 33 - 1: "text-xl font-bold [font-family:var(--theme-heading-font)]", 34 - 2: "text-lg font-bold [font-family:var(--theme-heading-font)]", 35 - 3: "text-base font-bold text-secondary [font-family:var(--theme-heading-font)]", 33 + 1: "text-xl font-bold", 34 + 2: "text-lg font-bold", 35 + 3: "text-base font-bold text-secondary ", 36 36 } as { [level: number]: string }; 37 37 38 38 export function TextBlock( ··· 41 41 preview?: boolean; 42 42 }, 43 43 ) { 44 - let isLocked = useEntity(props.entityID, "block/is-locked"); 45 44 let initialized = useHasPageLoaded(); 46 45 let first = props.previousBlock === null; 47 46 let permission = useEntitySetContext().permissions.write; 48 47 49 48 return ( 50 49 <> 51 - {(!initialized || 52 - !permission || 53 - props.preview || 54 - isLocked?.data.value) && ( 50 + {(!initialized || !permission || props.preview) && ( 55 51 <RenderedTextBlock 56 52 type={props.type} 57 53 entityID={props.entityID} ··· 61 57 previousBlock={props.previousBlock} 62 58 /> 63 59 )} 64 - {permission && !props.preview && !isLocked?.data.value && ( 60 + {permission && !props.preview && ( 65 61 <div 66 62 className={`w-full relative group ${!initialized ? "hidden" : ""}`} 67 63 > ··· 330 326 let { editorState } = props; 331 327 let rep = useReplicache(); 332 328 let smoker = useSmoker(); 333 - let isLocked = useEntity(props.entityID, "block/is-locked"); 334 329 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 335 330 336 331 let isBlueskyPost = ··· 340 335 // if its bluesky, change text to embed post 341 336 342 337 if ( 343 - !isLocked && 344 338 focused && 345 339 editorState && 346 340 betterIsUrl(editorState.doc.textContent) &&
+1 -3
components/Blocks/index.tsx
··· 181 181 : null, 182 182 ); 183 183 184 - let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked"); 185 184 if (!entity_set.permissions.write) return null; 186 185 if ( 187 - ((props.lastBlock?.type === "text" && !isLocked?.data.value) || 188 - props.lastBlock?.type === "heading") && 186 + (props.lastBlock?.type === "text" || props.lastBlock?.type === "heading") && 189 187 (!editorState?.editor || editorState.editor.doc.content.size <= 2) 190 188 ) 191 189 return null;
+5 -15
components/Blocks/useBlockKeyboardHandlers.ts
··· 23 23 ) { 24 24 let { rep, undoManager } = useReplicache(); 25 25 let entity_set = useEntitySetContext(); 26 - let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 27 26 28 27 let isSelected = useUIState((s) => { 29 28 let selectedBlocks = s.selectedBlocks; ··· 70 69 entity_set, 71 70 areYouSure, 72 71 setAreYouSure, 73 - isLocked, 74 72 }); 75 73 undoManager.endGroup(); 76 74 }; 77 75 window.addEventListener("keydown", listener); 78 76 return () => window.removeEventListener("keydown", listener); 79 - }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure, isLocked]); 77 + }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure]); 80 78 } 81 79 82 80 type Args = { 83 81 e: KeyboardEvent; 84 - isLocked: boolean; 85 82 props: BlockProps; 86 83 rep: Replicache<ReplicacheMutators>; 87 84 entity_set: { set: string }; ··· 133 130 } 134 131 135 132 let debounced: null | number = null; 136 - async function Backspace({ 137 - e, 138 - props, 139 - rep, 140 - areYouSure, 141 - setAreYouSure, 142 - isLocked, 143 - }: Args) { 133 + async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { 144 134 // if this is a textBlock, let the textBlock/keymap handle the backspace 145 - if (isLocked) return; 146 135 // if its an input, label, or teatarea with content, do nothing (do the broswer default instead) 147 136 let el = e.target as HTMLElement; 148 137 if ( ··· 154 143 if ((el as HTMLInputElement).value !== "") return; 155 144 } 156 145 157 - // if the block is a card or mailbox... 146 + // if the block is a card, mailbox, rsvp, or poll... 158 147 if ( 159 148 props.type === "card" || 160 149 props.type === "mailbox" || 161 - props.type === "rsvp" 150 + props.type === "rsvp" || 151 + props.type === "poll" 162 152 ) { 163 153 // ...and areYouSure state is false, set it to true 164 154 if (!areYouSure) {
+21 -3
components/Blocks/useBlockMouseHandlers.ts
··· 1 1 import { useSelectingMouse } from "components/SelectionManager/selectionState"; 2 - import { MouseEvent, useCallback, useRef } from "react"; 2 + import { MouseEvent, useCallback } from "react"; 3 3 import { useUIState } from "src/useUIState"; 4 4 import { Block } from "./Block"; 5 5 import { isTextBlock } from "src/utils/isTextBlock"; ··· 12 12 import { elementId } from "src/utils/elementId"; 13 13 14 14 let debounce: number | null = null; 15 + 16 + // Track scrolling state for mobile 17 + let isScrolling = false; 18 + let scrollTimeout: number | null = null; 19 + 20 + if (typeof window !== "undefined") { 21 + window.addEventListener( 22 + "scroll", 23 + () => { 24 + isScrolling = true; 25 + if (scrollTimeout) window.clearTimeout(scrollTimeout); 26 + scrollTimeout = window.setTimeout(() => { 27 + isScrolling = false; 28 + }, 150); 29 + }, 30 + true, 31 + ); 32 + } 15 33 export function useBlockMouseHandlers(props: Block) { 16 34 let entity_set = useEntitySetContext(); 17 35 let isMobile = useIsMobile(); ··· 22 40 if ((e.target as Element).tagName === "BUTTON") return; 23 41 if ((e.target as Element).tagName === "SELECT") return; 24 42 if ((e.target as Element).tagName === "OPTION") return; 25 - if (isMobile) return; 43 + if (isMobile && isScrolling) return; 26 44 if (!entity_set.permissions.write) return; 27 45 useSelectingMouse.setState({ start: props.value }); 28 46 if (e.shiftKey) { ··· 57 75 ); 58 76 let onMouseEnter = useCallback( 59 77 async (e: MouseEvent) => { 60 - if (isMobile) return; 78 + if (isMobile && isScrolling) return; 61 79 if (!entity_set.permissions.write) return; 62 80 if (debounce) window.clearTimeout(debounce); 63 81 debounce = window.setTimeout(async () => {
+47 -34
components/Canvas.tsx
··· 22 22 import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 25 + import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; 25 26 26 27 export function Canvas(props: { 27 28 entityID: string; ··· 286 287 }, 287 288 [props, rep, permissions], 288 289 ); 289 - let { dragDelta, handlers } = useDrag({ 290 + let { dragDelta, handlers: dragHandlers } = useDrag({ 290 291 onDragEnd, 291 - delay: isMobile, 292 292 }); 293 293 294 294 let widthOnDragEnd = useCallback( ··· 335 335 ); 336 336 let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); 337 337 338 - let { isLongPress, handlers: longPressHandlers } = useLongPress(() => { 339 - if (isLongPress.current && permissions.write) { 340 - focusBlock( 341 - { 342 - type: type?.data.value || "text", 343 - value: props.entityID, 344 - parent: props.parent, 345 - }, 346 - { type: "start" }, 347 - ); 348 - } 349 - }); 338 + let { isLongPress, longPressHandlers: longPressHandlers } = useLongPress( 339 + () => { 340 + if (isLongPress.current && permissions.write) { 341 + focusBlock( 342 + { 343 + type: type?.data.value || "text", 344 + value: props.entityID, 345 + parent: props.parent, 346 + }, 347 + { type: "start" }, 348 + ); 349 + } 350 + }, 351 + ); 350 352 let angle = 0; 351 353 if (rotateHandle.dragDelta) { 352 354 let originX = rect.x + rect.width / 2; ··· 383 385 }; 384 386 }, [props, type?.data.value]); 385 387 useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); 388 + let mouseHandlers = useBlockMouseHandlers(blockProps); 389 + 386 390 let isList = useEntity(props.entityID, "block/is-list"); 387 391 let isFocused = useUIState( 388 392 (s) => s.focusedEntity?.entityID === props.entityID, ··· 391 395 return ( 392 396 <div 393 397 ref={ref} 394 - {...(!props.preview ? { ...longPressHandlers } : {})} 395 - {...(isMobile && permissions.write ? { ...handlers } : {})} 398 + {...(!props.preview ? { ...longPressHandlers, ...mouseHandlers } : {})} 396 399 id={props.preview ? undefined : elementId.block(props.entityID).container} 397 - className={`absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} 400 + className={`canvasBlockWrapper absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} 398 401 style={{ 399 402 top: 0, 400 403 left: 0, ··· 403 406 transform, 404 407 }} 405 408 > 406 - {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} 407 - {!props.preview && permissions.write && <Gripper {...handlers} />} 409 + {!props.preview && permissions.write && ( 410 + <Gripper isFocused={isFocused} {...dragHandlers} /> 411 + )} 412 + 408 413 <div 409 - className={`contents ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 414 + className={` w-full ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 410 415 > 411 416 <BaseBlock 412 417 {...blockProps} ··· 424 429 <div 425 430 className={`resizeHandle 426 431 cursor-e-resize shrink-0 z-10 427 - hidden group-hover/canvas-block:block 428 - w-[5px] h-6 -ml-[3px] 429 - absolute top-1/2 right-3 -translate-y-1/2 translate-x-[2px] 430 - rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 432 + group-hover/canvas-block:block 433 + sm:w-[5px] w-3 sm:h-6 h-8 434 + absolute top-1/2 sm:right-2 right-1 -translate-y-1/2 435 + rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 436 + ${isFocused ? "block" : "hidden"} 437 + 438 + `} 431 439 {...widthHandle.handlers} 432 440 /> 433 441 )} ··· 436 444 <div 437 445 className={`rotateHandle 438 446 cursor-grab shrink-0 z-10 439 - hidden group-hover/canvas-block:block 440 - w-[8px] h-[8px] 441 - absolute bottom-0 -right-0 447 + group-hover/canvas-block:block 448 + sm:w-[8px] sm:h-[8px] w-4 h-4 449 + absolute sm:bottom-0 sm:right-0 -bottom-1 -right-1 442 450 -translate-y-1/2 -translate-x-1/2 443 - rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 451 + rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 452 + ${isFocused ? "block" : "hidden"} 453 + `} 444 454 {...rotateHandle.handlers} 445 455 /> 446 456 )} ··· 561 571 } 562 572 }; 563 573 564 - const Gripper = (props: { onMouseDown: (e: React.MouseEvent) => void }) => { 574 + const Gripper = (props: { 575 + onMouseDown: (e: React.MouseEvent) => void; 576 + isFocused: boolean; 577 + }) => { 565 578 return ( 566 579 <div 567 580 onMouseDown={props.onMouseDown} 568 581 onPointerDown={props.onMouseDown} 569 - className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none" 582 + className="gripper w-[9px] shrink-0 py-1 mr-1 cursor-grab touch-none" 570 583 > 571 - <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 "> 584 + <div className="h-full grid grid-cols-1 grid-rows-1 "> 572 585 {/* the gripper is two svg's stacked on top of each other. 573 586 One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */} 574 587 <div 575 - className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page hidden group-hover/canvas-block:block" 588 + className={`h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page group-hover/canvas-block:block ${props.isFocused ? "block" : "hidden"}`} 576 589 style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }} 577 590 /> 578 591 <div 579 - className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block" 592 + className={`h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary group-hover/canvas-block:block ${props.isFocused ? "block" : "hidden"}`} 580 593 style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }} 581 594 /> 582 - </Media> 595 + </div> 583 596 </div> 584 597 ); 585 598 };
+7
components/DesktopFooter.tsx
··· 4 4 import { Toolbar } from "./Toolbar"; 5 5 import { useEntitySetContext } from "./EntitySetProvider"; 6 6 import { focusBlock } from "src/utils/focusBlock"; 7 + import { hasBlockToolbar } from "app/[leaflet_id]/Footer"; 8 + import { useEntity } from "src/replicache"; 7 9 8 10 export function DesktopPageFooter(props: { pageID: string }) { 9 11 let focusedEntity = useUIState((s) => s.focusedEntity); ··· 13 15 : focusedEntity?.parent; 14 16 let entity_set = useEntitySetContext(); 15 17 18 + let blockType = useEntity(focusedEntity?.entityID || null, "block/type")?.data 19 + .value; 20 + 16 21 return ( 17 22 <Media 18 23 mobile={false} ··· 20 25 > 21 26 {focusedEntity && 22 27 focusedEntity.entityType === "block" && 28 + hasBlockToolbar(blockType) && 23 29 entity_set.permissions.write && 24 30 focusedBlockParentID === props.pageID && ( 25 31 <div ··· 29 35 }} 30 36 > 31 37 <Toolbar 38 + blockType={blockType} 32 39 pageID={focusedBlockParentID} 33 40 blockID={focusedEntity.entityID} 34 41 />
-120
components/FontLoader.tsx
··· 1 - // Server-side font loading component 2 - // Following Google's best practices: https://web.dev/articles/font-best-practices 3 - // - Preconnect to font origins for early connection 4 - // - Use font-display: swap (shows fallback immediately, swaps when ready) 5 - // - Don't block rendering - some FOUT is acceptable and better UX than invisible text 6 - 7 - import { 8 - getFontConfig, 9 - generateFontFaceCSS, 10 - getFontPreloadLinks, 11 - getGoogleFontsUrl, 12 - getFontFamilyValue, 13 - } from "src/fonts"; 14 - 15 - type FontLoaderProps = { 16 - headingFontId: string | undefined; 17 - bodyFontId: string | undefined; 18 - }; 19 - 20 - export function FontLoader({ headingFontId, bodyFontId }: FontLoaderProps) { 21 - const headingFont = getFontConfig(headingFontId); 22 - const bodyFont = getFontConfig(bodyFontId); 23 - 24 - // Collect all unique fonts to load 25 - const fontsToLoad = headingFont.id === bodyFont.id 26 - ? [headingFont] 27 - : [headingFont, bodyFont]; 28 - 29 - // Collect preload links (deduplicated) 30 - const preloadLinksSet = new Set<string>(); 31 - const preloadLinks: { href: string; type: string }[] = []; 32 - for (const font of fontsToLoad) { 33 - for (const link of getFontPreloadLinks(font)) { 34 - if (!preloadLinksSet.has(link.href)) { 35 - preloadLinksSet.add(link.href); 36 - preloadLinks.push(link); 37 - } 38 - } 39 - } 40 - 41 - // Collect font-face CSS 42 - const fontFaceCSS = fontsToLoad 43 - .map((font) => generateFontFaceCSS(font)) 44 - .filter(Boolean) 45 - .join("\n\n"); 46 - 47 - // Collect Google Fonts URLs (deduplicated) 48 - const googleFontsUrls = [...new Set( 49 - fontsToLoad 50 - .map((font) => getGoogleFontsUrl(font)) 51 - .filter((url): url is string => url !== null) 52 - )]; 53 - 54 - const headingFontValue = getFontFamilyValue(headingFont); 55 - const bodyFontValue = getFontFamilyValue(bodyFont); 56 - 57 - // Generate CSS that sets the font family via CSS variables 58 - // --theme-font is used for body text (keeps backwards compatibility) 59 - // --theme-heading-font is used for headings 60 - const fontVariableCSS = ` 61 - :root { 62 - --theme-heading-font: ${headingFontValue}; 63 - --theme-font: ${bodyFontValue}; 64 - } 65 - `.trim(); 66 - 67 - return ( 68 - <> 69 - {/* 70 - Google Fonts best practice: preconnect to both origins 71 - - fonts.googleapis.com serves the CSS 72 - - fonts.gstatic.com serves the font files (needs crossorigin for CORS) 73 - Place these as early as possible in <head> 74 - */} 75 - {googleFontsUrls.length > 0 && ( 76 - <> 77 - <link rel="preconnect" href="https://fonts.googleapis.com" /> 78 - <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> 79 - {googleFontsUrls.map((url) => ( 80 - <link key={url} rel="stylesheet" href={url} /> 81 - ))} 82 - </> 83 - )} 84 - {/* Preload local font files for early discovery */} 85 - {preloadLinks.map((link) => ( 86 - <link 87 - key={link.href} 88 - rel="preload" 89 - href={link.href} 90 - as="font" 91 - type={link.type} 92 - crossOrigin="anonymous" 93 - /> 94 - ))} 95 - {/* @font-face declarations (for local fonts) and CSS variable */} 96 - <style 97 - dangerouslySetInnerHTML={{ 98 - __html: `${fontFaceCSS}\n\n${fontVariableCSS}`, 99 - }} 100 - /> 101 - </> 102 - ); 103 - } 104 - 105 - // Helper to extract fonts from facts array (for server-side use) 106 - export function extractFontsFromFacts( 107 - facts: Array<{ entity: string; attribute: string; data: { value: string } }>, 108 - rootEntity: string 109 - ): { headingFontId: string | undefined; bodyFontId: string | undefined } { 110 - const headingFontFact = facts.find( 111 - (f) => f.entity === rootEntity && f.attribute === "theme/heading-font" 112 - ); 113 - const bodyFontFact = facts.find( 114 - (f) => f.entity === rootEntity && f.attribute === "theme/body-font" 115 - ); 116 - return { 117 - headingFontId: headingFontFact?.data?.value, 118 - bodyFontId: bodyFontFact?.data?.value, 119 - }; 120 - }
+19
components/Icons/DeleteTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const DeleteTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M4.84013 0.946324C5.45222 0.762486 6.19573 0.729124 6.97782 0.820347C7.32039 0.860495 7.56546 1.1711 7.52568 1.51371C7.48568 1.85656 7.17517 2.10252 6.83232 2.06253C6.16005 1.98415 5.5998 2.02378 5.20048 2.14359C4.79524 2.26531 4.64378 2.43772 4.59599 2.56351C4.34149 3.23723 5.58865 4.19255 6.08232 4.51761C6.54562 4.82266 7.09489 5.10483 7.70536 5.33597C8.80491 5.75229 9.85164 5.90795 10.6409 5.84769C11.4848 5.78311 11.8008 5.50429 11.8714 5.31839C11.9189 5.19263 11.9202 4.96386 11.6976 4.60453C11.478 4.25029 11.0842 3.84861 10.5286 3.46195C10.2455 3.26479 10.1754 2.87508 10.3724 2.59183C10.5696 2.30876 10.9592 2.23946 11.2425 2.43656C11.8885 2.88612 12.4235 3.40237 12.7601 3.94535C13.0932 4.48275 13.2786 5.13116 13.0403 5.76078C12.9083 6.10935 12.6779 6.37566 12.3909 6.57621C12.366 6.82447 12.311 7.37448 12.2425 8.01957C12.1491 8.89816 12.0306 9.96291 11.9329 10.6856C11.809 11.602 11.638 12.5144 11.4153 13.4121C11.237 14.0305 10.6957 14.4725 10.0989 14.75C9.43935 15.0567 8.58506 15.2285 7.63212 15.2285C5.83513 15.2285 4.30017 14.7464 3.83818 13.3604C3.76287 13.0881 3.70695 12.8096 3.6497 12.5332C3.55128 12.058 3.42826 11.4025 3.33134 10.6856C3.23301 9.95806 3.11264 8.74578 3.01689 7.72562C2.96878 7.21315 2.92679 6.74483 2.89677 6.40531C2.87398 6.14754 2.83052 5.88349 2.86454 5.62503C2.95213 4.95974 3.29709 4.69093 3.98271 4.32035C3.46821 3.68093 3.13499 2.89278 3.42704 2.12113C3.66559 1.49131 4.23434 1.12831 4.84013 0.946324ZM4.26103 7.60843C4.35712 8.63219 4.47618 9.82018 4.5706 10.5186C4.66257 11.1989 4.7799 11.8245 4.87431 12.2803C4.92135 12.5074 4.9622 12.6916 4.9915 12.8184C5.24073 13.8967 6.74216 13.9785 7.63212 13.9785C8.44689 13.9785 9.11364 13.8297 9.57255 13.6162C9.98848 13.4227 10.1815 13.2132 10.2727 12.8184C10.3608 12.4373 10.5558 11.538 10.6936 10.5186C10.7887 9.81541 10.9059 8.76587 10.9993 7.88675C11.027 7.62595 11.0512 7.38071 11.0735 7.16507C10.278 7.59273 9.18805 7.88086 7.96318 7.88089C7.26324 7.88089 6.60019 7.80834 6.0081 7.67875C5.37465 7.54007 4.79262 7.29359 4.20732 7.02347C4.22408 7.20782 4.24189 7.4046 4.26103 7.60843ZM5.65849 9.89847C5.93369 9.87744 6.17434 10.0843 6.1956 10.3594C6.21921 10.6683 6.24342 10.9585 6.26396 11.1602C6.29655 11.4801 6.34417 11.7883 6.38407 12.0176C6.40539 12.1401 6.4278 12.2622 6.45341 12.3838C6.51991 12.6498 6.33262 12.9262 6.06962 12.9825C5.79972 13.0399 5.55695 12.8734 5.47587 12.5977C5.43009 12.442 5.42115 12.3183 5.39872 12.1895C5.3563 11.9458 5.30461 11.6127 5.26884 11.2618C5.24694 11.0467 5.22223 10.7457 5.19853 10.4356C5.17773 10.1606 5.38354 9.91976 5.65849 9.89847ZM5.59208 8.40238C6.25224 8.40238 6.25244 9.45121 5.59208 9.45121C4.93177 9.4506 4.93197 8.40299 5.59208 8.40238ZM9.06767 1.26566C9.29634 1.00742 9.69106 0.983602 9.9495 1.21195C10.2079 1.44072 10.2319 1.83535 10.0032 2.09378L9.137 3.07132L8.9622 3.54496C8.84186 3.86848 8.48103 4.03346 8.15751 3.91312C7.83456 3.79265 7.67047 3.43357 7.79032 3.11039L8.00614 2.52738C8.03292 2.45553 8.07353 2.38952 8.12431 2.33207L9.06767 1.26566Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+20
components/Icons/ImageCoverImage.tsx
··· 1 + import { Props } from "./Props"; 2 + 1 3 export const ImageCoverImage = () => ( 2 4 <svg 3 5 width="24" ··· 12 14 /> 13 15 </svg> 14 16 ); 17 + 18 + export const ImageCoverImageRemove = (props: Props) => { 19 + return ( 20 + <svg 21 + width="24" 22 + height="24" 23 + viewBox="0 0 24 24" 24 + fill="none" 25 + xmlns="http://www.w3.org/2000/svg" 26 + {...props} 27 + > 28 + <path 29 + d="M21.7744 1.48538C22.0673 1.19249 22.542 1.19259 22.835 1.48538C23.1279 1.77824 23.1278 2.253 22.835 2.54592L2.22461 23.1602C1.93172 23.4526 1.45683 23.4528 1.16406 23.1602C0.871349 22.8674 0.871599 22.3926 1.16406 22.0996L21.7744 1.48538ZM22.9229 4.22561C23.1551 4.66244 23.2881 5.16029 23.2881 5.68948V18.3106C23.2879 20.0361 21.8886 21.4362 20.1631 21.4365H5.71582L6.96582 20.1865H20.1631C21.1982 20.1862 22.0379 19.3457 22.0381 18.3106V15.8067H11.3447L12.5947 14.5567H22.0381V10.1299L21.1738 9.42385L19.9775 10.9678C19.5515 11.5167 18.8233 11.7335 18.166 11.5078L16.3213 10.875C16.3092 10.8709 16.2965 10.8691 16.2842 10.8662L17.2705 9.87893L18.5723 10.3262C18.7238 10.378 18.892 10.3278 18.9902 10.2012L20.2061 8.63284L19.2764 7.87307L20.1641 6.9844L22.0381 8.51565V5.68948C22.0381 5.51371 22.0121 5.34397 21.9668 5.18264L22.9229 4.22561ZM17.6797 3.81448H3.83789C2.80236 3.81448 1.96289 4.65394 1.96289 5.68948V11.4805L4.8291 8.91897C5.18774 8.59894 5.70727 8.54438 6.12207 8.77346L6.20312 8.82327L10.083 11.4112L9.18164 12.3125L8.81055 12.0655L8.03027 12.8692C7.55062 13.3622 6.78587 13.438 6.21875 13.0489C6.17034 13.0156 6.10737 13.0113 6.05469 13.0371L4.79883 13.6573C4.258 13.9241 3.61319 13.8697 3.125 13.5157L2.26172 12.8887L1.96289 13.1573V14.5567H6.93945L5.68945 15.8067H1.96289V18.3115C1.96305 18.6614 2.06054 18.9882 2.22754 19.2686L1.32812 20.168C0.943293 19.6486 0.713079 19.0075 0.712891 18.3115V5.68948C0.712891 3.96359 2.112 2.56448 3.83789 2.56448H18.9287L17.6797 3.81448ZM14.4883 17.2578C14.9025 17.2578 15.2382 17.5937 15.2383 18.0078C15.2382 18.422 14.9025 18.7578 14.4883 18.7578H8.39453L9.89453 17.2578H14.4883ZM3.14941 18.3467C3.09734 18.2446 3.06545 18.1302 3.06543 18.0078C3.0655 17.5938 3.40144 17.2581 3.81543 17.2578H4.23828L3.14941 18.3467ZM4.71094 10.7012L4.70996 10.7002L3.21484 12.0362L3.85938 12.5039C3.97197 12.5854 4.12047 12.5976 4.24512 12.5362L5.50098 11.917C5.95928 11.6909 6.5044 11.7294 6.92578 12.0186C6.99104 12.0633 7.07958 12.0547 7.13477 11.9981L7.75488 11.3604L5.58984 9.91506L4.71094 10.7012ZM8.94629 4.52249C9.92559 4.52266 10.7195 5.31662 10.7197 6.29592C10.7197 7.27533 9.92567 8.06919 8.94629 8.06936C7.96687 8.06924 7.17292 7.27536 7.17285 6.29592C7.17304 5.31659 7.96694 4.52261 8.94629 4.52249ZM8.94629 5.52249C8.51923 5.52261 8.17304 5.86888 8.17285 6.29592C8.17292 6.72307 8.51915 7.06924 8.94629 7.06936C9.37338 7.06919 9.71966 6.72304 9.71973 6.29592C9.71954 5.86891 9.37331 5.52266 8.94629 5.52249Z" 30 + fill="currentColor" 31 + /> 32 + </svg> 33 + ); 34 + };
+1 -1
components/Pages/PageOptions.tsx
··· 93 93 94 94 <PageOptionButton 95 95 secondary 96 - onClick={() => undoManager.undo()} 96 + onClick={() => undoManager.redo()} 97 97 disabled={!undoState.canRedo} 98 98 > 99 99 <RedoTiny />
+1 -1
components/Popover/index.tsx
··· 43 43 <RadixPopover.Content 44 44 className={` 45 45 z-20 bg-bg-page 46 - px-3 py-2 46 + px-3 py-2 text-primary 47 47 max-w-(--radix-popover-content-available-width) 48 48 max-h-(--radix-popover-content-available-height) 49 49 border border-border rounded-md shadow-md
+5 -67
components/SelectionManager/index.tsx
··· 17 17 import { schema } from "../Blocks/TextBlock/schema"; 18 18 import { MarkType } from "prosemirror-model"; 19 19 import { useSelectingMouse, getSortedSelection } from "./selectionState"; 20 + import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 20 21 21 22 //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 22 23 // How does this relate to *when dragging* ? ··· 240 241 shift: true, 241 242 key: ["ArrowDown", "J"], 242 243 handler: async () => { 243 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 244 - let block = sortedBlocks[0]; 245 - let nextBlock = siblings 246 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 247 - .find( 248 - (f) => 249 - f.listData && 250 - block.listData && 251 - !f.listData.path.find((f) => f.entity === block.value), 252 - ); 253 - if ( 254 - nextBlock?.listData && 255 - block.listData && 256 - nextBlock.listData.depth === block.listData.depth - 1 257 - ) { 258 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 259 - useUIState.getState().toggleFold(nextBlock.value); 260 - await rep?.mutate.moveBlock({ 261 - block: block.value, 262 - oldParent: block.listData?.parent, 263 - newParent: nextBlock.value, 264 - position: { type: "first" }, 265 - }); 266 - } else { 267 - await rep?.mutate.moveBlockDown({ 268 - entityID: block.value, 269 - parent: block.listData?.parent || block.parent, 270 - }); 271 - } 244 + if (!rep) return; 245 + await moveBlockDown(rep, entity_set.set); 272 246 }, 273 247 }, 274 248 { ··· 276 250 shift: true, 277 251 key: ["ArrowUp", "K"], 278 252 handler: async () => { 279 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 280 - let block = sortedBlocks[0]; 281 - let previousBlock = 282 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 283 - if (previousBlock.value === block.listData?.parent) { 284 - previousBlock = 285 - siblings?.[ 286 - siblings.findIndex((s) => s.value === block.value) - 2 287 - ]; 288 - } 289 - 290 - if ( 291 - previousBlock?.listData && 292 - block.listData && 293 - block.listData.depth > 1 && 294 - !previousBlock.listData.path.find( 295 - (f) => f.entity === block.listData?.parent, 296 - ) 297 - ) { 298 - let depth = block.listData.depth; 299 - let newParent = previousBlock.listData.path.find( 300 - (f) => f.depth === depth - 1, 301 - ); 302 - if (!newParent) return; 303 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 304 - useUIState.getState().toggleFold(newParent.entity); 305 - rep?.mutate.moveBlock({ 306 - block: block.value, 307 - oldParent: block.listData?.parent, 308 - newParent: newParent.entity, 309 - position: { type: "end" }, 310 - }); 311 - } else { 312 - rep?.mutate.moveBlockUp({ 313 - entityID: block.value, 314 - parent: block.listData?.parent || block.parent, 315 - }); 316 - } 253 + if (!rep) return; 254 + await moveBlockUp(rep); 317 255 }, 318 256 }, 319 257
-137
components/ThemeManager/Pickers/TextPickers.tsx
··· 1 - "use client"; 2 - 3 - import { Color } from "react-aria-components"; 4 - import { Input } from "components/Input"; 5 - import { useState } from "react"; 6 - import { useEntity, useReplicache } from "src/replicache"; 7 - import { Menu } from "components/Menu"; 8 - import { pickers } from "../ThemeSetter"; 9 - import { ColorPicker } from "./ColorPicker"; 10 - import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 11 - import { useIsMobile } from "src/hooks/isMobile"; 12 - import { fonts, defaultFontId, FontConfig } from "src/fonts"; 13 - 14 - export const TextColorPicker = (props: { 15 - openPicker: pickers; 16 - setOpenPicker: (thisPicker: pickers) => void; 17 - value: Color; 18 - setValue: (c: Color) => void; 19 - }) => { 20 - return ( 21 - <ColorPicker 22 - label="Text" 23 - value={props.value} 24 - setValue={props.setValue} 25 - thisPicker={"text"} 26 - openPicker={props.openPicker} 27 - setOpenPicker={props.setOpenPicker} 28 - closePicker={() => props.setOpenPicker("null")} 29 - /> 30 - ); 31 - }; 32 - 33 - type FontAttribute = "theme/heading-font" | "theme/body-font"; 34 - 35 - export const FontPicker = (props: { 36 - label: string; 37 - entityID: string; 38 - attribute: FontAttribute; 39 - }) => { 40 - let isMobile = useIsMobile(); 41 - let { rep } = useReplicache(); 42 - let [searchValue, setSearchValue] = useState(""); 43 - let currentFont = useEntity(props.entityID, props.attribute); 44 - let fontId = currentFont?.data.value || defaultFontId; 45 - let font = fonts[fontId] || fonts[defaultFontId]; 46 - 47 - let fontList = Object.values(fonts); 48 - let filteredFonts = fontList 49 - .filter((f) => { 50 - const matchesSearch = f.displayName 51 - .toLocaleLowerCase() 52 - .includes(searchValue.toLocaleLowerCase()); 53 - return matchesSearch; 54 - }) 55 - .sort((a, b) => { 56 - return a.displayName.localeCompare(b.displayName); 57 - }); 58 - 59 - return ( 60 - <Menu 61 - asChild 62 - trigger={ 63 - <button className="flex gap-2 items-center w-full !outline-none min-w-0"> 64 - <div 65 - className={`w-6 h-6 rounded-md border border-border relative text-sm bg-bg-page shrink-0 ${props.label === "Heading" ? "font-bold" : "text-secondary"}`} 66 - > 67 - <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 "> 68 - Aa 69 - </div> 70 - </div> 71 - <div className="font-bold shrink-0">{props.label}</div> 72 - <div className="truncate">{font.displayName}</div> 73 - </button> 74 - } 75 - side={isMobile ? "bottom" : "right"} 76 - align="start" 77 - className="w-[250px] !gap-0 !outline-none max-h-72 " 78 - > 79 - <Input 80 - value={searchValue} 81 - className="px-3 pb-1 appearance-none !outline-none bg-transparent" 82 - placeholder="search..." 83 - onChange={(e) => { 84 - setSearchValue(e.currentTarget.value); 85 - }} 86 - /> 87 - <hr className="mx-2 border-border" /> 88 - <div className="flex flex-col h-full overflow-auto gap-0 pt-1"> 89 - {filteredFonts.map((fontOption) => { 90 - return ( 91 - <FontOption 92 - key={fontOption.id} 93 - onSelect={() => { 94 - rep?.mutate.assertFact({ 95 - entity: props.entityID, 96 - attribute: props.attribute, 97 - data: { type: "string", value: fontOption.id }, 98 - }); 99 - }} 100 - font={fontOption} 101 - selected={fontOption.id === fontId} 102 - /> 103 - ); 104 - })} 105 - </div> 106 - </Menu> 107 - ); 108 - }; 109 - 110 - const FontOption = (props: { 111 - onSelect: () => void; 112 - font: FontConfig; 113 - selected: boolean; 114 - }) => { 115 - return ( 116 - <DropdownMenu.RadioItem 117 - value={props.font.id} 118 - onSelect={props.onSelect} 119 - className={` 120 - fontOption 121 - z-10 px-1 py-0.5 122 - text-left text-secondary 123 - data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 124 - hover:bg-border-light hover:text-secondary 125 - outline-none 126 - cursor-pointer 127 - 128 - `} 129 - > 130 - <div 131 - className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 132 - > 133 - {props.font.displayName} 134 - </div> 135 - </DropdownMenu.RadioItem> 136 - ); 137 - };
-106
components/ThemeManager/PubPickers/PubFontPicker.tsx
··· 1 - "use client"; 2 - 3 - import { useState } from "react"; 4 - import { Menu } from "components/Menu"; 5 - import { Input } from "components/Input"; 6 - import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 7 - import { useIsMobile } from "src/hooks/isMobile"; 8 - import { fonts, defaultFontId, FontConfig } from "src/fonts"; 9 - 10 - export const PubFontPicker = (props: { 11 - label: string; 12 - value: string | undefined; 13 - onChange: (fontId: string) => void; 14 - }) => { 15 - let isMobile = useIsMobile(); 16 - let [searchValue, setSearchValue] = useState(""); 17 - let fontId = props.value || defaultFontId; 18 - let font = fonts[fontId] || fonts[defaultFontId]; 19 - 20 - let fontList = Object.values(fonts); 21 - let filteredFonts = fontList 22 - .filter((f) => { 23 - const matchesSearch = f.displayName 24 - .toLocaleLowerCase() 25 - .includes(searchValue.toLocaleLowerCase()); 26 - return matchesSearch; 27 - }) 28 - .sort((a, b) => { 29 - return a.displayName.localeCompare(b.displayName); 30 - }); 31 - 32 - return ( 33 - <Menu 34 - asChild 35 - trigger={ 36 - <button className="flex gap-2 items-center w-full !outline-none min-w-0"> 37 - <div 38 - className={`w-6 h-6 rounded-md border border-border relative text-sm bg-bg-page shrink-0 ${props.label === "Heading" ? "font-bold" : "text-secondary"}`} 39 - > 40 - <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 "> 41 - Aa 42 - </div> 43 - </div> 44 - <div className="font-bold shrink-0">{props.label}</div> 45 - <div className="truncate">{font.displayName}</div> 46 - </button> 47 - } 48 - side={isMobile ? "bottom" : "right"} 49 - align="start" 50 - className="w-[250px] !gap-0 !outline-none max-h-72 " 51 - > 52 - <Input 53 - value={searchValue} 54 - className="px-3 pb-1 appearance-none !outline-none bg-transparent" 55 - placeholder="search..." 56 - onChange={(e) => { 57 - setSearchValue(e.currentTarget.value); 58 - }} 59 - /> 60 - <hr className="mx-2 border-border" /> 61 - <div className="flex flex-col h-full overflow-auto gap-0 pt-1"> 62 - {filteredFonts.map((fontOption) => { 63 - return ( 64 - <FontOption 65 - key={fontOption.id} 66 - onSelect={() => { 67 - props.onChange(fontOption.id); 68 - }} 69 - font={fontOption} 70 - selected={fontOption.id === fontId} 71 - /> 72 - ); 73 - })} 74 - </div> 75 - </Menu> 76 - ); 77 - }; 78 - 79 - const FontOption = (props: { 80 - onSelect: () => void; 81 - font: FontConfig; 82 - selected: boolean; 83 - }) => { 84 - return ( 85 - <DropdownMenu.RadioItem 86 - value={props.font.id} 87 - onSelect={props.onSelect} 88 - className={` 89 - fontOption 90 - z-10 px-1 py-0.5 91 - text-left text-secondary 92 - data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 93 - hover:bg-border-light hover:text-secondary 94 - outline-none 95 - cursor-pointer 96 - 97 - `} 98 - > 99 - <div 100 - className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 101 - > 102 - {props.font.displayName} 103 - </div> 104 - </DropdownMenu.RadioItem> 105 - ); 106 - };
+14
components/ThemeManager/PubPickers/PubTextPickers.tsx
··· 26 26 openPicker={props.openPicker} 27 27 setOpenPicker={props.setOpenPicker} 28 28 /> 29 + {/* FONT PICKERS HIDDEN FOR NOW */} 30 + {/* <hr className="border-border-light" /> 31 + <div className="flex gap-2"> 32 + <div className="w-6 h-6 font-bold text-center rounded-md bg-border-light"> 33 + Aa 34 + </div> 35 + <div className="font-bold">Header</div> <div>iA Writer</div> 36 + </div> 37 + <div className="flex gap-2"> 38 + <div className="w-6 h-6 place-items-center text-center rounded-md bg-border-light"> 39 + Aa 40 + </div>{" "} 41 + <div className="font-bold">Body</div> <div>iA Writer</div> 42 + </div> */} 29 43 </div> 30 44 ); 31 45 };
-17
components/ThemeManager/PubThemeSetter.tsx
··· 20 20 import { useToaster } from "components/Toast"; 21 21 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 22 22 import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter"; 23 - import { PubFontPicker } from "./PubPickers/PubFontPicker"; 24 23 25 24 export type ImageState = { 26 25 src: string; ··· 61 60 let [pageWidth, setPageWidth] = useState<number>( 62 61 record?.theme?.pageWidth || 624, 63 62 ); 64 - let [headingFont, setHeadingFont] = useState<string | undefined>(record?.theme?.headingFont); 65 - let [bodyFont, setBodyFont] = useState<string | undefined>(record?.theme?.bodyFont); 66 63 let pubBGImage = image?.src || null; 67 64 let leafletBGRepeat = image?.repeat || null; 68 65 let toaster = useToaster(); ··· 88 85 primary: ColorToRGB(localPubTheme.primary), 89 86 accentBackground: ColorToRGB(localPubTheme.accent1), 90 87 accentText: ColorToRGB(localPubTheme.accent2), 91 - headingFont: headingFont, 92 - bodyFont: bodyFont, 93 88 }, 94 89 }); 95 90 ··· 194 189 setOpenPicker={(pickers) => setOpenPicker(pickers)} 195 190 hasPageBackground={showPageBackground} 196 191 /> 197 - <div className="bg-bg-page p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))] flex flex-col gap-1"> 198 - <PubFontPicker 199 - label="Heading" 200 - value={headingFont} 201 - onChange={setHeadingFont} 202 - /> 203 - <PubFontPicker 204 - label="Body" 205 - value={bodyFont} 206 - onChange={setBodyFont} 207 - /> 208 - </div> 209 192 <PubAccentPickers 210 193 accent1={localPubTheme.accent1} 211 194 setAccent1={(color) => {
+1 -70
components/ThemeManager/ThemeProvider.tsx
··· 22 22 PublicationThemeProvider, 23 23 } from "./PublicationThemeProvider"; 24 24 import { getColorDifference } from "./themeUtils"; 25 - import { getFontConfig, getGoogleFontsUrl, getFontFamilyValue } from "src/fonts"; 26 25 27 26 // define a function to set an Aria Color to a CSS Variable in RGB 28 27 function setCSSVariableToColor( ··· 39 38 local?: boolean; 40 39 children: React.ReactNode; 41 40 className?: string; 42 - initialHeadingFontId?: string; 43 - initialBodyFontId?: string; 44 41 }) { 45 42 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 46 43 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; ··· 59 56 entityID: string | null; 60 57 local?: boolean; 61 58 children: React.ReactNode; 62 - initialHeadingFontId?: string; 63 - initialBodyFontId?: string; 64 59 }) { 65 60 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 66 61 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); ··· 81 76 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 82 77 83 78 let pageWidth = useEntity(props.entityID, "theme/page-width"); 84 - // Use initial font IDs as fallback until Replicache syncs 85 - let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value ?? props.initialHeadingFontId; 86 - let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value ?? props.initialBodyFontId; 87 79 88 80 return ( 89 81 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> ··· 100 92 showPageBackground={showPageBackground} 101 93 pageWidth={pageWidth?.data.value} 102 94 hasBackgroundImage={hasBackgroundImage} 103 - headingFontId={headingFontId} 104 - bodyFontId={bodyFontId} 105 95 > 106 96 {props.children} 107 97 </BaseThemeProvider> ··· 123 113 showPageBackground, 124 114 pageWidth, 125 115 hasBackgroundImage, 126 - headingFontId, 127 - bodyFontId, 128 116 children, 129 117 }: { 130 118 local?: boolean; ··· 139 127 highlight2: AriaColor; 140 128 highlight3: AriaColor; 141 129 pageWidth?: number; 142 - headingFontId?: string; 143 - bodyFontId?: string; 144 130 children: React.ReactNode; 145 131 }) => { 146 132 // When showPageBackground is false and there's no background image, ··· 181 167 accentContrast = sortedAccents[0]; 182 168 } 183 169 184 - // Get font configs for CSS variables 185 - const headingFontConfig = getFontConfig(headingFontId); 186 - const bodyFontConfig = getFontConfig(bodyFontId); 187 - const headingFontValue = getFontFamilyValue(headingFontConfig); 188 - const bodyFontValue = getFontFamilyValue(bodyFontConfig); 189 - const headingGoogleFontsUrl = getGoogleFontsUrl(headingFontConfig); 190 - const bodyGoogleFontsUrl = getGoogleFontsUrl(bodyFontConfig); 191 - 192 - // Dynamically load Google Fonts when fonts change 193 - useEffect(() => { 194 - const loadGoogleFont = (url: string | null, fontFamily: string) => { 195 - if (!url) return; 196 - 197 - // Check if this font stylesheet is already in the document 198 - const existingLink = document.querySelector(`link[href="${url}"]`); 199 - if (existingLink) return; 200 - 201 - // Add preconnect hints if not present 202 - if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) { 203 - const preconnect1 = document.createElement("link"); 204 - preconnect1.rel = "preconnect"; 205 - preconnect1.href = "https://fonts.googleapis.com"; 206 - document.head.appendChild(preconnect1); 207 - 208 - const preconnect2 = document.createElement("link"); 209 - preconnect2.rel = "preconnect"; 210 - preconnect2.href = "https://fonts.gstatic.com"; 211 - preconnect2.crossOrigin = "anonymous"; 212 - document.head.appendChild(preconnect2); 213 - } 214 - 215 - // Load the Google Font stylesheet 216 - const link = document.createElement("link"); 217 - link.rel = "stylesheet"; 218 - link.href = url; 219 - document.head.appendChild(link); 220 - 221 - // Wait for the font to actually load before it gets applied 222 - if (document.fonts?.load) { 223 - document.fonts.load(`1em "${fontFamily}"`); 224 - } 225 - }; 226 - 227 - loadGoogleFont(headingGoogleFontsUrl, headingFontConfig.fontFamily); 228 - loadGoogleFont(bodyGoogleFontsUrl, bodyFontConfig.fontFamily); 229 - }, [headingGoogleFontsUrl, bodyGoogleFontsUrl, headingFontConfig.fontFamily, bodyFontConfig.fontFamily]); 230 - 231 170 useEffect(() => { 232 171 if (local) return; 233 172 let el = document.querySelector(":root") as HTMLElement; ··· 276 215 "--page-width-setting", 277 216 (pageWidth || 624).toString(), 278 217 ); 279 - 280 - // Set theme font CSS variables 281 - el?.style.setProperty("--theme-heading-font", headingFontValue); 282 - el?.style.setProperty("--theme-font", bodyFontValue); 283 218 }, [ 284 219 local, 285 220 bgLeaflet, ··· 292 227 accent2, 293 228 accentContrast, 294 229 pageWidth, 295 - headingFontValue, 296 - bodyFontValue, 297 - ]); // bodyFontValue sets --theme-font 230 + ]); 298 231 return ( 299 232 <div 300 233 className="leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch " ··· 316 249 "--page-width-setting": pageWidth || 624, 317 250 "--page-width-unitless": pageWidth || 624, 318 251 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 319 - "--theme-heading-font": headingFontValue, 320 - "--theme-font": bodyFontValue, 321 252 } as CSSProperties 322 253 } 323 254 >
+1 -9
components/ThemeManager/ThemeSetter.tsx
··· 22 22 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 23 23 import { useIsMobile } from "src/hooks/isMobile"; 24 24 import { Toggle } from "components/Toggle"; 25 - import { FontPicker } from "./Pickers/TextPickers"; 26 25 27 26 export type pickers = 28 27 | "null" ··· 158 157 openPicker={openPicker} 159 158 setOpenPicker={(pickers) => setOpenPicker(pickers)} 160 159 /> 161 - {!props.home && ( 162 - <div className="flex flex-col gap-1 bg-bg-page p-2 rounded-md border border-primary -mt-2"> 163 - <FontPicker label="Heading" entityID={props.entityID} attribute="theme/heading-font" /> 164 - <FontPicker label="Body" entityID={props.entityID} attribute="theme/body-font" /> 165 - </div> 166 - )} 167 160 <div className="flex flex-col -gap-[6px]"> 168 161 <div className={`flex flex-col z-10 -mb-[6px] `}> 169 162 <AccentPickers ··· 194 187 </div> 195 188 ); 196 189 }; 197 - 198 190 function WatermarkSetter(props: { entityID: string }) { 199 191 let { rep } = useReplicache(); 200 192 let checked = useEntity(props.entityID, "theme/page-leaflet-watermark"); ··· 308 300 onClick={() => { 309 301 props.setOpenPicker("text"); 310 302 }} 311 - className="cursor-pointer font-bold w-fit [font-family:var(--theme-heading-font)]" 303 + className="cursor-pointer font-bold w-fit" 312 304 > 313 305 Hello! 314 306 </p>
components/Toolbar/BlockToolbar.1.tsx

This is a binary file and will not be displayed.

-223
components/Toolbar/BlockToolbar.tsx
··· 1 - import { useEntity, useReplicache } from "src/replicache"; 2 - import { ToolbarButton } from "."; 3 - import { Separator, ShortcutKey } from "components/Layout"; 4 - import { metaKey } from "src/utils/metaKey"; 5 - import { useUIState } from "src/useUIState"; 6 - import { LockBlockButton } from "./LockBlockButton"; 7 - import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 - import { 9 - ImageFullBleedButton, 10 - ImageAltTextButton, 11 - ImageCoverButton, 12 - } from "./ImageToolbar"; 13 - import { DeleteSmall } from "components/Icons/DeleteSmall"; 14 - import { getSortedSelection } from "components/SelectionManager/selectionState"; 15 - 16 - export const BlockToolbar = (props: { 17 - setToolbarState: ( 18 - state: "areYouSure" | "block" | "text-alignment" | "img-alt-text", 19 - ) => void; 20 - }) => { 21 - let focusedEntity = useUIState((s) => s.focusedEntity); 22 - let focusedEntityType = useEntity( 23 - focusedEntity?.entityType === "page" 24 - ? focusedEntity.entityID 25 - : focusedEntity?.parent || null, 26 - "page/type", 27 - ); 28 - let blockType = useEntity( 29 - focusedEntity?.entityType === "block" ? focusedEntity?.entityID : null, 30 - "block/type", 31 - )?.data.value; 32 - 33 - return ( 34 - <div className="flex items-center gap-2 justify-between w-full"> 35 - <div className="flex items-center gap-2"> 36 - <ToolbarButton 37 - onClick={() => { 38 - props.setToolbarState("areYouSure"); 39 - }} 40 - tooltipContent="Delete Block" 41 - > 42 - <DeleteSmall /> 43 - </ToolbarButton> 44 - <Separator classname="h-6!" /> 45 - <MoveBlockButtons /> 46 - {blockType === "image" && ( 47 - <> 48 - <TextAlignmentButton setToolbarState={props.setToolbarState} /> 49 - <ImageFullBleedButton /> 50 - <ImageAltTextButton setToolbarState={props.setToolbarState} /> 51 - <ImageCoverButton /> 52 - {focusedEntityType?.data.value !== "canvas" && ( 53 - <Separator classname="h-6!" /> 54 - )} 55 - </> 56 - )} 57 - {(blockType === "button" || blockType === "datetime") && ( 58 - <> 59 - <TextAlignmentButton setToolbarState={props.setToolbarState} /> 60 - {focusedEntityType?.data.value !== "canvas" && ( 61 - <Separator classname="h-6!" /> 62 - )} 63 - </> 64 - )} 65 - 66 - <LockBlockButton /> 67 - </div> 68 - </div> 69 - ); 70 - }; 71 - 72 - const MoveBlockButtons = () => { 73 - let { rep } = useReplicache(); 74 - return ( 75 - <> 76 - <ToolbarButton 77 - hiddenOnCanvas 78 - onClick={async () => { 79 - if (!rep) return; 80 - let [sortedBlocks, siblings] = await getSortedSelection(rep); 81 - if (sortedBlocks.length > 1) return; 82 - let block = sortedBlocks[0]; 83 - let previousBlock = 84 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 85 - if (previousBlock.value === block.listData?.parent) { 86 - previousBlock = 87 - siblings?.[ 88 - siblings.findIndex((s) => s.value === block.value) - 2 89 - ]; 90 - } 91 - 92 - if ( 93 - previousBlock?.listData && 94 - block.listData && 95 - block.listData.depth > 1 && 96 - !previousBlock.listData.path.find( 97 - (f) => f.entity === block.listData?.parent, 98 - ) 99 - ) { 100 - let depth = block.listData.depth; 101 - let newParent = previousBlock.listData.path.find( 102 - (f) => f.depth === depth - 1, 103 - ); 104 - if (!newParent) return; 105 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 106 - useUIState.getState().toggleFold(newParent.entity); 107 - rep?.mutate.moveBlock({ 108 - block: block.value, 109 - oldParent: block.listData?.parent, 110 - newParent: newParent.entity, 111 - position: { type: "end" }, 112 - }); 113 - } else { 114 - rep?.mutate.moveBlockUp({ 115 - entityID: block.value, 116 - parent: block.listData?.parent || block.parent, 117 - }); 118 - } 119 - }} 120 - tooltipContent={ 121 - <div className="flex flex-col gap-1 justify-center"> 122 - <div className="text-center">Move Up</div> 123 - <div className="flex gap-1"> 124 - <ShortcutKey>Shift</ShortcutKey> +{" "} 125 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 126 - <ShortcutKey> โ†‘ </ShortcutKey> 127 - </div> 128 - </div> 129 - } 130 - > 131 - <MoveBlockUp /> 132 - </ToolbarButton> 133 - 134 - <ToolbarButton 135 - hiddenOnCanvas 136 - onClick={async () => { 137 - if (!rep) return; 138 - let [sortedBlocks, siblings] = await getSortedSelection(rep); 139 - if (sortedBlocks.length > 1) return; 140 - let block = sortedBlocks[0]; 141 - let nextBlock = siblings 142 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 143 - .find( 144 - (f) => 145 - f.listData && 146 - block.listData && 147 - !f.listData.path.find((f) => f.entity === block.value), 148 - ); 149 - if ( 150 - nextBlock?.listData && 151 - block.listData && 152 - nextBlock.listData.depth === block.listData.depth - 1 153 - ) { 154 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 155 - useUIState.getState().toggleFold(nextBlock.value); 156 - rep?.mutate.moveBlock({ 157 - block: block.value, 158 - oldParent: block.listData?.parent, 159 - newParent: nextBlock.value, 160 - position: { type: "first" }, 161 - }); 162 - } else { 163 - rep?.mutate.moveBlockDown({ 164 - entityID: block.value, 165 - parent: block.listData?.parent || block.parent, 166 - }); 167 - } 168 - }} 169 - tooltipContent={ 170 - <div className="flex flex-col gap-1 justify-center"> 171 - <div className="text-center">Move Down</div> 172 - <div className="flex gap-1"> 173 - <ShortcutKey>Shift</ShortcutKey> +{" "} 174 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 175 - <ShortcutKey> โ†“ </ShortcutKey> 176 - </div> 177 - </div> 178 - } 179 - > 180 - <MoveBlockDown /> 181 - </ToolbarButton> 182 - <Separator classname="h-6!" /> 183 - </> 184 - ); 185 - }; 186 - 187 - const MoveBlockDown = () => { 188 - return ( 189 - <svg 190 - width="24" 191 - height="24" 192 - viewBox="0 0 24 24" 193 - fill="none" 194 - xmlns="http://www.w3.org/2000/svg" 195 - > 196 - <path 197 - fillRule="evenodd" 198 - clipRule="evenodd" 199 - d="M18.3444 3.56272L3.89705 5.84775C3.48792 5.91246 3.20871 6.29658 3.27342 6.7057L3.83176 10.2358C3.89647 10.645 4.28058 10.9242 4.68971 10.8595L19.137 8.57444C19.5462 8.50973 19.8254 8.12561 19.7607 7.71649L19.2023 4.18635C19.1376 3.77722 18.7535 3.49801 18.3444 3.56272ZM3.70177 4.61309C2.69864 4.77175 1.9884 5.65049 2.01462 6.63905C1.6067 6.92894 1.37517 7.43373 1.45854 7.96083L2.02167 11.5213C2.19423 12.6123 3.21854 13.3568 4.30955 13.1843L15.5014 11.4142L15.3472 10.4394L16.6131 10.2392L17.2948 13.9166L15.3038 12.4752C14.9683 12.2322 14.4994 12.3073 14.2565 12.6428C14.0135 12.9783 14.0886 13.4472 14.4241 13.6902L18.5417 16.6712L21.5228 12.5536C21.7658 12.2181 21.6907 11.7492 21.3552 11.5063C21.0197 11.2634 20.5508 11.3385 20.3079 11.674L18.7926 13.7669L18.0952 10.0048L19.3323 9.80909C20.4233 9.63654 21.1679 8.61222 20.9953 7.52121L20.437 3.99107C20.2644 2.90007 19.2401 2.15551 18.1491 2.32807L3.70177 4.61309ZM12.5175 14.1726C12.8583 14.118 13.0904 13.7974 13.0358 13.4566C12.9812 13.1157 12.6606 12.8837 12.3198 12.9383L4.48217 14.1937C3.37941 14.3704 2.62785 15.4065 2.80232 16.5096L3.35244 19.9878C3.52716 21.0925 4.56428 21.8463 5.66893 21.6716L20.0583 19.3958C21.1618 19.2212 21.9155 18.186 21.7426 17.0822L21.6508 16.4961C21.5974 16.1551 21.2776 15.922 20.9366 15.9754C20.5956 16.0288 20.3624 16.3486 20.4158 16.6896L20.5077 17.2757C20.5738 17.6981 20.2854 18.0943 19.8631 18.1611L5.47365 20.437C5.05089 20.5038 4.65396 20.2153 4.5871 19.7925L4.03697 16.3143C3.9702 15.8921 4.25783 15.4956 4.67988 15.428L12.5175 14.1726ZM5.48645 8.13141C5.4213 7.72235 5.70009 7.33793 6.10914 7.27278L12.7667 6.21241C13.1757 6.14726 13.5602 6.42605 13.6253 6.83511C13.6905 7.24417 13.4117 7.62859 13.0026 7.69374L6.34508 8.75411C5.93602 8.81926 5.5516 8.54047 5.48645 8.13141Z" 200 - fill="currentColor" 201 - /> 202 - </svg> 203 - ); 204 - }; 205 - 206 - const MoveBlockUp = () => { 207 - return ( 208 - <svg 209 - width="24" 210 - height="24" 211 - viewBox="0 0 24 24" 212 - fill="none" 213 - xmlns="http://www.w3.org/2000/svg" 214 - > 215 - <path 216 - fillRule="evenodd" 217 - clipRule="evenodd" 218 - d="M4.12086 10.3069C3.69777 10.3744 3.30016 10.0858 3.23323 9.66265L2.68364 6.18782C2.61677 5.76506 2.90529 5.36813 3.32805 5.30127L17.7149 3.0258C18.1378 2.95892 18.5348 3.24759 18.6015 3.67049L18.7835 4.82361C18.8373 5.16457 19.1573 5.39736 19.4983 5.34356C19.8392 5.28975 20.072 4.96974 20.0182 4.62878L19.8363 3.47566C19.6619 2.37067 18.6246 1.61639 17.5197 1.79115L3.13278 4.06661C2.02813 4.24133 1.27427 5.27845 1.44899 6.3831L1.99857 9.85793C2.17346 10.9637 3.21238 11.7177 4.31788 11.5413L11.5185 10.392C11.8594 10.3376 12.0916 10.0171 12.0372 9.67628C11.9828 9.33542 11.6624 9.1032 11.3215 9.15761L4.12086 10.3069ZM19.9004 11.6151L5.45305 13.9001C5.04392 13.9649 4.76471 14.349 4.82942 14.7581L5.38775 18.2882C5.45246 18.6974 5.83658 18.9766 6.24571 18.9119L20.6931 16.6268C21.1022 16.5621 21.3814 16.178 21.3167 15.7689L20.7583 12.2388C20.6936 11.8296 20.3095 11.5504 19.9004 11.6151ZM5.25777 12.6655C4.21806 12.8299 3.49299 13.7679 3.57645 14.8C3.17867 15.1511 2.9637 15.6918 3.05264 16.2541L3.57767 19.5737C3.75023 20.6647 4.77455 21.4093 5.86556 21.2367L19.9927 19.0023C20.7197 18.8873 21.2751 18.3524 21.4519 17.6846C22.2223 17.3097 22.6921 16.4638 22.5513 15.5736L21.993 12.0435C21.8204 10.9525 20.7961 10.2079 19.7051 10.3805L17.9019 10.6657L17.3957 7.46986L19.3483 8.96297C19.6773 9.21457 20.148 9.1518 20.3996 8.82276C20.6512 8.49373 20.5885 8.02302 20.2594 7.77141L16.2213 4.68355L13.1334 8.72172C12.8818 9.05076 12.9445 9.52146 13.2736 9.77307C13.6026 10.0247 14.0733 9.96191 14.3249 9.63287L15.8945 7.58034L16.4203 10.9L5.25777 12.6655ZM7.66514 15.3252C7.25609 15.3903 6.97729 15.7748 7.04245 16.1838C7.1076 16.5929 7.49202 16.8717 7.90108 16.8065L14.5586 15.7461C14.9677 15.681 15.2465 15.2966 15.1813 14.8875C15.1162 14.4785 14.7317 14.1997 14.3227 14.2648L7.66514 15.3252Z" 219 - fill="currentColor" 220 - /> 221 - </svg> 222 - ); 223 - };
+36 -8
components/Toolbar/ImageToolbar.tsx
··· 6 6 import { ImageAltSmall, ImageRemoveAltSmall } from "components/Icons/ImageAlt"; 7 7 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 8 8 import { useSubscribe } from "src/replicache/useSubscribe"; 9 - import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 9 + import { 10 + ImageCoverImage, 11 + ImageCoverImageRemove, 12 + } from "components/Icons/ImageCoverImage"; 13 + import { Separator } from "components/Layout"; 14 + import { TextAlignmentButton } from "./TextAlignmentToolbar"; 15 + 16 + export const ImageToolbar = (props: { 17 + setToolbarState: (state: "image" | "text-alignment") => void; 18 + }) => { 19 + let focusedEntity = useUIState((s) => s.focusedEntity); 20 + let focusedEntityType = useEntity( 21 + focusedEntity?.entityType === "page" 22 + ? focusedEntity.entityID 23 + : focusedEntity?.parent || null, 24 + "page/type", 25 + ); 26 + 27 + return ( 28 + <div className="flex items-center gap-2 justify-between w-full"> 29 + <div className="flex items-center gap-2"> 30 + <TextAlignmentButton setToolbarState={props.setToolbarState} /> 31 + <ImageAltTextButton /> 32 + <ImageFullBleedButton /> 33 + <ImageCoverButton /> 34 + {focusedEntityType?.data.value !== "canvas" && ( 35 + <Separator classname="h-6!" /> 36 + )} 37 + </div> 38 + </div> 39 + ); 40 + }; 10 41 11 42 export const ImageFullBleedButton = (props: {}) => { 12 43 let { rep } = useReplicache(); ··· 36 67 ); 37 68 }; 38 69 39 - export const ImageAltTextButton = (props: { 40 - setToolbarState: (s: "img-alt-text") => void; 41 - }) => { 70 + export const ImageAltTextButton = (props: {}) => { 42 71 let { rep } = useReplicache(); 43 72 let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null; 44 73 ··· 48 77 let altEditorOpen = useUIState((s) => s.openPopover === focusedBlock); 49 78 let hasSrc = useEntity(focusedBlock, "block/image")?.data; 50 79 if (!hasSrc) return null; 51 - 52 80 return ( 53 81 <ToolbarButton 54 82 active={altText !== undefined} 55 83 onClick={async (e) => { 56 84 e.preventDefault(); 57 85 if (!focusedBlock) return; 58 - if (!altText) { 86 + if (altText === undefined) { 59 87 await rep?.mutate.assertFact({ 60 88 entity: focusedBlock, 61 89 attribute: "image/alt", ··· 109 137 } 110 138 }} 111 139 tooltipContent={ 112 - <div>{isCoverImage ? "Remove Cover Image" : "Set as Cover Image"}</div> 140 + <div>{isCoverImage ? "Remove Cover Image" : "Use as Cover Image"}</div> 113 141 } 114 142 > 115 - <ImageCoverImage /> 143 + {isCoverImage ? <ImageCoverImageRemove /> : <ImageCoverImage />} 116 144 </ToolbarButton> 117 145 ); 118 146 };
-103
components/Toolbar/LockBlockButton.tsx
··· 1 - import { useUIState } from "src/useUIState"; 2 - import { ToolbarButton } from "."; 3 - import { useEntity, useReplicache } from "src/replicache"; 4 - 5 - import { focusBlock } from "src/utils/focusBlock"; 6 - import { Props } from "components/Icons/Props"; 7 - 8 - export function LockBlockButton() { 9 - let focusedBlock = useUIState((s) => s.focusedEntity); 10 - let selectedBlocks = useUIState((s) => s.selectedBlocks); 11 - let type = useEntity(focusedBlock?.entityID || null, "block/type"); 12 - let locked = useEntity(focusedBlock?.entityID || null, "block/is-locked"); 13 - let { rep } = useReplicache(); 14 - if (focusedBlock?.entityType !== "block") return; 15 - return ( 16 - <ToolbarButton 17 - disabled={false} 18 - onClick={async () => { 19 - if (!locked?.data.value) { 20 - await rep?.mutate.assertFact({ 21 - entity: focusedBlock.entityID, 22 - attribute: "block/is-locked", 23 - data: { value: true, type: "boolean" }, 24 - }); 25 - if (selectedBlocks.length > 1) { 26 - for (let block of selectedBlocks) { 27 - await rep?.mutate.assertFact({ 28 - attribute: "block/is-locked", 29 - entity: block.value, 30 - data: { value: true, type: "boolean" }, 31 - }); 32 - } 33 - } 34 - } else { 35 - await rep?.mutate.retractFact({ factID: locked.id }); 36 - if (selectedBlocks.length > 1) { 37 - for (let block of selectedBlocks) { 38 - await rep?.mutate.retractAttribute({ 39 - attribute: "block/is-locked", 40 - entity: block.value, 41 - }); 42 - } 43 - } else { 44 - type && 45 - focusBlock( 46 - { 47 - type: type.data.value, 48 - parent: focusedBlock.parent, 49 - value: focusedBlock.entityID, 50 - }, 51 - { type: "end" }, 52 - ); 53 - } 54 - } 55 - }} 56 - tooltipContent={ 57 - <span>{!locked?.data.value ? "Lock Editing" : " Unlock to Edit"}</span> 58 - } 59 - > 60 - {!locked?.data.value ? <LockSmall /> : <UnlockSmall />} 61 - </ToolbarButton> 62 - ); 63 - } 64 - 65 - const LockSmall = (props: Props) => { 66 - return ( 67 - <svg 68 - width="24" 69 - height="24" 70 - viewBox="0 0 24 24" 71 - fill="none" 72 - xmlns="http://www.w3.org/2000/svg" 73 - {...props} 74 - > 75 - <path 76 - fillRule="evenodd" 77 - clipRule="evenodd" 78 - d="M12 3.9657C9.73217 3.9657 7.89374 5.80413 7.89374 8.07196V10.1794H7.78851C6.82201 10.1794 6.03851 10.9629 6.03851 11.9294V17C6.03851 18.6569 7.38166 20 9.03851 20H14.9615C16.6184 20 17.9615 18.6569 17.9615 17V11.9294C17.9615 10.9629 17.178 10.1794 16.2115 10.1794H16.1063V8.07196C16.1063 5.80413 14.2678 3.9657 12 3.9657ZM14.3563 10.1794V8.07196C14.3563 6.77063 13.3013 5.7157 12 5.7157C10.6987 5.7157 9.64374 6.77063 9.64374 8.07196V10.1794H14.3563ZM12.5824 15.3512C12.9924 15.1399 13.2727 14.7123 13.2727 14.2193C13.2727 13.5165 12.7029 12.9467 12 12.9467C11.2972 12.9467 10.7274 13.5165 10.7274 14.2193C10.7274 14.7271 11.0247 15.1654 11.4548 15.3696L11.2418 17.267C11.2252 17.4152 11.3411 17.5449 11.4902 17.5449H12.5147C12.6621 17.5449 12.7774 17.4181 12.7636 17.2714L12.5824 15.3512Z" 79 - fill="currentColor" 80 - /> 81 - </svg> 82 - ); 83 - }; 84 - 85 - const UnlockSmall = (props: Props) => { 86 - return ( 87 - <svg 88 - width="24" 89 - height="24" 90 - viewBox="0 0 24 24" 91 - fill="none" 92 - xmlns="http://www.w3.org/2000/svg" 93 - {...props} 94 - > 95 - <path 96 - fillRule="evenodd" 97 - clipRule="evenodd" 98 - d="M7.89376 6.62482C7.89376 4.35699 9.7322 2.51855 12 2.51855C14.2678 2.51855 16.1063 4.35699 16.1063 6.62482V10.1794H16.2115C17.178 10.1794 17.9615 10.9629 17.9615 11.9294V17C17.9615 18.6569 16.6184 20 14.9615 20H9.03854C7.38168 20 6.03854 18.6569 6.03854 17V11.9294C6.03854 10.9629 6.82204 10.1794 7.78854 10.1794H14.3563V6.62482C14.3563 5.32349 13.3013 4.26855 12 4.26855C10.6987 4.26855 9.64376 5.32349 9.64376 6.62482V7.72078C9.64376 8.20403 9.25201 8.59578 8.76876 8.59578C8.28551 8.59578 7.89376 8.20403 7.89376 7.72078V6.62482ZM13.1496 14.2193C13.1496 14.7123 12.8693 15.1399 12.4593 15.3512L12.6405 17.2714C12.6544 17.4181 12.539 17.5449 12.3916 17.5449H11.3672C11.218 17.5449 11.1021 17.4152 11.1187 17.267L11.3317 15.3696C10.9016 15.1654 10.6043 14.7271 10.6043 14.2193C10.6043 13.5165 11.1741 12.9467 11.8769 12.9467C12.5798 12.9467 13.1496 13.5165 13.1496 14.2193ZM5.62896 5.3862C5.4215 5.20395 5.10558 5.2244 4.92333 5.43186C4.74109 5.63932 4.76153 5.95525 4.969 6.13749L6.06209 7.09771C6.26955 7.27996 6.58548 7.25951 6.76772 7.05205C6.94997 6.84458 6.92952 6.52866 6.72206 6.34642L5.62896 5.3862ZM3.5165 6.64283C3.25418 6.55657 2.97159 6.69929 2.88533 6.96161C2.79906 7.22393 2.94178 7.50652 3.20411 7.59278L5.54822 8.36366C5.81054 8.44992 6.09313 8.3072 6.1794 8.04488C6.26566 7.78256 6.12294 7.49997 5.86062 7.41371L3.5165 6.64283ZM3.54574 9.42431C3.52207 9.14918 3.72592 8.90696 4.00105 8.8833L5.52254 8.75244C5.79766 8.72878 6.03988 8.93263 6.06354 9.20776C6.08721 9.48288 5.88335 9.7251 5.60823 9.74876L4.08674 9.87962C3.81162 9.90329 3.5694 9.69943 3.54574 9.42431Z" 99 - fill="currentColor" 100 - /> 101 - </svg> 102 - ); 103 - };
+33 -12
components/Toolbar/MultiSelectToolbar.tsx
··· 2 2 import { ReplicacheMutators, useReplicache } from "src/replicache"; 3 3 import { ToolbarButton } from "./index"; 4 4 import { copySelection } from "src/utils/copySelection"; 5 - import { useSmoker } from "components/Toast"; 6 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 7 - import { Replicache } from "replicache"; 8 - import { LockBlockButton } from "./LockBlockButton"; 5 + import { useSmoker, useToaster } from "components/Toast"; 6 + 9 7 import { Props } from "components/Icons/Props"; 10 8 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 9 import { getSortedSelection } from "components/SelectionManager/selectionState"; 10 + import { deleteBlock } from "src/utils/deleteBlock"; 11 + import { Separator, ShortcutKey } from "components/Layout"; 12 12 13 13 export const MultiselectToolbar = (props: { 14 - setToolbarState: ( 15 - state: "areYouSure" | "multiselect" | "text-alignment", 16 - ) => void; 14 + setToolbarState: (state: "multiselect" | "text-alignment") => void; 17 15 }) => { 18 - const { rep } = useReplicache(); 16 + const { rep, undoManager } = useReplicache(); 19 17 const smoker = useSmoker(); 18 + const toaster = useToaster(); 20 19 21 20 const handleCopy = async (event: React.MouseEvent) => { 22 21 if (!rep) return; 23 - const [sortedSelection] = await getSortedSelection(rep); 22 + let [sortedSelection] = await getSortedSelection(rep); 24 23 await copySelection(rep, sortedSelection); 25 24 smoker({ 26 25 position: { x: event.clientX, y: event.clientY }, ··· 33 32 <div className="flex items-center gap-2"> 34 33 <ToolbarButton 35 34 tooltipContent="Delete Selected Blocks" 36 - onClick={() => { 37 - props.setToolbarState("areYouSure"); 35 + onClick={async (e) => { 36 + e.stopPropagation(); 37 + if (!rep) return; 38 + let [sortedSelection] = await getSortedSelection(rep); 39 + await deleteBlock( 40 + sortedSelection.map((b) => b.value), 41 + rep, 42 + undoManager, 43 + ); 44 + 45 + toaster({ 46 + content: ( 47 + <div className="font-bold items-center flex"> 48 + {sortedSelection.length} block 49 + {sortedSelection.length === 1 ? "" : "s"} deleted!{" "} 50 + <span className="px-2 flex"> 51 + <ShortcutKey>Ctrl</ShortcutKey> 52 + <ShortcutKey>Z</ShortcutKey>{" "} 53 + </span> 54 + to undo. 55 + </div> 56 + ), 57 + type: "success", 58 + }); 38 59 }} 39 60 > 40 61 <TrashSmall /> ··· 47 68 <CopySmall /> 48 69 </ToolbarButton> 49 70 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 50 - <LockBlockButton /> 71 + <Separator classname="h-6!" /> 51 72 </div> 52 73 </div> 53 74 );
+8
components/Toolbar/TextAlignmentToolbar.tsx
··· 7 7 export function TextAlignmentToolbar() { 8 8 let focusedBlock = useUIState((s) => s.focusedEntity); 9 9 let { rep } = useReplicache(); 10 + let alignment = useEntity( 11 + focusedBlock?.entityID || null, 12 + "block/text-alignment", 13 + )?.data.value; 10 14 let setAlignment = useCallback( 11 15 (alignment: Fact<"block/text-alignment">["data"]["value"]) => { 12 16 let blocks = useUIState.getState().selectedBlocks; ··· 26 30 <ToolbarButton 27 31 onClick={() => setAlignment("left")} 28 32 tooltipContent="Align Left" 33 + active={alignment === "left"} 29 34 > 30 35 <AlignLeftSmall /> 31 36 </ToolbarButton> 32 37 <ToolbarButton 33 38 onClick={() => setAlignment("center")} 34 39 tooltipContent="Align Center" 40 + active={alignment === "center"} 35 41 > 36 42 <AlignCenterSmall /> 37 43 </ToolbarButton> 38 44 <ToolbarButton 39 45 onClick={() => setAlignment("right")} 40 46 tooltipContent="Align Right" 47 + active={alignment === "right"} 41 48 > 42 49 <AlignRightSmall /> 43 50 </ToolbarButton> ··· 45 52 <ToolbarButton 46 53 onClick={() => setAlignment("justify")} 47 54 tooltipContent="Align Justified" 55 + active={alignment === "justify"} 48 56 > 49 57 <AlignJustifiedSmall /> 50 58 </ToolbarButton>
-3
components/Toolbar/TextToolbar.tsx
··· 8 8 import { ToolbarTypes } from "."; 9 9 import { schema } from "components/Blocks/TextBlock/schema"; 10 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { LockBlockButton } from "./LockBlockButton"; 12 11 import { Props } from "components/Icons/Props"; 13 12 import { isMac } from "src/utils/isDevice"; 14 13 ··· 81 80 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 82 81 <ListButton setToolbarState={props.setToolbarState} /> 83 82 <Separator classname="h-6!" /> 84 - 85 - <LockBlockButton /> 86 83 </> 87 84 ); 88 85 };
+57 -87
components/Toolbar/index.tsx
··· 11 11 import { ListToolbar } from "./ListToolbar"; 12 12 import { HighlightToolbar } from "./HighlightToolbar"; 13 13 import { TextToolbar } from "./TextToolbar"; 14 - import { BlockToolbar } from "./BlockToolbar"; 14 + import { ImageToolbar } from "./ImageToolbar"; 15 15 import { MultiselectToolbar } from "./MultiSelectToolbar"; 16 - import { AreYouSure } from "components/Blocks/DeleteBlock"; 17 - import { deleteBlock } from "src/utils/deleteBlock"; 18 16 import { TooltipButton } from "components/Buttons"; 19 17 import { TextAlignmentToolbar } from "./TextAlignmentToolbar"; 20 18 import { useIsMobile } from "src/hooks/isMobile"; 21 19 import { CloseTiny } from "components/Icons/CloseTiny"; 22 20 23 21 export type ToolbarTypes = 24 - | "areYouSure" 25 22 | "default" 26 - | "block" 27 23 | "multiselect" 28 24 | "highlight" 29 25 | "link" ··· 31 27 | "text-alignment" 32 28 | "list" 33 29 | "linkBlock" 34 - | "img-alt-text"; 35 - 36 - export const Toolbar = (props: { pageID: string; blockID: string }) => { 37 - let { rep } = useReplicache(); 30 + | "img-alt-text" 31 + | "image"; 38 32 33 + export const Toolbar = (props: { 34 + pageID: string; 35 + blockID: string; 36 + blockType: string | null | undefined; 37 + }) => { 39 38 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 40 39 41 - let focusedEntity = useUIState((s) => s.focusedEntity); 42 - let selectedBlocks = useUIState((s) => s.selectedBlocks); 43 40 let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 44 - 45 - let blockType = useEntity(props.blockID, "block/type")?.data.value; 41 + let selectedBlocks = useUIState((s) => s.selectedBlocks); 46 42 47 43 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); 48 44 let setLastUsedHighlight = (color: "1" | "2" | "3") => ··· 64 60 }; 65 61 }, [toolbarState]); 66 62 67 - useEffect(() => { 68 - if (!blockType) return; 69 - if ( 70 - blockType !== "heading" && 71 - blockType !== "text" && 72 - blockType !== "blockquote" 73 - ) { 74 - setToolbarState("block"); 75 - } else { 76 - setToolbarState("default"); 77 - } 78 - }, [blockType]); 63 + let isTextBlock = 64 + props.blockType === "heading" || 65 + props.blockType === "text" || 66 + props.blockType === "blockquote"; 79 67 80 68 useEffect(() => { 81 - if ( 82 - selectedBlocks.length > 1 && 83 - !["areYousure", "text-alignment"].includes(toolbarState) 84 - ) { 69 + if (selectedBlocks.length > 1) { 85 70 setToolbarState("multiselect"); 86 - } else if (toolbarState === "multiselect") { 71 + return; 72 + } 73 + if (isTextBlock) { 87 74 setToolbarState("default"); 88 75 } 89 - }, [selectedBlocks.length, toolbarState]); 90 - let isMobile = useIsMobile(); 76 + if (props.blockType === "image") { 77 + setToolbarState("image"); 78 + } 79 + if (props.blockType === "button" || props.blockType === "datetime") { 80 + setToolbarState("text-alignment"); 81 + } else null; 82 + }, [props.blockType, selectedBlocks]); 91 83 84 + let isMobile = useIsMobile(); 92 85 return ( 93 86 <Tooltip.Provider> 94 87 <div ··· 125 118 <TextBlockTypeToolbar onClose={() => setToolbarState("default")} /> 126 119 ) : toolbarState === "text-alignment" ? ( 127 120 <TextAlignmentToolbar /> 128 - ) : toolbarState === "block" ? ( 129 - <BlockToolbar setToolbarState={setToolbarState} /> 121 + ) : toolbarState === "image" ? ( 122 + <ImageToolbar setToolbarState={setToolbarState} /> 130 123 ) : toolbarState === "multiselect" ? ( 131 124 <MultiselectToolbar setToolbarState={setToolbarState} /> 132 - ) : toolbarState === "areYouSure" ? ( 133 - <AreYouSure 134 - compact 135 - type={blockType} 136 - entityID={selectedBlocks.map((b) => b.value)} 137 - onClick={() => { 138 - rep && 139 - deleteBlock( 140 - selectedBlocks.map((b) => b.value), 141 - rep, 142 - ); 143 - }} 144 - closeAreYouSure={() => { 145 - setToolbarState( 146 - selectedBlocks.length > 1 147 - ? "multiselect" 148 - : blockType !== "heading" && blockType !== "text" 149 - ? "block" 150 - : "default", 151 - ); 152 - }} 153 - /> 154 125 ) : null} 155 126 </div> 156 127 {/* if the thing is are you sure state, don't show the x... is each thing handling its own are you sure? theres no need for that */} 157 - {toolbarState !== "areYouSure" && ( 158 - <button 159 - className="toolbarBackToDefault hover:text-accent-contrast" 160 - onMouseDown={(e) => { 161 - e.preventDefault(); 162 - if ( 163 - toolbarState === "multiselect" || 164 - toolbarState === "block" || 165 - toolbarState === "default" 166 - ) { 167 - useUIState.setState(() => ({ 168 - focusedEntity: { 169 - entityType: "page", 170 - entityID: props.pageID, 171 - }, 172 - selectedBlocks: [], 173 - })); 174 - } else { 175 - if (blockType !== "heading" && blockType !== "text") { 176 - setToolbarState("block"); 177 - } else { 178 - setToolbarState("default"); 179 - } 128 + 129 + <button 130 + className="toolbarBackToDefault hover:text-accent-contrast" 131 + onMouseDown={(e) => { 132 + e.preventDefault(); 133 + if ( 134 + toolbarState === "multiselect" || 135 + toolbarState === "image" || 136 + toolbarState === "default" 137 + ) { 138 + // close the toolbar 139 + useUIState.setState(() => ({ 140 + focusedEntity: { 141 + entityType: "page", 142 + entityID: props.pageID, 143 + }, 144 + selectedBlocks: [], 145 + })); 146 + } else { 147 + if (props.blockType === "image") { 148 + setToolbarState("image"); 180 149 } 181 - }} 182 - > 183 - <CloseTiny /> 184 - </button> 185 - )} 150 + if (isTextBlock) { 151 + setToolbarState("default"); 152 + } 153 + } 154 + }} 155 + > 156 + <CloseTiny /> 157 + </button> 186 158 </div> 187 159 </Tooltip.Provider> 188 160 ); ··· 198 170 hiddenOnCanvas?: boolean; 199 171 }) => { 200 172 let focusedEntity = useUIState((s) => s.focusedEntity); 201 - let isLocked = useEntity(focusedEntity?.entityID || null, "block/is-locked"); 202 - let isDisabled = 203 - props.disabled === undefined ? !!isLocked?.data.value : props.disabled; 173 + let isDisabled = props.disabled; 204 174 205 175 let focusedEntityType = useEntity( 206 176 focusedEntity?.entityType === "page"
+1
drizzle/schema.ts
··· 140 140 email: text("email"), 141 141 atp_did: text("atp_did"), 142 142 interface_state: jsonb("interface_state"), 143 + metadata: jsonb("metadata"), 143 144 }, 144 145 (table) => { 145 146 return {
+2 -10
lexicons/api/lexicons.ts
··· 1896 1896 'lex:pub.leaflet.theme.color#rgb', 1897 1897 ], 1898 1898 }, 1899 - headingFont: { 1900 - type: 'string', 1901 - maxLength: 100, 1902 - }, 1903 - bodyFont: { 1904 - type: 'string', 1905 - maxLength: 100, 1906 - }, 1907 1899 }, 1908 1900 }, 1909 1901 }, ··· 2223 2215 type: 'ref', 2224 2216 }, 2225 2217 theme: { 2226 - type: 'ref', 2227 - ref: 'lex:pub.leaflet.publication#theme', 2218 + type: 'union', 2219 + refs: ['lex:pub.leaflet.publication#theme'], 2228 2220 }, 2229 2221 description: { 2230 2222 maxGraphemes: 300,
-2
lexicons/api/types/pub/leaflet/publication.ts
··· 76 76 | $Typed<PubLeafletThemeColor.Rgba> 77 77 | $Typed<PubLeafletThemeColor.Rgb> 78 78 | { $type: string } 79 - headingFont?: string 80 - bodyFont?: string 81 79 } 82 80 83 81 const hashTheme = 'theme'
+1 -1
lexicons/api/types/site/standard/publication.ts
··· 15 15 export interface Record { 16 16 $type: 'site.standard.publication' 17 17 basicTheme?: SiteStandardThemeBasic.Main 18 - theme?: PubLeafletPublication.Theme 18 + theme?: $Typed<PubLeafletPublication.Theme> | { $type: string } 19 19 description?: string 20 20 icon?: BlobRef 21 21 name: string
-8
lexicons/pub/leaflet/publication.json
··· 112 112 "pub.leaflet.theme.color#rgba", 113 113 "pub.leaflet.theme.color#rgb" 114 114 ] 115 - }, 116 - "headingFont": { 117 - "type": "string", 118 - "maxLength": 100 119 - }, 120 - "bodyFont": { 121 - "type": "string", 122 - "maxLength": 100 123 115 } 124 116 } 125 117 }
+2 -2
lexicons/site/standard/publication.json
··· 9 9 "type": "ref" 10 10 }, 11 11 "theme": { 12 - "type": "ref", 13 - "ref": "pub.leaflet.publication#theme" 12 + "type": "union", 13 + "refs": ["pub.leaflet.publication#theme"] 14 14 }, 15 15 "description": { 16 16 "maxGraphemes": 300,
+40 -5
lexicons/src/normalize.ts
··· 14 14 */ 15 15 16 16 import type * as PubLeafletDocument from "../api/types/pub/leaflet/document"; 17 - import type * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 17 + import * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 18 18 import type * as PubLeafletContent from "../api/types/pub/leaflet/content"; 19 19 import type * as SiteStandardDocument from "../api/types/site/standard/document"; 20 20 import type * as SiteStandardPublication from "../api/types/site/standard/publication"; ··· 31 31 }; 32 32 33 33 // Normalized publication type - uses the generated site.standard.publication type 34 - export type NormalizedPublication = SiteStandardPublication.Record; 34 + // with the theme narrowed to only the valid pub.leaflet.publication#theme type 35 + // (isTheme validates that $type is present, so we use $Typed) 36 + // Note: We explicitly list fields rather than using Omit because the generated Record type 37 + // has an index signature [k: string]: unknown that interferes with property typing 38 + export type NormalizedPublication = { 39 + $type: "site.standard.publication"; 40 + name: string; 41 + url: string; 42 + description?: string; 43 + icon?: SiteStandardPublication.Record["icon"]; 44 + basicTheme?: SiteStandardThemeBasic.Main; 45 + theme?: $Typed<PubLeafletPublication.Theme>; 46 + preferences?: SiteStandardPublication.Preferences; 47 + }; 35 48 36 49 /** 37 50 * Checks if the record is a pub.leaflet.document ··· 210 223 ): NormalizedPublication | null { 211 224 if (!record || typeof record !== "object") return null; 212 225 213 - // Pass through site.standard records directly 226 + // Pass through site.standard records directly, but validate the theme 214 227 if (isStandardPublication(record)) { 215 - return record; 228 + // Validate theme - only keep if it's a valid pub.leaflet.publication#theme 229 + const theme = PubLeafletPublication.isTheme(record.theme) 230 + ? (record.theme as $Typed<PubLeafletPublication.Theme>) 231 + : undefined; 232 + return { 233 + ...record, 234 + theme, 235 + }; 216 236 } 217 237 218 238 if (isLeafletPublication(record)) { ··· 225 245 226 246 const basicTheme = leafletThemeToBasicTheme(record.theme); 227 247 248 + // Validate theme - only keep if it's a valid pub.leaflet.publication#theme with $type set 249 + // For legacy records without $type, add it during normalization 250 + let theme: $Typed<PubLeafletPublication.Theme> | undefined; 251 + if (record.theme) { 252 + if (PubLeafletPublication.isTheme(record.theme)) { 253 + theme = record.theme as $Typed<PubLeafletPublication.Theme>; 254 + } else { 255 + // Legacy theme without $type - add it 256 + theme = { 257 + ...record.theme, 258 + $type: "pub.leaflet.publication#theme", 259 + }; 260 + } 261 + } 262 + 228 263 // Convert preferences to site.standard format (strip/replace $type) 229 264 const preferences: SiteStandardPublication.Preferences | undefined = 230 265 record.preferences ··· 243 278 description: record.description, 244 279 icon: record.icon, 245 280 basicTheme, 246 - theme: record.theme, 281 + theme, 247 282 preferences, 248 283 }; 249 284 }
-2
lexicons/src/publication.ts
··· 49 49 showPageBackground: { type: "boolean", default: false }, 50 50 accentBackground: ColorUnion, 51 51 accentText: ColorUnion, 52 - headingFont: { type: "string", maxLength: 100 }, 53 - bodyFont: { type: "string", maxLength: 100 }, 54 52 }, 55 53 }, 56 54 },
public/fonts/AtkinsonHyperlegibleNext-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/AtkinsonHyperlegibleNext-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/Lora-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/Lora-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/NotoSans-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/NotoSans-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/SourceSans3-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/SourceSans3-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/iAWriterQuattroV-Italic.ttf

This is a binary file and will not be displayed.

public/fonts/iAWriterQuattroV.ttf

This is a binary file and will not be displayed.

public/fonts/iaw-quattro-vf-Italic.woff2

This is a binary file and will not be displayed.

public/fonts/iaw-quattro-vf.woff2

This is a binary file and will not be displayed.

-154
src/fonts.ts
··· 1 - // Font configuration for self-hosted and Google Fonts 2 - // This replicates what next/font does but allows dynamic selection per-leaflet 3 - 4 - export type FontConfig = { 5 - id: string; 6 - displayName: string; 7 - fontFamily: string; 8 - fallback: string[]; 9 - } & ( 10 - | { 11 - // Self-hosted fonts with local files 12 - type: "local"; 13 - files: { 14 - path: string; 15 - style: "normal" | "italic"; 16 - weight?: string; 17 - }[]; 18 - } 19 - | { 20 - // Google Fonts loaded via CDN 21 - type: "google"; 22 - googleFontsFamily: string; // e.g., "Open+Sans:ital,wght@0,400;0,700;1,400;1,700" 23 - } 24 - | { 25 - // System fonts (no loading required) 26 - type: "system"; 27 - } 28 - ); 29 - 30 - export const fonts: Record<string, FontConfig> = { 31 - // Self-hosted variable fonts (WOFF2) 32 - quattro: { 33 - id: "quattro", 34 - displayName: "iA Writer Quattro", 35 - fontFamily: "iA Writer Quattro V", 36 - type: "local", 37 - files: [ 38 - { path: "/fonts/iaw-quattro-vf.woff2", style: "normal", weight: "400 700" }, 39 - { path: "/fonts/iaw-quattro-vf-Italic.woff2", style: "italic", weight: "400 700" }, 40 - ], 41 - fallback: ["system-ui", "sans-serif"], 42 - }, 43 - lora: { 44 - id: "lora", 45 - displayName: "Lora", 46 - fontFamily: "Lora", 47 - type: "local", 48 - files: [ 49 - { path: "/fonts/Lora-Variable.woff2", style: "normal", weight: "400 700" }, 50 - { path: "/fonts/Lora-Italic-Variable.woff2", style: "italic", weight: "400 700" }, 51 - ], 52 - fallback: ["Georgia", "serif"], 53 - }, 54 - "source-sans": { 55 - id: "source-sans", 56 - displayName: "Source Sans", 57 - fontFamily: "Source Sans 3", 58 - type: "local", 59 - files: [ 60 - { path: "/fonts/SourceSans3-Variable.woff2", style: "normal", weight: "200 900" }, 61 - { path: "/fonts/SourceSans3-Italic-Variable.woff2", style: "italic", weight: "200 900" }, 62 - ], 63 - fallback: ["system-ui", "sans-serif"], 64 - }, 65 - "atkinson-hyperlegible": { 66 - id: "atkinson-hyperlegible", 67 - displayName: "Atkinson Hyperlegible", 68 - fontFamily: "Atkinson Hyperlegible Next", 69 - type: "local", 70 - files: [ 71 - { path: "/fonts/AtkinsonHyperlegibleNext-Variable.woff2", style: "normal", weight: "200 800" }, 72 - { path: "/fonts/AtkinsonHyperlegibleNext-Italic-Variable.woff2", style: "italic", weight: "200 800" }, 73 - ], 74 - fallback: ["system-ui", "sans-serif"], 75 - }, 76 - "noto-sans": { 77 - id: "noto-sans", 78 - displayName: "Noto Sans", 79 - fontFamily: "Noto Sans", 80 - type: "local", 81 - files: [ 82 - { path: "/fonts/NotoSans-Variable.woff2", style: "normal", weight: "100 900" }, 83 - { path: "/fonts/NotoSans-Italic-Variable.woff2", style: "italic", weight: "100 900" }, 84 - ], 85 - fallback: ["Arial", "sans-serif"], 86 - }, 87 - 88 - // Google Fonts (no variable version available) 89 - "alegreya-sans": { 90 - id: "alegreya-sans", 91 - displayName: "Alegreya Sans", 92 - fontFamily: "Alegreya Sans", 93 - type: "google", 94 - googleFontsFamily: "Alegreya+Sans:ital,wght@0,400;0,700;1,400;1,700", 95 - fallback: ["system-ui", "sans-serif"], 96 - }, 97 - "space-mono": { 98 - id: "space-mono", 99 - displayName: "Space Mono", 100 - fontFamily: "Space Mono", 101 - type: "google", 102 - googleFontsFamily: "Space+Mono:ital,wght@0,400;0,700;1,400;1,700", 103 - fallback: ["monospace"], 104 - }, 105 - }; 106 - 107 - export const defaultFontId = "quattro"; 108 - 109 - export function getFontConfig(fontId: string | undefined): FontConfig { 110 - return fonts[fontId || defaultFontId] || fonts[defaultFontId]; 111 - } 112 - 113 - // Generate @font-face CSS for a local font 114 - export function generateFontFaceCSS(font: FontConfig): string { 115 - if (font.type !== "local") return ""; 116 - return font.files 117 - .map((file) => { 118 - const format = file.path.endsWith(".woff2") ? "woff2" : "truetype"; 119 - return ` 120 - @font-face { 121 - font-family: '${font.fontFamily}'; 122 - src: url('${file.path}') format('${format}'); 123 - font-style: ${file.style}; 124 - font-weight: ${file.weight || "normal"}; 125 - font-display: swap; 126 - }`.trim(); 127 - }) 128 - .join("\n\n"); 129 - } 130 - 131 - // Generate preload link attributes for a local font 132 - export function getFontPreloadLinks(font: FontConfig): { href: string; type: string }[] { 133 - if (font.type !== "local") return []; 134 - return font.files.map((file) => ({ 135 - href: file.path, 136 - type: file.path.endsWith(".woff2") ? "font/woff2" : "font/ttf", 137 - })); 138 - } 139 - 140 - // Get Google Fonts URL for a font 141 - // Using display=swap per Google's recommendation: shows fallback immediately, swaps when ready 142 - // This is better UX than blocking text rendering (display=block) 143 - export function getGoogleFontsUrl(font: FontConfig): string | null { 144 - if (font.type !== "google") return null; 145 - return `https://fonts.googleapis.com/css2?family=${font.googleFontsFamily}&display=swap`; 146 - } 147 - 148 - // Get the CSS font-family value with fallbacks 149 - export function getFontFamilyValue(font: FontConfig): string { 150 - const family = font.fontFamily.includes(" ") 151 - ? `'${font.fontFamily}'` 152 - : font.fontFamily; 153 - return [family, ...font.fallback].join(", "); 154 - }
+1 -1
src/hooks/useLongPress.ts
··· 90 90 return useMemo( 91 91 () => ({ 92 92 isLongPress: isLongPress, 93 - handlers: { 93 + longPressHandlers: { 94 94 onPointerDown, 95 95 onPointerUp: end, 96 96 onClickCapture: click,
+106 -2
src/notifications.ts
··· 21 21 | { type: "comment"; comment_uri: string; parent_uri?: string } 22 22 | { type: "subscribe"; subscription_uri: string } 23 23 | { type: "quote"; bsky_post_uri: string; document_uri: string } 24 + | { type: "bsky_post_embed"; document_uri: string; bsky_post_uri: string } 24 25 | { type: "mention"; document_uri: string; mention_type: "did" } 25 26 | { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string } 26 27 | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } ··· 32 33 | HydratedCommentNotification 33 34 | HydratedSubscribeNotification 34 35 | HydratedQuoteNotification 36 + | HydratedBskyPostEmbedNotification 35 37 | HydratedMentionNotification 36 38 | HydratedCommentMentionNotification; 37 39 export async function hydrateNotifications( 38 40 notifications: NotificationRow[], 39 41 ): Promise<Array<HydratedNotification>> { 40 42 // Call all hydrators in parallel 41 - const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 43 + const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ 42 44 hydrateCommentNotifications(notifications), 43 45 hydrateSubscribeNotifications(notifications), 44 46 hydrateQuoteNotifications(notifications), 47 + hydrateBskyPostEmbedNotifications(notifications), 45 48 hydrateMentionNotifications(notifications), 46 49 hydrateCommentMentionNotifications(notifications), 47 50 ]); 48 51 49 52 // Combine all hydrated notifications 50 - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications]; 53 + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications]; 51 54 52 55 // Sort by created_at to maintain order 53 56 allHydrated.sort( ··· 198 201 document_uri: notification.data.document_uri, 199 202 bskyPost, 200 203 document, 204 + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 205 + normalizedPublication: normalizePublicationRecord( 206 + document.documents_in_publications[0]?.publications?.record, 207 + ), 208 + }; 209 + }) 210 + .filter((n) => n !== null); 211 + } 212 + 213 + export type HydratedBskyPostEmbedNotification = Awaited< 214 + ReturnType<typeof hydrateBskyPostEmbedNotifications> 215 + >[0]; 216 + 217 + async function hydrateBskyPostEmbedNotifications(notifications: NotificationRow[]) { 218 + const bskyPostEmbedNotifications = notifications.filter( 219 + (n): n is NotificationRow & { data: ExtractNotificationType<"bsky_post_embed"> } => 220 + (n.data as NotificationData)?.type === "bsky_post_embed", 221 + ); 222 + 223 + if (bskyPostEmbedNotifications.length === 0) { 224 + return []; 225 + } 226 + 227 + // Fetch document data (the leaflet that embedded the post) 228 + const documentUris = bskyPostEmbedNotifications.map((n) => n.data.document_uri); 229 + const bskyPostUris = bskyPostEmbedNotifications.map((n) => n.data.bsky_post_uri); 230 + 231 + const [{ data: documents }, { data: cachedBskyPosts }] = await Promise.all([ 232 + supabaseServerClient 233 + .from("documents") 234 + .select("*, documents_in_publications(publications(*))") 235 + .in("uri", documentUris), 236 + supabaseServerClient 237 + .from("bsky_posts") 238 + .select("*") 239 + .in("uri", bskyPostUris), 240 + ]); 241 + 242 + // Find which posts we need to fetch from the API 243 + const cachedPostUris = new Set(cachedBskyPosts?.map((p) => p.uri) ?? []); 244 + const missingPostUris = bskyPostUris.filter((uri) => !cachedPostUris.has(uri)); 245 + 246 + // Fetch missing posts from Bluesky API 247 + const fetchedPosts = new Map<string, { text: string } | null>(); 248 + if (missingPostUris.length > 0) { 249 + try { 250 + const { AtpAgent } = await import("@atproto/api"); 251 + const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 252 + const response = await agent.app.bsky.feed.getPosts({ uris: missingPostUris }); 253 + for (const post of response.data.posts) { 254 + const record = post.record as { text?: string }; 255 + fetchedPosts.set(post.uri, { text: record.text ?? "" }); 256 + } 257 + } catch (error) { 258 + console.error("Failed to fetch Bluesky posts:", error); 259 + } 260 + } 261 + 262 + // Extract unique DIDs from document URIs to resolve handles 263 + const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))]; 264 + 265 + // Resolve DIDs to handles in parallel 266 + const didToHandleMap = new Map<string, string | null>(); 267 + await Promise.all( 268 + documentCreatorDids.map(async (did) => { 269 + try { 270 + const resolved = await idResolver.did.resolve(did); 271 + const handle = resolved?.alsoKnownAs?.[0] 272 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 273 + : null; 274 + didToHandleMap.set(did, handle); 275 + } catch (error) { 276 + console.error(`Failed to resolve DID ${did}:`, error); 277 + didToHandleMap.set(did, null); 278 + } 279 + }), 280 + ); 281 + 282 + return bskyPostEmbedNotifications 283 + .map((notification) => { 284 + const document = documents?.find((d) => d.uri === notification.data.document_uri); 285 + if (!document) return null; 286 + 287 + const documentCreatorDid = new AtUri(notification.data.document_uri).host; 288 + const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 289 + 290 + // Get post text from cache or fetched data 291 + const cachedPost = cachedBskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri); 292 + const postView = cachedPost?.post_view as { record?: { text?: string } } | undefined; 293 + const bskyPostText = postView?.record?.text ?? fetchedPosts.get(notification.data.bsky_post_uri)?.text ?? null; 294 + 295 + return { 296 + id: notification.id, 297 + recipient: notification.recipient, 298 + created_at: notification.created_at, 299 + type: "bsky_post_embed" as const, 300 + document_uri: notification.data.document_uri, 301 + bsky_post_uri: notification.data.bsky_post_uri, 302 + document, 303 + documentCreatorHandle, 304 + bskyPostText, 201 305 normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 202 306 normalizedPublication: normalizePublicationRecord( 203 307 document.documents_in_publications[0]?.publications?.record,
+1 -5
src/replicache/attributes.ts
··· 187 187 } as const; 188 188 189 189 export const ThemeAttributes = { 190 - "theme/heading-font": { 191 - type: "string", 192 - cardinality: "one", 193 - }, 194 - "theme/body-font": { 190 + "theme/font": { 195 191 type: "string", 196 192 cardinality: "one", 197 193 },
+7
src/replicache/clientMutationContext.ts
··· 67 67 textData.value = base64.fromByteArray(updateBytes); 68 68 } 69 69 } 70 + } else if (f.id) { 71 + // For cardinality "many" with an explicit ID, fetch the existing fact 72 + // so undo can restore it instead of deleting 73 + let fact = await tx.get(f.id); 74 + if (fact) { 75 + existingFact = [fact as Fact<any>]; 76 + } 70 77 } 71 78 if (!ignoreUndo) 72 79 undoManager.add({
+31 -10
src/replicache/mutations.ts
··· 4 4 import { SupabaseClient } from "@supabase/supabase-js"; 5 5 import { Database } from "supabase/database.types"; 6 6 import { generateKeyBetween } from "fractional-indexing"; 7 + import { v7 } from "uuid"; 7 8 8 9 export type MutationContext = { 9 10 permission_token_id: string; ··· 307 308 { blockEntity: string } | { blockEntity: string }[] 308 309 > = async (args, ctx) => { 309 310 for (let block of [args].flat()) { 310 - let [isLocked] = await ctx.scanIndex.eav( 311 - block.blockEntity, 312 - "block/is-locked", 313 - ); 314 - if (isLocked?.data.value) continue; 315 311 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image"); 316 312 await ctx.runOnServer(async ({ supabase }) => { 317 313 if (image) { ··· 427 423 }, 428 424 }); 429 425 }; 430 - const moveBlockDown: Mutation<{ entityID: string; parent: string }> = async ( 431 - args, 432 - ctx, 433 - ) => { 426 + const moveBlockDown: Mutation<{ 427 + entityID: string; 428 + parent: string; 429 + permission_set?: string; 430 + }> = async (args, ctx) => { 434 431 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( 435 432 (a, b) => (a.data.position > b.data.position ? 1 : -1), 436 433 ); 437 434 let index = children.findIndex((f) => f.data.value === args.entityID); 438 435 if (index === -1) return; 439 436 let next = children[index + 1]; 440 - if (!next) return; 437 + if (!next) { 438 + // If this is the last block, create a new empty block above it using the addBlock helper 439 + if (!args.permission_set) return; // Can't create block without permission_set 440 + 441 + let newEntityID = v7(); 442 + let previousBlock = children[index - 1]; 443 + let position = generateKeyBetween( 444 + previousBlock?.data.position || null, 445 + children[index].data.position, 446 + ); 447 + 448 + // Call the addBlock mutation helper directly 449 + await addBlock( 450 + { 451 + parent: args.parent, 452 + permission_set: args.permission_set, 453 + factID: v7(), 454 + type: "text", 455 + newEntityID: newEntityID, 456 + position: position, 457 + }, 458 + ctx, 459 + ); 460 + return; 461 + } 441 462 await ctx.retractFact(children[index].id); 442 463 await ctx.assertFact({ 443 464 id: children[index].id,
+10 -2
src/utils/deleteBlock.ts
··· 4 4 import { scanIndex } from "src/replicache/utils"; 5 5 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 6 import { focusBlock } from "src/utils/focusBlock"; 7 + import { UndoManager } from "src/undoManager"; 7 8 8 9 export async function deleteBlock( 9 10 entities: string[], 10 11 rep: Replicache<ReplicacheMutators>, 12 + undoManager?: UndoManager, 11 13 ) { 12 14 // get what pagess we need to close as a result of deleting this block 13 15 let pagesToClose = [] as string[]; ··· 32 34 } 33 35 } 34 36 35 - // the next and previous blocks in the block list 36 - // if the focused thing is a page and not a block, return 37 + // figure out what to focus 37 38 let focusedBlock = useUIState.getState().focusedEntity; 38 39 let parent = 39 40 focusedBlock?.entityType === "page" ··· 44 45 let parentType = await rep?.query((tx) => 45 46 scanIndex(tx).eav(parent, "page/type"), 46 47 ); 48 + // if the page is a canvas, focus the page 47 49 if (parentType[0]?.data.value === "canvas") { 48 50 useUIState 49 51 .getState() 50 52 .setFocusedBlock({ entityType: "page", entityID: parent }); 51 53 useUIState.getState().setSelectedBlocks([]); 52 54 } else { 55 + // if the page is a doc, focus the previous block (or if there isn't a prev block, focus the next block) 53 56 let siblings = 54 57 (await rep?.query((tx) => getBlocksWithType(tx, parent))) || []; 55 58 ··· 105 108 } 106 109 } 107 110 111 + // close the pages 108 112 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 113 + undoManager && undoManager.startGroup(); 114 + 115 + // delete the blocks 109 116 await Promise.all( 110 117 entities.map((entity) => 111 118 rep?.mutate.removeBlock({ ··· 113 120 }), 114 121 ), 115 122 ); 123 + undoManager && undoManager.endGroup(); 116 124 }
+3 -1
src/utils/focusBlock.ts
··· 48 48 } 49 49 50 50 if (pos?.offset !== undefined) { 51 - el?.focus(); 51 + // trying to focus the block in a subpage causes the page to flash and scroll back to the parent page. 52 + // idk how to fix so i'm giving up -- celine 53 + // el?.focus(); 52 54 requestAnimationFrame(() => { 53 55 el?.setSelectionRange(pos.offset, pos.offset); 54 56 });
+81
src/utils/moveBlock.ts
··· 1 + import { Replicache } from "replicache"; 2 + import { ReplicacheMutators } from "src/replicache"; 3 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 4 + import { useUIState } from "src/useUIState"; 5 + 6 + export const moveBlockUp = async (rep: Replicache<ReplicacheMutators>) => { 7 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 8 + if (sortedBlocks.length > 1) return; 9 + let block = sortedBlocks[0]; 10 + let previousBlock = 11 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 12 + if (previousBlock.value === block.listData?.parent) { 13 + previousBlock = 14 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 2]; 15 + } 16 + 17 + if ( 18 + previousBlock?.listData && 19 + block.listData && 20 + block.listData.depth > 1 && 21 + !previousBlock.listData.path.find( 22 + (f) => f.entity === block.listData?.parent, 23 + ) 24 + ) { 25 + let depth = block.listData.depth; 26 + let newParent = previousBlock.listData.path.find( 27 + (f) => f.depth === depth - 1, 28 + ); 29 + if (!newParent) return; 30 + if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 31 + useUIState.getState().toggleFold(newParent.entity); 32 + rep?.mutate.moveBlock({ 33 + block: block.value, 34 + oldParent: block.listData?.parent, 35 + newParent: newParent.entity, 36 + position: { type: "end" }, 37 + }); 38 + } else { 39 + rep?.mutate.moveBlockUp({ 40 + entityID: block.value, 41 + parent: block.listData?.parent || block.parent, 42 + }); 43 + } 44 + }; 45 + 46 + export const moveBlockDown = async ( 47 + rep: Replicache<ReplicacheMutators>, 48 + permission_set: string, 49 + ) => { 50 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 51 + if (sortedBlocks.length > 1) return; 52 + let block = sortedBlocks[0]; 53 + let nextBlock = siblings 54 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 55 + .find( 56 + (f) => 57 + f.listData && 58 + block.listData && 59 + !f.listData.path.find((f) => f.entity === block.value), 60 + ); 61 + if ( 62 + nextBlock?.listData && 63 + block.listData && 64 + nextBlock.listData.depth === block.listData.depth - 1 65 + ) { 66 + if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 67 + useUIState.getState().toggleFold(nextBlock.value); 68 + rep?.mutate.moveBlock({ 69 + block: block.value, 70 + oldParent: block.listData?.parent, 71 + newParent: nextBlock.value, 72 + position: { type: "first" }, 73 + }); 74 + } else { 75 + rep?.mutate.moveBlockDown({ 76 + entityID: block.value, 77 + parent: block.listData?.parent || block.parent, 78 + permission_set: permission_set, 79 + }); 80 + } 81 + };
+1 -1
src/utils/uriHelpers.ts
··· 44 44 ).toString(); 45 45 legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString(); 46 46 } 47 - return `name.eq."${nameOrRkey}"",uri.eq."${standard}"",uri.eq."${legacy}"`; 47 + return `name.eq."${nameOrRkey}"",uri.eq."${standard}",uri.eq."${legacy}"`; 48 48 }
+3
supabase/database.types.ts
··· 551 551 home_page: string 552 552 id: string 553 553 interface_state: Json | null 554 + metadata: Json | null 554 555 } 555 556 Insert: { 556 557 atp_did?: string | null ··· 559 560 home_page?: string 560 561 id?: string 561 562 interface_state?: Json | null 563 + metadata?: Json | null 562 564 } 563 565 Update: { 564 566 atp_did?: string | null ··· 567 569 home_page?: string 568 570 id?: string 569 571 interface_state?: Json | null 572 + metadata?: Json | null 570 573 } 571 574 Relationships: [ 572 575 {
+1
supabase/migrations/20260123000000_add_metadata_to_identities.sql
··· 1 + alter table "public"."identities" add column "metadata" jsonb;
+1 -1
tailwind.config.js
··· 65 65 }, 66 66 67 67 fontFamily: { 68 - sans: ["var(--theme-font, var(--font-quattro))"], 68 + sans: ["var(--font-quattro)"], 69 69 serif: ["Garamond"], 70 70 }, 71 71 },