···1616export async function deleteLeaflet(permission_token: PermissionToken) {
1717 const client = await pool.connect();
1818 const db = drizzle(client);
1919+2020+ // Get the current user's identity
2121+ let identity = await getIdentityData();
2222+2323+ // Check publication and document ownership in one query
2424+ let { data: tokenData } = await supabaseServerClient
2525+ .from("permission_tokens")
2626+ .select(
2727+ `
2828+ id,
2929+ leaflets_in_publications(publication, publications!inner(identity_did)),
3030+ leaflets_to_documents(document, documents!inner(uri))
3131+ `,
3232+ )
3333+ .eq("id", permission_token.id)
3434+ .single();
3535+3636+ if (tokenData) {
3737+ // Check if leaflet is in a publication
3838+ const leafletInPubs = tokenData.leaflets_in_publications || [];
3939+ if (leafletInPubs.length > 0) {
4040+ if (!identity) {
4141+ throw new Error(
4242+ "Unauthorized: You must be logged in to delete a leaflet in a publication",
4343+ );
4444+ }
4545+ const isOwner = leafletInPubs.some(
4646+ (pub: any) => pub.publications.identity_did === identity.atp_did,
4747+ );
4848+ if (!isOwner) {
4949+ throw new Error(
5050+ "Unauthorized: You must own the publication to delete this leaflet",
5151+ );
5252+ }
5353+ }
5454+5555+ // Check if there's a standalone published document
5656+ const leafletDocs = tokenData.leaflets_to_documents || [];
5757+ if (leafletDocs.length > 0) {
5858+ if (!identity) {
5959+ throw new Error(
6060+ "Unauthorized: You must be logged in to delete a published leaflet",
6161+ );
6262+ }
6363+ for (let leafletDoc of leafletDocs) {
6464+ const docUri = leafletDoc.documents?.uri;
6565+ // Extract the DID from the document URI (format: at://did:plc:xxx/...)
6666+ if (docUri && identity.atp_did && !docUri.includes(identity.atp_did)) {
6767+ throw new Error(
6868+ "Unauthorized: You must own the published document to delete this leaflet",
6969+ );
7070+ }
7171+ }
7272+ }
7373+ }
7474+1975 await db.transaction(async (tx) => {
2076 let [token] = await tx
2177 .select()
···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};
+17-21
app/(home-pages)/home/HomeLayout.tsx
···3030 PublicationBanner,
3131} from "./HomeEmpty/HomeEmpty";
32323333-type Leaflet = {
3333+export type Leaflet = {
3434 added_at: string;
3535 archived?: boolean | null;
3636 token: PermissionToken & {
···3838 GetLeafletDataReturnType["result"]["data"],
3939 null
4040 >["leaflets_in_publications"];
4141+ leaflets_to_documents?: Exclude<
4242+ GetLeafletDataReturnType["result"]["data"],
4343+ null
4444+ >["leaflets_to_documents"];
4145 };
4246};
4347···130134 ...identity.permission_token_on_homepage.reduce(
131135 (acc, tok) => {
132136 let title =
133133- tok.permission_tokens.leaflets_in_publications[0]?.title;
137137+ tok.permission_tokens.leaflets_in_publications[0]?.title ||
138138+ tok.permission_tokens.leaflets_to_documents[0]?.title;
134139 if (title) acc[tok.permission_tokens.root_entity] = title;
135140 return acc;
136141 },
···222227 value={{
223228 ...leaflet,
224229 leaflets_in_publications: leaflet.leaflets_in_publications || [],
230230+ leaflets_to_documents: leaflet.leaflets_to_documents || [],
225231 blocked_by_admin: null,
226232 custom_domain_routes: [],
227233 }}
···229235 <LeafletListItem
230236 title={props?.titles?.[leaflet.root_entity]}
231237 archived={archived}
232232- token={leaflet}
233233- draftInPublication={
234234- leaflet.leaflets_in_publications?.[0]?.publication
235235- }
236236- published={!!leaflet.leaflets_in_publications?.find((l) => l.doc)}
237237- publishedAt={
238238- leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents
239239- ?.indexed_at
240240- }
241241- document_uri={
242242- leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents
243243- ?.uri
244244- }
245245- leaflet_id={leaflet.root_entity}
246238 loggedIn={!!identity}
247239 display={display}
248240 added_at={added_at}
···292284293285 let filteredLeaflets = sortedLeaflets.filter(
294286 ({ token: leaflet, archived: archived }) => {
295295- let published = !!leaflet.leaflets_in_publications?.find((l) => l.doc);
287287+ let published =
288288+ !!leaflet.leaflets_in_publications?.find((l) => l.doc) ||
289289+ !!leaflet.leaflets_to_documents?.find((l) => l.document);
296290 let drafts = !!leaflet.leaflets_in_publications?.length && !published;
297291 let docs = !leaflet.leaflets_in_publications?.length && !archived;
298298- // If no filters are active, show all
292292+293293+ // If no filters are active, show everything that is not archived
299294 if (
300295 !filter.drafts &&
301296 !filter.published &&
···304299 )
305300 return archived === false || archived === null || archived == undefined;
306301302302+ //if a filter is on, return itemsd of that filter that are also NOT archived
307303 return (
308308- (filter.drafts && drafts) ||
309309- (filter.published && published) ||
310310- (filter.docs && docs) ||
304304+ (filter.drafts && drafts && !archived) ||
305305+ (filter.published && published && !archived) ||
306306+ (filter.docs && docs && !archived) ||
311307 (filter.archived && archived)
312308 );
313309 },
···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-1
app/lish/createPub/UpdatePubForm.tsx
···170170 </a>
171171 </p>
172172 <p className="text-xs text-tertiary font-normal">
173173- 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!
174178 </p>
175179 </div>
176180 </Checkbox>
+3-11
app/lish/createPub/getPublicationURL.ts
···33import { isProductionDomain } from "src/utils/isProductionDeployment";
44import { Json } from "supabase/database.types";
5566-export function getPublicationURL(pub: {
77- uri: string;
88- name: string;
99- record: Json;
1010-}) {
66+export function getPublicationURL(pub: { uri: string; record: Json }) {
117 let record = pub.record as PubLeafletPublication.Record;
128 if (isProductionDomain() && record?.base_path)
139 return `https://${record.base_path}`;
1410 else return getBasePublicationURL(pub);
1511}
16121717-export function getBasePublicationURL(pub: {
1818- uri: string;
1919- name: string;
2020- record: Json;
2121-}) {
1313+export function getBasePublicationURL(pub: { uri: string; record: Json }) {
2214 let record = pub.record as PubLeafletPublication.Record;
2315 let aturi = new AtUri(pub.uri);
2424- return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name || pub.name)}`;
1616+ return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`;
2517}
···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={`
+5-2
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(() => {
···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 { 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+;