From 40e42a84f5202bba88e0548d487181d3a398b177 Mon Sep 17 00:00:00 2001 From: celine Date: Tue, 3 Mar 2026 16:40:23 -0500 Subject: [PATCH 1/9] adding a missing gap in the publication button list --- components/ActionBar/Publications.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ActionBar/Publications.tsx b/components/ActionBar/Publications.tsx index 7fae0df9..ad44ea6b 100644 --- a/components/ActionBar/Publications.tsx +++ b/components/ActionBar/Publications.tsx @@ -41,7 +41,7 @@ export const PublicationButtons = (props: { return (
{hasLooseleafs && ( <> -- 2.51.2 From 52258dbcb1fa05286d35feb072687f827024afba Mon Sep 17 00:00:00 2001 From: celine Date: Tue, 3 Mar 2026 18:23:13 -0500 Subject: [PATCH 2/9] adjusting some issues in popover --- app/[leaflet_id]/actions/HelpButton.tsx | 6 +- .../settings/PublicationSettings.tsx | 15 +- app/lish/createPub/UpdatePubForm.tsx | 3 +- components/Icons/PopoverArrow.tsx | 2 +- components/Popover/index.tsx | 1 - components/ThemeManager/PubThemeSetter.tsx | 310 +++++++++--------- components/ThemeManager/ThemeProvider.tsx | 4 +- 7 files changed, 177 insertions(+), 164 deletions(-) diff --git a/app/[leaflet_id]/actions/HelpButton.tsx b/app/[leaflet_id]/actions/HelpButton.tsx index 12877a6a..aeab5ce5 100644 --- a/app/[leaflet_id]/actions/HelpButton.tsx +++ b/app/[leaflet_id]/actions/HelpButton.tsx @@ -19,10 +19,12 @@ export const HelpButton = (props: { noShortcuts?: boolean }) => { side={isMobile ? "top" : "right"} align={isMobile ? "center" : "start"} asChild - className="max-w-xs w-full" + className="max-w-xs w-full p-0!" trigger={} label="About" />} > -
+
{/* about links */} diff --git a/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx b/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx index 92f72613..73cd71a6 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx @@ -27,7 +27,7 @@ export function PublicationSettingsButton(props: { publication: string }) { onOpenChange={() => setState("menu")} side={isMobile ? "top" : "right"} align={isMobile ? "center" : "start"} - className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} + className={`flex flex-col max-w-xs w-[1000px] ${state === "theme" && "bg-white!"} pb-0!`} arrowFill={theme.colors["border-light"]} trigger={ )} +
); } @@ -93,23 +94,23 @@ const PubSettingsMenu = (props: { props.setState("general"); }} > - General Settings + Publication Settings
@@ -124,7 +125,7 @@ export const PubSettingsHeader = (props: { children: React.ReactNode; }) => { return ( -
+
{props.children} {props.state !== "menu" && (
diff --git a/app/lish/createPub/UpdatePubForm.tsx b/app/lish/createPub/UpdatePubForm.tsx index f9844796..c7301c5c 100644 --- a/app/lish/createPub/UpdatePubForm.tsx +++ b/app/lish/createPub/UpdatePubForm.tsx @@ -74,6 +74,7 @@ export const EditPubForm = (props: { return (
{ if (!pubData) return; e.preventDefault(); @@ -104,7 +105,7 @@ export const EditPubForm = (props: { > General Settings -
+

Logo (optional) diff --git a/components/Icons/PopoverArrow.tsx b/components/Icons/PopoverArrow.tsx index 25b77aca..82bfee19 100644 --- a/components/Icons/PopoverArrow.tsx +++ b/components/Icons/PopoverArrow.tsx @@ -11,7 +11,7 @@ export const PopoverArrow = ( height="8" viewBox="0 0 16 8" fill="none" - className="-mt-px" + className="" xmlns="http://www.w3.org/2000/svg" > - { - e.preventDefault(); - if (!pub) return; - props.setLoading(true); - let result = await updatePublicationTheme({ - uri: pub.uri, - theme: { - pageBackground: ColorToRGBA(localPubTheme.bgPage), - showPageBackground: showPageBackground, - backgroundColor: image - ? ColorToRGBA(localPubTheme.bgLeaflet) - : ColorToRGB(localPubTheme.bgLeaflet), - backgroundRepeat: image?.repeat, - backgroundImage: image ? image.file : null, - pageWidth: pageWidth, - primary: ColorToRGB(localPubTheme.primary), - accentBackground: ColorToRGB(localPubTheme.accent1), - accentText: ColorToRGB(localPubTheme.accent2), - }, - }); + +

+ { + e.preventDefault(); + if (!pub) return; + props.setLoading(true); + let result = await updatePublicationTheme({ + uri: pub.uri, + theme: { + pageBackground: ColorToRGBA(localPubTheme.bgPage), + showPageBackground: showPageBackground, + backgroundColor: image + ? ColorToRGBA(localPubTheme.bgLeaflet) + : ColorToRGB(localPubTheme.bgLeaflet), + backgroundRepeat: image?.repeat, + backgroundImage: image ? image.file : null, + pageWidth: pageWidth, + primary: ColorToRGB(localPubTheme.primary), + accentBackground: ColorToRGB(localPubTheme.accent1), + accentText: ColorToRGB(localPubTheme.accent2), + }, + }); - if (!result.success) { - props.setLoading(false); - if (result.error && isOAuthSessionError(result.error)) { - toaster({ - content: , - type: "error", - }); - } else { - toaster({ - content: "Failed to update theme", - type: "error", - }); + if (!result.success) { + props.setLoading(false); + if (result.error && isOAuthSessionError(result.error)) { + toaster({ + content: , + type: "error", + }); + } else { + toaster({ + content: "Failed to update theme", + type: "error", + }); + } + return; } - return; - } - mutate((pub) => { - if (result.publication && pub?.publication) - return { - ...pub, - publication: { ...pub.publication, ...result.publication }, - }; - return pub; - }, false); - props.setLoading(false); - }} - > - { + if (result.publication && pub?.publication) + return { + ...pub, + publication: { ...pub.publication, ...result.publication }, + }; + return pub; + }, false); + props.setLoading(false); + }} > - Theme and Layout - - + + Theme and Layout + + + +
+ +
+
+
+ { + setTheme((t) => ({ ...t, bgPage: color })); + }} + setBackgroundColor={(color) => { + setTheme((t) => ({ ...t, bgLeaflet: color })); + }} + openPicker={openPicker} + setOpenPicker={setOpenPicker} + hasPageBackground={!!showPageBackground} + setHasPageBackground={setShowPageBackground} + /> +
+ + +
+
-
- -
-
- + { setTheme((t) => ({ ...t, bgPage: color })); }} - setBackgroundColor={(color) => { - setTheme((t) => ({ ...t, bgLeaflet: color })); + setPrimary={(color) => { + setTheme((t) => ({ ...t, primary: color })); + }} + openPicker={openPicker} + setOpenPicker={(pickers) => setOpenPicker(pickers)} + hasPageBackground={showPageBackground} + /> + { + setTheme((t) => ({ ...t, accent1: color })); + }} + accent2={localPubTheme.accent2} + setAccent2={(color) => { + setTheme((t) => ({ ...t, accent2: color })); }} openPicker={openPicker} - setOpenPicker={setOpenPicker} - hasPageBackground={!!showPageBackground} - setHasPageBackground={setShowPageBackground} + setOpenPicker={(pickers) => setOpenPicker(pickers)} />
- -
-
- -
-
- { - setTheme((t) => ({ ...t, bgPage: color })); - }} - setPrimary={(color) => { - setTheme((t) => ({ ...t, primary: color })); - }} - openPicker={openPicker} - setOpenPicker={(pickers) => setOpenPicker(pickers)} - hasPageBackground={showPageBackground} - /> - { - setTheme((t) => ({ ...t, accent1: color })); - }} - accent2={localPubTheme.accent2} - setAccent2={(color) => { - setTheme((t) => ({ ...t, accent2: color })); - }} - openPicker={openPicker} - setOpenPicker={(pickers) => setOpenPicker(pickers)} - /> -
-
-
-
-
Preview
- {" "} - - +
+
+
Preview
+ {" "} + + +
+ {sample === "pub" ? ( + + ) : ( + + )}
- {sample === "pub" ? ( - - ) : ( - - )}
diff --git a/components/ThemeManager/ThemeProvider.tsx b/components/ThemeManager/ThemeProvider.tsx index b68e51b6..40d0e6ea 100644 --- a/components/ThemeManager/ThemeProvider.tsx +++ b/components/ThemeManager/ThemeProvider.tsx @@ -122,6 +122,7 @@ export const BaseThemeProvider = ({ showPageBackground, pageWidth, hasBackgroundImage, + className, children, }: { local?: boolean; @@ -136,6 +137,7 @@ export const BaseThemeProvider = ({ highlight2: AriaColor; highlight3: AriaColor; pageWidth?: number; + className?: string; children: React.ReactNode; }) => { // When showPageBackground is false and there's no background image, @@ -239,7 +241,7 @@ export const BaseThemeProvider = ({ ]); return (
Date: Thu, 5 Mar 2026 16:50:42 -0500 Subject: [PATCH 3/9] add get profile posts rpc --- .../p/[didOrHandle]/getProfilePosts.ts | 92 ++++++++----------- supabase/database.types.ts | 19 ++++ ...5000000_add_get_profile_posts_function.sql | 46 ++++++++++ 3 files changed, 101 insertions(+), 56 deletions(-) create mode 100644 supabase/migrations/20260305000000_add_get_profile_posts_function.sql diff --git a/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts b/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts index 92ca1bf6..07ab25b7 100644 --- a/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts +++ b/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts @@ -20,79 +20,59 @@ export async function getProfilePosts( ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { const limit = 20; - let query = supabaseServerClient - .from("documents") - .select( - `*, - comments_on_documents(count), - document_mentions_in_bsky(count), - recommends_on_documents(count), - documents_in_publications(publications(*))`, - ) - .like("uri", `at://${did}/%`) - .order("sort_date", { ascending: false }) - .order("uri", { ascending: false }) - .limit(limit); - - if (cursor) { - query = query.or( - `sort_date.lt.${cursor.sort_date},and(sort_date.eq.${cursor.sort_date},uri.lt.${cursor.uri})`, - ); + let [{ data: rawFeed, error }, { data: profile }] = await Promise.all([ + supabaseServerClient.rpc("get_profile_posts", { + p_did: did, + p_cursor_sort_date: cursor?.sort_date ?? null, + p_cursor_uri: cursor?.uri ?? null, + p_limit: limit, + }), + supabaseServerClient + .from("bsky_profiles") + .select("handle") + .eq("did", did) + .single(), + ]); + + if (error) { + console.error("[getProfilePosts] rpc error:", error); + return { posts: [], nextCursor: null }; } - let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = - await Promise.all([ - query, - supabaseServerClient - .from("publications") - .select("*") - .eq("identity_did", did), - supabaseServerClient - .from("bsky_profiles") - .select("handle") - .eq("did", did) - .single(), - ]); - - // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces - const docs = deduplicateByUriOrdered(rawDocs || []); - const pubs = deduplicateByUriOrdered(rawPubs || []); + let feed = deduplicateByUriOrdered(rawFeed || []); + if (feed.length === 0) return { posts: [], nextCursor: null }; - // Build a map of publications for quick lookup - let pubMap = new Map[number]>(); - for (let pub of pubs || []) { - pubMap.set(pub.uri, pub); - } - - // Transform data to Post[] format let handle = profile?.handle ? `@${profile.handle}` : null; let posts: Post[] = []; - for (let doc of docs || []) { - // Normalize records - filter out unrecognized formats - const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); + for (let row of feed) { + const normalizedData = normalizeDocumentRecord(row.data, row.uri); if (!normalizedData) continue; - let pubFromDoc = doc.documents_in_publications?.[0]?.publications; - let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; + const normalizedPubRecord = row.publication_record + ? normalizePublicationRecord(row.publication_record) + : null; let post: Post = { author: handle, documents: { data: normalizedData, - uri: doc.uri, - sort_date: doc.sort_date, - comments_on_documents: doc.comments_on_documents, - document_mentions_in_bsky: doc.document_mentions_in_bsky, - recommends_on_documents: doc.recommends_on_documents, + uri: row.uri, + sort_date: row.sort_date, + comments_on_documents: [{ count: Number(row.comments_count) }], + document_mentions_in_bsky: [{ count: Number(row.mentions_count) }], + recommends_on_documents: [{ count: Number(row.recommends_count) }], }, }; - if (pub) { + if (row.publication_uri) { post.publication = { - href: getPublicationURL(pub), - pubRecord: normalizePublicationRecord(pub.record), - uri: pub.uri, + href: getPublicationURL({ + uri: row.publication_uri, + record: row.publication_record, + }), + pubRecord: normalizedPubRecord, + uri: row.publication_uri, }; } diff --git a/supabase/database.types.ts b/supabase/database.types.ts index dfd01d43..78767dc4 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -1360,6 +1360,25 @@ export type Database = { like: unknown }[] } + get_profile_posts: { + Args: { + p_did: string + p_cursor_sort_date?: string | null + p_cursor_uri?: string | null + p_limit?: number + } + Returns: { + uri: string + data: Json + sort_date: string + comments_count: number + mentions_count: number + recommends_count: number + publication_uri: string + publication_record: Json + publication_name: string + }[] + } get_reader_feed: { Args: { p_identity: string diff --git a/supabase/migrations/20260305000000_add_get_profile_posts_function.sql b/supabase/migrations/20260305000000_add_get_profile_posts_function.sql new file mode 100644 index 00000000..71e5f1bf --- /dev/null +++ b/supabase/migrations/20260305000000_add_get_profile_posts_function.sql @@ -0,0 +1,46 @@ +CREATE OR REPLACE FUNCTION get_profile_posts( + p_did text, + p_cursor_sort_date timestamptz DEFAULT NULL, + p_cursor_uri text DEFAULT NULL, + p_limit int DEFAULT 20 +) +RETURNS TABLE ( + uri text, + data jsonb, + sort_date timestamptz, + comments_count bigint, + mentions_count bigint, + recommends_count bigint, + publication_uri text, + publication_record jsonb, + publication_name text +) +LANGUAGE sql STABLE +AS $$ + SELECT + d.uri, + d.data, + d.sort_date, + (SELECT count(*) FROM comments_on_documents c WHERE c.document = d.uri), + (SELECT count(*) FROM document_mentions_in_bsky m WHERE m.document = d.uri), + (SELECT count(*) FROM recommends_on_documents r WHERE r.document = d.uri), + pub.uri, + pub.record, + pub.name + FROM documents d + LEFT JOIN LATERAL ( + SELECT p.uri, p.record, p.name + FROM documents_in_publications dip + JOIN publications p ON p.uri = dip.publication + WHERE dip.document = d.uri + LIMIT 1 + ) pub ON true + WHERE d.uri LIKE 'at://' || p_did || '/%' + AND ( + p_cursor_sort_date IS NULL + OR d.sort_date < p_cursor_sort_date + OR (d.sort_date = p_cursor_sort_date AND d.uri < p_cursor_uri) + ) + ORDER BY d.sort_date DESC, d.uri DESC + LIMIT p_limit; +$$; -- 2.51.2 From 3a1d56594c5fd4fa2e409252eaa53cfa1b809a86 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 5 Mar 2026 17:02:45 -0500 Subject: [PATCH 4/9] make profile doc function faster --- ...05100000_fix_profile_posts_performance.sql | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 supabase/migrations/20260305100000_fix_profile_posts_performance.sql diff --git a/supabase/migrations/20260305100000_fix_profile_posts_performance.sql b/supabase/migrations/20260305100000_fix_profile_posts_performance.sql new file mode 100644 index 00000000..5407ba38 --- /dev/null +++ b/supabase/migrations/20260305100000_fix_profile_posts_performance.sql @@ -0,0 +1,66 @@ +-- Add missing indexes on document foreign keys used by get_profile_posts +CREATE INDEX IF NOT EXISTS comments_on_documents_document_idx + ON public.comments_on_documents (document); + +CREATE INDEX IF NOT EXISTS document_mentions_in_bsky_document_idx + ON public.document_mentions_in_bsky (document); + +CREATE INDEX IF NOT EXISTS documents_in_publications_document_idx + ON public.documents_in_publications (document); + +-- Add a stored generated column to extract the DID from at:// URIs +-- at://did:plc:xxx/collection/rkey -> did:plc:xxx +ALTER TABLE public.documents + ADD COLUMN identity_did text GENERATED ALWAYS AS (split_part(uri, '/', 3)) STORED; + +-- Composite index for efficient profile post lookups: filter by DID, order by sort_date +CREATE INDEX documents_identity_did_sort_idx + ON public.documents (identity_did, sort_date DESC, uri DESC); + +-- Rewrite get_profile_posts to use the new identity_did column +CREATE OR REPLACE FUNCTION get_profile_posts( + p_did text, + p_cursor_sort_date timestamptz DEFAULT NULL, + p_cursor_uri text DEFAULT NULL, + p_limit int DEFAULT 20 +) +RETURNS TABLE ( + uri text, + data jsonb, + sort_date timestamptz, + comments_count bigint, + mentions_count bigint, + recommends_count bigint, + publication_uri text, + publication_record jsonb, + publication_name text +) +LANGUAGE sql STABLE +AS $$ + SELECT + d.uri, + d.data, + d.sort_date, + (SELECT count(*) FROM comments_on_documents c WHERE c.document = d.uri), + (SELECT count(*) FROM document_mentions_in_bsky m WHERE m.document = d.uri), + (SELECT count(*) FROM recommends_on_documents r WHERE r.document = d.uri), + pub.uri, + pub.record, + pub.name + FROM documents d + LEFT JOIN LATERAL ( + SELECT p.uri, p.record, p.name + FROM documents_in_publications dip + JOIN publications p ON p.uri = dip.publication + WHERE dip.document = d.uri + LIMIT 1 + ) pub ON true + WHERE d.identity_did = p_did + AND ( + p_cursor_sort_date IS NULL + OR d.sort_date < p_cursor_sort_date + OR (d.sort_date = p_cursor_sort_date AND d.uri < p_cursor_uri) + ) + ORDER BY d.sort_date DESC, d.uri DESC + LIMIT p_limit; +$$; -- 2.51.2 From 37fa344c4d3acedf1277ac78d4a97fb7cb4482a2 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 5 Mar 2026 17:10:33 -0500 Subject: [PATCH 5/9] fix bad migration perf --- ...05100000_fix_profile_posts_performance.sql | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/supabase/migrations/20260305100000_fix_profile_posts_performance.sql b/supabase/migrations/20260305100000_fix_profile_posts_performance.sql index 5407ba38..ddcd251c 100644 --- a/supabase/migrations/20260305100000_fix_profile_posts_performance.sql +++ b/supabase/migrations/20260305100000_fix_profile_posts_performance.sql @@ -1,23 +1,19 @@ -- Add missing indexes on document foreign keys used by get_profile_posts -CREATE INDEX IF NOT EXISTS comments_on_documents_document_idx +CREATE INDEX CONCURRENTLY IF NOT EXISTS comments_on_documents_document_idx ON public.comments_on_documents (document); -CREATE INDEX IF NOT EXISTS document_mentions_in_bsky_document_idx +CREATE INDEX CONCURRENTLY IF NOT EXISTS document_mentions_in_bsky_document_idx ON public.document_mentions_in_bsky (document); -CREATE INDEX IF NOT EXISTS documents_in_publications_document_idx +CREATE INDEX CONCURRENTLY IF NOT EXISTS documents_in_publications_document_idx ON public.documents_in_publications (document); --- Add a stored generated column to extract the DID from at:// URIs --- at://did:plc:xxx/collection/rkey -> did:plc:xxx -ALTER TABLE public.documents - ADD COLUMN identity_did text GENERATED ALWAYS AS (split_part(uri, '/', 3)) STORED; +-- Expression index to look up documents by DID without adding a column +-- at://did:plc:xxx/collection/rkey -> split_part gives did:plc:xxx +CREATE INDEX CONCURRENTLY IF NOT EXISTS documents_identity_did_sort_idx + ON public.documents (split_part(uri, '/', 3), sort_date DESC, uri DESC); --- Composite index for efficient profile post lookups: filter by DID, order by sort_date -CREATE INDEX documents_identity_did_sort_idx - ON public.documents (identity_did, sort_date DESC, uri DESC); - --- Rewrite get_profile_posts to use the new identity_did column +-- Rewrite get_profile_posts to use the expression index CREATE OR REPLACE FUNCTION get_profile_posts( p_did text, p_cursor_sort_date timestamptz DEFAULT NULL, @@ -55,7 +51,7 @@ AS $$ WHERE dip.document = d.uri LIMIT 1 ) pub ON true - WHERE d.identity_did = p_did + WHERE split_part(d.uri, '/', 3) = p_did AND ( p_cursor_sort_date IS NULL OR d.sort_date < p_cursor_sort_date -- 2.51.2 From b36ddf7f66651d92738a7767b24cd48fed02263e Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 5 Mar 2026 15:58:02 -0500 Subject: [PATCH 6/9] interaction panel in reader gets flexbox fixes and some styling updates --- app/(home-pages)/reader/GlobalContent.tsx | 13 ++++-- app/(home-pages)/reader/InboxContent.tsx | 17 ++++--- .../reader/InteractionDrawers.tsx | 44 +++++++++++-------- app/(home-pages)/reader/NewContent.tsx | 11 ++++- app/(home-pages)/reader/layout.tsx | 5 +-- components/PostListing.tsx | 13 +++--- components/ThemeManager/themeDefaults.ts | 4 +- components/ThemeManager/themeUtils.ts | 4 +- 8 files changed, 67 insertions(+), 44 deletions(-) diff --git a/app/(home-pages)/reader/GlobalContent.tsx b/app/(home-pages)/reader/GlobalContent.tsx index b8fc3fb9..94134cfe 100644 --- a/app/(home-pages)/reader/GlobalContent.tsx +++ b/app/(home-pages)/reader/GlobalContent.tsx @@ -8,6 +8,7 @@ import { DesktopInteractionPreviewDrawer, MobileInteractionPreviewDrawer, } from "./InteractionDrawers"; +import { useSelectedPostListing } from "src/useSelectedPostState"; export const GlobalContent = (props: { promise: Promise<{ posts: Post[] }>; @@ -29,6 +30,8 @@ export const GlobalContent = (props: { const posts = data?.posts ?? []; + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); + if (posts.length === 0) { return (
@@ -38,10 +41,14 @@ export const GlobalContent = (props: { } return ( -
-
+
+
{posts.map((p) => ( - + ))}
diff --git a/app/(home-pages)/reader/InboxContent.tsx b/app/(home-pages)/reader/InboxContent.tsx index e3cfe223..1c15d9c6 100644 --- a/app/(home-pages)/reader/InboxContent.tsx +++ b/app/(home-pages)/reader/InboxContent.tsx @@ -8,17 +8,16 @@ import { getReaderFeed } from "./getReaderFeed"; import { useEffect, useRef } from "react"; import Link from "next/link"; import { PostListing } from "components/PostListing"; -import { useHasBackgroundImage } from "components/Pages/useHasBackgroundImage"; import { DesktopInteractionPreviewDrawer, MobileInteractionPreviewDrawer, } from "./InteractionDrawers"; +import { useSelectedPostListing } from "src/useSelectedPostState"; export const InboxContent = (props: { promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; }) => { const { posts, nextCursor } = use(props.promise); - const getKey = ( pageIndex: number, previousPageData: { @@ -47,6 +46,8 @@ export const InboxContent = (props: { const loadMoreRef = useRef(null); + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); + // Set up intersection observer to load more when trigger element is visible useEffect(() => { const observer = new IntersectionObserver( @@ -78,13 +79,15 @@ export const InboxContent = (props: { if (allPosts.length === 0) return ; - let hasBackgroundImage = useHasBackgroundImage(); - return ( -
-
+
+
{sortedPosts.map((p) => ( - + ))} {/* Trigger element for loading more posts */}
{ return (
@@ -31,7 +31,7 @@ export const DesktopInteractionPreviewDrawer = () => { return ( @@ -55,7 +55,12 @@ const PreviewDrawerContent = (props: { { keepPreviousData: false }, ); - if (!props.selectedPost || !props.selectedPost.document) return null; + if (!props.selectedPost || !props.selectedPost.document) + return ( +
+ Click a post's comments or mentions to preview them here! +
+ ); const postUrl = getDocumentURL( props.selectedPost.document, @@ -70,22 +75,25 @@ const PreviewDrawerContent = (props: { return ( <> -
-
{drawerTitle}
- +
+
+
{drawerTitle}
+ +
+ + + See Full Post + + +
- - - See Full Post - - {isLoading ? (
loading diff --git a/app/(home-pages)/reader/NewContent.tsx b/app/(home-pages)/reader/NewContent.tsx index 3fe20467..9a0e7296 100644 --- a/app/(home-pages)/reader/NewContent.tsx +++ b/app/(home-pages)/reader/NewContent.tsx @@ -10,6 +10,7 @@ import { DesktopInteractionPreviewDrawer, MobileInteractionPreviewDrawer, } from "./InteractionDrawers"; +import { useSelectedPostListing } from "src/useSelectedPostState"; export const NewContent = (props: { promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; @@ -37,6 +38,8 @@ export const NewContent = (props: { }, ); + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); + const loadMoreRef = useRef(null); useEffect(() => { @@ -71,9 +74,13 @@ export const NewContent = (props: { return (
-
+
{allPosts.map((p) => ( - + ))}
!tab.requiresAuth || isLoggedIn); const isActive = (href: string) => { - if (href === "/reader") - return pathname === "/reader" || pathname === "/"; + if (href === "/reader") return pathname === "/reader" || pathname === "/"; if ( href === "/reader/hot" && !isLoggedIn && diff --git a/components/PostListing.tsx b/components/PostListing.tsx index 6954100e..19948d7d 100644 --- a/components/PostListing.tsx +++ b/components/PostListing.tsx @@ -13,10 +13,9 @@ import type { Post } from "app/(home-pages)/reader/getReaderFeed"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; -import { InteractionPreview, TagPopover } from "./InteractionsPreview"; +import { TagPopover } from "./InteractionsPreview"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { useSmoker } from "./Toast"; -import { Separator } from "./Layout"; import { CommentTiny } from "./Icons/CommentTiny"; import { QuoteTiny } from "./Icons/QuoteTiny"; import { ShareTiny } from "./Icons/ShareTiny"; @@ -27,7 +26,7 @@ import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; import { RecommendButton } from "./RecommendButton"; import { getFirstParagraph } from "src/utils/getFirstParagraph"; -export const PostListing = (props: Post) => { +export const PostListing = (props: Post & { selected?: boolean }) => { let pubRecord = props.publication?.pubRecord as | NormalizedPublication | undefined; @@ -101,8 +100,8 @@ export const PostListing = (props: Post) => { className={` relative flex flex-col overflow-hidden - selected-outline border-border-light rounded-lg w-full hover:outline-accent-contrast - hover:border-accent-contrast + selected-outline rounded-lg w-full + ${props.selected ? "outline-2 outline-offset-1 outline-accent-contrast border-accent-contrast" : "hover:outline-accent-contrast hover:border-accent-contrast border-border-light"} ${showPageBackground ? "bg-bg-page " : "bg-bg-leaflet"} `} style={ hasBackgroundImage @@ -135,12 +134,12 @@ export const PostListing = (props: Post) => { )}
{postRecord.title && ( -

+

{postRecord.title}

)} -

+

{postRecord.description || getFirstParagraph(postRecord)}

diff --git a/components/ThemeManager/themeDefaults.ts b/components/ThemeManager/themeDefaults.ts index df775809..32ef966f 100644 --- a/components/ThemeManager/themeDefaults.ts +++ b/components/ThemeManager/themeDefaults.ts @@ -9,13 +9,13 @@ export const PubThemeDefaults = { pageBackground: "#FDFCFA", primary: "#272727", accentText: "#FFFFFF", - accentBackground: "#0000FF", + accentBackground: "#639431", } as const; // RGB color defaults (parsed from hex values above) export const PubThemeDefaultsRGB = { background: { r: 253, g: 252, b: 250 }, // #FDFCFA foreground: { r: 39, g: 39, b: 39 }, // #272727 - accent: { r: 0, g: 0, b: 255 }, // #0000FF + accent: { r: 99, g: 148, b: 49 }, // #639431 accentForeground: { r: 255, g: 255, b: 255 }, // #FFFFFF } as const; diff --git a/components/ThemeManager/themeUtils.ts b/components/ThemeManager/themeUtils.ts index a25c48b2..35151728 100644 --- a/components/ThemeManager/themeUtils.ts +++ b/components/ThemeManager/themeUtils.ts @@ -12,8 +12,8 @@ export const ThemeDefaults = { //everywhere else, accent-background = accent-1 and accent-text = accent-2. // we just need to create a migration pipeline before we can change this "theme/accent-text": "#FFFFFF", - "theme/accent-background": "#0000FF", - "theme/accent-contrast": "#0000FF", + "theme/accent-background": "#639431", + "theme/accent-contrast": "#639431", }; // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast -- 2.51.2 From 43fa8794a9b00ddc22e83cadcdad67e8b1e37b39 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 5 Mar 2026 18:11:19 -0500 Subject: [PATCH 7/9] don't index bridgy docs at all --- app/api/inngest/client.ts | 7 +- ...document_metadata.ts => index_document.ts} | 60 ++++++++++---- app/api/inngest/route.tsx | 4 +- appview/index.ts | 79 ++++++------------- 4 files changed, 77 insertions(+), 73 deletions(-) rename app/api/inngest/functions/{sync_document_metadata.ts => index_document.ts} (60%) diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts index ffce505c..e9bd13fa 100644 --- a/app/api/inngest/client.ts +++ b/app/api/inngest/client.ts @@ -1,6 +1,6 @@ import { Inngest } from "inngest"; - import { EventSchemas } from "inngest"; +import { Json } from "supabase/database.types"; export type Events = { "feeds/index-follows": { @@ -51,10 +51,13 @@ export type Events = { documentUris?: string[]; }; }; - "appview/sync-document-metadata": { + "appview/index-document": { data: { document_uri: string; + document_data: Json; bsky_post_uri?: string; + publication: string | null; + did: string; }; }; "user/write-records-to-pds": { diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/index_document.ts similarity index 60% rename from app/api/inngest/functions/sync_document_metadata.ts rename to app/api/inngest/functions/index_document.ts index af2c3d4c..295f97f5 100644 --- a/app/api/inngest/functions/sync_document_metadata.ts +++ b/app/api/inngest/functions/index_document.ts @@ -1,16 +1,27 @@ import { inngest } from "../client"; import { supabaseServerClient } from "supabase/serverClient"; -import { AtpAgent, AtUri } from "@atproto/api"; +import { AtpAgent } from "@atproto/api"; import { idResolver } from "app/(home-pages)/reader/idResolver"; // 1m, 2m, 4m, 8m, 16m, 32m, 1h, 2h, 4h, 8h, 8h, 8h (~37h total) const SLEEP_INTERVALS = [ - "1m", "2m", "4m", "8m", "16m", "32m", "1h", "2h", "4h", "8h", "8h", "8h", + "1m", + "2m", + "4m", + "8m", + "16m", + "32m", + "1h", + "2h", + "4h", + "8h", + "8h", + "8h", ]; -export const sync_document_metadata = inngest.createFunction( +export const index_document = inngest.createFunction( { - id: "sync_document_metadata_v2", + id: "index_document_v2", debounce: { key: "event.data.document_uri", period: "60s", @@ -18,11 +29,10 @@ export const sync_document_metadata = inngest.createFunction( }, concurrency: [{ key: "event.data.document_uri", limit: 1 }], }, - { event: "appview/sync-document-metadata" }, + { event: "appview/index-document" }, async ({ event, step }) => { - const { document_uri, bsky_post_uri } = event.data; - - const did = new AtUri(document_uri).host; + const { document_uri, document_data, bsky_post_uri, publication, did } = + event.data; const handleResult = await step.run("resolve-handle", async () => { const doc = await idResolver.did.resolve(did); @@ -39,15 +49,37 @@ export const sync_document_metadata = inngest.createFunction( }); if (!handleResult) return { error: "No Handle" }; - await step.run("set-indexed", async () => { - return await supabaseServerClient + if (handleResult.isBridgy) { + return { handle: handleResult.handle, skipped: true }; + } + + await step.run("write-document", async () => { + const docResult = await supabaseServerClient .from("documents") - .update({ indexed: !handleResult.isBridgy }) - .eq("uri", document_uri) - .select(); + .upsert({ + uri: document_uri, + data: document_data, + indexed: true, + }); + if (docResult.error) console.log(docResult.error); + + if (publication) { + const docInPubResult = await supabaseServerClient + .from("documents_in_publications") + .upsert({ + publication, + document: document_uri, + }); + await supabaseServerClient + .from("documents_in_publications") + .delete() + .neq("publication", publication) + .eq("document", document_uri); + if (docInPubResult.error) console.log(docInPubResult.error); + } }); - if (!bsky_post_uri || handleResult.isBridgy) { + if (!bsky_post_uri) { return { handle: handleResult.handle }; } diff --git a/app/api/inngest/route.tsx b/app/api/inngest/route.tsx index a74f1d98..f3e7ac1f 100644 --- a/app/api/inngest/route.tsx +++ b/app/api/inngest/route.tsx @@ -13,7 +13,7 @@ import { check_oauth_session, } from "./functions/cleanup_expired_oauth_sessions"; import { write_records_to_pds } from "./functions/write_records_to_pds"; -import { sync_document_metadata } from "./functions/sync_document_metadata"; +import { index_document } from "./functions/index_document"; export const { GET, POST, PUT } = serve({ client: inngest, @@ -29,6 +29,6 @@ export const { GET, POST, PUT } = serve({ cleanup_expired_oauth_sessions, check_oauth_session, write_records_to_pds, - sync_document_metadata, + index_document, ], }); diff --git a/appview/index.ts b/appview/index.ts index 31ead5fb..dcdf00ff 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -104,40 +104,25 @@ async function handleEvent(evt: Event) { console.log(record.error); return; } - let docResult = await supabase.from("documents").upsert({ - uri: evt.uri.toString(), - data: record.value as Json, - }); - if (docResult.error) console.log(docResult.error); - await inngest.send({ - name: "appview/sync-document-metadata", - data: { - document_uri: evt.uri.toString(), - bsky_post_uri: record.value.postRef?.uri, - }, - }); + let publication: string | null = null; if (record.value.publication) { let publicationURI = new AtUri(record.value.publication); - if (publicationURI.host !== evt.uri.host) { console.log("Unauthorized to create post!"); return; } - let docInPublicationResult = await supabase - .from("documents_in_publications") - .upsert({ - publication: record.value.publication, - document: evt.uri.toString(), - }); - await supabase - .from("documents_in_publications") - .delete() - .neq("publication", record.value.publication) - .eq("document", evt.uri.toString()); - - if (docInPublicationResult.error) - console.log(docInPublicationResult.error); + publication = record.value.publication; } + await inngest.send({ + name: "appview/index-document", + data: { + document_uri: evt.uri.toString(), + document_data: record.value as Json, + bsky_post_uri: record.value.postRef?.uri, + publication, + did: evt.did, + }, + }); } if (evt.event === "delete") { await supabase.from("documents").delete().eq("uri", evt.uri.toString()); @@ -271,45 +256,29 @@ async function handleEvent(evt: Event) { console.log(record.error); return; } - let docResult = await supabase.from("documents").upsert({ - uri: evt.uri.toString(), - data: record.value as Json, - }); - if (docResult.error) console.log(docResult.error); - await inngest.send({ - name: "appview/sync-document-metadata", - data: { - document_uri: evt.uri.toString(), - bsky_post_uri: record.value.bskyPostRef?.uri, - }, - }); - // site.standard.document uses "site" field to reference the publication // For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey) // For standalone documents, site is an HTTPS URL (https://leaflet.pub/p/did:plc:xxx) // Only link to publications table for AT-URI sites + let publication: string | null = null; if (record.value.site && record.value.site.startsWith("at://")) { let siteURI = new AtUri(record.value.site); - if (siteURI.host !== evt.uri.host) { console.log("Unauthorized to create document in site!"); return; } - let docInPublicationResult = await supabase - .from("documents_in_publications") - .upsert({ - publication: record.value.site, - document: evt.uri.toString(), - }); - await supabase - .from("documents_in_publications") - .delete() - .neq("publication", record.value.site) - .eq("document", evt.uri.toString()); - - if (docInPublicationResult.error) - console.log(docInPublicationResult.error); + publication = record.value.site; } + await inngest.send({ + name: "appview/index-document", + data: { + document_uri: evt.uri.toString(), + document_data: record.value as Json, + bsky_post_uri: record.value.bskyPostRef?.uri, + publication, + did: evt.did, + }, + }); } if (evt.event === "delete") { await supabase.from("documents").delete().eq("uri", evt.uri.toString()); -- 2.51.2 From ca5b2290814a9231457b1d7b68c8a4781329164f Mon Sep 17 00:00:00 2001 From: Brendan Schlagel Date: Fri, 6 Mar 2026 13:41:36 -0500 Subject: [PATCH 8/9] bump default green accent color a bit darker for accessibility --- components/ThemeManager/themeDefaults.ts | 4 ++-- components/ThemeManager/themeUtils.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/ThemeManager/themeDefaults.ts b/components/ThemeManager/themeDefaults.ts index 32ef966f..f3092571 100644 --- a/components/ThemeManager/themeDefaults.ts +++ b/components/ThemeManager/themeDefaults.ts @@ -9,13 +9,13 @@ export const PubThemeDefaults = { pageBackground: "#FDFCFA", primary: "#272727", accentText: "#FFFFFF", - accentBackground: "#639431", + accentBackground: "#57822B", } as const; // RGB color defaults (parsed from hex values above) export const PubThemeDefaultsRGB = { background: { r: 253, g: 252, b: 250 }, // #FDFCFA foreground: { r: 39, g: 39, b: 39 }, // #272727 - accent: { r: 99, g: 148, b: 49 }, // #639431 + accent: { r: 87, g: 130, b: 43 }, // #57822B accentForeground: { r: 255, g: 255, b: 255 }, // #FFFFFF } as const; diff --git a/components/ThemeManager/themeUtils.ts b/components/ThemeManager/themeUtils.ts index 35151728..50ef8866 100644 --- a/components/ThemeManager/themeUtils.ts +++ b/components/ThemeManager/themeUtils.ts @@ -12,8 +12,8 @@ export const ThemeDefaults = { //everywhere else, accent-background = accent-1 and accent-text = accent-2. // we just need to create a migration pipeline before we can change this "theme/accent-text": "#FFFFFF", - "theme/accent-background": "#639431", - "theme/accent-contrast": "#639431", + "theme/accent-background": "#57822B", + "theme/accent-contrast": "#57822B", }; // used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast -- 2.51.2 From 66f9d44120048c2d2df64b29de562909d1308e97 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 6 Mar 2026 15:51:40 -0500 Subject: [PATCH 9/9] Add custom fonts! Squashed commit of the following: commit 8b5625aa7bbdbc89ee3355f4b258ce00690b891f Author: Jared Pereira Date: Fri Mar 6 15:11:02 2026 -0500 remove pub picker on subpage and fix font label commit 4f9a2dc7c38e6abc56a76490ee77d728d5abb452 Author: Jared Pereira Date: Fri Mar 6 15:10:57 2026 -0500 support h4 commit e5d8f977bfdde8a6b97f561c09f320879f4baaa9 Merge: 4d41f6f4 43fa8794 Author: Jared Pereira Date: Fri Mar 6 13:57:36 2026 -0500 Merge branch 'main' into feature/fonts commit 4d41f6f4b4454d565dcece8dc0663ece00e45287 Author: Jared Pereira Date: Fri Mar 6 11:56:35 2026 -0500 merge blockprops style better commit 073e43d90f5bdf532b5192c712cf12f8bb6ee728 Author: Jared Pereira Date: Fri Mar 6 11:12:01 2026 -0500 fix font sizes commit 980fcd7d49beb6625ffd94f62dce7cfa0930b9f0 Author: Jared Pereira Date: Thu Mar 5 22:56:33 2026 -0500 handle header sizes and base size for fonts commit 3e831c1f9ab1f545e5a153300790f29047631e4c Author: Jared Pereira Date: Thu Mar 5 21:53:07 2026 -0500 fix undo manager commit 025ac8d16765272a4fc354e3baf8ad468e922b8d Author: Jared Pereira Date: Thu Mar 5 21:43:46 2026 -0500 fix setting fonts commit a1d30fc91a7b9874f3ef561c855837e5b224af29 Author: Jared Pereira Date: Thu Feb 5 01:01:42 2026 +0000 add fonts commit 83049703cc02efb4c5a4f94ad3dbfb43c50c07a3 Author: Jared Pereira Date: Fri Jan 30 18:09:40 2026 -0500 wip support custom google fonts commit 7235f59982a1a683a1f91da4f7db38dda3f9f73b Author: Jared Pereira Date: Thu Jan 22 17:10:18 2026 -0500 more fonts!! commit 10da7354ddb83a15082b75e317c0b0e3cf971c9b Author: Jared Pereira Date: Thu Jan 22 13:49:15 2026 -0500 implemnt basic font logic --- app/[leaflet_id]/Leaflet.tsx | 8 +- app/[leaflet_id]/page.tsx | 35 ++- app/globals.css | 33 ++- app/layout.tsx | 4 +- .../[rkey]/Blocks/PublishedPageBlock.tsx | 2 +- .../[rkey]/DocumentPageRenderer.tsx | 2 + .../[publication]/[rkey]/PostContent.tsx | 18 +- .../[rkey]/StaticPostContent.tsx | 11 +- app/lish/createPub/updatePublication.ts | 4 + components/Blocks/ExternalLinkBlock.tsx | 2 +- components/Blocks/PageLinkBlock.tsx | 2 +- components/Blocks/TextBlock/index.tsx | 37 ++- components/Blocks/TextBlock/inputRules.ts | 2 +- components/Blocks/TextBlock/useHandlePaste.ts | 5 + components/FontLoader.tsx | 121 +++++++++ components/ThemeManager/PageThemeSetter.tsx | 1 + .../ThemeManager/Pickers/PageThemePickers.tsx | 9 + .../ThemeManager/Pickers/TextPickers.tsx | 214 +++++++++++++++ .../ThemeManager/PubPickers/PubFontPicker.tsx | 179 +++++++++++++ .../PubPickers/PubTextPickers.tsx | 14 - components/ThemeManager/PubThemeSetter.tsx | 17 ++ .../ThemeManager/PublicationThemeProvider.tsx | 5 + components/ThemeManager/ThemeProvider.tsx | 66 +++++ components/ThemeManager/ThemeSetter.tsx | 4 +- lexicons/api/lexicons.ts | 8 + lexicons/api/types/pub/leaflet/publication.ts | 2 + lexicons/pub/leaflet/publication.json | 8 + lexicons/src/publication.ts | 2 + public/fonts/Lora-Italic-Variable.woff2 | Bin 0 -> 40772 bytes public/fonts/Lora-Variable.woff2 | Bin 0 -> 37788 bytes public/fonts/NotoSans-Italic-Variable.woff2 | Bin 0 -> 39520 bytes public/fonts/NotoSans-Variable.woff2 | Bin 0 -> 35820 bytes .../fonts/SourceSans3-Italic-Variable.woff2 | Bin 0 -> 28532 bytes public/fonts/SourceSans3-Variable.woff2 | Bin 0 -> 28740 bytes public/fonts/iAWriterQuattroV-Italic.ttf | Bin 145388 -> 0 bytes public/fonts/iAWriterQuattroV.ttf | Bin 163744 -> 0 bytes public/fonts/iaw-quattro-vf-Italic.woff2 | Bin 0 -> 50104 bytes public/fonts/iaw-quattro-vf.woff2 | Bin 0 -> 51480 bytes src/fonts.ts | 252 ++++++++++++++++++ src/replicache/attributes.ts | 6 +- src/undoManager.ts | 5 +- src/utils/blockTextSize.ts | 7 + tailwind.config.js | 2 +- 43 files changed, 1022 insertions(+), 65 deletions(-) create mode 100644 components/FontLoader.tsx create mode 100644 components/ThemeManager/Pickers/TextPickers.tsx create mode 100644 components/ThemeManager/PubPickers/PubFontPicker.tsx create mode 100644 public/fonts/Lora-Italic-Variable.woff2 create mode 100644 public/fonts/Lora-Variable.woff2 create mode 100644 public/fonts/NotoSans-Italic-Variable.woff2 create mode 100644 public/fonts/NotoSans-Variable.woff2 create mode 100644 public/fonts/SourceSans3-Italic-Variable.woff2 create mode 100644 public/fonts/SourceSans3-Variable.woff2 delete mode 100644 public/fonts/iAWriterQuattroV-Italic.ttf delete mode 100644 public/fonts/iAWriterQuattroV.ttf create mode 100644 public/fonts/iaw-quattro-vf-Italic.woff2 create mode 100644 public/fonts/iaw-quattro-vf.woff2 create mode 100644 src/fonts.ts create mode 100644 src/utils/blockTextSize.ts diff --git a/app/[leaflet_id]/Leaflet.tsx b/app/[leaflet_id]/Leaflet.tsx index a98ee708..7ccc8d92 100644 --- a/app/[leaflet_id]/Leaflet.tsx +++ b/app/[leaflet_id]/Leaflet.tsx @@ -18,6 +18,8 @@ export function Leaflet(props: { token: PermissionToken; initialFacts: Fact[]; leaflet_id: string; + initialHeadingFontId?: string; + initialBodyFontId?: string; }) { return ( - + diff --git a/app/[leaflet_id]/page.tsx b/app/[leaflet_id]/page.tsx index be3c7b31..851ac894 100644 --- a/app/[leaflet_id]/page.tsx +++ b/app/[leaflet_id]/page.tsx @@ -14,6 +14,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; +import { FontLoader, extractFontsFromFacts } from "components/FontLoader"; export const preferredRegion = ["sfo1"]; export const dynamic = "force-dynamic"; @@ -48,19 +49,29 @@ export default async function LeafletPage(props: Props) { getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), ]); let initialFacts = (data as unknown as Fact[]) || []; + + // Extract font settings from facts for server-side font loading + const { headingFontId, bodyFontId } = extractFontsFromFacts(initialFacts as any, rootEntity); + return ( - - - + <> + {/* Server-side font loading with preload and @font-face */} + + + + + ); } diff --git a/app/globals.css b/app/globals.css index 73d26f5b..818a7281 100644 --- a/app/globals.css +++ b/app/globals.css @@ -193,10 +193,6 @@ @apply hover:underline; } - pre { - font-family: var(--font-quattro); - } - p { font-size: inherit; } @@ -205,6 +201,35 @@ @apply text-tertiary; @apply italic; } + + /* Scope custom fonts to document content only (not sidebar/UI chrome) */ + .pageScrollWrapper { + font-family: var(--theme-font, var(--font-quattro)); + font-size: var(--theme-font-base-size, 16px); + } + + .pageScrollWrapper h1, + .pageScrollWrapper h2, + .pageScrollWrapper h3, + .pageScrollWrapper h4 { + font-family: var(--theme-heading-font, var(--theme-font, var(--font-quattro))); + } + + /* Scale heading sizes relative to the custom base font size */ + .pageScrollWrapper h1 { font-size: 2em; } + .pageScrollWrapper h2 { font-size: 1.625em; } + .pageScrollWrapper h3 { font-size: 1.125em; } + .pageScrollWrapper h4 { font-size: 1em; } + + /* Scale text size classes relative to the custom base font size. + Tailwind's text-sm/text-base/text-lg compile to fixed rem values + which ignore the custom base size set on .pageScrollWrapper. */ + .pageScrollWrapper .textSizeSmall { font-size: 0.875em; } + .pageScrollWrapper .textSizeLarge { font-size: 1.125em; } + + .pageScrollWrapper pre { + font-family: var(--theme-font, var(--font-quattro)); + } /*END FONT STYLING*/ /* START GLOBAL STYLING */ diff --git a/app/layout.tsx b/app/layout.tsx index 138eafce..16e89e78 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -39,11 +39,11 @@ export const preferredRegion = ["sfo1"]; const quattro = localFont({ src: [ { - path: "../public/fonts/iAWriterQuattroV.ttf", + path: "../public/fonts/iaw-quattro-vf.woff2", style: "normal", }, { - path: "../public/fonts/iAWriterQuattroV-Italic.ttf", + path: "../public/fonts/iaw-quattro-vf-Italic.woff2", style: "italic", }, ], diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx index dca43492..fbe42825 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx @@ -111,7 +111,7 @@ export function DocLinkBlock(props: {
{title && (
+
+

-

+ ); if (b.block.level === 2) return ( -

+

-

+ ); if (b.block.level === 3) return ( -

+

-

+ ); // if (b.block.level === 4) return

{b.block.plaintext}

; // if (b.block.level === 5) return
{b.block.plaintext}
; return ( -
+
+

+

); if (b.block.level === 2) return ( -

+

); if (b.block.level === 3) return ( -

+

); // if (b.block.level === 4) return

{b.block.plaintext}

; // if (b.block.level === 5) return
{b.block.plaintext}
; return ( -
+
); diff --git a/app/lish/createPub/updatePublication.ts b/app/lish/createPub/updatePublication.ts index 77e1a191..4eaf4874 100644 --- a/app/lish/createPub/updatePublication.ts +++ b/app/lish/createPub/updatePublication.ts @@ -319,6 +319,8 @@ export async function updatePublicationTheme({ showPageBackground: boolean; accentBackground: Color; accentText: Color; + headingFont?: string; + bodyFont?: string; }; }): Promise { return withPublicationUpdate( @@ -361,6 +363,8 @@ export async function updatePublicationTheme({ accentText: { ...theme.accentText, }, + headingFont: theme.headingFont, + bodyFont: theme.bodyFont, }; // Derive basicTheme from the theme colors for site.standard.publication diff --git a/components/Blocks/ExternalLinkBlock.tsx b/components/Blocks/ExternalLinkBlock.tsx index 57a92d82..52e32883 100644 --- a/components/Blocks/ExternalLinkBlock.tsx +++ b/components/Blocks/ExternalLinkBlock.tsx @@ -78,7 +78,7 @@ export const ExternalLinkBlock = (
{leafletMetadata[0] && (
{leafletMetadata[0].listData && ( s.editorStates[props.entityID], @@ -262,7 +274,11 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { // unless we break *only* on urls, this is better than tailwind 'break-all' // b/c break-all can cause breaks in the middle of words, but break-word still // forces break if a single text string (e.g. a url) spans more than a full line - style={{ wordBreak: "break-word" }} + style={{ + wordBreak: "break-word", + fontFamily: props.type === "heading" ? "var(--theme-heading-font)" : "var(--theme-font)", + ...(props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : {}), + }} className={` ${alignmentClass} grow resize-none align-top whitespace-pre-wrap bg-transparent @@ -286,6 +302,7 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { props.nextBlock === null ? ( // if this is the only block on the page and is empty or is a canvas, show placeholder
{ + new InputRule(/^([#]{1,4})\s$/, (state, match) => { let tr = state.tr; tr.delete(0, match[0].length); let headingLevel = match[1].length; diff --git a/components/Blocks/TextBlock/useHandlePaste.ts b/components/Blocks/TextBlock/useHandlePaste.ts index f3bad456..5a4e021a 100644 --- a/components/Blocks/TextBlock/useHandlePaste.ts +++ b/components/Blocks/TextBlock/useHandlePaste.ts @@ -244,6 +244,11 @@ const createBlockFromHTML = ( type = "heading"; break; } + case "H4": { + headingLevel = 4; + type = "heading"; + break; + } case "DIV": { type = "card"; break; diff --git a/components/FontLoader.tsx b/components/FontLoader.tsx new file mode 100644 index 00000000..3de78a07 --- /dev/null +++ b/components/FontLoader.tsx @@ -0,0 +1,121 @@ +// Server-side font loading component +// Following Google's best practices: https://web.dev/articles/font-best-practices +// - Preconnect to font origins for early connection +// - Use font-display: swap (shows fallback immediately, swaps when ready) +// - Don't block rendering - some FOUT is acceptable and better UX than invisible text + +import { + getFontConfig, + generateFontFaceCSS, + getFontPreloadLinks, + getGoogleFontsUrl, + getFontFamilyValue, + getFontBaseSize, +} from "src/fonts"; + +type FontLoaderProps = { + headingFontId: string | undefined; + bodyFontId: string | undefined; +}; + +export function FontLoader({ headingFontId, bodyFontId }: FontLoaderProps) { + const headingFont = getFontConfig(headingFontId); + const bodyFont = getFontConfig(bodyFontId); + + // Collect all unique fonts to load + const fontsToLoad = headingFont.id === bodyFont.id + ? [headingFont] + : [headingFont, bodyFont]; + + // Collect preload links (deduplicated) + const preloadLinksSet = new Set(); + const preloadLinks: { href: string; type: string }[] = []; + for (const font of fontsToLoad) { + for (const link of getFontPreloadLinks(font)) { + if (!preloadLinksSet.has(link.href)) { + preloadLinksSet.add(link.href); + preloadLinks.push(link); + } + } + } + + // Collect font-face CSS + const fontFaceCSS = fontsToLoad + .map((font) => generateFontFaceCSS(font)) + .filter(Boolean) + .join("\n\n"); + + // Collect Google Fonts URLs (deduplicated) + const googleFontsUrls = [...new Set( + fontsToLoad + .map((font) => getGoogleFontsUrl(font)) + .filter((url): url is string => url !== null) + )]; + + const headingFontValue = getFontFamilyValue(headingFont); + const bodyFontValue = getFontFamilyValue(bodyFont); + const bodyFontBaseSize = getFontBaseSize(bodyFont); + + // Set font CSS variables scoped to .leafletWrapper so they don't affect app UI + const fontVariableCSS = ` +.leafletWrapper { + --theme-heading-font: ${headingFontValue}; + --theme-font: ${bodyFontValue}; + --theme-font-base-size: ${bodyFontBaseSize}px; +} +`.trim(); + + return ( + <> + {/* + Google Fonts best practice: preconnect to both origins + - fonts.googleapis.com serves the CSS + - fonts.gstatic.com serves the font files (needs crossorigin for CORS) + Place these as early as possible in + */} + {googleFontsUrls.length > 0 && ( + <> + + + {googleFontsUrls.map((url) => ( + + ))} + + )} + {/* Preload local font files for early discovery */} + {preloadLinks.map((link) => ( + + ))} + {/* @font-face declarations (for local fonts) and CSS variable */} +