a tool for shared writing and social publishing

Merge branch 'main' into feature/footnotes

+1506 -403
+35 -55
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 20 20 ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 21 21 const limit = 20; 22 22 23 - let query = supabaseServerClient 24 - .from("documents") 25 - .select( 26 - `*, 27 - comments_on_documents(count), 28 - document_mentions_in_bsky(count), 29 - recommends_on_documents(count), 30 - documents_in_publications(publications(*))`, 31 - ) 32 - .like("uri", `at://${did}/%`) 33 - .order("sort_date", { ascending: false }) 34 - .order("uri", { ascending: false }) 35 - .limit(limit); 23 + let [{ data: rawFeed, error }, { data: profile }] = await Promise.all([ 24 + supabaseServerClient.rpc("get_profile_posts", { 25 + p_did: did, 26 + p_cursor_sort_date: cursor?.sort_date ?? null, 27 + p_cursor_uri: cursor?.uri ?? null, 28 + p_limit: limit, 29 + }), 30 + supabaseServerClient 31 + .from("bsky_profiles") 32 + .select("handle") 33 + .eq("did", did) 34 + .single(), 35 + ]); 36 36 37 - if (cursor) { 38 - query = query.or( 39 - `sort_date.lt.${cursor.sort_date},and(sort_date.eq.${cursor.sort_date},uri.lt.${cursor.uri})`, 40 - ); 37 + if (error) { 38 + console.error("[getProfilePosts] rpc error:", error); 39 + return { posts: [], nextCursor: null }; 41 40 } 42 41 43 - let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = 44 - await Promise.all([ 45 - query, 46 - supabaseServerClient 47 - .from("publications") 48 - .select("*") 49 - .eq("identity_did", did), 50 - supabaseServerClient 51 - .from("bsky_profiles") 52 - .select("handle") 53 - .eq("did", did) 54 - .single(), 55 - ]); 56 - 57 - // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces 58 - const docs = deduplicateByUriOrdered(rawDocs || []); 59 - const pubs = deduplicateByUriOrdered(rawPubs || []); 42 + let feed = deduplicateByUriOrdered(rawFeed || []); 43 + if (feed.length === 0) return { posts: [], nextCursor: null }; 60 44 61 - // Build a map of publications for quick lookup 62 - let pubMap = new Map<string, NonNullable<typeof pubs>[number]>(); 63 - for (let pub of pubs || []) { 64 - pubMap.set(pub.uri, pub); 65 - } 66 - 67 - // Transform data to Post[] format 68 45 let handle = profile?.handle ? `@${profile.handle}` : null; 69 46 let posts: Post[] = []; 70 47 71 - for (let doc of docs || []) { 72 - // Normalize records - filter out unrecognized formats 73 - const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); 48 + for (let row of feed) { 49 + const normalizedData = normalizeDocumentRecord(row.data, row.uri); 74 50 if (!normalizedData) continue; 75 51 76 - let pubFromDoc = doc.documents_in_publications?.[0]?.publications; 77 - let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; 52 + const normalizedPubRecord = row.publication_record 53 + ? normalizePublicationRecord(row.publication_record) 54 + : null; 78 55 79 56 let post: Post = { 80 57 author: handle, 81 58 documents: { 82 59 data: normalizedData, 83 - uri: doc.uri, 84 - sort_date: doc.sort_date, 85 - comments_on_documents: doc.comments_on_documents, 86 - document_mentions_in_bsky: doc.document_mentions_in_bsky, 87 - recommends_on_documents: doc.recommends_on_documents, 60 + uri: row.uri, 61 + sort_date: row.sort_date, 62 + comments_on_documents: [{ count: Number(row.comments_count) }], 63 + document_mentions_in_bsky: [{ count: Number(row.mentions_count) }], 64 + recommends_on_documents: [{ count: Number(row.recommends_count) }], 88 65 }, 89 66 }; 90 67 91 - if (pub) { 68 + if (row.publication_uri) { 92 69 post.publication = { 93 - href: getPublicationURL(pub), 94 - pubRecord: normalizePublicationRecord(pub.record), 95 - uri: pub.uri, 70 + href: getPublicationURL({ 71 + uri: row.publication_uri, 72 + record: row.publication_record, 73 + }), 74 + pubRecord: normalizedPubRecord, 75 + uri: row.publication_uri, 96 76 }; 97 77 } 98 78
+10 -3
app/(home-pages)/reader/GlobalContent.tsx
··· 8 8 DesktopInteractionPreviewDrawer, 9 9 MobileInteractionPreviewDrawer, 10 10 } from "./InteractionDrawers"; 11 + import { useSelectedPostListing } from "src/useSelectedPostState"; 11 12 12 13 export const GlobalContent = (props: { 13 14 promise: Promise<{ posts: Post[] }>; ··· 29 30 30 31 const posts = data?.posts ?? []; 31 32 33 + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 34 + 32 35 if (posts.length === 0) { 33 36 return ( 34 37 <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> ··· 38 41 } 39 42 40 43 return ( 41 - <div className="flex flex-row gap-6 w-full"> 42 - <div className="flex flex-col gap-8 w-full"> 44 + <div className="globalReader flex flex-row gap-6 w-full"> 45 + <div className="globalPostListings flex flex-col gap-6 min-w-0 grow w-full"> 43 46 {posts.map((p) => ( 44 - <PostListing {...p} key={p.documents.uri} /> 47 + <PostListing 48 + {...p} 49 + key={p.documents.uri} 50 + selected={selectedPost?.document_uri === p.documents.uri} 51 + /> 45 52 ))} 46 53 </div> 47 54 <DesktopInteractionPreviewDrawer />
+10 -7
app/(home-pages)/reader/InboxContent.tsx
··· 8 8 import { useEffect, useRef } from "react"; 9 9 import Link from "next/link"; 10 10 import { PostListing } from "components/PostListing"; 11 - import { useHasBackgroundImage } from "components/Pages/useHasBackgroundImage"; 12 11 import { 13 12 DesktopInteractionPreviewDrawer, 14 13 MobileInteractionPreviewDrawer, 15 14 } from "./InteractionDrawers"; 15 + import { useSelectedPostListing } from "src/useSelectedPostState"; 16 16 17 17 export const InboxContent = (props: { 18 18 promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; 19 19 }) => { 20 20 const { posts, nextCursor } = use(props.promise); 21 - 22 21 const getKey = ( 23 22 pageIndex: number, 24 23 previousPageData: { ··· 46 45 ); 47 46 48 47 const loadMoreRef = useRef<HTMLDivElement>(null); 48 + 49 + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 49 50 50 51 // Set up intersection observer to load more when trigger element is visible 51 52 useEffect(() => { ··· 78 79 79 80 if (allPosts.length === 0) return <ReaderEmpty />; 80 81 81 - let hasBackgroundImage = useHasBackgroundImage(); 82 - 83 82 return ( 84 - <div className="flex flex-row gap-6 w-full "> 85 - <div className="flex flex-col gap-6 w-full relative"> 83 + <div className="inboxReader flex flex-row gap-6 w-full "> 84 + <div className="inboxPostListings flex flex-col gap-6 min-w-0 grow w-full relative"> 86 85 {sortedPosts.map((p) => ( 87 - <PostListing {...p} key={p.documents.uri} /> 86 + <PostListing 87 + {...p} 88 + key={p.documents.uri} 89 + selected={selectedPost?.document_uri === p.documents.uri} 90 + /> 88 91 ))} 89 92 {/* Trigger element for loading more posts */} 90 93 <div
+26 -18
app/(home-pages)/reader/InteractionDrawers.tsx
··· 19 19 20 20 return ( 21 21 <div 22 - className={`z-20 fixed bottom-0 left-0 right-0 border border-border-light shrink-0 w-screen h-[90vh] px-3 bg-bg-leaflet rounded-t-lg overflow-auto ${selectedPost === null ? "hidden" : "block md:hidden "}`} 22 + className={`mobileInteractionPreview shrink-0 z-20 fixed bottom-0 left-0 right-0 border border-border-light w-screen h-[90vh] px-3 bg-bg-leaflet rounded-t-lg overflow-auto ${selectedPost === null ? "hidden" : "block md:hidden "}`} 23 23 > 24 24 <PreviewDrawerContent selectedPost={selectedPost} /> 25 25 </div> ··· 31 31 32 32 return ( 33 33 <div 34 - className={`hidden md:block border border-border-light shrink-0 w-96 mr-2 px-3 h-[calc(100vh-100px)] sticky top-11 bottom-4 right-0 rounded-lg overflow-auto ${selectedPost === null ? "shadow-none border-dashed bg-transparent" : "shadow-md border-border bg-bg-page "}`} 34 + className={`desktopInteractionPreview shrink-0 hidden md:block border border-border-light w-96 mr-2 px-3 h-[calc(100vh-100px)] sticky top-11 bottom-4 right-0 rounded-lg overflow-auto ${selectedPost === null ? "shadow-none border-dashed bg-transparent" : "shadow-md border-border bg-bg-page "}`} 35 35 > 36 36 <PreviewDrawerContent selectedPost={selectedPost} /> 37 37 </div> ··· 55 55 { keepPreviousData: false }, 56 56 ); 57 57 58 - if (!props.selectedPost || !props.selectedPost.document) return null; 58 + if (!props.selectedPost || !props.selectedPost.document) 59 + return ( 60 + <div className="italic text-tertiary pt-4 text-center"> 61 + Click a post's comments or mentions to preview them here! 62 + </div> 63 + ); 59 64 60 65 const postUrl = getDocumentURL( 61 66 props.selectedPost.document, ··· 70 75 71 76 return ( 72 77 <> 73 - <div className="w-full text-sm text-tertiary flex justify-between pt-3 gap-3"> 74 - <div className="truncate min-w-0 grow">{drawerTitle}</div> 75 - <button 76 - className="text-tertiary" 77 - onClick={() => 78 - useSelectedPostListing.getState().setSelectedPostListing(null) 79 - } 80 - > 81 - <CloseTiny /> 82 - </button> 78 + <div className="sticky top-0 bg-bg-page z-10"> 79 + <div className=" w-full text-sm text-tertiary flex justify-between pt-3 gap-3"> 80 + <div className="truncate min-w-0 grow">{drawerTitle}</div> 81 + <button 82 + className="text-tertiary" 83 + onClick={() => 84 + useSelectedPostListing.getState().setSelectedPostListing(null) 85 + } 86 + > 87 + <CloseTiny /> 88 + </button> 89 + </div> 90 + <SpeedyLink className="shrink-0 flex gap-1 items-center" href={postUrl}> 91 + <ButtonPrimary fullWidth compact className="text-sm! mt-1"> 92 + See Full Post <GoToArrow /> 93 + </ButtonPrimary> 94 + </SpeedyLink> 95 + <hr className="mt-2 border-border-light" /> 83 96 </div> 84 - <SpeedyLink className="shrink-0 flex gap-1 items-center" href={postUrl}> 85 - <ButtonPrimary fullWidth compact className="text-sm! mt-1"> 86 - See Full Post <GoToArrow /> 87 - </ButtonPrimary> 88 - </SpeedyLink> 89 97 {isLoading ? ( 90 98 <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm mt-8"> 91 99 <span>loading</span>
+9 -2
app/(home-pages)/reader/NewContent.tsx
··· 10 10 DesktopInteractionPreviewDrawer, 11 11 MobileInteractionPreviewDrawer, 12 12 } from "./InteractionDrawers"; 13 + import { useSelectedPostListing } from "src/useSelectedPostState"; 13 14 14 15 export const NewContent = (props: { 15 16 promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; ··· 36 37 revalidateFirstPage: false, 37 38 }, 38 39 ); 40 + 41 + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 39 42 40 43 const loadMoreRef = useRef<HTMLDivElement>(null); 41 44 ··· 71 74 72 75 return ( 73 76 <div className="flex flex-row gap-6 w-full"> 74 - <div className="flex flex-col gap-6 w-full relative"> 77 + <div className="flex flex-col gap-6 w-full grow min-w-0 relative"> 75 78 {allPosts.map((p) => ( 76 - <PostListing {...p} key={p.documents.uri} /> 79 + <PostListing 80 + {...p} 81 + key={p.documents.uri} 82 + selected={selectedPost?.document_uri === p.documents.uri} 83 + /> 77 84 ))} 78 85 <div 79 86 ref={loadMoreRef}
+2 -3
app/(home-pages)/reader/layout.tsx
··· 12 12 13 13 const allTabs = [ 14 14 { name: "Subs", href: "/reader", requiresAuth: true }, 15 - { name: "What's Hot", href: "/reader/hot", requiresAuth: false }, 15 + { name: "Trending", href: "/reader/hot", requiresAuth: false }, 16 16 { name: "New", href: "/reader/new", requiresAuth: false }, 17 17 ]; 18 18 ··· 27 27 const tabs = allTabs.filter((tab) => !tab.requiresAuth || isLoggedIn); 28 28 29 29 const isActive = (href: string) => { 30 - if (href === "/reader") 31 - return pathname === "/reader" || pathname === "/"; 30 + if (href === "/reader") return pathname === "/reader" || pathname === "/"; 32 31 if ( 33 32 href === "/reader/hot" && 34 33 !isLoggedIn &&
+7 -1
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; 21 23 }) { 22 24 return ( 23 25 <ReplicacheProvider ··· 29 31 <EntitySetProvider 30 32 set={props.token.permission_token_rights[0].entity_set} 31 33 > 32 - <ThemeProvider entityID={props.leaflet_id}> 34 + <ThemeProvider 35 + entityID={props.leaflet_id} 36 + initialHeadingFontId={props.initialHeadingFontId} 37 + initialBodyFontId={props.initialBodyFontId} 38 + > 33 39 <ThemeBackgroundProvider entityID={props.leaflet_id}> 34 40 <UpdateLeafletTitle entityID={props.leaflet_id} /> 35 41 <AddLeafletToHomepage />
+4 -2
app/[leaflet_id]/actions/HelpButton.tsx
··· 19 19 side={isMobile ? "top" : "right"} 20 20 align={isMobile ? "center" : "start"} 21 21 asChild 22 - className="max-w-xs w-full" 22 + className="max-w-xs w-full p-0!" 23 23 trigger={<ActionButton icon={<HelpSmall />} label="About" />} 24 24 > 25 - <div className="flex flex-col text-sm gap-2 text-secondary"> 25 + <div 26 + className={`flex flex-col text-sm gap-2 p-3 text-secondary max-h-[70vh] overflow-y-auto p-2" : ""}`} 27 + > 26 28 {/* about links */} 27 29 <HelpLink text="📖 Leaflet Manual" url="https://about.leaflet.pub" /> 28 30 <HelpLink text="💡 Make with Leaflet" url="https://make.leaflet.pub" />
+23 -12
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"; 17 18 18 19 export const preferredRegion = ["sfo1"]; 19 20 export const dynamic = "force-dynamic"; ··· 48 49 getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 49 50 ]); 50 51 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 + 51 56 return ( 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> 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 + </> 64 75 ); 65 76 } 66 77
+5 -2
app/api/inngest/client.ts
··· 1 1 import { Inngest } from "inngest"; 2 - 3 2 import { EventSchemas } from "inngest"; 3 + import { Json } from "supabase/database.types"; 4 4 5 5 export type Events = { 6 6 "feeds/index-follows": { ··· 51 51 documentUris?: string[]; 52 52 }; 53 53 }; 54 - "appview/sync-document-metadata": { 54 + "appview/index-document": { 55 55 data: { 56 56 document_uri: string; 57 + document_data: Json; 57 58 bsky_post_uri?: string; 59 + publication: string | null; 60 + did: string; 58 61 }; 59 62 }; 60 63 "user/write-records-to-pds": {
+46 -14
app/api/inngest/functions/sync_document_metadata.ts app/api/inngest/functions/index_document.ts
··· 1 1 import { inngest } from "../client"; 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 - import { AtpAgent, AtUri } from "@atproto/api"; 3 + import { AtpAgent } from "@atproto/api"; 4 4 import { idResolver } from "app/(home-pages)/reader/idResolver"; 5 5 6 6 // 1m, 2m, 4m, 8m, 16m, 32m, 1h, 2h, 4h, 8h, 8h, 8h (~37h total) 7 7 const SLEEP_INTERVALS = [ 8 - "1m", "2m", "4m", "8m", "16m", "32m", "1h", "2h", "4h", "8h", "8h", "8h", 8 + "1m", 9 + "2m", 10 + "4m", 11 + "8m", 12 + "16m", 13 + "32m", 14 + "1h", 15 + "2h", 16 + "4h", 17 + "8h", 18 + "8h", 19 + "8h", 9 20 ]; 10 21 11 - export const sync_document_metadata = inngest.createFunction( 22 + export const index_document = inngest.createFunction( 12 23 { 13 - id: "sync_document_metadata_v2", 24 + id: "index_document_v2", 14 25 debounce: { 15 26 key: "event.data.document_uri", 16 27 period: "60s", ··· 18 29 }, 19 30 concurrency: [{ key: "event.data.document_uri", limit: 1 }], 20 31 }, 21 - { event: "appview/sync-document-metadata" }, 32 + { event: "appview/index-document" }, 22 33 async ({ event, step }) => { 23 - const { document_uri, bsky_post_uri } = event.data; 24 - 25 - const did = new AtUri(document_uri).host; 34 + const { document_uri, document_data, bsky_post_uri, publication, did } = 35 + event.data; 26 36 27 37 const handleResult = await step.run("resolve-handle", async () => { 28 38 const doc = await idResolver.did.resolve(did); ··· 39 49 }); 40 50 if (!handleResult) return { error: "No Handle" }; 41 51 42 - await step.run("set-indexed", async () => { 43 - return await supabaseServerClient 52 + if (handleResult.isBridgy) { 53 + return { handle: handleResult.handle, skipped: true }; 54 + } 55 + 56 + await step.run("write-document", async () => { 57 + const docResult = await supabaseServerClient 44 58 .from("documents") 45 - .update({ indexed: !handleResult.isBridgy }) 46 - .eq("uri", document_uri) 47 - .select(); 59 + .upsert({ 60 + uri: document_uri, 61 + data: document_data, 62 + indexed: true, 63 + }); 64 + if (docResult.error) console.log(docResult.error); 65 + 66 + if (publication) { 67 + const docInPubResult = await supabaseServerClient 68 + .from("documents_in_publications") 69 + .upsert({ 70 + publication, 71 + document: document_uri, 72 + }); 73 + await supabaseServerClient 74 + .from("documents_in_publications") 75 + .delete() 76 + .neq("publication", publication) 77 + .eq("document", document_uri); 78 + if (docInPubResult.error) console.log(docInPubResult.error); 79 + } 48 80 }); 49 81 50 - if (!bsky_post_uri || handleResult.isBridgy) { 82 + if (!bsky_post_uri) { 51 83 return { handle: handleResult.handle }; 52 84 } 53 85
+2 -2
app/api/inngest/route.tsx
··· 13 13 check_oauth_session, 14 14 } from "./functions/cleanup_expired_oauth_sessions"; 15 15 import { write_records_to_pds } from "./functions/write_records_to_pds"; 16 - import { sync_document_metadata } from "./functions/sync_document_metadata"; 16 + import { index_document } from "./functions/index_document"; 17 17 18 18 export const { GET, POST, PUT } = serve({ 19 19 client: inngest, ··· 29 29 cleanup_expired_oauth_sessions, 30 30 check_oauth_session, 31 31 write_records_to_pds, 32 - sync_document_metadata, 32 + index_document, 33 33 ], 34 34 });
+29 -4
app/globals.css
··· 193 193 @apply hover:underline; 194 194 } 195 195 196 - pre { 197 - font-family: var(--font-quattro); 198 - } 199 - 200 196 p { 201 197 font-size: inherit; 202 198 } ··· 204 200 ::placeholder { 205 201 @apply text-tertiary; 206 202 @apply italic; 203 + } 204 + 205 + /* Scope custom fonts to document content only (not sidebar/UI chrome) */ 206 + .pageScrollWrapper { 207 + font-family: var(--theme-font, var(--font-quattro)); 208 + font-size: var(--theme-font-base-size, 16px); 209 + } 210 + 211 + .pageScrollWrapper h1, 212 + .pageScrollWrapper h2, 213 + .pageScrollWrapper h3, 214 + .pageScrollWrapper h4 { 215 + font-family: var(--theme-heading-font, var(--theme-font, var(--font-quattro))); 216 + } 217 + 218 + /* Scale heading sizes relative to the custom base font size */ 219 + .pageScrollWrapper h1 { font-size: 2em; } 220 + .pageScrollWrapper h2 { font-size: 1.625em; } 221 + .pageScrollWrapper h3 { font-size: 1.125em; } 222 + .pageScrollWrapper h4 { font-size: 1em; } 223 + 224 + /* Scale text size classes relative to the custom base font size. 225 + Tailwind's text-sm/text-base/text-lg compile to fixed rem values 226 + which ignore the custom base size set on .pageScrollWrapper. */ 227 + .pageScrollWrapper .textSizeSmall { font-size: 0.875em; } 228 + .pageScrollWrapper .textSizeLarge { font-size: 1.125em; } 229 + 230 + .pageScrollWrapper pre { 231 + font-family: var(--theme-font, var(--font-quattro)); 207 232 } 208 233 /*END FONT STYLING*/ 209 234
+2 -2
app/layout.tsx
··· 39 39 const quattro = localFont({ 40 40 src: [ 41 41 { 42 - path: "../public/fonts/iAWriterQuattroV.ttf", 42 + path: "../public/fonts/iaw-quattro-vf.woff2", 43 43 style: "normal", 44 44 }, 45 45 { 46 - path: "../public/fonts/iAWriterQuattroV-Italic.ttf", 46 + path: "../public/fonts/iaw-quattro-vf-Italic.woff2", 47 47 style: "italic", 48 48 }, 49 49 ],
+1 -1
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
··· 111 111 <div className="grow"> 112 112 {title && ( 113 113 <div 114 - className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 114 + className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 115 115 > 116 116 <TextBlock 117 117 facets={title.facets}
+2
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 21 21 } from "src/utils/normalizeRecords"; 22 22 import { DocumentProvider } from "contexts/DocumentContext"; 23 23 import { LeafletContentProvider } from "contexts/LeafletContentContext"; 24 + import { FontLoader } from "components/FontLoader"; 24 25 import { mergePreferences } from "src/utils/mergePreferences"; 25 26 26 27 export async function DocumentPageRenderer({ ··· 122 123 return ( 123 124 <DocumentProvider value={document}> 124 125 <LeafletContentProvider value={{ pages }}> 126 + <FontLoader headingFontId={document.theme?.headingFont} bodyFontId={document.theme?.bodyFont} /> 125 127 <PublicationThemeProvider 126 128 theme={document.theme} 127 129 pub_creator={pub_creator}
+10 -9
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 33 33 import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; 34 34 import { PollData } from "./fetchPollData"; 35 35 import { ButtonPrimary } from "components/Buttons"; 36 + import { blockTextSize } from "src/utils/blockTextSize"; 36 37 import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 37 38 38 39 export function PostContent({ ··· 289 290 <div className="pt-2 pb-2 px-3 grow min-w-0"> 290 291 <div className="flex flex-col w-full min-w-0 h-full grow "> 291 292 <div 292 - className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 293 + className={`linkBlockTitle bg-transparent -mb-0.5 border-none font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 293 294 style={{ 294 295 overflow: "hidden", 295 296 textOverflow: "ellipsis", ··· 376 377 <p 377 378 className={`textBlock ${className} ${b.block.textSize === "small" ? "text-sm text-secondary" : b.block.textSize === "large" ? "text-lg" : ""}`} 378 379 {...blockProps} 380 + style={{ ...blockProps.style, fontSize: blockTextSize.p }} 379 381 > 380 382 <TextBlock 381 383 facets={b.block.facets} ··· 391 393 case PubLeafletBlocksHeader.isMain(b.block): { 392 394 if (b.block.level === 1) 393 395 return ( 394 - <h2 className={`h1Block ${className}`} {...blockProps}> 396 + <h1 className={`h1Block ${className}`} {...blockProps} style={{ ...blockProps.style, fontSize: blockTextSize.h1 }}> 395 397 <TextBlock 396 398 {...b.block} 397 399 index={index} 398 400 preview={preview} 399 401 pageId={pageId} 400 - footnoteIndexMap={footnoteIndexMap} 401 402 /> 402 - </h2> 403 + </h1> 403 404 ); 404 405 if (b.block.level === 2) 405 406 return ( 406 - <h3 className={`h2Block ${className}`} {...blockProps}> 407 + <h2 className={`h2Block ${className}`} {...blockProps} style={{ ...blockProps.style, fontSize: blockTextSize.h2 }}> 407 408 <TextBlock 408 409 {...b.block} 409 410 index={index} ··· 411 412 pageId={pageId} 412 413 footnoteIndexMap={footnoteIndexMap} 413 414 /> 414 - </h3> 415 + </h2> 415 416 ); 416 417 if (b.block.level === 3) 417 418 return ( 418 - <h4 className={`h3Block ${className}`} {...blockProps}> 419 + <h3 className={`h3Block ${className}`} {...blockProps} style={{ ...blockProps.style, fontSize: blockTextSize.h3 }}> 419 420 <TextBlock 420 421 {...b.block} 421 422 index={index} ··· 423 424 pageId={pageId} 424 425 footnoteIndexMap={footnoteIndexMap} 425 426 /> 426 - </h4> 427 + </h3> 427 428 ); 428 429 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 429 430 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 430 431 return ( 431 - <h6 className={`h6Block ${className}`} {...blockProps}> 432 + <h6 className={`h6Block ${className}`} {...blockProps} style={{ ...blockProps.style, fontSize: blockTextSize.h4 }}> 432 433 <TextBlock 433 434 {...b.block} 434 435 index={index}
+6 -5
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 12 12 PubLeafletPagesLinearDocument, 13 13 } from "lexicons/api"; 14 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 + import { blockTextSize } from "src/utils/blockTextSize"; 15 16 import { TextBlockCore, TextBlockCoreProps } from "./Blocks/TextBlockCore"; 16 17 import { StaticMathBlock } from "./Blocks/StaticMathBlock"; 17 18 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; ··· 119 120 } 120 121 case PubLeafletBlocksText.isMain(b.block): 121 122 return ( 122 - <p> 123 + <p style={{ fontSize: blockTextSize.p }}> 123 124 <StaticBaseTextBlock 124 125 facets={b.block.facets} 125 126 plaintext={b.block.plaintext} ··· 130 131 case PubLeafletBlocksHeader.isMain(b.block): { 131 132 if (b.block.level === 1) 132 133 return ( 133 - <h1> 134 + <h1 style={{ fontSize: blockTextSize.h1 }}> 134 135 <StaticBaseTextBlock {...b.block} index={[]} /> 135 136 </h1> 136 137 ); 137 138 if (b.block.level === 2) 138 139 return ( 139 - <h2> 140 + <h2 style={{ fontSize: blockTextSize.h2 }}> 140 141 <StaticBaseTextBlock {...b.block} index={[]} /> 141 142 </h2> 142 143 ); 143 144 if (b.block.level === 3) 144 145 return ( 145 - <h3> 146 + <h3 style={{ fontSize: blockTextSize.h3 }}> 146 147 <StaticBaseTextBlock {...b.block} index={[]} /> 147 148 </h3> 148 149 ); 149 150 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 150 151 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 151 152 return ( 152 - <h6> 153 + <h6 style={{ fontSize: blockTextSize.h4 }}> 153 154 <StaticBaseTextBlock {...b.block} index={[]} /> 154 155 </h6> 155 156 );
+8 -7
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
··· 27 27 onOpenChange={() => setState("menu")} 28 28 side={isMobile ? "top" : "right"} 29 29 align={isMobile ? "center" : "start"} 30 - className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} 30 + className={`flex flex-col max-w-xs w-[1000px] ${state === "theme" && "bg-white!"} pb-0!`} 31 31 arrowFill={theme.colors["border-light"]} 32 32 trigger={ 33 33 <ActionButton ··· 64 64 setLoading={setLoading} 65 65 /> 66 66 )} 67 + <div className="spacer h-2 w-full" aria-hidden /> 67 68 </Popover> 68 69 ); 69 70 } ··· 93 94 props.setState("general"); 94 95 }} 95 96 > 96 - General Settings 97 + Publication Settings 97 98 <ArrowRightTiny /> 98 99 </button> 99 100 <button 100 101 className={menuItemClassName} 101 102 type="button" 102 - onClick={() => props.setState("theme")} 103 + onClick={() => props.setState("post-options")} 103 104 > 104 - Theme and Layout 105 + Post Settings 105 106 <ArrowRightTiny /> 106 107 </button> 107 108 <button 108 109 className={menuItemClassName} 109 110 type="button" 110 - onClick={() => props.setState("post-options")} 111 + onClick={() => props.setState("theme")} 111 112 > 112 - Post Options 113 + Theme and Layout 113 114 <ArrowRightTiny /> 114 115 </button> 115 116 </div> ··· 124 125 children: React.ReactNode; 125 126 }) => { 126 127 return ( 127 - <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 128 + <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1 flex-shrink-0"> 128 129 {props.children} 129 130 {props.state !== "menu" && ( 130 131 <div className="flex gap-2">
+2 -1
app/lish/createPub/UpdatePubForm.tsx
··· 74 74 75 75 return ( 76 76 <form 77 + className="min-h-0 flex-1 flex flex-col pb-2" 77 78 onSubmit={async (e) => { 78 79 if (!pubData) return; 79 80 e.preventDefault(); ··· 104 105 > 105 106 General Settings 106 107 </PubSettingsHeader> 107 - <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2"> 108 + <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2 overflow-y-auto min-h-0"> 108 109 <div className="flex items-center justify-between gap-2 mt-2 "> 109 110 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 110 111 Logo <span className="font-normal">(optional)</span>
+4
app/lish/createPub/updatePublication.ts
··· 319 319 showPageBackground: boolean; 320 320 accentBackground: Color; 321 321 accentText: Color; 322 + headingFont?: string; 323 + bodyFont?: string; 322 324 }; 323 325 }): Promise<UpdatePublicationResult> { 324 326 return withPublicationUpdate( ··· 361 363 accentText: { 362 364 ...theme.accentText, 363 365 }, 366 + headingFont: theme.headingFont, 367 + bodyFont: theme.bodyFont, 364 368 }; 365 369 366 370 // Derive basicTheme from the theme colors for site.standard.publication
+24 -55
appview/index.ts
··· 104 104 console.log(record.error); 105 105 return; 106 106 } 107 - let docResult = await supabase.from("documents").upsert({ 108 - uri: evt.uri.toString(), 109 - data: record.value as Json, 110 - }); 111 - if (docResult.error) console.log(docResult.error); 112 - await inngest.send({ 113 - name: "appview/sync-document-metadata", 114 - data: { 115 - document_uri: evt.uri.toString(), 116 - bsky_post_uri: record.value.postRef?.uri, 117 - }, 118 - }); 107 + let publication: string | null = null; 119 108 if (record.value.publication) { 120 109 let publicationURI = new AtUri(record.value.publication); 121 - 122 110 if (publicationURI.host !== evt.uri.host) { 123 111 console.log("Unauthorized to create post!"); 124 112 return; 125 113 } 126 - let docInPublicationResult = await supabase 127 - .from("documents_in_publications") 128 - .upsert({ 129 - publication: record.value.publication, 130 - document: evt.uri.toString(), 131 - }); 132 - await supabase 133 - .from("documents_in_publications") 134 - .delete() 135 - .neq("publication", record.value.publication) 136 - .eq("document", evt.uri.toString()); 137 - 138 - if (docInPublicationResult.error) 139 - console.log(docInPublicationResult.error); 114 + publication = record.value.publication; 140 115 } 116 + await inngest.send({ 117 + name: "appview/index-document", 118 + data: { 119 + document_uri: evt.uri.toString(), 120 + document_data: record.value as Json, 121 + bsky_post_uri: record.value.postRef?.uri, 122 + publication, 123 + did: evt.did, 124 + }, 125 + }); 141 126 } 142 127 if (evt.event === "delete") { 143 128 await supabase.from("documents").delete().eq("uri", evt.uri.toString()); ··· 271 256 console.log(record.error); 272 257 return; 273 258 } 274 - let docResult = await supabase.from("documents").upsert({ 275 - uri: evt.uri.toString(), 276 - data: record.value as Json, 277 - }); 278 - if (docResult.error) console.log(docResult.error); 279 - await inngest.send({ 280 - name: "appview/sync-document-metadata", 281 - data: { 282 - document_uri: evt.uri.toString(), 283 - bsky_post_uri: record.value.bskyPostRef?.uri, 284 - }, 285 - }); 286 - 287 259 // site.standard.document uses "site" field to reference the publication 288 260 // For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey) 289 261 // For standalone documents, site is an HTTPS URL (https://leaflet.pub/p/did:plc:xxx) 290 262 // Only link to publications table for AT-URI sites 263 + let publication: string | null = null; 291 264 if (record.value.site && record.value.site.startsWith("at://")) { 292 265 let siteURI = new AtUri(record.value.site); 293 - 294 266 if (siteURI.host !== evt.uri.host) { 295 267 console.log("Unauthorized to create document in site!"); 296 268 return; 297 269 } 298 - let docInPublicationResult = await supabase 299 - .from("documents_in_publications") 300 - .upsert({ 301 - publication: record.value.site, 302 - document: evt.uri.toString(), 303 - }); 304 - await supabase 305 - .from("documents_in_publications") 306 - .delete() 307 - .neq("publication", record.value.site) 308 - .eq("document", evt.uri.toString()); 309 - 310 - if (docInPublicationResult.error) 311 - console.log(docInPublicationResult.error); 270 + publication = record.value.site; 312 271 } 272 + await inngest.send({ 273 + name: "appview/index-document", 274 + data: { 275 + document_uri: evt.uri.toString(), 276 + document_data: record.value as Json, 277 + bsky_post_uri: record.value.bskyPostRef?.uri, 278 + publication, 279 + did: evt.did, 280 + }, 281 + }); 313 282 } 314 283 if (evt.event === "delete") { 315 284 await supabase.from("documents").delete().eq("uri", evt.uri.toString());
+1 -1
components/ActionBar/Publications.tsx
··· 41 41 42 42 return ( 43 43 <div 44 - className={`pubListWrapper w-full flex flex-col sm:bg-transparent sm:border-0 ${props.className}`} 44 + className={`pubListWrapper w-full flex flex-col gap-1 sm:bg-transparent sm:border-0 ${props.className}`} 45 45 > 46 46 {hasLooseleafs && ( 47 47 <>
+1 -1
components/Blocks/ExternalLinkBlock.tsx
··· 78 78 <div className="pt-2 pb-2 px-3 grow min-w-0"> 79 79 <div className="flex flex-col w-full min-w-0 h-full grow "> 80 80 <div 81 - className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 81 + className={`linkBlockTitle bg-transparent -mb-0.5 border-none font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 82 82 style={{ 83 83 overflow: "hidden", 84 84 textOverflow: "ellipsis",
+1 -1
components/Blocks/PageLinkBlock.tsx
··· 96 96 <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip "> 97 97 {leafletMetadata[0] && ( 98 98 <div 99 - className={`pageBlockOne outline-hidden resize-none align-top flex gap-2 ${leafletMetadata[0].type === "heading" ? "font-bold text-base" : ""}`} 99 + className={`pageBlockOne outline-hidden resize-none align-top flex gap-2 ${leafletMetadata[0].type === "heading" ? "font-bold" : ""}`} 100 100 > 101 101 {leafletMetadata[0].listData && ( 102 102 <ListMarker
+27 -10
components/Blocks/TextBlock/index.tsx
··· 26 26 import { useMountProsemirror } from "./mountProsemirror"; 27 27 import { schema } from "./schema"; 28 28 import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; 29 + import { blockTextSize } from "src/utils/blockTextSize"; 29 30 30 31 import { Mention, MentionAutocomplete } from "components/Mention"; 31 32 import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 32 33 33 34 const HeadingStyle = { 34 - 1: "text-xl font-bold", 35 - 2: "text-lg font-bold", 36 - 3: "text-base font-bold text-secondary ", 35 + 1: "font-bold [font-family:var(--theme-heading-font)]", 36 + 2: "font-bold [font-family:var(--theme-heading-font)]", 37 + 3: "font-bold text-secondary [font-family:var(--theme-heading-font)]", 38 + 4: "font-bold text-secondary [font-family:var(--theme-heading-font)]", 39 + } as { [level: number]: string }; 40 + 41 + const headingFontSize = { 42 + 1: blockTextSize.h1, 43 + 2: blockTextSize.h2, 44 + 3: blockTextSize.h3, 45 + 4: blockTextSize.h4, 37 46 } as { [level: number]: string }; 38 47 39 48 export function TextBlock( ··· 128 137 }[alignment]; 129 138 let textStyle = 130 139 textSize?.data.value === "small" 131 - ? "text-sm" 140 + ? "textSizeSmall" 132 141 : textSize?.data.value === "large" 133 - ? "text-lg" 142 + ? "textSizeLarge" 134 143 : ""; 135 144 let { permissions } = useEntitySetContext(); 136 145 ··· 159 168 } 160 169 return ( 161 170 <div 162 - style={{ wordBreak: "break-word" }} // better than tailwind break-all! 171 + style={{ 172 + wordBreak: "break-word", 173 + ...(props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : {}), 174 + }} 163 175 onClick={(e) => { 164 176 let target = e.target as HTMLElement; 165 177 let footnoteRef = target.closest(".footnote-ref") as HTMLElement | null; ··· 204 216 }[alignment]; 205 217 let textStyle = 206 218 textSize?.data.value === "small" 207 - ? "text-sm text-secondary" 219 + ? "textSizeSmall text-secondary" 208 220 : textSize?.data.value === "large" 209 - ? "text-lg text-primary" 210 - : "text-base text-primary"; 221 + ? "textSizeLarge text-primary" 222 + : "text-primary"; 211 223 212 224 let editorState = useEditorStates( 213 225 (s) => s.editorStates[props.entityID], ··· 276 288 // unless we break *only* on urls, this is better than tailwind 'break-all' 277 289 // b/c break-all can cause breaks in the middle of words, but break-word still 278 290 // forces break if a single text string (e.g. a url) spans more than a full line 279 - style={{ wordBreak: "break-word" }} 291 + style={{ 292 + wordBreak: "break-word", 293 + fontFamily: props.type === "heading" ? "var(--theme-heading-font)" : "var(--theme-font)", 294 + ...(props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : {}), 295 + }} 280 296 className={` 281 297 ${alignmentClass} 282 298 grow resize-none align-top whitespace-pre-wrap bg-transparent ··· 300 316 props.nextBlock === null ? ( 301 317 // if this is the only block on the page and is empty or is a canvas, show placeholder 302 318 <div 319 + style={props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : undefined} 303 320 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col 304 321 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 305 322 `}
+1 -1
components/Blocks/TextBlock/inputRules.ts
··· 212 212 }), 213 213 214 214 //Header 215 - new InputRule(/^([#]{1,3})\s$/, (state, match) => { 215 + new InputRule(/^([#]{1,4})\s$/, (state, match) => { 216 216 let tr = state.tr; 217 217 tr.delete(0, match[0].length); 218 218 let headingLevel = match[1].length;
+5
components/Blocks/TextBlock/useHandlePaste.ts
··· 244 244 type = "heading"; 245 245 break; 246 246 } 247 + case "H4": { 248 + headingLevel = 4; 249 + type = "heading"; 250 + break; 251 + } 247 252 case "DIV": { 248 253 type = "card"; 249 254 break;
+121
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 + getFontBaseSize, 14 + } from "src/fonts"; 15 + 16 + type FontLoaderProps = { 17 + headingFontId: string | undefined; 18 + bodyFontId: string | undefined; 19 + }; 20 + 21 + export function FontLoader({ headingFontId, bodyFontId }: FontLoaderProps) { 22 + const headingFont = getFontConfig(headingFontId); 23 + const bodyFont = getFontConfig(bodyFontId); 24 + 25 + // Collect all unique fonts to load 26 + const fontsToLoad = headingFont.id === bodyFont.id 27 + ? [headingFont] 28 + : [headingFont, bodyFont]; 29 + 30 + // Collect preload links (deduplicated) 31 + const preloadLinksSet = new Set<string>(); 32 + const preloadLinks: { href: string; type: string }[] = []; 33 + for (const font of fontsToLoad) { 34 + for (const link of getFontPreloadLinks(font)) { 35 + if (!preloadLinksSet.has(link.href)) { 36 + preloadLinksSet.add(link.href); 37 + preloadLinks.push(link); 38 + } 39 + } 40 + } 41 + 42 + // Collect font-face CSS 43 + const fontFaceCSS = fontsToLoad 44 + .map((font) => generateFontFaceCSS(font)) 45 + .filter(Boolean) 46 + .join("\n\n"); 47 + 48 + // Collect Google Fonts URLs (deduplicated) 49 + const googleFontsUrls = [...new Set( 50 + fontsToLoad 51 + .map((font) => getGoogleFontsUrl(font)) 52 + .filter((url): url is string => url !== null) 53 + )]; 54 + 55 + const headingFontValue = getFontFamilyValue(headingFont); 56 + const bodyFontValue = getFontFamilyValue(bodyFont); 57 + const bodyFontBaseSize = getFontBaseSize(bodyFont); 58 + 59 + // Set font CSS variables scoped to .leafletWrapper so they don't affect app UI 60 + const fontVariableCSS = ` 61 + .leafletWrapper { 62 + --theme-heading-font: ${headingFontValue}; 63 + --theme-font: ${bodyFontValue}; 64 + --theme-font-base-size: ${bodyFontBaseSize}px; 65 + } 66 + `.trim(); 67 + 68 + return ( 69 + <> 70 + {/* 71 + Google Fonts best practice: preconnect to both origins 72 + - fonts.googleapis.com serves the CSS 73 + - fonts.gstatic.com serves the font files (needs crossorigin for CORS) 74 + Place these as early as possible in <head> 75 + */} 76 + {googleFontsUrls.length > 0 && ( 77 + <> 78 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 79 + <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> 80 + {googleFontsUrls.map((url) => ( 81 + <link key={url} rel="stylesheet" href={url} /> 82 + ))} 83 + </> 84 + )} 85 + {/* Preload local font files for early discovery */} 86 + {preloadLinks.map((link) => ( 87 + <link 88 + key={link.href} 89 + rel="preload" 90 + href={link.href} 91 + as="font" 92 + type={link.type} 93 + crossOrigin="anonymous" 94 + /> 95 + ))} 96 + {/* @font-face declarations (for local fonts) and CSS variable */} 97 + <style 98 + dangerouslySetInnerHTML={{ 99 + __html: `${fontFaceCSS}\n\n${fontVariableCSS}`, 100 + }} 101 + /> 102 + </> 103 + ); 104 + } 105 + 106 + // Helper to extract fonts from facts array (for server-side use) 107 + export function extractFontsFromFacts( 108 + facts: Array<{ entity: string; attribute: string; data: { value: string } }>, 109 + rootEntity: string 110 + ): { headingFontId: string | undefined; bodyFontId: string | undefined } { 111 + const headingFontFact = facts.find( 112 + (f) => f.entity === rootEntity && f.attribute === "theme/heading-font" 113 + ); 114 + const bodyFontFact = facts.find( 115 + (f) => f.entity === rootEntity && f.attribute === "theme/body-font" 116 + ); 117 + return { 118 + headingFontId: headingFontFact?.data?.value, 119 + bodyFontId: bodyFontFact?.data?.value, 120 + }; 121 + }
+1 -1
components/Icons/PopoverArrow.tsx
··· 11 11 height="8" 12 12 viewBox="0 0 16 8" 13 13 fill="none" 14 - className="-mt-px" 14 + className="" 15 15 xmlns="http://www.w3.org/2000/svg" 16 16 > 17 17 <path
-1
components/Popover/index.tsx
··· 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 50 - overflow-y-scroll 51 50 ${props.className} 52 51 `} 53 52 side={props.side}
+6 -7
components/PostListing.tsx
··· 13 13 14 14 import Link from "next/link"; 15 15 import { useEffect, useRef, useState } from "react"; 16 - import { InteractionPreview, TagPopover } from "./InteractionsPreview"; 16 + import { TagPopover } from "./InteractionsPreview"; 17 17 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 18 18 import { useSmoker } from "./Toast"; 19 - import { Separator } from "./Layout"; 20 19 import { CommentTiny } from "./Icons/CommentTiny"; 21 20 import { QuoteTiny } from "./Icons/QuoteTiny"; 22 21 import { ShareTiny } from "./Icons/ShareTiny"; ··· 27 26 import { RecommendButton } from "./RecommendButton"; 28 27 import { getFirstParagraph } from "src/utils/getFirstParagraph"; 29 28 30 - export const PostListing = (props: Post) => { 29 + export const PostListing = (props: Post & { selected?: boolean }) => { 31 30 let pubRecord = props.publication?.pubRecord as 32 31 | NormalizedPublication 33 32 | undefined; ··· 101 100 className={` 102 101 relative 103 102 flex flex-col overflow-hidden 104 - selected-outline border-border-light rounded-lg w-full hover:outline-accent-contrast 105 - hover:border-accent-contrast 103 + selected-outline rounded-lg w-full 104 + ${props.selected ? "outline-2 outline-offset-1 outline-accent-contrast border-accent-contrast" : "hover:outline-accent-contrast hover:border-accent-contrast border-border-light"} 106 105 ${showPageBackground ? "bg-bg-page " : "bg-bg-leaflet"} `} 107 106 style={ 108 107 hasBackgroundImage ··· 135 134 )} 136 135 <div className="postListingInfo px-3 py-2"> 137 136 {postRecord.title && ( 138 - <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base"> 137 + <h3 className="postListingTitle text-primary line-clamp-2 sm:text-lg text-base pb-0.5"> 139 138 {postRecord.title} 140 139 </h3> 141 140 )} 142 141 143 - <p className="postListingDescription text-secondary line-clamp-3 sm:text-base text-sm"> 142 + <p className="postListingDescription text-secondary line-clamp-3 leading-snug sm:text-base text-sm"> 144 143 {postRecord.description || getFirstParagraph(postRecord)} 145 144 </p> 146 145 <div className="flex flex-col-reverse gap-2 text-sm text-tertiary items-center justify-start pt-1.5 w-full">
+1
components/ThemeManager/PageThemeSetter.tsx
··· 66 66 entityID={props.entityID} 67 67 openPicker={openPicker} 68 68 setOpenPicker={(pickers) => setOpenPicker(pickers)} 69 + hideFonts 69 70 /> 70 71 </div> 71 72 <AccentPickers
+9
components/ThemeManager/Pickers/PageThemePickers.tsx
··· 21 21 import { ImageInput, ImageSettings } from "./ImagePicker"; 22 22 23 23 import { ColorPicker, thumbStyle } from "./ColorPicker"; 24 + import { FontPicker } from "./TextPickers"; 24 25 import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 25 26 import { Replicache } from "replicache"; 26 27 import { CanvasBackgroundPattern } from "components/Canvas"; ··· 31 32 entityID: string; 32 33 openPicker: pickers; 33 34 setOpenPicker: (thisPicker: pickers) => void; 35 + home?: boolean; 36 + hideFonts?: boolean; 34 37 }) => { 35 38 let { rep } = useReplicache(); 36 39 let set = useMemo(() => { ··· 57 60 openPicker={props.openPicker} 58 61 setOpenPicker={props.setOpenPicker} 59 62 /> 63 + {!props.home && !props.hideFonts && ( 64 + <> 65 + <FontPicker label="Heading" entityID={props.entityID} attribute="theme/heading-font" /> 66 + <FontPicker label="Body" entityID={props.entityID} attribute="theme/body-font" /> 67 + </> 68 + )} 60 69 </div> 61 70 ); 62 71 };
+214
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 { 13 + fonts, 14 + defaultFontId, 15 + FontConfig, 16 + isCustomFontId, 17 + parseGoogleFontInput, 18 + createCustomFontId, 19 + getFontConfig, 20 + } from "src/fonts"; 21 + 22 + export const TextColorPicker = (props: { 23 + openPicker: pickers; 24 + setOpenPicker: (thisPicker: pickers) => void; 25 + value: Color; 26 + setValue: (c: Color) => void; 27 + }) => { 28 + return ( 29 + <ColorPicker 30 + label="Text" 31 + value={props.value} 32 + setValue={props.setValue} 33 + thisPicker={"text"} 34 + openPicker={props.openPicker} 35 + setOpenPicker={props.setOpenPicker} 36 + closePicker={() => props.setOpenPicker("null")} 37 + /> 38 + ); 39 + }; 40 + 41 + type FontAttribute = "theme/heading-font" | "theme/body-font"; 42 + 43 + export const FontPicker = (props: { 44 + label: string; 45 + entityID: string; 46 + attribute: FontAttribute; 47 + }) => { 48 + let isMobile = useIsMobile(); 49 + let { rep } = useReplicache(); 50 + let [showCustomInput, setShowCustomInput] = useState(false); 51 + let [customFontValue, setCustomFontValue] = useState(""); 52 + let currentFont = useEntity(props.entityID, props.attribute); 53 + let fontId = currentFont?.data.value || defaultFontId; 54 + let font = getFontConfig(fontId); 55 + let isCustom = isCustomFontId(fontId); 56 + 57 + let fontList = Object.values(fonts).sort((a, b) => 58 + a.displayName.localeCompare(b.displayName), 59 + ); 60 + 61 + const handleCustomSubmit = () => { 62 + const parsed = parseGoogleFontInput(customFontValue); 63 + if (parsed) { 64 + const customId = createCustomFontId( 65 + parsed.fontName, 66 + parsed.googleFontsFamily, 67 + ); 68 + rep?.mutate.assertFact({ 69 + entity: props.entityID, 70 + attribute: props.attribute, 71 + data: { type: "string", value: customId }, 72 + }); 73 + setShowCustomInput(false); 74 + setCustomFontValue(""); 75 + } 76 + }; 77 + 78 + return ( 79 + <Menu 80 + asChild 81 + trigger={ 82 + <button className="flex gap-2 items-center w-full !outline-none min-w-0"> 83 + <div 84 + 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"}`} 85 + > 86 + <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 "> 87 + Aa 88 + </div> 89 + </div> 90 + <div className="font-bold shrink-0">{props.label}</div> 91 + <div className="truncate">{font.displayName}</div> 92 + </button> 93 + } 94 + side={isMobile ? "bottom" : "right"} 95 + align="start" 96 + className="w-[250px] !gap-0 !outline-none max-h-72 " 97 + > 98 + {showCustomInput ? ( 99 + <div className="p-2 flex flex-col gap-2"> 100 + <div className="text-sm text-secondary"> 101 + Paste a Google Font name 102 + </div> 103 + <Input 104 + value={customFontValue} 105 + className="w-full" 106 + placeholder="e.g. Roboto, Open Sans, Playfair Display" 107 + autoFocus 108 + onChange={(e) => setCustomFontValue(e.currentTarget.value)} 109 + onKeyDown={(e) => { 110 + if (e.key === "Enter") { 111 + e.preventDefault(); 112 + handleCustomSubmit(); 113 + } else if (e.key === "Escape") { 114 + setShowCustomInput(false); 115 + setCustomFontValue(""); 116 + } 117 + }} 118 + /> 119 + <div className="flex gap-2"> 120 + <button 121 + className="flex-1 px-2 py-1 text-sm rounded-md bg-accent-1 text-accent-2 hover:opacity-80" 122 + onClick={handleCustomSubmit} 123 + > 124 + Add Font 125 + </button> 126 + <button 127 + className="px-2 py-1 text-sm rounded-md text-secondary hover:bg-border-light" 128 + onClick={() => { 129 + setShowCustomInput(false); 130 + setCustomFontValue(""); 131 + }} 132 + > 133 + Cancel 134 + </button> 135 + </div> 136 + </div> 137 + ) : ( 138 + <div className="flex flex-col h-full overflow-auto gap-0 py-1"> 139 + {fontList.map((fontOption) => { 140 + return ( 141 + <FontOption 142 + key={fontOption.id} 143 + onSelect={() => { 144 + rep?.mutate.assertFact({ 145 + entity: props.entityID, 146 + attribute: props.attribute, 147 + data: { type: "string", value: fontOption.id }, 148 + }); 149 + }} 150 + font={fontOption} 151 + selected={fontOption.id === fontId} 152 + /> 153 + ); 154 + })} 155 + {isCustom && ( 156 + <FontOption 157 + key={fontId} 158 + onSelect={() => {}} 159 + font={font} 160 + selected={true} 161 + /> 162 + )} 163 + <hr className="mx-2 my-1 border-border" /> 164 + <DropdownMenu.Item 165 + onSelect={(e) => { 166 + e.preventDefault(); 167 + setShowCustomInput(true); 168 + }} 169 + className={` 170 + fontOption 171 + z-10 px-1 py-0.5 172 + text-left text-secondary 173 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 174 + hover:bg-border-light hover:text-secondary 175 + outline-none 176 + cursor-pointer 177 + `} 178 + > 179 + <div className="px-2 py-0 rounded-md">Custom Google Font...</div> 180 + </DropdownMenu.Item> 181 + </div> 182 + )} 183 + </Menu> 184 + ); 185 + }; 186 + 187 + const FontOption = (props: { 188 + onSelect: () => void; 189 + font: FontConfig; 190 + selected: boolean; 191 + }) => { 192 + return ( 193 + <DropdownMenu.RadioItem 194 + value={props.font.id} 195 + onSelect={props.onSelect} 196 + className={` 197 + fontOption 198 + z-10 px-1 py-0.5 199 + text-left text-secondary 200 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 201 + hover:bg-border-light hover:text-secondary 202 + outline-none 203 + cursor-pointer 204 + 205 + `} 206 + > 207 + <div 208 + className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 209 + > 210 + {props.font.displayName} 211 + </div> 212 + </DropdownMenu.RadioItem> 213 + ); 214 + };
+179
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 { 9 + fonts, 10 + defaultFontId, 11 + FontConfig, 12 + isCustomFontId, 13 + parseGoogleFontInput, 14 + createCustomFontId, 15 + getFontConfig, 16 + } from "src/fonts"; 17 + 18 + export const PubFontPicker = (props: { 19 + label: string; 20 + value: string | undefined; 21 + onChange: (fontId: string) => void; 22 + }) => { 23 + let isMobile = useIsMobile(); 24 + let [showCustomInput, setShowCustomInput] = useState(false); 25 + let [customFontValue, setCustomFontValue] = useState(""); 26 + let fontId = props.value || defaultFontId; 27 + let font = getFontConfig(fontId); 28 + let isCustom = isCustomFontId(fontId); 29 + 30 + let fontList = Object.values(fonts).sort((a, b) => 31 + a.displayName.localeCompare(b.displayName), 32 + ); 33 + 34 + const handleCustomSubmit = () => { 35 + const parsed = parseGoogleFontInput(customFontValue); 36 + if (parsed) { 37 + const customId = createCustomFontId( 38 + parsed.fontName, 39 + parsed.googleFontsFamily, 40 + ); 41 + props.onChange(customId); 42 + setShowCustomInput(false); 43 + setCustomFontValue(""); 44 + } 45 + }; 46 + 47 + return ( 48 + <Menu 49 + asChild 50 + trigger={ 51 + <button className="flex gap-2 items-center w-full !outline-none min-w-0"> 52 + <div 53 + 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"}`} 54 + > 55 + <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 "> 56 + Aa 57 + </div> 58 + </div> 59 + <div className="font-bold shrink-0">{props.label}</div> 60 + <div className="truncate">{font.displayName}</div> 61 + </button> 62 + } 63 + side={isMobile ? "bottom" : "right"} 64 + align="start" 65 + className="w-[250px] !gap-0 !outline-none max-h-72 " 66 + > 67 + {showCustomInput ? ( 68 + <div className="p-2 flex flex-col gap-2"> 69 + <div className="text-sm text-secondary"> 70 + Paste a Google Font name 71 + </div> 72 + <Input 73 + value={customFontValue} 74 + className="w-full" 75 + placeholder="e.g. Roboto, Open Sans, Playfair Display" 76 + autoFocus 77 + onChange={(e) => setCustomFontValue(e.currentTarget.value)} 78 + onKeyDown={(e) => { 79 + if (e.key === "Enter") { 80 + e.preventDefault(); 81 + handleCustomSubmit(); 82 + } else if (e.key === "Escape") { 83 + setShowCustomInput(false); 84 + setCustomFontValue(""); 85 + } 86 + }} 87 + /> 88 + <div className="flex gap-2"> 89 + <button 90 + className="flex-1 px-2 py-1 text-sm rounded-md bg-accent-1 text-accent-2 hover:opacity-80" 91 + onClick={handleCustomSubmit} 92 + > 93 + Add Font 94 + </button> 95 + <button 96 + className="px-2 py-1 text-sm rounded-md text-secondary hover:bg-border-light" 97 + onClick={() => { 98 + setShowCustomInput(false); 99 + setCustomFontValue(""); 100 + }} 101 + > 102 + Cancel 103 + </button> 104 + </div> 105 + </div> 106 + ) : ( 107 + <div className="flex flex-col h-full overflow-auto gap-0 py-1"> 108 + {fontList.map((fontOption) => { 109 + return ( 110 + <FontOption 111 + key={fontOption.id} 112 + onSelect={() => { 113 + props.onChange(fontOption.id); 114 + }} 115 + font={fontOption} 116 + selected={fontOption.id === fontId} 117 + /> 118 + ); 119 + })} 120 + {isCustom && ( 121 + <FontOption 122 + key={fontId} 123 + onSelect={() => {}} 124 + font={font} 125 + selected={true} 126 + /> 127 + )} 128 + <hr className="mx-2 my-1 border-border" /> 129 + <DropdownMenu.Item 130 + onSelect={(e) => { 131 + e.preventDefault(); 132 + setShowCustomInput(true); 133 + }} 134 + className={` 135 + fontOption 136 + z-10 px-1 py-0.5 137 + text-left text-secondary 138 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 139 + hover:bg-border-light hover:text-secondary 140 + outline-none 141 + cursor-pointer 142 + `} 143 + > 144 + <div className="px-2 py-0 rounded-md">Custom Google Font...</div> 145 + </DropdownMenu.Item> 146 + </div> 147 + )} 148 + </Menu> 149 + ); 150 + }; 151 + 152 + const FontOption = (props: { 153 + onSelect: () => void; 154 + font: FontConfig; 155 + selected: boolean; 156 + }) => { 157 + return ( 158 + <DropdownMenu.RadioItem 159 + value={props.font.id} 160 + onSelect={props.onSelect} 161 + className={` 162 + fontOption 163 + z-10 px-1 py-0.5 164 + text-left text-secondary 165 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 166 + hover:bg-border-light hover:text-secondary 167 + outline-none 168 + cursor-pointer 169 + 170 + `} 171 + > 172 + <div 173 + className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 174 + > 175 + {props.font.displayName} 176 + </div> 177 + </DropdownMenu.RadioItem> 178 + ); 179 + };
-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> */} 43 29 </div> 44 30 ); 45 31 };
+176 -151
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"; 23 24 24 25 export type ImageState = { 25 26 src: string; ··· 60 61 let [pageWidth, setPageWidth] = useState<number>( 61 62 record?.theme?.pageWidth || 624, 62 63 ); 64 + let [headingFont, setHeadingFont] = useState<string | undefined>(record?.theme?.headingFont); 65 + let [bodyFont, setBodyFont] = useState<string | undefined>(record?.theme?.bodyFont); 63 66 let pubBGImage = image?.src || null; 64 67 let leafletBGRepeat = image?.repeat || null; 65 68 let toaster = useToaster(); 66 69 67 70 return ( 68 - <BaseThemeProvider local {...localPubTheme} hasBackgroundImage={!!image}> 69 - <form 70 - onSubmit={async (e) => { 71 - e.preventDefault(); 72 - if (!pub) return; 73 - props.setLoading(true); 74 - let result = await updatePublicationTheme({ 75 - uri: pub.uri, 76 - theme: { 77 - pageBackground: ColorToRGBA(localPubTheme.bgPage), 78 - showPageBackground: showPageBackground, 79 - backgroundColor: image 80 - ? ColorToRGBA(localPubTheme.bgLeaflet) 81 - : ColorToRGB(localPubTheme.bgLeaflet), 82 - backgroundRepeat: image?.repeat, 83 - backgroundImage: image ? image.file : null, 84 - pageWidth: pageWidth, 85 - primary: ColorToRGB(localPubTheme.primary), 86 - accentBackground: ColorToRGB(localPubTheme.accent1), 87 - accentText: ColorToRGB(localPubTheme.accent2), 88 - }, 89 - }); 71 + <BaseThemeProvider 72 + local 73 + {...localPubTheme} 74 + hasBackgroundImage={!!image} 75 + className="min-h-0!" 76 + > 77 + <div className="min-h-0 flex-1 flex flex-col pb-0.5"> 78 + <form 79 + className="flex-shrink-0" 80 + onSubmit={async (e) => { 81 + e.preventDefault(); 82 + if (!pub) return; 83 + props.setLoading(true); 84 + let result = await updatePublicationTheme({ 85 + uri: pub.uri, 86 + theme: { 87 + pageBackground: ColorToRGBA(localPubTheme.bgPage), 88 + showPageBackground: showPageBackground, 89 + backgroundColor: image 90 + ? ColorToRGBA(localPubTheme.bgLeaflet) 91 + : ColorToRGB(localPubTheme.bgLeaflet), 92 + backgroundRepeat: image?.repeat, 93 + backgroundImage: image ? image.file : null, 94 + pageWidth: pageWidth, 95 + primary: ColorToRGB(localPubTheme.primary), 96 + accentBackground: ColorToRGB(localPubTheme.accent1), 97 + accentText: ColorToRGB(localPubTheme.accent2), 98 + headingFont: headingFont, 99 + bodyFont: bodyFont, 100 + }, 101 + }); 90 102 91 - if (!result.success) { 92 - props.setLoading(false); 93 - if (result.error && isOAuthSessionError(result.error)) { 94 - toaster({ 95 - content: <OAuthErrorMessage error={result.error} />, 96 - type: "error", 97 - }); 98 - } else { 99 - toaster({ 100 - content: "Failed to update theme", 101 - type: "error", 102 - }); 103 + if (!result.success) { 104 + props.setLoading(false); 105 + if (result.error && isOAuthSessionError(result.error)) { 106 + toaster({ 107 + content: <OAuthErrorMessage error={result.error} />, 108 + type: "error", 109 + }); 110 + } else { 111 + toaster({ 112 + content: "Failed to update theme", 113 + type: "error", 114 + }); 115 + } 116 + return; 103 117 } 104 - return; 105 - } 106 118 107 - mutate((pub) => { 108 - if (result.publication && pub?.publication) 109 - return { 110 - ...pub, 111 - publication: { ...pub.publication, ...result.publication }, 112 - }; 113 - return pub; 114 - }, false); 115 - props.setLoading(false); 116 - }} 117 - > 118 - <PubSettingsHeader 119 - loading={props.loading} 120 - setLoadingAction={props.setLoading} 121 - backToMenuAction={props.backToMenu} 122 - state={"theme"} 119 + mutate((pub) => { 120 + if (result.publication && pub?.publication) 121 + return { 122 + ...pub, 123 + publication: { ...pub.publication, ...result.publication }, 124 + }; 125 + return pub; 126 + }, false); 127 + props.setLoading(false); 128 + }} 123 129 > 124 - Theme and Layout 125 - </PubSettingsHeader> 126 - </form> 130 + <PubSettingsHeader 131 + loading={props.loading} 132 + setLoadingAction={props.setLoading} 133 + backToMenuAction={props.backToMenu} 134 + state={"theme"} 135 + > 136 + Theme and Layout 137 + </PubSettingsHeader> 138 + </form> 127 139 128 - <div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 mt-2 "> 129 - <PubPageWidthSetter 130 - pageWidth={pageWidth} 131 - setPageWidth={setPageWidth} 132 - thisPicker="page-width" 133 - openPicker={openPicker} 134 - setOpenPicker={setOpenPicker} 135 - /> 136 - <div className="themeBGLeaflet flex flex-col"> 140 + <div className="themeSetterContent flex flex-col w-full overflow-y-scroll min-h-0 -mb-2 pt-2 "> 141 + <PubPageWidthSetter 142 + pageWidth={pageWidth} 143 + setPageWidth={setPageWidth} 144 + thisPicker="page-width" 145 + openPicker={openPicker} 146 + setOpenPicker={setOpenPicker} 147 + /> 148 + <div className="themeBGLeaflet flex flex-col"> 149 + <div 150 + className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 151 + > 152 + <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 153 + <BackgroundPicker 154 + bgImage={image} 155 + setBgImage={setImage} 156 + backgroundColor={localPubTheme.bgLeaflet} 157 + pageBackground={localPubTheme.bgPage} 158 + setPageBackground={(color) => { 159 + setTheme((t) => ({ ...t, bgPage: color })); 160 + }} 161 + setBackgroundColor={(color) => { 162 + setTheme((t) => ({ ...t, bgLeaflet: color })); 163 + }} 164 + openPicker={openPicker} 165 + setOpenPicker={setOpenPicker} 166 + hasPageBackground={!!showPageBackground} 167 + setHasPageBackground={setShowPageBackground} 168 + /> 169 + </div> 170 + 171 + <SectionArrow 172 + fill="white" 173 + stroke="#CCCCCC" 174 + className="ml-2 -mt-[1px]" 175 + /> 176 + </div> 177 + </div> 178 + 137 179 <div 138 - className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 180 + style={{ 181 + backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 182 + backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 183 + backgroundPosition: "center", 184 + backgroundSize: !leafletBGRepeat 185 + ? "cover" 186 + : `calc(${leafletBGRepeat}px / 2 )`, 187 + }} 188 + className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 139 189 > 140 - <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 141 - <BackgroundPicker 142 - bgImage={image} 143 - setBgImage={setImage} 144 - backgroundColor={localPubTheme.bgLeaflet} 190 + <div className={`flex flex-col gap-3 z-10`}> 191 + <PagePickers 145 192 pageBackground={localPubTheme.bgPage} 193 + primary={localPubTheme.primary} 146 194 setPageBackground={(color) => { 147 195 setTheme((t) => ({ ...t, bgPage: color })); 148 196 }} 149 - setBackgroundColor={(color) => { 150 - setTheme((t) => ({ ...t, bgLeaflet: color })); 197 + setPrimary={(color) => { 198 + setTheme((t) => ({ ...t, primary: color })); 151 199 }} 152 200 openPicker={openPicker} 153 - setOpenPicker={setOpenPicker} 154 - hasPageBackground={!!showPageBackground} 155 - setHasPageBackground={setShowPageBackground} 201 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 202 + hasPageBackground={showPageBackground} 203 + /> 204 + <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"> 205 + <PubFontPicker 206 + label="Heading" 207 + value={headingFont} 208 + onChange={setHeadingFont} 209 + /> 210 + <PubFontPicker 211 + label="Body" 212 + value={bodyFont} 213 + onChange={setBodyFont} 214 + /> 215 + </div> 216 + <PubAccentPickers 217 + accent1={localPubTheme.accent1} 218 + setAccent1={(color) => { 219 + setTheme((t) => ({ ...t, accent1: color })); 220 + }} 221 + accent2={localPubTheme.accent2} 222 + setAccent2={(color) => { 223 + setTheme((t) => ({ ...t, accent2: color })); 224 + }} 225 + openPicker={openPicker} 226 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 156 227 /> 157 228 </div> 158 - 159 - <SectionArrow 160 - fill="white" 161 - stroke="#CCCCCC" 162 - className="ml-2 -mt-[1px]" 163 - /> 164 229 </div> 165 - </div> 166 - 167 - <div 168 - style={{ 169 - backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 170 - backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 171 - backgroundPosition: "center", 172 - backgroundSize: !leafletBGRepeat 173 - ? "cover" 174 - : `calc(${leafletBGRepeat}px / 2 )`, 175 - }} 176 - className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 177 - > 178 - <div className={`flex flex-col gap-3 z-10`}> 179 - <PagePickers 180 - pageBackground={localPubTheme.bgPage} 181 - primary={localPubTheme.primary} 182 - setPageBackground={(color) => { 183 - setTheme((t) => ({ ...t, bgPage: color })); 184 - }} 185 - setPrimary={(color) => { 186 - setTheme((t) => ({ ...t, primary: color })); 187 - }} 188 - openPicker={openPicker} 189 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 190 - hasPageBackground={showPageBackground} 191 - /> 192 - <PubAccentPickers 193 - accent1={localPubTheme.accent1} 194 - setAccent1={(color) => { 195 - setTheme((t) => ({ ...t, accent1: color })); 196 - }} 197 - accent2={localPubTheme.accent2} 198 - setAccent2={(color) => { 199 - setTheme((t) => ({ ...t, accent2: color })); 200 - }} 201 - openPicker={openPicker} 202 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 203 - /> 230 + <div className="flex flex-col mt-4 "> 231 + <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 232 + <div className="text-sm">Preview</div> 233 + <Separator classname="h-4!" />{" "} 234 + <button 235 + className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 236 + onClick={() => setSample("pub")} 237 + > 238 + Pub 239 + </button> 240 + <button 241 + className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 242 + onClick={() => setSample("post")} 243 + > 244 + Post 245 + </button> 246 + </div> 247 + {sample === "pub" ? ( 248 + <SamplePub 249 + pubBGImage={pubBGImage} 250 + pubBGRepeat={leafletBGRepeat} 251 + showPageBackground={showPageBackground} 252 + /> 253 + ) : ( 254 + <SamplePost 255 + pubBGImage={pubBGImage} 256 + pubBGRepeat={leafletBGRepeat} 257 + showPageBackground={showPageBackground} 258 + /> 259 + )} 204 260 </div> 205 - </div> 206 - <div className="flex flex-col mt-4 "> 207 - <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 208 - <div className="text-sm">Preview</div> 209 - <Separator classname="h-4!" />{" "} 210 - <button 211 - className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 212 - onClick={() => setSample("pub")} 213 - > 214 - Pub 215 - </button> 216 - <button 217 - className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 218 - onClick={() => setSample("post")} 219 - > 220 - Post 221 - </button> 222 - </div> 223 - {sample === "pub" ? ( 224 - <SamplePub 225 - pubBGImage={pubBGImage} 226 - pubBGRepeat={leafletBGRepeat} 227 - showPageBackground={showPageBackground} 228 - /> 229 - ) : ( 230 - <SamplePost 231 - pubBGImage={pubBGImage} 232 - pubBGRepeat={leafletBGRepeat} 233 - showPageBackground={showPageBackground} 234 - /> 235 - )} 236 261 </div> 237 262 </div> 238 263 </BaseThemeProvider>
+5
components/ThemeManager/PublicationThemeProvider.tsx
··· 145 145 let highlight2 = useColorAttribute(null, "theme/highlight-2"); 146 146 let highlight3 = useColorAttribute(null, "theme/highlight-3"); 147 147 148 + let headingFontId = theme?.headingFont; 149 + let bodyFontId = theme?.bodyFont; 150 + 148 151 return { 149 152 bgLeaflet, 150 153 bgPage, ··· 156 159 highlight3, 157 160 showPageBackground, 158 161 pageWidth, 162 + headingFontId, 163 + bodyFontId, 159 164 }; 160 165 }; 161 166
+69 -1
components/ThemeManager/ThemeProvider.tsx
··· 29 29 PublicationThemeProvider, 30 30 } from "./PublicationThemeProvider"; 31 31 import { getColorDifference } from "./themeUtils"; 32 + import { getFontConfig, getGoogleFontsUrl, getFontFamilyValue, generateFontFaceCSS, getFontBaseSize } from "src/fonts"; 32 33 33 34 // define a function to set an Aria Color to a CSS Variable in RGB 34 35 function setCSSVariableToColor( ··· 45 46 local?: boolean; 46 47 children: React.ReactNode; 47 48 className?: string; 49 + initialHeadingFontId?: string; 50 + initialBodyFontId?: string; 48 51 }) { 49 52 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 50 53 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; ··· 63 66 entityID: string | null; 64 67 local?: boolean; 65 68 children: React.ReactNode; 69 + initialHeadingFontId?: string; 70 + initialBodyFontId?: string; 66 71 }) { 67 72 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 68 73 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); ··· 83 88 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 84 89 85 90 let pageWidth = useEntity(props.entityID, "theme/page-width"); 91 + // Use initial font IDs as fallback until Replicache syncs 92 + let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value ?? props.initialHeadingFontId; 93 + let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value ?? props.initialBodyFontId; 86 94 87 95 return ( 88 96 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> ··· 100 108 showPageBackground={showPageBackground} 101 109 pageWidth={pageWidth?.data.value} 102 110 hasBackgroundImage={hasBackgroundImage} 111 + headingFontId={headingFontId} 112 + bodyFontId={bodyFontId} 103 113 > 104 114 {props.children} 105 115 </BaseThemeProvider> ··· 122 132 showPageBackground, 123 133 pageWidth, 124 134 hasBackgroundImage, 135 + headingFontId, 136 + bodyFontId, 137 + className, 125 138 children, 126 139 }: { 127 140 local?: boolean; ··· 136 149 highlight2: AriaColor; 137 150 highlight3: AriaColor; 138 151 pageWidth?: number; 152 + headingFontId?: string; 153 + bodyFontId?: string; 154 + className?: string; 139 155 children: React.ReactNode; 140 156 }) => { 141 157 // When showPageBackground is false and there's no background image, ··· 176 192 accentContrast = sortedAccents[0]; 177 193 } 178 194 195 + // Get font configs for CSS variables 196 + const headingFontConfig = getFontConfig(headingFontId); 197 + const bodyFontConfig = getFontConfig(bodyFontId); 198 + const headingFontValue = getFontFamilyValue(headingFontConfig); 199 + const bodyFontValue = getFontFamilyValue(bodyFontConfig); 200 + const bodyFontBaseSize = getFontBaseSize(bodyFontConfig); 201 + const headingGoogleFontsUrl = getGoogleFontsUrl(headingFontConfig); 202 + const bodyGoogleFontsUrl = getGoogleFontsUrl(bodyFontConfig); 203 + 204 + // Dynamically load Google Fonts when fonts change 205 + useEffect(() => { 206 + const loadGoogleFont = (url: string | null, fontFamily: string) => { 207 + if (!url) return; 208 + 209 + // Check if this font stylesheet is already in the document 210 + const existingLink = document.querySelector(`link[href="${url}"]`); 211 + if (existingLink) return; 212 + 213 + // Add preconnect hints if not present 214 + if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) { 215 + const preconnect1 = document.createElement("link"); 216 + preconnect1.rel = "preconnect"; 217 + preconnect1.href = "https://fonts.googleapis.com"; 218 + document.head.appendChild(preconnect1); 219 + 220 + const preconnect2 = document.createElement("link"); 221 + preconnect2.rel = "preconnect"; 222 + preconnect2.href = "https://fonts.gstatic.com"; 223 + preconnect2.crossOrigin = "anonymous"; 224 + document.head.appendChild(preconnect2); 225 + } 226 + 227 + // Load the Google Font stylesheet 228 + const link = document.createElement("link"); 229 + link.rel = "stylesheet"; 230 + link.href = url; 231 + document.head.appendChild(link); 232 + 233 + // Wait for the font to actually load before it gets applied 234 + if (document.fonts?.load) { 235 + document.fonts.load(`1em "${fontFamily}"`); 236 + } 237 + }; 238 + 239 + loadGoogleFont(headingGoogleFontsUrl, headingFontConfig.fontFamily); 240 + loadGoogleFont(bodyGoogleFontsUrl, bodyFontConfig.fontFamily); 241 + }, [headingGoogleFontsUrl, bodyGoogleFontsUrl, headingFontConfig.fontFamily, bodyFontConfig.fontFamily]); 242 + 179 243 useEffect(() => { 180 244 if (local) return; 181 245 let el = document.querySelector(":root") as HTMLElement; ··· 224 288 "--page-width-setting", 225 289 (pageWidth || 624).toString(), 226 290 ); 291 + 227 292 }, [ 228 293 local, 229 294 bgLeaflet, ··· 239 304 ]); 240 305 return ( 241 306 <div 242 - className="leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch " 307 + className={`leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch ${className || ""}`} 243 308 style={ 244 309 { 245 310 "--bg-leaflet": colorToString(bgLeaflet, "rgb"), ··· 258 323 "--page-width-setting": pageWidth || 624, 259 324 "--page-width-unitless": pageWidth || 624, 260 325 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 326 + "--theme-heading-font": headingFontValue, 327 + "--theme-font": bodyFontValue, 328 + "--theme-font-base-size": `${bodyFontBaseSize}px`, 261 329 } as CSSProperties 262 330 } 263 331 >
+3 -1
components/ThemeManager/ThemeSetter.tsx
··· 156 156 entityID={props.entityID} 157 157 openPicker={openPicker} 158 158 setOpenPicker={(pickers) => setOpenPicker(pickers)} 159 + home={props.home} 159 160 /> 160 161 <div className="flex flex-col -gap-[6px]"> 161 162 <div className={`flex flex-col z-10 -mb-[6px] `}> ··· 187 188 </div> 188 189 ); 189 190 }; 191 + 190 192 function WatermarkSetter(props: { entityID: string }) { 191 193 let { rep } = useReplicache(); 192 194 let checked = useEntity(props.entityID, "theme/page-leaflet-watermark"); ··· 300 302 onClick={() => { 301 303 props.setOpenPicker("text"); 302 304 }} 303 - className="cursor-pointer font-bold w-fit" 305 + className="cursor-pointer font-bold w-fit [font-family:var(--theme-heading-font)]" 304 306 > 305 307 Hello! 306 308 </p>
+2 -2
components/ThemeManager/themeDefaults.ts
··· 9 9 pageBackground: "#FDFCFA", 10 10 primary: "#272727", 11 11 accentText: "#FFFFFF", 12 - accentBackground: "#0000FF", 12 + accentBackground: "#57822B", 13 13 } as const; 14 14 15 15 // RGB color defaults (parsed from hex values above) 16 16 export const PubThemeDefaultsRGB = { 17 17 background: { r: 253, g: 252, b: 250 }, // #FDFCFA 18 18 foreground: { r: 39, g: 39, b: 39 }, // #272727 19 - accent: { r: 0, g: 0, b: 255 }, // #0000FF 19 + accent: { r: 87, g: 130, b: 43 }, // #57822B 20 20 accentForeground: { r: 255, g: 255, b: 255 }, // #FFFFFF 21 21 } as const;
+2 -2
components/ThemeManager/themeUtils.ts
··· 12 12 //everywhere else, accent-background = accent-1 and accent-text = accent-2. 13 13 // we just need to create a migration pipeline before we can change this 14 14 "theme/accent-text": "#FFFFFF", 15 - "theme/accent-background": "#0000FF", 16 - "theme/accent-contrast": "#0000FF", 15 + "theme/accent-background": "#57822B", 16 + "theme/accent-contrast": "#57822B", 17 17 }; 18 18 19 19 // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
+8
lexicons/api/lexicons.ts
··· 1995 1995 'lex:pub.leaflet.theme.color#rgb', 1996 1996 ], 1997 1997 }, 1998 + headingFont: { 1999 + type: 'string', 2000 + maxLength: 100, 2001 + }, 2002 + bodyFont: { 2003 + type: 'string', 2004 + maxLength: 100, 2005 + }, 1998 2006 }, 1999 2007 }, 2000 2008 },
+2
lexicons/api/types/pub/leaflet/publication.ts
··· 77 77 | $Typed<PubLeafletThemeColor.Rgba> 78 78 | $Typed<PubLeafletThemeColor.Rgb> 79 79 | { $type: string } 80 + headingFont?: string 81 + bodyFont?: string 80 82 } 81 83 82 84 const hashTheme = 'theme'
+8
lexicons/pub/leaflet/publication.json
··· 116 116 "pub.leaflet.theme.color#rgba", 117 117 "pub.leaflet.theme.color#rgb" 118 118 ] 119 + }, 120 + "headingFont": { 121 + "type": "string", 122 + "maxLength": 100 123 + }, 124 + "bodyFont": { 125 + "type": "string", 126 + "maxLength": 100 119 127 } 120 128 } 121 129 }
+2
lexicons/src/publication.ts
··· 50 50 showPageBackground: { type: "boolean", default: false }, 51 51 accentBackground: ColorUnion, 52 52 accentText: ColorUnion, 53 + headingFont: { type: "string", maxLength: 100 }, 54 + bodyFont: { type: "string", maxLength: 100 }, 53 55 }, 54 56 }, 55 57 },
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.

