···5353 }
54545555 // Check if there's a standalone published document
5656- const leafletDoc = tokenData.leaflets_to_documents;
5757- if (leafletDoc && leafletDoc.document) {
5858- if (!identity || !identity.atp_did) {
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- const docUri = leafletDoc.documents?.uri;
6464- // Extract the DID from the document URI (format: at://did:plc:xxx/...)
6565- if (docUri && !docUri.includes(identity.atp_did)) {
6666- throw new Error(
6767- "Unauthorized: You must own the published document to delete this leaflet",
6868- );
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+ }
6971 }
7072 }
7173 }
···8183 .where(eq(permission_tokens.id, permission_token.id));
82848385 if (!token?.permission_token_rights?.write) return;
8484- const entitySet = token.permission_token_rights.entity_set;
8585- if (!entitySet) return;
8686- await tx.delete(entities).where(eq(entities.set, entitySet));
8686+ await tx
8787+ .delete(entities)
8888+ .where(eq(entities.set, token.permission_token_rights.entity_set));
8789 await tx
8890 .delete(permission_tokens)
8991 .where(eq(permission_tokens.id, permission_token.id));
-3
actions/publications/moveLeafletToPublication.ts
···1111) {
1212 let identity = await getIdentityData();
1313 if (!identity || !identity.atp_did) return null;
1414-1515- // Verify publication ownership
1614 let { data: publication } = await supabaseServerClient
1715 .from("publications")
1816 .select("*")
···2018 .single();
2119 if (publication?.identity_did !== identity.atp_did) return;
22202323- // Save as a publication draft
2421 await supabaseServerClient.from("leaflets_in_publications").insert({
2522 publication: publication_uri,
2623 leaflet: leaflet_id,
-26
actions/publications/saveLeafletDraft.ts
···11-"use server";
22-33-import { getIdentityData } from "actions/getIdentityData";
44-import { supabaseServerClient } from "supabase/serverClient";
55-66-export async function saveLeafletDraft(
77- leaflet_id: string,
88- metadata: { title: string; description: string },
99- entitiesToDelete: string[],
1010-) {
1111- let identity = await getIdentityData();
1212- if (!identity || !identity.atp_did) return null;
1313-1414- // Save as a looseleaf draft in leaflets_to_documents with null document
1515- await supabaseServerClient.from("leaflets_to_documents").upsert({
1616- leaflet: leaflet_id,
1717- document: null,
1818- title: metadata.title,
1919- description: metadata.description,
2020- });
2121-2222- await supabaseServerClient
2323- .from("entities")
2424- .delete()
2525- .in("id", entitiesToDelete);
2626-}
+193-19
actions/publishToPublication.ts
···3232import { scanIndexLocal } from "src/replicache/utils";
3333import type { Fact } from "src/replicache";
3434import type { Attribute } from "src/replicache/attributes";
3535-import {
3636- Delta,
3737- YJSFragmentToString,
3838-} from "components/Blocks/TextBlock/RenderYJSFragment";
3535+import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString";
3936import { ids } from "lexicons/api/lexicons";
4037import { BlobRef } from "@atproto/lexicon";
4138import { AtUri } from "@atproto/syntax";
···5047 ColorToRGBA,
5148} from "components/ThemeManager/colorToLexicons";
5249import { parseColor } from "@react-stately/color";
5050+import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
5151+import { v7 } from "uuid";
53525453export async function publishToPublication({
5554 root_entity,
···5756 leaflet_id,
5857 title,
5958 description,
5959+ tags,
6060 entitiesToDelete,
6161}: {
6262 root_entity: string;
···6464 leaflet_id: string;
6565 title?: string;
6666 description?: string;
6767+ tags?: string[];
6768 entitiesToDelete?: string[];
6869}) {
6970 const oauthClient = await createOauthClient();
···143144 ...(theme && { theme }),
144145 title: title || "Untitled",
145146 description: description || "",
147147+ ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags)
146148 pages: pages.map((p) => {
147149 if (p.type === "canvas") {
148150 return {
···210212 }
211213 }
212214215215+ // Create notifications for mentions (only on first publish)
216216+ if (!existingDocUri) {
217217+ await createMentionNotifications(result.uri, record, credentialSession.did!);
218218+ }
219219+213220 return { rkey, record: JSON.parse(JSON.stringify(record)) };
214221}
215222···298305 if (!b) return [];
299306 let block: PubLeafletPagesLinearDocument.Block = {
300307 $type: "pub.leaflet.pages.linearDocument#block",
301301- alignment,
302308 block: b,
303309 };
310310+ if (alignment) block.alignment = alignment;
304311 return [block];
305312 } else {
306313 let block: PubLeafletPagesLinearDocument.Block = {
···342349 Y.applyUpdate(doc, update);
343350 let nodes = doc.getXmlElement("prosemirror").toArray();
344351 let stringValue = YJSFragmentToString(nodes[0]);
345345- let facets = YJSFragmentToFacets(nodes[0]);
352352+ let { facets } = YJSFragmentToFacets(nodes[0]);
346353 return [stringValue, facets] as const;
347354 };
348355 if (b.type === "card") {
···398405 let [stringValue, facets] = getBlockContent(b.value);
399406 let block: $Typed<PubLeafletBlocksHeader.Main> = {
400407 $type: "pub.leaflet.blocks.header",
401401- level: headingLevel?.data.value || 1,
408408+ level: Math.floor(headingLevel?.data.value || 1),
402409 plaintext: stringValue,
403410 facets,
404411 };
···431438 let block: $Typed<PubLeafletBlocksIframe.Main> = {
432439 $type: "pub.leaflet.blocks.iframe",
433440 url: url.data.value,
434434- height: height?.data.value || 600,
441441+ height: Math.floor(height?.data.value || 600),
435442 };
436443 return block;
437444 }
···445452 $type: "pub.leaflet.blocks.image",
446453 image: blobref,
447454 aspectRatio: {
448448- height: image.data.height,
449449- width: image.data.width,
455455+ height: Math.floor(image.data.height),
456456+ width: Math.floor(image.data.width),
450457 },
451458 alt: altText ? altText.data.value : undefined,
452459 };
···603610604611function YJSFragmentToFacets(
605612 node: Y.XmlElement | Y.XmlText | Y.XmlHook,
606606-): PubLeafletRichtextFacet.Main[] {
613613+ byteOffset: number = 0,
614614+): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } {
607615 if (node.constructor === Y.XmlElement) {
608608- return node
609609- .toArray()
610610- .map((f) => YJSFragmentToFacets(f))
611611- .flat();
616616+ // Handle inline mention nodes
617617+ if (node.nodeName === "didMention") {
618618+ const text = node.getAttribute("text") || "";
619619+ const unicodestring = new UnicodeString(text);
620620+ const facet: PubLeafletRichtextFacet.Main = {
621621+ index: {
622622+ byteStart: byteOffset,
623623+ byteEnd: byteOffset + unicodestring.length,
624624+ },
625625+ features: [
626626+ {
627627+ $type: "pub.leaflet.richtext.facet#didMention",
628628+ did: node.getAttribute("did"),
629629+ },
630630+ ],
631631+ };
632632+ return { facets: [facet], byteLength: unicodestring.length };
633633+ }
634634+635635+ if (node.nodeName === "atMention") {
636636+ const text = node.getAttribute("text") || "";
637637+ const unicodestring = new UnicodeString(text);
638638+ const facet: PubLeafletRichtextFacet.Main = {
639639+ index: {
640640+ byteStart: byteOffset,
641641+ byteEnd: byteOffset + unicodestring.length,
642642+ },
643643+ features: [
644644+ {
645645+ $type: "pub.leaflet.richtext.facet#atMention",
646646+ atURI: node.getAttribute("atURI"),
647647+ },
648648+ ],
649649+ };
650650+ return { facets: [facet], byteLength: unicodestring.length };
651651+ }
652652+653653+ if (node.nodeName === "hard_break") {
654654+ const unicodestring = new UnicodeString("\n");
655655+ return { facets: [], byteLength: unicodestring.length };
656656+ }
657657+658658+ // For other elements (like paragraph), process children
659659+ let allFacets: PubLeafletRichtextFacet.Main[] = [];
660660+ let currentOffset = byteOffset;
661661+ for (const child of node.toArray()) {
662662+ const result = YJSFragmentToFacets(child, currentOffset);
663663+ allFacets.push(...result.facets);
664664+ currentOffset += result.byteLength;
665665+ }
666666+ return { facets: allFacets, byteLength: currentOffset - byteOffset };
612667 }
668668+613669 if (node.constructor === Y.XmlText) {
614670 let facets: PubLeafletRichtextFacet.Main[] = [];
615671 let delta = node.toDelta() as Delta[];
616616- let byteStart = 0;
672672+ let byteStart = byteOffset;
673673+ let totalLength = 0;
617674 for (let d of delta) {
618675 let unicodestring = new UnicodeString(d.insert);
619676 let facet: PubLeafletRichtextFacet.Main = {
···646703 });
647704 if (facet.features.length > 0) facets.push(facet);
648705 byteStart += unicodestring.length;
706706+ totalLength += unicodestring.length;
649707 }
650650- return facets;
708708+ return { facets, byteLength: totalLength };
651709 }
652652- return [];
710710+ return { facets: [], byteLength: 0 };
653711}
654712655713type ExcludeString<T> = T extends string
···712770 image: blob.data.blob,
713771 repeat: backgroundImageRepeat?.data.value ? true : false,
714772 ...(backgroundImageRepeat?.data.value && {
715715- width: backgroundImageRepeat.data.value,
773773+ width: Math.floor(backgroundImageRepeat.data.value),
716774 }),
717775 };
718776 }
···725783726784 return undefined;
727785}
786786+787787+/**
788788+ * Extract mentions from a published document and create notifications
789789+ */
790790+async function createMentionNotifications(
791791+ documentUri: string,
792792+ record: PubLeafletDocument.Record,
793793+ authorDid: string,
794794+) {
795795+ const mentionedDids = new Set<string>();
796796+ const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
797797+ const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
798798+799799+ // Extract mentions from all text blocks in all pages
800800+ for (const page of record.pages) {
801801+ if (page.$type === "pub.leaflet.pages.linearDocument") {
802802+ const linearPage = page as PubLeafletPagesLinearDocument.Main;
803803+ for (const blockWrapper of linearPage.blocks) {
804804+ const block = blockWrapper.block;
805805+ if (block.$type === "pub.leaflet.blocks.text") {
806806+ const textBlock = block as PubLeafletBlocksText.Main;
807807+ if (textBlock.facets) {
808808+ for (const facet of textBlock.facets) {
809809+ for (const feature of facet.features) {
810810+ // Check for DID mentions
811811+ if (PubLeafletRichtextFacet.isDidMention(feature)) {
812812+ if (feature.did !== authorDid) {
813813+ mentionedDids.add(feature.did);
814814+ }
815815+ }
816816+ // Check for AT URI mentions (publications and documents)
817817+ if (PubLeafletRichtextFacet.isAtMention(feature)) {
818818+ const uri = new AtUri(feature.atURI);
819819+820820+ if (uri.collection === "pub.leaflet.publication") {
821821+ // Get the publication owner's DID
822822+ const { data: publication } = await supabaseServerClient
823823+ .from("publications")
824824+ .select("identity_did")
825825+ .eq("uri", feature.atURI)
826826+ .single();
827827+828828+ if (publication && publication.identity_did !== authorDid) {
829829+ mentionedPublications.set(publication.identity_did, feature.atURI);
830830+ }
831831+ } else if (uri.collection === "pub.leaflet.document") {
832832+ // Get the document owner's DID
833833+ const { data: document } = await supabaseServerClient
834834+ .from("documents")
835835+ .select("uri, data")
836836+ .eq("uri", feature.atURI)
837837+ .single();
838838+839839+ if (document) {
840840+ const docRecord = document.data as PubLeafletDocument.Record;
841841+ if (docRecord.author !== authorDid) {
842842+ mentionedDocuments.set(docRecord.author, feature.atURI);
843843+ }
844844+ }
845845+ }
846846+ }
847847+ }
848848+ }
849849+ }
850850+ }
851851+ }
852852+ }
853853+ }
854854+855855+ // Create notifications for DID mentions
856856+ for (const did of mentionedDids) {
857857+ const notification: Notification = {
858858+ id: v7(),
859859+ recipient: did,
860860+ data: {
861861+ type: "mention",
862862+ document_uri: documentUri,
863863+ mention_type: "did",
864864+ },
865865+ };
866866+ await supabaseServerClient.from("notifications").insert(notification);
867867+ await pingIdentityToUpdateNotification(did);
868868+ }
869869+870870+ // Create notifications for publication mentions
871871+ for (const [recipientDid, publicationUri] of mentionedPublications) {
872872+ const notification: Notification = {
873873+ id: v7(),
874874+ recipient: recipientDid,
875875+ data: {
876876+ type: "mention",
877877+ document_uri: documentUri,
878878+ mention_type: "publication",
879879+ mentioned_uri: publicationUri,
880880+ },
881881+ };
882882+ await supabaseServerClient.from("notifications").insert(notification);
883883+ await pingIdentityToUpdateNotification(recipientDid);
884884+ }
885885+886886+ // Create notifications for document mentions
887887+ for (const [recipientDid, mentionedDocUri] of mentionedDocuments) {
888888+ const notification: Notification = {
889889+ id: v7(),
890890+ recipient: recipientDid,
891891+ data: {
892892+ type: "mention",
893893+ document_uri: documentUri,
894894+ mention_type: "document",
895895+ mentioned_uri: mentionedDocUri,
896896+ },
897897+ };
898898+ await supabaseServerClient.from("notifications").insert(notification);
899899+ await pingIdentityToUpdateNotification(recipientDid);
900900+ }
901901+}
···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>(
+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";
···2727import { useState, useMemo } from "react";
2828import { useIsMobile } from "src/hooks/isMobile";
2929import { useReplicache, useEntity } from "src/replicache";
3030+import { useSubscribe } from "src/replicache/useSubscribe";
3031import { Json } from "supabase/database.types";
3132import {
3233 useBlocks,
···3435} from "src/hooks/queries/useBlocks";
3536import * as Y from "yjs";
3637import * as base64 from "base64-js";
3737-import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
3838+import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
3839import { BlueskyLogin } from "app/login/LoginForm";
3940import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
4040-import { saveLeafletDraft } from "actions/publications/saveLeafletDraft";
4141import { AddTiny } from "components/Icons/AddTiny";
42424343export const PublishButton = (props: { entityID: string }) => {
···6464const UpdateButton = () => {
6565 let [isLoading, setIsLoading] = useState(false);
6666 let { data: pub, mutate } = useLeafletPublicationData();
6767- let { permission_token, rootEntity } = useReplicache();
6767+ let { permission_token, rootEntity, rep } = useReplicache();
6868 let { identity } = useIdentityData();
6969 let toaster = useToaster();
7070+7171+ // Get tags from Replicache state (same as draft editor)
7272+ let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags"));
7373+ const currentTags = Array.isArray(tags) ? tags : [];
70747175 return (
7276 <ActionButton
···8286 leaflet_id: permission_token.id,
8387 title: pub.title,
8488 description: pub.description,
8989+ tags: currentTags,
8590 });
8691 setIsLoading(false);
8792 mutate();
···109114 let { identity } = useIdentityData();
110115 let { permission_token } = useReplicache();
111116 let query = useSearchParams();
112112- console.log(query.get("publish"));
113117 let [open, setOpen] = useState(query.get("publish") !== null);
114118115119 let isMobile = useIsMobile();
···177181 <hr className="border-border-light mt-3 mb-2" />
178182179183 <div className="flex gap-2 items-center place-self-end">
180180- {selectedPub && selectedPub !== "create" && (
184184+ {selectedPub !== "looseleaf" && selectedPub && (
181185 <SaveAsDraftButton
182186 selectedPub={selectedPub}
183187 leafletId={permission_token.id}
···230234 if (props.selectedPub === "create") return;
231235 e.preventDefault();
232236 setIsLoading(true);
233233-234234- // Use different actions for looseleaf vs publication
235235- if (props.selectedPub === "looseleaf") {
236236- await saveLeafletDraft(
237237- props.leafletId,
238238- props.metadata,
239239- props.entitiesToDelete,
240240- );
241241- } else {
242242- await moveLeafletToPublication(
243243- props.leafletId,
244244- props.selectedPub,
245245- props.metadata,
246246- props.entitiesToDelete,
247247- );
248248- }
249249-237237+ await moveLeafletToPublication(
238238+ props.leafletId,
239239+ props.selectedPub,
240240+ props.metadata,
241241+ props.entitiesToDelete,
242242+ );
250243 await Promise.all([rep?.pull(), mutate()]);
251244 setIsLoading(false);
252245 }}
+1-1
app/[leaflet_id]/page.tsx
···4455import type { Fact } from "src/replicache";
66import type { Attribute } from "src/replicache/attributes";
77-import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
77+import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
88import { Leaflet } from "./Leaflet";
99import { scanIndexLocal } from "src/replicache/utils";
1010import { getRSVPData } from "actions/getRSVPData";
···2323 currentPubUri: string | undefined;
2424}) => {
2525 let { identity } = useIdentityData();
2626- let hasLooseleafs = identity?.permission_token_on_homepage.find(
2626+ let hasLooseleafs = !!identity?.permission_token_on_homepage.find(
2727 (f) =>
2828 f.permission_tokens.leaflets_to_documents &&
2929- f.permission_tokens.leaflets_to_documents.document,
2929+ f.permission_tokens.leaflets_to_documents[0]?.document,
3030 );
3131- console.log(hasLooseleafs);
32313332 // don't show pub list button if not logged in or no pub list
3433 // we show a "start a pub" banner instead
+46
components/AtMentionLink.tsx
···11+import { AtUri } from "@atproto/api";
22+import { atUriToUrl } from "src/utils/mentionUtils";
33+44+/**
55+ * Component for rendering at-uri mentions (publications and documents) as clickable links.
66+ * NOTE: This component's styling and behavior should match the ProseMirror schema rendering
77+ * in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other.
88+ */
99+export function AtMentionLink({
1010+ atURI,
1111+ children,
1212+ className = "",
1313+}: {
1414+ atURI: string;
1515+ children: React.ReactNode;
1616+ className?: string;
1717+}) {
1818+ const aturi = new AtUri(atURI);
1919+ const isPublication = aturi.collection === "pub.leaflet.publication";
2020+ const isDocument = aturi.collection === "pub.leaflet.document";
2121+2222+ // Show publication icon if available
2323+ const icon =
2424+ isPublication || isDocument ? (
2525+ <img
2626+ src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`}
2727+ className="inline-block w-5 h-5 rounded-full mr-1 align-text-top"
2828+ alt=""
2929+ width="20"
3030+ height="20"
3131+ loading="lazy"
3232+ />
3333+ ) : null;
3434+3535+ return (
3636+ <a
3737+ href={atUriToUrl(atURI)}
3838+ target="_blank"
3939+ rel="noopener noreferrer"
4040+ className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
4141+ >
4242+ {icon}
4343+ {children}
4444+ </a>
4545+ );
4646+}
···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(
···368338 );
369339};
370340371371-// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
372372-export function getColorContrast(color1: string, color2: string) {
373373- ColorSpace.register(sRGB);
374374-375375- let parsedColor1 = parse(`rgb(${color1})`);
376376- let parsedColor2 = parse(`rgb(${color2})`);
377377-378378- return contrastLstar(parsedColor1, parsedColor2);
379379-}
+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";
···3232 (p) => p.leaflets_in_publications?.length,
3333 )?.leaflets_in_publications?.[0];
34343535- // If not found, check for standalone documents (looseleafs)
3636- let standaloneDoc = data?.leaflets_to_documents;
3737-3838- // Only use standaloneDoc if it exists and has meaningful data
3939- // (either published with a document, or saved as draft with a title)
4040- if (
4141- !pubData &&
4242- standaloneDoc &&
4343- (standaloneDoc.document || standaloneDoc.title)
4444- ) {
3535+ // If not found, check for standalone documents
3636+ let standaloneDoc =
3737+ data?.leaflets_to_documents?.[0] ||
3838+ data?.permission_token_rights[0].entity_sets?.permission_tokens.find(
3939+ (p) => p.leaflets_to_documents?.length,
4040+ )?.leaflets_to_documents?.[0];
4141+ if (!pubData && standaloneDoc) {
4542 // Transform standalone document data to match the expected format
4643 pubData = {
4744 ...standaloneDoc,
4845 publications: null, // No publication for standalone docs
4946 doc: standaloneDoc.document,
5050- leaflet: data.id,
5147 };
5248 }
5353-5454- // Also check nested permission tokens for looseleafs
5555- if (!pubData) {
5656- let nestedStandaloneDoc =
5757- data?.permission_token_rights[0].entity_sets?.permission_tokens?.find(
5858- (p) =>
5959- p.leaflets_to_documents &&
6060- (p.leaflets_to_documents.document || p.leaflets_to_documents.title),
6161- )?.leaflets_to_documents;
6262-6363- if (nestedStandaloneDoc) {
6464- pubData = {
6565- ...nestedStandaloneDoc,
6666- publications: null,
6767- doc: nestedStandaloneDoc.document,
6868- leaflet: data.id,
6969- };
7070- }
7171- }
7272-7349 return pubData;
7450}
+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+;