···53 }
5455 // Check if there's a standalone published document
56- const leafletDoc = tokenData.leaflets_to_documents;
57- if (leafletDoc && leafletDoc.document) {
58- if (!identity || !identity.atp_did) {
59 throw new Error(
60 "Unauthorized: You must be logged in to delete a published leaflet",
61 );
62 }
63- const docUri = leafletDoc.documents?.uri;
64- // Extract the DID from the document URI (format: at://did:plc:xxx/...)
65- if (docUri && !docUri.includes(identity.atp_did)) {
66- throw new Error(
67- "Unauthorized: You must own the published document to delete this leaflet",
68- );
0069 }
70 }
71 }
···81 .where(eq(permission_tokens.id, permission_token.id));
8283 if (!token?.permission_token_rights?.write) return;
84- const entitySet = token.permission_token_rights.entity_set;
85- if (!entitySet) return;
86- await tx.delete(entities).where(eq(entities.set, entitySet));
87 await tx
88 .delete(permission_tokens)
89 .where(eq(permission_tokens.id, permission_token.id));
···53 }
5455 // Check if there's a standalone published document
56+ const leafletDocs = tokenData.leaflets_to_documents || [];
57+ if (leafletDocs.length > 0) {
58+ if (!identity) {
59 throw new Error(
60 "Unauthorized: You must be logged in to delete a published leaflet",
61 );
62 }
63+ for (let leafletDoc of leafletDocs) {
64+ const docUri = leafletDoc.documents?.uri;
65+ // Extract the DID from the document URI (format: at://did:plc:xxx/...)
66+ if (docUri && identity.atp_did && !docUri.includes(identity.atp_did)) {
67+ throw new Error(
68+ "Unauthorized: You must own the published document to delete this leaflet",
69+ );
70+ }
71 }
72 }
73 }
···83 .where(eq(permission_tokens.id, permission_token.id));
8485 if (!token?.permission_token_rights?.write) return;
86+ await tx
87+ .delete(entities)
88+ .where(eq(entities.set, token.permission_token_rights.entity_set));
89 await tx
90 .delete(permission_tokens)
91 .where(eq(permission_tokens.id, permission_token.id));
-3
actions/publications/moveLeafletToPublication.ts
···11) {
12 let identity = await getIdentityData();
13 if (!identity || !identity.atp_did) return null;
14-15- // Verify publication ownership
16 let { data: publication } = await supabaseServerClient
17 .from("publications")
18 .select("*")
···20 .single();
21 if (publication?.identity_did !== identity.atp_did) return;
2223- // Save as a publication draft
24 await supabaseServerClient.from("leaflets_in_publications").insert({
25 publication: publication_uri,
26 leaflet: leaflet_id,
···11import type { Attribute } from "src/replicache/attributes";
12import { Database } from "supabase/database.types";
13import * as Y from "yjs";
14-import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
15import { pool } from "supabase/pool";
1617let supabase = createServerClient<Database>(
···11import type { Attribute } from "src/replicache/attributes";
12import { Database } from "supabase/database.types";
13import * as Y from "yjs";
14+import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
15import { pool } from "supabase/pool";
1617let supabase = createServerClient<Database>(
+1
app/(home-pages)/discover/PubListing.tsx
···1"use client";
2import { AtUri } from "@atproto/syntax";
3import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions";
04import { PubIcon } from "components/ActionBar/Publications";
5import { Separator } from "components/Layout";
6import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
···1"use client";
2import { AtUri } from "@atproto/syntax";
3import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions";
4+import { SubscribeWithBluesky } from "app/lish/Subscribe";
5import { PubIcon } from "components/ActionBar/Publications";
6import { Separator } from "components/Layout";
7import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
···27import { useState, useMemo } from "react";
28import { useIsMobile } from "src/hooks/isMobile";
29import { useReplicache, useEntity } from "src/replicache";
030import { Json } from "supabase/database.types";
31import {
32 useBlocks,
···34} from "src/hooks/queries/useBlocks";
35import * as Y from "yjs";
36import * as base64 from "base64-js";
37-import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
38import { BlueskyLogin } from "app/login/LoginForm";
39import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
40-import { saveLeafletDraft } from "actions/publications/saveLeafletDraft";
41import { AddTiny } from "components/Icons/AddTiny";
4243export const PublishButton = (props: { entityID: string }) => {
···64const UpdateButton = () => {
65 let [isLoading, setIsLoading] = useState(false);
66 let { data: pub, mutate } = useLeafletPublicationData();
67- let { permission_token, rootEntity } = useReplicache();
68 let { identity } = useIdentityData();
69 let toaster = useToaster();
00007071 return (
72 <ActionButton
···82 leaflet_id: permission_token.id,
83 title: pub.title,
84 description: pub.description,
085 });
86 setIsLoading(false);
87 mutate();
···109 let { identity } = useIdentityData();
110 let { permission_token } = useReplicache();
111 let query = useSearchParams();
112- console.log(query.get("publish"));
113 let [open, setOpen] = useState(query.get("publish") !== null);
114115 let isMobile = useIsMobile();
···177 <hr className="border-border-light mt-3 mb-2" />
178179 <div className="flex gap-2 items-center place-self-end">
180- {selectedPub && selectedPub !== "create" && (
181 <SaveAsDraftButton
182 selectedPub={selectedPub}
183 leafletId={permission_token.id}
···230 if (props.selectedPub === "create") return;
231 e.preventDefault();
232 setIsLoading(true);
233-234- // Use different actions for looseleaf vs publication
235- if (props.selectedPub === "looseleaf") {
236- await saveLeafletDraft(
237- props.leafletId,
238- props.metadata,
239- props.entitiesToDelete,
240- );
241- } else {
242- await moveLeafletToPublication(
243- props.leafletId,
244- props.selectedPub,
245- props.metadata,
246- props.entitiesToDelete,
247- );
248- }
249-250 await Promise.all([rep?.pull(), mutate()]);
251 setIsLoading(false);
252 }}
···27import { useState, useMemo } from "react";
28import { useIsMobile } from "src/hooks/isMobile";
29import { useReplicache, useEntity } from "src/replicache";
30+import { useSubscribe } from "src/replicache/useSubscribe";
31import { Json } from "supabase/database.types";
32import {
33 useBlocks,
···35} from "src/hooks/queries/useBlocks";
36import * as Y from "yjs";
37import * as base64 from "base64-js";
38+import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
39import { BlueskyLogin } from "app/login/LoginForm";
40import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
041import { AddTiny } from "components/Icons/AddTiny";
4243export const PublishButton = (props: { entityID: string }) => {
···64const UpdateButton = () => {
65 let [isLoading, setIsLoading] = useState(false);
66 let { data: pub, mutate } = useLeafletPublicationData();
67+ let { permission_token, rootEntity, rep } = useReplicache();
68 let { identity } = useIdentityData();
69 let toaster = useToaster();
70+71+ // Get tags from Replicache state (same as draft editor)
72+ let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags"));
73+ const currentTags = Array.isArray(tags) ? tags : [];
7475 return (
76 <ActionButton
···86 leaflet_id: permission_token.id,
87 title: pub.title,
88 description: pub.description,
89+ tags: currentTags,
90 });
91 setIsLoading(false);
92 mutate();
···114 let { identity } = useIdentityData();
115 let { permission_token } = useReplicache();
116 let query = useSearchParams();
0117 let [open, setOpen] = useState(query.get("publish") !== null);
118119 let isMobile = useIsMobile();
···181 <hr className="border-border-light mt-3 mb-2" />
182183 <div className="flex gap-2 items-center place-self-end">
184+ {selectedPub !== "looseleaf" && selectedPub && (
185 <SaveAsDraftButton
186 selectedPub={selectedPub}
187 leafletId={permission_token.id}
···234 if (props.selectedPub === "create") return;
235 e.preventDefault();
236 setIsLoading(true);
237+ await moveLeafletToPublication(
238+ props.leafletId,
239+ props.selectedPub,
240+ props.metadata,
241+ props.entitiesToDelete,
242+ );
00000000000243 await Promise.all([rep?.pull(), mutate()]);
244 setIsLoading(false);
245 }}
+1-1
app/[leaflet_id]/page.tsx
···45import type { Fact } from "src/replicache";
6import type { Attribute } from "src/replicache/attributes";
7-import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
8import { Leaflet } from "./Leaflet";
9import { scanIndexLocal } from "src/replicache/utils";
10import { getRSVPData } from "actions/getRSVPData";
···45import type { Fact } from "src/replicache";
6import type { Attribute } from "src/replicache/attributes";
7+import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
8import { Leaflet } from "./Leaflet";
9import { scanIndexLocal } from "src/replicache/utils";
10import { getRSVPData } from "actions/getRSVPData";
···23 currentPubUri: string | undefined;
24}) => {
25 let { identity } = useIdentityData();
26- let hasLooseleafs = identity?.permission_token_on_homepage.find(
27 (f) =>
28 f.permission_tokens.leaflets_to_documents &&
29- f.permission_tokens.leaflets_to_documents.document,
30 );
31- console.log(hasLooseleafs);
3233 // don't show pub list button if not logged in or no pub list
34 // we show a "start a pub" banner instead
···23 currentPubUri: string | undefined;
24}) => {
25 let { identity } = useIdentityData();
26+ let hasLooseleafs = !!identity?.permission_token_on_homepage.find(
27 (f) =>
28 f.permission_tokens.leaflets_to_documents &&
29+ f.permission_tokens.leaflets_to_documents[0]?.document,
30 );
03132 // don't show pub list button if not logged in or no pub list
33 // we show a "start a pub" banner instead
+46
components/AtMentionLink.tsx
···0000000000000000000000000000000000000000000000
···1+import { AtUri } from "@atproto/api";
2+import { atUriToUrl } from "src/utils/mentionUtils";
3+4+/**
5+ * Component for rendering at-uri mentions (publications and documents) as clickable links.
6+ * NOTE: This component's styling and behavior should match the ProseMirror schema rendering
7+ * in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other.
8+ */
9+export function AtMentionLink({
10+ atURI,
11+ children,
12+ className = "",
13+}: {
14+ atURI: string;
15+ children: React.ReactNode;
16+ className?: string;
17+}) {
18+ const aturi = new AtUri(atURI);
19+ const isPublication = aturi.collection === "pub.leaflet.publication";
20+ const isDocument = aturi.collection === "pub.leaflet.document";
21+22+ // Show publication icon if available
23+ const icon =
24+ isPublication || isDocument ? (
25+ <img
26+ src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`}
27+ className="inline-block w-5 h-5 rounded-full mr-1 align-text-top"
28+ alt=""
29+ width="20"
30+ height="20"
31+ loading="lazy"
32+ />
33+ ) : null;
34+35+ return (
36+ <a
37+ href={atUriToUrl(atURI)}
38+ target="_blank"
39+ rel="noopener noreferrer"
40+ className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
41+ >
42+ {icon}
43+ {children}
44+ </a>
45+ );
46+}
···5 CSSProperties,
6 useContext,
7 useEffect,
8- useMemo,
9- useState,
10} from "react";
11import {
12 colorToString,
···14 useColorAttributeNullable,
15} from "./useColorAttribute";
16import { Color as AriaColor, parseColor } from "react-aria-components";
17-import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
1819import { useEntity } from "src/replicache";
20import { useLeafletPublicationData } from "components/PageSWRDataProvider";
···23 PublicationThemeProvider,
24} from "./PublicationThemeProvider";
25import { PubLeafletPublication } from "lexicons/api";
26-27-type CSSVariables = {
28- "--bg-leaflet": string;
29- "--bg-page": string;
30- "--primary": string;
31- "--accent-1": string;
32- "--accent-2": string;
33- "--accent-contrast": string;
34- "--highlight-1": string;
35- "--highlight-2": string;
36- "--highlight-3": string;
37-};
38-39-// define the color defaults for everything
40-export const ThemeDefaults = {
41- "theme/page-background": "#FDFCFA",
42- "theme/card-background": "#FFFFFF",
43- "theme/primary": "#272727",
44- "theme/highlight-1": "#FFFFFF",
45- "theme/highlight-2": "#EDD280",
46- "theme/highlight-3": "#FFCDC3",
47-48- //everywhere else, accent-background = accent-1 and accent-text = accent-2.
49- // we just need to create a migration pipeline before we can change this
50- "theme/accent-text": "#FFFFFF",
51- "theme/accent-background": "#0000FF",
52- "theme/accent-contrast": "#0000FF",
53-};
5455// define a function to set an Aria Color to a CSS Variable in RGB
56function setCSSVariableToColor(
···368 );
369};
370371-// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
372-export function getColorContrast(color1: string, color2: string) {
373- ColorSpace.register(sRGB);
374-375- let parsedColor1 = parse(`rgb(${color1})`);
376- let parsedColor2 = parse(`rgb(${color2})`);
377-378- return contrastLstar(parsedColor1, parsedColor2);
379-}
···5 CSSProperties,
6 useContext,
7 useEffect,
008} from "react";
9import {
10 colorToString,
···12 useColorAttributeNullable,
13} from "./useColorAttribute";
14import { Color as AriaColor, parseColor } from "react-aria-components";
01516import { useEntity } from "src/replicache";
17import { useLeafletPublicationData } from "components/PageSWRDataProvider";
···20 PublicationThemeProvider,
21} from "./PublicationThemeProvider";
22import { PubLeafletPublication } from "lexicons/api";
23+import { getColorContrast } from "./themeUtils";
0000000000000000000000000002425// define a function to set an Aria Color to a CSS Variable in RGB
26function setCSSVariableToColor(
···338 );
339};
340000000000
+27
components/ThemeManager/themeUtils.ts
···000000000000000000000000000
···1+import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
2+3+// define the color defaults for everything
4+export const ThemeDefaults = {
5+ "theme/page-background": "#FDFCFA",
6+ "theme/card-background": "#FFFFFF",
7+ "theme/primary": "#272727",
8+ "theme/highlight-1": "#FFFFFF",
9+ "theme/highlight-2": "#EDD280",
10+ "theme/highlight-3": "#FFCDC3",
11+12+ //everywhere else, accent-background = accent-1 and accent-text = accent-2.
13+ // we just need to create a migration pipeline before we can change this
14+ "theme/accent-text": "#FFFFFF",
15+ "theme/accent-background": "#0000FF",
16+ "theme/accent-contrast": "#0000FF",
17+};
18+19+// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
20+export function getColorContrast(color1: string, color2: string) {
21+ ColorSpace.register(sRGB);
22+23+ let parsedColor1 = parse(`rgb(${color1})`);
24+ let parsedColor2 = parse(`rgb(${color2})`);
25+26+ return contrastLstar(parsedColor1, parsedColor2);
27+}
+1-1
components/ThemeManager/useColorAttribute.ts
···2import { Color, parseColor } from "react-aria-components";
3import { useEntity, useReplicache } from "src/replicache";
4import { FilterAttributes } from "src/replicache/attributes";
5-import { ThemeDefaults } from "./ThemeProvider";
67export function useColorAttribute(
8 entity: string | null,
···2import { Color, parseColor } from "react-aria-components";
3import { useEntity, useReplicache } from "src/replicache";
4import { FilterAttributes } from "src/replicache/attributes";
5+import { ThemeDefaults } from "./themeUtils";
67export function useColorAttribute(
8 entity: string | null,
+5-14
components/Toolbar/BlockToolbar.tsx
···2import { ToolbarButton } from ".";
3import { Separator, ShortcutKey } from "components/Layout";
4import { metaKey } from "src/utils/metaKey";
5-import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6import { useUIState } from "src/useUIState";
7import { LockBlockButton } from "./LockBlockButton";
8import { TextAlignmentButton } from "./TextAlignmentToolbar";
9import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
10import { DeleteSmall } from "components/Icons/DeleteSmall";
01112export const BlockToolbar = (props: {
13 setToolbarState: (
···6667const MoveBlockButtons = () => {
68 let { rep } = useReplicache();
69- const getSortedSelection = async () => {
70- let selectedBlocks = useUIState.getState().selectedBlocks;
71- let siblings =
72- (await rep?.query((tx) =>
73- getBlocksWithType(tx, selectedBlocks[0].parent),
74- )) || [];
75- let sortedBlocks = siblings.filter((s) =>
76- selectedBlocks.find((sb) => sb.value === s.value),
77- );
78- return [sortedBlocks, siblings];
79- };
80 return (
81 <>
82 <ToolbarButton
83 hiddenOnCanvas
84 onClick={async () => {
85- let [sortedBlocks, siblings] = await getSortedSelection();
086 if (sortedBlocks.length > 1) return;
87 let block = sortedBlocks[0];
88 let previousBlock =
···139 <ToolbarButton
140 hiddenOnCanvas
141 onClick={async () => {
142- let [sortedBlocks, siblings] = await getSortedSelection();
0143 if (sortedBlocks.length > 1) return;
144 let block = sortedBlocks[0];
145 let nextBlock = siblings
···2import { ToolbarButton } from ".";
3import { Separator, ShortcutKey } from "components/Layout";
4import { metaKey } from "src/utils/metaKey";
05import { useUIState } from "src/useUIState";
6import { LockBlockButton } from "./LockBlockButton";
7import { TextAlignmentButton } from "./TextAlignmentToolbar";
8import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
9import { DeleteSmall } from "components/Icons/DeleteSmall";
10+import { getSortedSelection } from "components/SelectionManager/selectionState";
1112export const BlockToolbar = (props: {
13 setToolbarState: (
···6667const MoveBlockButtons = () => {
68 let { rep } = useReplicache();
0000000000069 return (
70 <>
71 <ToolbarButton
72 hiddenOnCanvas
73 onClick={async () => {
74+ if (!rep) return;
75+ let [sortedBlocks, siblings] = await getSortedSelection(rep);
76 if (sortedBlocks.length > 1) return;
77 let block = sortedBlocks[0];
78 let previousBlock =
···129 <ToolbarButton
130 hiddenOnCanvas
131 onClick={async () => {
132+ if (!rep) return;
133+ let [sortedBlocks, siblings] = await getSortedSelection(rep);
134 if (sortedBlocks.length > 1) return;
135 let block = sortedBlocks[0];
136 let nextBlock = siblings
+1-1
components/Toolbar/MultiSelectToolbar.tsx
···8import { LockBlockButton } from "./LockBlockButton";
9import { Props } from "components/Icons/Props";
10import { TextAlignmentButton } from "./TextAlignmentToolbar";
11-import { getSortedSelection } from "components/SelectionManager";
1213export const MultiselectToolbar = (props: {
14 setToolbarState: (
···8import { LockBlockButton } from "./LockBlockButton";
9import { Props } from "components/Icons/Props";
10import { TextAlignmentButton } from "./TextAlignmentToolbar";
11+import { getSortedSelection } from "components/SelectionManager/selectionState";
1213export const MultiselectToolbar = (props: {
14 setToolbarState: (
+2-1
components/Toolbar/index.tsx
···13import { TextToolbar } from "./TextToolbar";
14import { BlockToolbar } from "./BlockToolbar";
15import { MultiselectToolbar } from "./MultiSelectToolbar";
16-import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock";
017import { TooltipButton } from "components/Buttons";
18import { TextAlignmentToolbar } from "./TextAlignmentToolbar";
19import { useIsMobile } from "src/hooks/isMobile";
···13import { TextToolbar } from "./TextToolbar";
14import { BlockToolbar } from "./BlockToolbar";
15import { MultiselectToolbar } from "./MultiSelectToolbar";
16+import { AreYouSure } from "components/Blocks/DeleteBlock";
17+import { deleteBlock } from "src/utils/deleteBlock";
18import { TooltipButton } from "components/Buttons";
19import { TextAlignmentToolbar } from "./TextAlignmentToolbar";
20import { useIsMobile } from "src/hooks/isMobile";
+1-1
components/utils/UpdateLeafletTitle.tsx
···8import { useEntity, useReplicache } from "src/replicache";
9import * as Y from "yjs";
10import * as base64 from "base64-js";
11-import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
12import { useParams, useRouter, useSearchParams } from "next/navigation";
13import { focusBlock } from "src/utils/focusBlock";
14import { useIsMobile } from "src/hooks/isMobile";
···8import { useEntity, useReplicache } from "src/replicache";
9import * as Y from "yjs";
10import * as base64 from "base64-js";
11+import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
12import { useParams, useRouter, useSearchParams } from "next/navigation";
13import { focusBlock } from "src/utils/focusBlock";
14import { useIsMobile } from "src/hooks/isMobile";
···2829 // On initial page load, use header timezone. After hydration, use system timezone
30 const effectiveTimezone = !hasPageLoaded
31- ? timezone
32 : Intl.DateTimeFormat().resolvedOptions().timeZone;
003334 // Apply timezone if available
35 if (effectiveTimezone) {
···2829 // On initial page load, use header timezone. After hydration, use system timezone
30 const effectiveTimezone = !hasPageLoaded
31+ ? timezone || "UTC"
32 : Intl.DateTimeFormat().resolvedOptions().timeZone;
33+34+ console.log("tz", effectiveTimezone);
3536 // Apply timezone if available
37 if (effectiveTimezone) {
···1+import { AtUri } from "@atproto/api";
2+3+/**
4+ * Converts a DID to a Bluesky profile URL
5+ */
6+export function didToBlueskyUrl(did: string): string {
7+ return `https://bsky.app/profile/${did}`;
8+}
9+10+/**
11+ * Converts an AT URI (publication or document) to the appropriate URL
12+ */
13+export function atUriToUrl(atUri: string): string {
14+ try {
15+ const uri = new AtUri(atUri);
16+17+ if (uri.collection === "pub.leaflet.publication") {
18+ // Publication URL: /lish/{did}/{rkey}
19+ return `/lish/${uri.host}/${uri.rkey}`;
20+ } else if (uri.collection === "pub.leaflet.document") {
21+ // Document URL - we need to resolve this via the API
22+ // For now, create a redirect route that will handle it
23+ return `/lish/uri/${encodeURIComponent(atUri)}`;
24+ }
25+26+ return "#";
27+ } catch (e) {
28+ console.error("Failed to parse AT URI:", atUri, e);
29+ return "#";
30+ }
31+}
32+33+/**
34+ * Opens a mention link in the appropriate way
35+ * - DID mentions open in a new tab (external Bluesky)
36+ * - Publication/document mentions navigate in the same tab
37+ */
38+export function handleMentionClick(
39+ e: MouseEvent | React.MouseEvent,
40+ type: "did" | "at-uri",
41+ value: string
42+) {
43+ e.preventDefault();
44+ e.stopPropagation();
45+46+ if (type === "did") {
47+ // Open Bluesky profile in new tab
48+ window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer");
49+ } else {
50+ // Navigate to publication/document in same tab
51+ const url = atUriToUrl(value);
52+ if (url.startsWith("/lish/uri/")) {
53+ // Redirect route - navigate to it
54+ window.location.href = url;
55+ } else {
56+ window.location.href = url;
57+ }
58+ }
59+}
···1+-- Create GIN index on the tags array in the JSONB data field
2+-- This allows efficient querying of documents by tag
3+CREATE INDEX IF NOT EXISTS idx_documents_tags
4+ ON "public"."documents" USING gin ((data->'tags'));
5+6+-- Function to search and aggregate tags from documents
7+-- This does the aggregation in the database rather than fetching all documents
8+CREATE OR REPLACE FUNCTION search_tags(search_query text)
9+RETURNS TABLE (name text, document_count bigint) AS $$
10+BEGIN
11+ RETURN QUERY
12+ SELECT
13+ LOWER(tag::text) as name,
14+ COUNT(DISTINCT d.uri) as document_count
15+ FROM
16+ "public"."documents" d,
17+ jsonb_array_elements_text(d.data->'tags') as tag
18+ WHERE
19+ CASE
20+ WHEN search_query = '' THEN true
21+ ELSE LOWER(tag::text) LIKE '%' || search_query || '%'
22+ END
23+ GROUP BY
24+ LOWER(tag::text)
25+ ORDER BY
26+ COUNT(DISTINCT d.uri) DESC,
27+ LOWER(tag::text) ASC
28+ LIMIT 20;
29+END;
30+$$ LANGUAGE plpgsql STABLE;
···1+set check_function_bodies = off;
2+3+CREATE OR REPLACE FUNCTION public.pull_data(token_id uuid, client_group_id text)
4+ RETURNS pull_result
5+ LANGUAGE plpgsql
6+AS $function$DECLARE
7+ result pull_result;
8+BEGIN
9+ -- Get client group data as JSON array
10+ SELECT json_agg(row_to_json(rc))
11+ FROM replicache_clients rc
12+ WHERE rc.client_group = client_group_id
13+ INTO result.client_groups;
14+15+ -- Get facts as JSON array
16+ SELECT json_agg(row_to_json(f))
17+ FROM permission_tokens pt,
18+ get_facts(pt.root_entity) f
19+ WHERE pt.id = token_id
20+ INTO result.facts;
21+22+ -- Get publication data - try leaflets_in_publications first, then leaflets_to_documents
23+ SELECT json_agg(row_to_json(lip))
24+ FROM leaflets_in_publications lip
25+ WHERE lip.leaflet = token_id
26+ INTO result.publications;
27+28+ -- If no publication data found, try leaflets_to_documents (for standalone documents)
29+ IF result.publications IS NULL THEN
30+ SELECT json_agg(row_to_json(ltd))
31+ FROM leaflets_to_documents ltd
32+ WHERE ltd.leaflet = token_id
33+ INTO result.publications;
34+ END IF;
35+36+ RETURN result;
37+END;$function$
38+;