···1111import type { Attribute } from "src/replicache/attributes";
1212import { Database } from "supabase/database.types";
1313import * as Y from "yjs";
1414-import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
1414+import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
1515import { pool } from "supabase/pool";
16161717let supabase = createServerClient<Database>(
+2-1
app/(home-pages)/discover/PubListing.tsx
···11"use client";
22import { AtUri } from "@atproto/syntax";
33import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions";
44+import { SubscribeWithBluesky } from "app/lish/Subscribe";
45import { PubIcon } from "components/ActionBar/Publications";
56import { Separator } from "components/Layout";
67import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
···1617 },
1718) => {
1819 let record = props.record as PubLeafletPublication.Record;
1919- let theme = usePubTheme(record);
2020+ let theme = usePubTheme(record.theme);
2021 let backgroundImage = record?.theme?.backgroundImage?.image?.ref
2122 ? blobRefToSrc(
2223 record?.theme?.backgroundImage?.image?.ref,
+1-2
app/(home-pages)/home/Actions/Actions.tsx
···11"use client";
22import { ThemePopover } from "components/ThemeManager/ThemeSetter";
33import { CreateNewLeafletButton } from "./CreateNewButton";
44-import { HelpPopover } from "components/HelpPopover";
44+import { HelpButton } from "app/[leaflet_id]/actions/HelpButton";
55import { AccountSettings } from "./AccountSettings";
66import { useIdentityData } from "components/IdentityProvider";
77import { useReplicache } from "src/replicache";
···1818 ) : (
1919 <LoginActionButton />
2020 )}
2121- <HelpPopover />
2221 </>
2322 );
2423};
···11-import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
22-33-export const PubNotFound = () => {
44- return (
55- <NotFoundLayout>
66- <p className="font-bold">Sorry, we can't find this publication!</p>
77- <p>
88- This may be a glitch on our end. If the issue persists please{" "}
99- <a href="mailto:contact@leaflet.pub">send us a note</a>.
1010- </p>
1111- </NotFoundLayout>
1212- );
1313-};
···11import {
22 PubLeafletDocument,
33 PubLeafletPagesLinearDocument,
44+ PubLeafletPagesCanvas,
45 PubLeafletBlocksCode,
56} from "lexicons/api";
67import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
7889export async function extractCodeBlocks(
99- blocks: PubLeafletPagesLinearDocument.Block[],
1010+ blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[],
1011): Promise<Map<string, string>> {
1112 const codeBlocks = new Map<string, string>();
12131313- // Process all pages in the document
1414+ // Process all blocks (works for both linear and canvas)
1415 for (let i = 0; i < blocks.length; i++) {
1516 const block = blocks[i];
1617 const currentIndex = [i];
···55 CSSProperties,
66 useContext,
77 useEffect,
88- useMemo,
99- useState,
108} from "react";
119import {
1210 colorToString,
···1412 useColorAttributeNullable,
1513} from "./useColorAttribute";
1614import { Color as AriaColor, parseColor } from "react-aria-components";
1717-import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
18151916import { useEntity } from "src/replicache";
2017import { useLeafletPublicationData } from "components/PageSWRDataProvider";
···2320 PublicationThemeProvider,
2421} from "./PublicationThemeProvider";
2522import { PubLeafletPublication } from "lexicons/api";
2626-2727-type CSSVariables = {
2828- "--bg-leaflet": string;
2929- "--bg-page": string;
3030- "--primary": string;
3131- "--accent-1": string;
3232- "--accent-2": string;
3333- "--accent-contrast": string;
3434- "--highlight-1": string;
3535- "--highlight-2": string;
3636- "--highlight-3": string;
3737-};
3838-3939-// define the color defaults for everything
4040-export const ThemeDefaults = {
4141- "theme/page-background": "#FDFCFA",
4242- "theme/card-background": "#FFFFFF",
4343- "theme/primary": "#272727",
4444- "theme/highlight-1": "#FFFFFF",
4545- "theme/highlight-2": "#EDD280",
4646- "theme/highlight-3": "#FFCDC3",
4747-4848- //everywhere else, accent-background = accent-1 and accent-text = accent-2.
4949- // we just need to create a migration pipeline before we can change this
5050- "theme/accent-text": "#FFFFFF",
5151- "theme/accent-background": "#0000FF",
5252- "theme/accent-contrast": "#0000FF",
5353-};
2323+import { getColorContrast } from "./themeUtils";
54245525// define a function to set an Aria Color to a CSS Variable in RGB
5626function setCSSVariableToColor(
···7343 return (
7444 <PublicationThemeProvider
7545 {...props}
7676- record={pub.publications?.record as PubLeafletPublication.Record}
4646+ theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme}
7747 pub_creator={pub.publications?.identity_did}
7848 />
7949 );
···339309 return (
340310 <PublicationBackgroundProvider
341311 pub_creator={pub?.publications.identity_did || ""}
342342- record={pub?.publications.record as PubLeafletPublication.Record}
312312+ theme={
313313+ (pub.publications?.record as PubLeafletPublication.Record)?.theme
314314+ }
343315 >
344316 {props.children}
345317 </PublicationBackgroundProvider>
···366338 );
367339};
368340369369-// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
370370-export function getColorContrast(color1: string, color2: string) {
371371- ColorSpace.register(sRGB);
372372-373373- let parsedColor1 = parse(`rgb(${color1})`);
374374- let parsedColor2 = parse(`rgb(${color2})`);
375375-376376- return contrastLstar(parsedColor1, parsedColor2);
377377-}
+2-2
components/ThemeManager/ThemeSetter.tsx
···7070 }, [rep, props.entityID]);
71717272 if (!permission) return null;
7373- if (pub) return null;
7373+ if (pub?.publications) return null;
74747575 return (
7676 <>
···111111 }, [rep, props.entityID]);
112112113113 if (!permission) return null;
114114- if (pub) return null;
114114+ if (pub?.publications) return null;
115115 return (
116116 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar">
117117 <div className="themeBGLeaflet flex">
+44
components/ThemeManager/colorToLexicons.ts
···11+import { Color } from "react-aria-components";
22+33+export function ColorToRGBA(color: Color) {
44+ if (!color)
55+ return {
66+ $type: "pub.leaflet.theme.color#rgba" as const,
77+ r: 0,
88+ g: 0,
99+ b: 0,
1010+ a: 1,
1111+ };
1212+ let c = color.toFormat("rgba");
1313+ const r = c.getChannelValue("red");
1414+ const g = c.getChannelValue("green");
1515+ const b = c.getChannelValue("blue");
1616+ const a = c.getChannelValue("alpha");
1717+ return {
1818+ $type: "pub.leaflet.theme.color#rgba" as const,
1919+ r: Math.round(r),
2020+ g: Math.round(g),
2121+ b: Math.round(b),
2222+ a: Math.round(a * 100),
2323+ };
2424+}
2525+2626+export function ColorToRGB(color: Color) {
2727+ if (!color)
2828+ return {
2929+ $type: "pub.leaflet.theme.color#rgb" as const,
3030+ r: 0,
3131+ g: 0,
3232+ b: 0,
3333+ };
3434+ let c = color.toFormat("rgb");
3535+ const r = c.getChannelValue("red");
3636+ const g = c.getChannelValue("green");
3737+ const b = c.getChannelValue("blue");
3838+ return {
3939+ $type: "pub.leaflet.theme.color#rgb" as const,
4040+ r: Math.round(r),
4141+ g: Math.round(g),
4242+ b: Math.round(b),
4343+ };
4444+}
+27
components/ThemeManager/themeUtils.ts
···11+import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
22+33+// define the color defaults for everything
44+export const ThemeDefaults = {
55+ "theme/page-background": "#FDFCFA",
66+ "theme/card-background": "#FFFFFF",
77+ "theme/primary": "#272727",
88+ "theme/highlight-1": "#FFFFFF",
99+ "theme/highlight-2": "#EDD280",
1010+ "theme/highlight-3": "#FFCDC3",
1111+1212+ //everywhere else, accent-background = accent-1 and accent-text = accent-2.
1313+ // we just need to create a migration pipeline before we can change this
1414+ "theme/accent-text": "#FFFFFF",
1515+ "theme/accent-background": "#0000FF",
1616+ "theme/accent-contrast": "#0000FF",
1717+};
1818+1919+// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
2020+export function getColorContrast(color1: string, color2: string) {
2121+ ColorSpace.register(sRGB);
2222+2323+ let parsedColor1 = parse(`rgb(${color1})`);
2424+ let parsedColor2 = parse(`rgb(${color2})`);
2525+2626+ return contrastLstar(parsedColor1, parsedColor2);
2727+}
+1-1
components/ThemeManager/useColorAttribute.ts
···22import { Color, parseColor } from "react-aria-components";
33import { useEntity, useReplicache } from "src/replicache";
44import { FilterAttributes } from "src/replicache/attributes";
55-import { ThemeDefaults } from "./ThemeProvider";
55+import { ThemeDefaults } from "./themeUtils";
6677export function useColorAttribute(
88 entity: string | null,
+5-14
components/Toolbar/BlockToolbar.tsx
···22import { ToolbarButton } from ".";
33import { Separator, ShortcutKey } from "components/Layout";
44import { metaKey } from "src/utils/metaKey";
55-import { getBlocksWithType } from "src/hooks/queries/useBlocks";
65import { useUIState } from "src/useUIState";
76import { LockBlockButton } from "./LockBlockButton";
87import { TextAlignmentButton } from "./TextAlignmentToolbar";
98import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
109import { DeleteSmall } from "components/Icons/DeleteSmall";
1010+import { getSortedSelection } from "components/SelectionManager/selectionState";
11111212export const BlockToolbar = (props: {
1313 setToolbarState: (
···66666767const MoveBlockButtons = () => {
6868 let { rep } = useReplicache();
6969- const getSortedSelection = async () => {
7070- let selectedBlocks = useUIState.getState().selectedBlocks;
7171- let siblings =
7272- (await rep?.query((tx) =>
7373- getBlocksWithType(tx, selectedBlocks[0].parent),
7474- )) || [];
7575- let sortedBlocks = siblings.filter((s) =>
7676- selectedBlocks.find((sb) => sb.value === s.value),
7777- );
7878- return [sortedBlocks, siblings];
7979- };
8069 return (
8170 <>
8271 <ToolbarButton
8372 hiddenOnCanvas
8473 onClick={async () => {
8585- let [sortedBlocks, siblings] = await getSortedSelection();
7474+ if (!rep) return;
7575+ let [sortedBlocks, siblings] = await getSortedSelection(rep);
8676 if (sortedBlocks.length > 1) return;
8777 let block = sortedBlocks[0];
8878 let previousBlock =
···139129 <ToolbarButton
140130 hiddenOnCanvas
141131 onClick={async () => {
142142- let [sortedBlocks, siblings] = await getSortedSelection();
132132+ if (!rep) return;
133133+ let [sortedBlocks, siblings] = await getSortedSelection(rep);
143134 if (sortedBlocks.length > 1) return;
144135 let block = sortedBlocks[0];
145136 let nextBlock = siblings
+1-1
components/Toolbar/MultiSelectToolbar.tsx
···88import { LockBlockButton } from "./LockBlockButton";
99import { Props } from "components/Icons/Props";
1010import { TextAlignmentButton } from "./TextAlignmentToolbar";
1111-import { getSortedSelection } from "components/SelectionManager";
1111+import { getSortedSelection } from "components/SelectionManager/selectionState";
12121313export const MultiselectToolbar = (props: {
1414 setToolbarState: (
+2-1
components/Toolbar/index.tsx
···1313import { TextToolbar } from "./TextToolbar";
1414import { BlockToolbar } from "./BlockToolbar";
1515import { MultiselectToolbar } from "./MultiSelectToolbar";
1616-import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock";
1616+import { AreYouSure } from "components/Blocks/DeleteBlock";
1717+import { deleteBlock } from "src/utils/deleteBlock";
1718import { TooltipButton } from "components/Buttons";
1819import { TextAlignmentToolbar } from "./TextAlignmentToolbar";
1920import { useIsMobile } from "src/hooks/isMobile";
+1-1
components/utils/UpdateLeafletTitle.tsx
···88import { useEntity, useReplicache } from "src/replicache";
99import * as Y from "yjs";
1010import * as base64 from "base64-js";
1111-import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
1111+import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
1212import { useParams, useRouter, useSearchParams } from "next/navigation";
1313import { focusBlock } from "src/utils/focusBlock";
1414import { useIsMobile } from "src/hooks/isMobile";
···66import { validate as _validate } from '../../../lexicons'
77import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
88import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef'
99+import type * as PubLeafletPublication from './publication'
910import type * as PubLeafletPagesLinearDocument from './pages/linearDocument'
1011import type * as PubLeafletPagesCanvas from './pages/canvas'
1112···1920 postRef?: ComAtprotoRepoStrongRef.Main
2021 description?: string
2122 publishedAt?: string
2222- publication: string
2323+ publication?: string
2324 author: string
2525+ theme?: PubLeafletPublication.Theme
2626+ tags?: string[]
2427 pages: (
2528 | $Typed<PubLeafletPagesLinearDocument.Main>
2629 | $Typed<PubLeafletPagesCanvas.Main>
···11-import { cookies, headers, type UnsafeUnwrappedHeaders } from "next/headers";
22-export function getIsBot() {
33- const userAgent =
44- (headers() as unknown as UnsafeUnwrappedHeaders).get("user-agent") || "";
55- const botPatterns = [
66- /bot/i,
77- /crawler/i,
88- /spider/i,
99- /googlebot/i,
1010- /bingbot/i,
1111- /yahoo/i,
1212- // Add more patterns as needed
1313- ];
1414-1515- return botPatterns.some((pattern) => pattern.test(userAgent));
1616-}
+59
src/utils/mentionUtils.ts
···11+import { AtUri } from "@atproto/api";
22+33+/**
44+ * Converts a DID to a Bluesky profile URL
55+ */
66+export function didToBlueskyUrl(did: string): string {
77+ return `https://bsky.app/profile/${did}`;
88+}
99+1010+/**
1111+ * Converts an AT URI (publication or document) to the appropriate URL
1212+ */
1313+export function atUriToUrl(atUri: string): string {
1414+ try {
1515+ const uri = new AtUri(atUri);
1616+1717+ if (uri.collection === "pub.leaflet.publication") {
1818+ // Publication URL: /lish/{did}/{rkey}
1919+ return `/lish/${uri.host}/${uri.rkey}`;
2020+ } else if (uri.collection === "pub.leaflet.document") {
2121+ // Document URL - we need to resolve this via the API
2222+ // For now, create a redirect route that will handle it
2323+ return `/lish/uri/${encodeURIComponent(atUri)}`;
2424+ }
2525+2626+ return "#";
2727+ } catch (e) {
2828+ console.error("Failed to parse AT URI:", atUri, e);
2929+ return "#";
3030+ }
3131+}
3232+3333+/**
3434+ * Opens a mention link in the appropriate way
3535+ * - DID mentions open in a new tab (external Bluesky)
3636+ * - Publication/document mentions navigate in the same tab
3737+ */
3838+export function handleMentionClick(
3939+ e: MouseEvent | React.MouseEvent,
4040+ type: "did" | "at-uri",
4141+ value: string
4242+) {
4343+ e.preventDefault();
4444+ e.stopPropagation();
4545+4646+ if (type === "did") {
4747+ // Open Bluesky profile in new tab
4848+ window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer");
4949+ } else {
5050+ // Navigate to publication/document in same tab
5151+ const url = atUriToUrl(value);
5252+ if (url.startsWith("/lish/uri/")) {
5353+ // Redirect route - navigate to it
5454+ window.location.href = url;
5555+ } else {
5656+ window.location.href = url;
5757+ }
5858+ }
5959+}
···11+-- Create GIN index on the tags array in the JSONB data field
22+-- This allows efficient querying of documents by tag
33+CREATE INDEX IF NOT EXISTS idx_documents_tags
44+ ON "public"."documents" USING gin ((data->'tags'));
55+66+-- Function to search and aggregate tags from documents
77+-- This does the aggregation in the database rather than fetching all documents
88+CREATE OR REPLACE FUNCTION search_tags(search_query text)
99+RETURNS TABLE (name text, document_count bigint) AS $$
1010+BEGIN
1111+ RETURN QUERY
1212+ SELECT
1313+ LOWER(tag::text) as name,
1414+ COUNT(DISTINCT d.uri) as document_count
1515+ FROM
1616+ "public"."documents" d,
1717+ jsonb_array_elements_text(d.data->'tags') as tag
1818+ WHERE
1919+ CASE
2020+ WHEN search_query = '' THEN true
2121+ ELSE LOWER(tag::text) LIKE '%' || search_query || '%'
2222+ END
2323+ GROUP BY
2424+ LOWER(tag::text)
2525+ ORDER BY
2626+ COUNT(DISTINCT d.uri) DESC,
2727+ LOWER(tag::text) ASC
2828+ LIMIT 20;
2929+END;
3030+$$ LANGUAGE plpgsql STABLE;
···11+set check_function_bodies = off;
22+33+CREATE OR REPLACE FUNCTION public.pull_data(token_id uuid, client_group_id text)
44+ RETURNS pull_result
55+ LANGUAGE plpgsql
66+AS $function$DECLARE
77+ result pull_result;
88+BEGIN
99+ -- Get client group data as JSON array
1010+ SELECT json_agg(row_to_json(rc))
1111+ FROM replicache_clients rc
1212+ WHERE rc.client_group = client_group_id
1313+ INTO result.client_groups;
1414+1515+ -- Get facts as JSON array
1616+ SELECT json_agg(row_to_json(f))
1717+ FROM permission_tokens pt,
1818+ get_facts(pt.root_entity) f
1919+ WHERE pt.id = token_id
2020+ INTO result.facts;
2121+2222+ -- Get publication data - try leaflets_in_publications first, then leaflets_to_documents
2323+ SELECT json_agg(row_to_json(lip))
2424+ FROM leaflets_in_publications lip
2525+ WHERE lip.leaflet = token_id
2626+ INTO result.publications;
2727+2828+ -- If no publication data found, try leaflets_to_documents (for standalone documents)
2929+ IF result.publications IS NULL THEN
3030+ SELECT json_agg(row_to_json(ltd))
3131+ FROM leaflets_to_documents ltd
3232+ WHERE ltd.leaflet = token_id
3333+ INTO result.publications;
3434+ END IF;
3535+3636+ RETURN result;
3737+END;$function$
3838+;