+252
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 + baseSize?: number; // base font size in px for document content 10 + } & ( 11 + | { 12 + // Self-hosted fonts with local files 13 + type: "local"; 14 + files: { 15 + path: string; 16 + style: "normal" | "italic"; 17 + weight?: string; 18 + }[]; 19 + } 20 + | { 21 + // Google Fonts loaded via CDN 22 + type: "google"; 23 + googleFontsFamily: string; // e.g., "Open+Sans:ital,wght@0,400;0,700;1,400;1,700" 24 + } 25 + | { 26 + // System fonts (no loading required) 27 + type: "system"; 28 + } 29 + ); 30 + 31 + export const fonts: Record<string, FontConfig> = { 32 + // Self-hosted variable fonts (WOFF2) 33 + quattro: { 34 + id: "quattro", 35 + displayName: "iA Writer Quattro", 36 + fontFamily: "iA Writer Quattro V", 37 + baseSize: 16, 38 + type: "local", 39 + files: [ 40 + { 41 + path: "/fonts/iaw-quattro-vf.woff2", 42 + style: "normal", 43 + weight: "400 700", 44 + }, 45 + { 46 + path: "/fonts/iaw-quattro-vf-Italic.woff2", 47 + style: "italic", 48 + weight: "400 700", 49 + }, 50 + ], 51 + fallback: ["system-ui", "sans-serif"], 52 + }, 53 + lora: { 54 + id: "lora", 55 + displayName: "Lora", 56 + fontFamily: "Lora", 57 + baseSize: 17, 58 + type: "local", 59 + files: [ 60 + { 61 + path: "/fonts/Lora-Variable.woff2", 62 + style: "normal", 63 + weight: "400 700", 64 + }, 65 + { 66 + path: "/fonts/Lora-Italic-Variable.woff2", 67 + style: "italic", 68 + weight: "400 700", 69 + }, 70 + ], 71 + fallback: ["Georgia", "serif"], 72 + }, 73 + "atkinson-hyperlegible": { 74 + id: "atkinson-hyperlegible", 75 + displayName: "Atkinson Hyperlegible", 76 + fontFamily: "Atkinson Hyperlegible Next", 77 + baseSize: 18, 78 + type: "google", 79 + googleFontsFamily: 80 + "Atkinson+Hyperlegible+Next:ital,wght@0,200..800;1,200..800", 81 + fallback: ["system-ui", "sans-serif"], 82 + }, 83 + // Additional Google Fonts - Mono 84 + "sometype-mono": { 85 + id: "sometype-mono", 86 + displayName: "Sometype Mono", 87 + fontFamily: "Sometype Mono", 88 + baseSize: 17, 89 + type: "google", 90 + googleFontsFamily: "Sometype+Mono:ital,wght@0,400;0,700;1,400;1,700", 91 + fallback: ["monospace"], 92 + }, 93 + 94 + // Additional Google Fonts - Sans 95 + montserrat: { 96 + id: "montserrat", 97 + displayName: "Montserrat", 98 + fontFamily: "Montserrat", 99 + baseSize: 17, 100 + type: "google", 101 + googleFontsFamily: "Montserrat:ital,wght@0,400;0,700;1,400;1,700", 102 + fallback: ["system-ui", "sans-serif"], 103 + }, 104 + "source-sans": { 105 + id: "source-sans", 106 + displayName: "Source Sans 3", 107 + fontFamily: "Source Sans 3", 108 + baseSize: 18, 109 + type: "google", 110 + googleFontsFamily: "Source+Sans+3:ital,wght@0,400;0,700;1,400;1,700", 111 + fallback: ["system-ui", "sans-serif"], 112 + }, 113 + }; 114 + 115 + export const defaultFontId = "quattro"; 116 + export const defaultBaseSize = 16; 117 + 118 + // Parse a Google Fonts URL or string to extract the font name and family parameter 119 + // Supports various formats: 120 + // - Full URL: https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap 121 + // - Family param: Open+Sans:ital,wght@0,400;0,700 122 + // - Just font name: Open Sans 123 + export function parseGoogleFontInput(input: string): { 124 + fontName: string; 125 + googleFontsFamily: string; 126 + } | null { 127 + const trimmed = input.trim(); 128 + if (!trimmed) return null; 129 + 130 + // Try to parse as full URL 131 + try { 132 + const url = new URL(trimmed); 133 + const family = url.searchParams.get("family"); 134 + if (family) { 135 + // Extract font name from family param (before the colon if present) 136 + const fontName = family.split(":")[0].replace(/\+/g, " "); 137 + return { fontName, googleFontsFamily: family }; 138 + } 139 + } catch { 140 + // Not a valid URL, continue with other parsing 141 + } 142 + 143 + // Check if it's a family parameter with weight/style specifiers (contains : or @) 144 + if (trimmed.includes(":") || trimmed.includes("@")) { 145 + const fontName = trimmed.split(":")[0].replace(/\+/g, " "); 146 + // Ensure plus signs are used for spaces in the family param 147 + const googleFontsFamily = trimmed.includes("+") 148 + ? trimmed 149 + : trimmed.replace(/ /g, "+"); 150 + return { fontName, googleFontsFamily }; 151 + } 152 + 153 + // Treat as just a font name - construct a basic family param with common weights 154 + const fontName = trimmed.replace(/\+/g, " "); 155 + const googleFontsFamily = `${trimmed.replace(/ /g, "+")}:wght@400;700`; 156 + return { fontName, googleFontsFamily }; 157 + } 158 + 159 + // Custom font ID format: "custom:FontName:googleFontsFamily" 160 + export function createCustomFontId( 161 + fontName: string, 162 + googleFontsFamily: string, 163 + ): string { 164 + return `custom:${fontName}:${googleFontsFamily}`; 165 + } 166 + 167 + export function isCustomFontId(fontId: string): boolean { 168 + return fontId.startsWith("custom:"); 169 + } 170 + 171 + export function parseCustomFontId(fontId: string): { 172 + fontName: string; 173 + googleFontsFamily: string; 174 + } | null { 175 + if (!isCustomFontId(fontId)) return null; 176 + const parts = fontId.slice("custom:".length).split(":"); 177 + if (parts.length < 2) return null; 178 + const fontName = parts[0]; 179 + const googleFontsFamily = parts.slice(1).join(":"); 180 + return { fontName, googleFontsFamily }; 181 + } 182 + 183 + export function getFontConfig(fontId: string | undefined): FontConfig { 184 + if (!fontId) return fonts[defaultFontId]; 185 + 186 + // Check for custom font 187 + if (isCustomFontId(fontId)) { 188 + const parsed = parseCustomFontId(fontId); 189 + if (parsed) { 190 + return { 191 + id: fontId, 192 + displayName: parsed.fontName, 193 + fontFamily: parsed.fontName, 194 + type: "google", 195 + googleFontsFamily: parsed.googleFontsFamily, 196 + fallback: ["system-ui", "sans-serif"], 197 + }; 198 + } 199 + } 200 + 201 + return fonts[fontId] || fonts[defaultFontId]; 202 + } 203 + 204 + // Generate @font-face CSS for a local font 205 + export function generateFontFaceCSS(font: FontConfig): string { 206 + if (font.type !== "local") return ""; 207 + return font.files 208 + .map((file) => { 209 + const format = file.path.endsWith(".woff2") ? "woff2" : "truetype"; 210 + return ` 211 + @font-face { 212 + font-family: '${font.fontFamily}'; 213 + src: url('${file.path}') format('${format}'); 214 + font-style: ${file.style}; 215 + font-weight: ${file.weight || "normal"}; 216 + font-display: swap; 217 + }`.trim(); 218 + }) 219 + .join("\n\n"); 220 + } 221 + 222 + // Generate preload link attributes for a local font 223 + export function getFontPreloadLinks( 224 + font: FontConfig, 225 + ): { href: string; type: string }[] { 226 + if (font.type !== "local") return []; 227 + return font.files.map((file) => ({ 228 + href: file.path, 229 + type: file.path.endsWith(".woff2") ? "font/woff2" : "font/ttf", 230 + })); 231 + } 232 + 233 + // Get Google Fonts URL for a font 234 + // Using display=swap per Google's recommendation: shows fallback immediately, swaps when ready 235 + // This is better UX than blocking text rendering (display=block) 236 + export function getGoogleFontsUrl(font: FontConfig): string | null { 237 + if (font.type !== "google") return null; 238 + return `https://fonts.googleapis.com/css2?family=${font.googleFontsFamily}&display=swap`; 239 + } 240 + 241 + // Get the base font size for a font config 242 + export function getFontBaseSize(font: FontConfig): number { 243 + return font.baseSize ?? defaultBaseSize; 244 + } 245 + 246 + // Get the CSS font-family value with fallbacks 247 + export function getFontFamilyValue(font: FontConfig): string { 248 + const family = font.fontFamily.includes(" ") 249 + ? `'${font.fontFamily}'` 250 + : font.fontFamily; 251 + return [family, ...font.fallback].join(", "); 252 + }
+5 -1
src/replicache/attributes.ts
··· 203 203 } as const; 204 204 205 205 export const ThemeAttributes = { 206 - "theme/font": { 206 + "theme/heading-font": { 207 + type: "string", 208 + cardinality: "one", 209 + }, 210 + "theme/body-font": { 207 211 type: "string", 208 212 cardinality: "one", 209 213 },
+3 -2
src/undoManager.ts
··· 31 31 undo: () => undoManager.undo(), 32 32 redo: () => undoManager.redo(), 33 33 withUndoGroup: <T>(cb: () => T) => { 34 - if (!isGrouping) um.startGroup(); 34 + const wasGrouping = isGrouping; 35 + if (!wasGrouping) um.startGroup(); 35 36 const r = cb(); 36 - if (!isGrouping) um.endGroup(); 37 + if (!wasGrouping) um.endGroup(); 37 38 return r; 38 39 }, 39 40 };
+7
src/utils/blockTextSize.ts
··· 1 + export const blockTextSize = { 2 + p: "1em", 3 + h1: "2em", 4 + h2: "1.5em", 5 + h3: "1.25em", 6 + h4: "1.125em", 7 + } as const;
+19
supabase/database.types.ts
··· 1360 1360 like: unknown 1361 1361 }[] 1362 1362 } 1363 + get_profile_posts: { 1364 + Args: { 1365 + p_did: string 1366 + p_cursor_sort_date?: string | null 1367 + p_cursor_uri?: string | null 1368 + p_limit?: number 1369 + } 1370 + Returns: { 1371 + uri: string 1372 + data: Json 1373 + sort_date: string 1374 + comments_count: number 1375 + mentions_count: number 1376 + recommends_count: number 1377 + publication_uri: string 1378 + publication_record: Json 1379 + publication_name: string 1380 + }[] 1381 + } 1363 1382 get_reader_feed: { 1364 1383 Args: { 1365 1384 p_identity: string
+46
supabase/migrations/20260305000000_add_get_profile_posts_function.sql
··· 1 + CREATE OR REPLACE FUNCTION get_profile_posts( 2 + p_did text, 3 + p_cursor_sort_date timestamptz DEFAULT NULL, 4 + p_cursor_uri text DEFAULT NULL, 5 + p_limit int DEFAULT 20 6 + ) 7 + RETURNS TABLE ( 8 + uri text, 9 + data jsonb, 10 + sort_date timestamptz, 11 + comments_count bigint, 12 + mentions_count bigint, 13 + recommends_count bigint, 14 + publication_uri text, 15 + publication_record jsonb, 16 + publication_name text 17 + ) 18 + LANGUAGE sql STABLE 19 + AS $$ 20 + SELECT 21 + d.uri, 22 + d.data, 23 + d.sort_date, 24 + (SELECT count(*) FROM comments_on_documents c WHERE c.document = d.uri), 25 + (SELECT count(*) FROM document_mentions_in_bsky m WHERE m.document = d.uri), 26 + (SELECT count(*) FROM recommends_on_documents r WHERE r.document = d.uri), 27 + pub.uri, 28 + pub.record, 29 + pub.name 30 + FROM documents d 31 + LEFT JOIN LATERAL ( 32 + SELECT p.uri, p.record, p.name 33 + FROM documents_in_publications dip 34 + JOIN publications p ON p.uri = dip.publication 35 + WHERE dip.document = d.uri 36 + LIMIT 1 37 + ) pub ON true 38 + WHERE d.uri LIKE 'at://' || p_did || '/%' 39 + AND ( 40 + p_cursor_sort_date IS NULL 41 + OR d.sort_date < p_cursor_sort_date 42 + OR (d.sort_date = p_cursor_sort_date AND d.uri < p_cursor_uri) 43 + ) 44 + ORDER BY d.sort_date DESC, d.uri DESC 45 + LIMIT p_limit; 46 + $$;
+62
supabase/migrations/20260305100000_fix_profile_posts_performance.sql
··· 1 + -- Add missing indexes on document foreign keys used by get_profile_posts 2 + CREATE INDEX CONCURRENTLY IF NOT EXISTS comments_on_documents_document_idx 3 + ON public.comments_on_documents (document); 4 + 5 + CREATE INDEX CONCURRENTLY IF NOT EXISTS document_mentions_in_bsky_document_idx 6 + ON public.document_mentions_in_bsky (document); 7 + 8 + CREATE INDEX CONCURRENTLY IF NOT EXISTS documents_in_publications_document_idx 9 + ON public.documents_in_publications (document); 10 + 11 + -- Expression index to look up documents by DID without adding a column 12 + -- at://did:plc:xxx/collection/rkey -> split_part gives did:plc:xxx 13 + CREATE INDEX CONCURRENTLY IF NOT EXISTS documents_identity_did_sort_idx 14 + ON public.documents (split_part(uri, '/', 3), sort_date DESC, uri DESC); 15 + 16 + -- Rewrite get_profile_posts to use the expression index 17 + CREATE OR REPLACE FUNCTION get_profile_posts( 18 + p_did text, 19 + p_cursor_sort_date timestamptz DEFAULT NULL, 20 + p_cursor_uri text DEFAULT NULL, 21 + p_limit int DEFAULT 20 22 + ) 23 + RETURNS TABLE ( 24 + uri text, 25 + data jsonb, 26 + sort_date timestamptz, 27 + comments_count bigint, 28 + mentions_count bigint, 29 + recommends_count bigint, 30 + publication_uri text, 31 + publication_record jsonb, 32 + publication_name text 33 + ) 34 + LANGUAGE sql STABLE 35 + AS $$ 36 + SELECT 37 + d.uri, 38 + d.data, 39 + d.sort_date, 40 + (SELECT count(*) FROM comments_on_documents c WHERE c.document = d.uri), 41 + (SELECT count(*) FROM document_mentions_in_bsky m WHERE m.document = d.uri), 42 + (SELECT count(*) FROM recommends_on_documents r WHERE r.document = d.uri), 43 + pub.uri, 44 + pub.record, 45 + pub.name 46 + FROM documents d 47 + LEFT JOIN LATERAL ( 48 + SELECT p.uri, p.record, p.name 49 + FROM documents_in_publications dip 50 + JOIN publications p ON p.uri = dip.publication 51 + WHERE dip.document = d.uri 52 + LIMIT 1 53 + ) pub ON true 54 + WHERE split_part(d.uri, '/', 3) = p_did 55 + AND ( 56 + p_cursor_sort_date IS NULL 57 + OR d.sort_date < p_cursor_sort_date 58 + OR (d.sort_date = p_cursor_sort_date AND d.uri < p_cursor_uri) 59 + ) 60 + ORDER BY d.sort_date DESC, d.uri DESC 61 + LIMIT p_limit; 62 + $$;
+1 -1
tailwind.config.js
··· 65 65 }, 66 66 67 67 fontFamily: { 68 - sans: ["var(--font-quattro)"], 68 + sans: ["var(--theme-font, var(--font-quattro))"], 69 69 serif: ["Garamond"], 70 70 }, 71 71 },