···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};
···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];
···127127 onChange={(e) => setShowInDiscover(e.target.checked)}
128128 >
129129 <div className=" pt-0.5 flex flex-col text-sm text-tertiary ">
130130- <p className="font-bold italic">
131131- Show In{" "}
130130+ <p className="font-bold italic">Show In Discover</p>
131131+ <p className="text-sm text-tertiary font-normal">
132132+ Your posts will appear on our{" "}
132133 <a href="/discover" target="_blank">
133134 Discover
134134- </a>
135135- </p>
136136- <p className="text-sm text-tertiary font-normal">
137137- You'll be able to change this later!
135135+ </a>{" "}
136136+ page. You can change this at any time!
138137 </p>
139138 </div>
140139 </Checkbox>
141140 <hr className="border-border-light" />
142141143143- <div className="flex w-full justify-center">
142142+ <div className="flex w-full justify-end">
144143 <ButtonPrimary
145144 type="submit"
146145 disabled={
+5-2
app/lish/createPub/UpdatePubForm.tsx
···6666 if (!pubData) return;
6767 e.preventDefault();
6868 props.setLoadingAction(true);
6969- console.log("step 1:update");
7069 let data = await updatePublication({
7170 uri: pubData.uri,
7271 name: nameValue,
···171170 </a>
172171 </p>
173172 <p className="text-xs text-tertiary font-normal">
174174- This publication will appear on our public Discover page
173173+ Your posts will appear on our{" "}
174174+ <a href="/discover" target="_blank">
175175+ Discover
176176+ </a>{" "}
177177+ page. You can change this at any time!
175178 </p>
176179 </div>
177180 </Checkbox>
···77import { getPollData } from "actions/pollActions";
88import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
99import { createContext, useContext } from "react";
1010+import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData";
1111+import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
1212+import { AtUri } from "@atproto/syntax";
10131114export const StaticLeafletDataContext = createContext<
1215 null | GetLeafletDataReturnType["result"]["data"]
···6669};
6770export function useLeafletPublicationData() {
6871 let { data, mutate } = useLeafletData();
7272+7373+ // First check for leaflets in publications
7474+ let pubData = getPublicationMetadataFromLeafletData(data);
7575+6976 return {
7070- data:
7171- data?.leaflets_in_publications?.[0] ||
7272- data?.permission_token_rights[0].entity_sets?.permission_tokens?.find(
7373- (p) => p.leaflets_in_publications.length,
7474- )?.leaflets_in_publications?.[0] ||
7575- null,
7777+ data: pubData || null,
7678 mutate,
7779 };
7880}
···8082 let { data, mutate } = useLeafletData();
8183 return { data: data?.custom_domain_routes, mutate: mutate };
8284}
8585+8686+export function useLeafletPublicationStatus() {
8787+ const data = useContext(StaticLeafletDataContext);
8888+ if (!data) return null;
8989+9090+ const publishedInPublication = data.leaflets_in_publications?.find(
9191+ (l) => l.doc,
9292+ );
9393+ const publishedStandalone = data.leaflets_to_documents?.find(
9494+ (l) => !!l.documents,
9595+ );
9696+9797+ const documentUri =
9898+ publishedInPublication?.documents?.uri ?? publishedStandalone?.document;
9999+100100+ // Compute the full post URL for sharing
101101+ let postShareLink: string | undefined;
102102+ if (publishedInPublication?.publications && publishedInPublication.documents) {
103103+ // Published in a publication - use publication URL + document rkey
104104+ const docUri = new AtUri(publishedInPublication.documents.uri);
105105+ postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
106106+ } else if (publishedStandalone?.document) {
107107+ // Standalone published post - use /p/{did}/{rkey} format
108108+ const docUri = new AtUri(publishedStandalone.document);
109109+ postShareLink = `/p/${docUri.host}/${docUri.rkey}`;
110110+ }
111111+112112+ return {
113113+ token: data,
114114+ leafletId: data.root_entity,
115115+ shareLink: data.id,
116116+ // Draft state - in a publication but not yet published
117117+ draftInPublication:
118118+ data.leaflets_in_publications?.[0]?.publication ?? undefined,
119119+ // Published state
120120+ isPublished: !!(publishedInPublication || publishedStandalone),
121121+ publishedAt:
122122+ publishedInPublication?.documents?.indexed_at ??
123123+ publishedStandalone?.documents?.indexed_at,
124124+ documentUri,
125125+ // Full URL for sharing published posts
126126+ postShareLink,
127127+ };
128128+}
+5-2
components/Pages/Page.tsx
···1212import { Blocks } from "components/Blocks";
1313import { PublicationMetadata } from "./PublicationMetadata";
1414import { useCardBorderHidden } from "./useCardBorderHidden";
1515-import { focusPage } from ".";
1515+import { focusPage } from "src/utils/focusPage";
1616import { PageOptions } from "./PageOptions";
1717import { CardThemeProvider } from "components/ThemeManager/ThemeProvider";
1818import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
1919+import { usePreserveScroll } from "src/hooks/usePreserveScroll";
19202021export function Page(props: {
2122 entityID: string;
···6061 />
6162 }
6263 >
6363- {props.first && (
6464+ {props.first && pageType === "doc" && (
6465 <>
6566 <PublicationMetadata />
6667 </>
···8384 pageType: "canvas" | "doc";
8485 drawerOpen: boolean | undefined;
8586}) => {
8787+ let { ref } = usePreserveScroll<HTMLDivElement>(props.id);
8688 return (
8789 // this div wraps the contents AND the page options.
8890 // it needs to be its own div because this container does NOT scroll, and therefore doesn't clip the absolutely positioned pageOptions
···9597 it needs to be a separate div so that the user can scroll from anywhere on the page if there isn't a card border
9698 */}
9799 <div
100100+ ref={ref}
98101 onClick={props.onClickAction}
99102 id={props.id}
100103 className={`
+7-6
components/Pages/PageShareMenu.tsx
···11import { useLeafletDomains } from "components/PageSWRDataProvider";
22-import { ShareButton, usePublishLink } from "components/ShareOptions";
22+import {
33+ ShareButton,
44+ useReadOnlyShareLink,
55+} from "app/[leaflet_id]/actions/ShareOptions";
36import { useEffect, useState } from "react";
4758export const PageShareMenu = (props: { entityID: string }) => {
66- let publishLink = usePublishLink();
99+ let publishLink = useReadOnlyShareLink();
710 let { data: domains } = useLeafletDomains();
811 let [collabLink, setCollabLink] = useState<null | string>(null);
912 useEffect(() => {
···1417 <div>
1518 <ShareButton
1619 text="Share Edit Link"
1717- subtext=""
1818- helptext="recipients can edit the full Leaflet"
2020+ subtext="Recipients can edit the full Leaflet"
1921 smokerText="Collab link copied!"
2022 id="get-page-collab-link"
2123 link={`${collabLink}?page=${props.entityID}`}
2224 />
2325 <ShareButton
2426 text="Share View Link"
2525- subtext=""
2626- helptext="recipients can view the full Leaflet"
2727+ subtext="Recipients can view the full Leaflet"
2728 smokerText="Publish link copied!"
2829 id="get-page-publish-link"
2930 fullLink={
···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/// <reference types="next" />
22/// <reference types="next/image-types/global" />
33-/// <reference path="./.next/types/routes.d.ts" />
33+import "./.next/dev/types/routes.d.ts";
4455// NOTE: This file should not be edited
66// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
···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 table "public"."leaflets_to_documents" (
22+ "leaflet" uuid not null,
33+ "document" text not null,
44+ "created_at" timestamp with time zone not null default now(),
55+ "title" text not null default ''::text,
66+ "description" text not null default ''::text
77+);
88+99+alter table "public"."leaflets_to_documents" enable row level security;
1010+1111+CREATE UNIQUE INDEX leaflets_to_documents_pkey ON public.leaflets_to_documents USING btree (leaflet, document);
1212+1313+alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_pkey" PRIMARY KEY using index "leaflets_to_documents_pkey";
1414+1515+alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid;
1616+1717+alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_document_fkey";
1818+1919+alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_leaflet_fkey" FOREIGN KEY (leaflet) REFERENCES permission_tokens(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
2020+2121+alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_leaflet_fkey";
2222+2323+grant delete on table "public"."leaflets_to_documents" to "anon";
2424+2525+grant insert on table "public"."leaflets_to_documents" to "anon";
2626+2727+grant references on table "public"."leaflets_to_documents" to "anon";
2828+2929+grant select on table "public"."leaflets_to_documents" to "anon";
3030+3131+grant trigger on table "public"."leaflets_to_documents" to "anon";
3232+3333+grant truncate on table "public"."leaflets_to_documents" to "anon";
3434+3535+grant update on table "public"."leaflets_to_documents" to "anon";
3636+3737+grant delete on table "public"."leaflets_to_documents" to "authenticated";
3838+3939+grant insert on table "public"."leaflets_to_documents" to "authenticated";
4040+4141+grant references on table "public"."leaflets_to_documents" to "authenticated";
4242+4343+grant select on table "public"."leaflets_to_documents" to "authenticated";
4444+4545+grant trigger on table "public"."leaflets_to_documents" to "authenticated";
4646+4747+grant truncate on table "public"."leaflets_to_documents" to "authenticated";
4848+4949+grant update on table "public"."leaflets_to_documents" to "authenticated";
5050+5151+grant delete on table "public"."leaflets_to_documents" to "service_role";
5252+5353+grant insert on table "public"."leaflets_to_documents" to "service_role";
5454+5555+grant references on table "public"."leaflets_to_documents" to "service_role";
5656+5757+grant select on table "public"."leaflets_to_documents" to "service_role";
5858+5959+grant trigger on table "public"."leaflets_to_documents" to "service_role";
6060+6161+grant truncate on table "public"."leaflets_to_documents" to "service_role";
6262+6363+grant update on table "public"."leaflets_to_documents" to "service_role";
···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+;