···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>(
+2-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";
···16 },
17) => {
18 let record = props.record as PubLeafletPublication.Record;
19- let theme = usePubTheme(record);
20 let backgroundImage = record?.theme?.backgroundImage?.image?.ref
21 ? blobRefToSrc(
22 record?.theme?.backgroundImage?.image?.ref,
···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";
···17 },
18) => {
19 let record = props.record as PubLeafletPublication.Record;
20+ let theme = usePubTheme(record.theme);
21 let backgroundImage = record?.theme?.backgroundImage?.image?.ref
22 ? blobRefToSrc(
23 record?.theme?.backgroundImage?.image?.ref,
···1import { MentionTiny } from "components/Icons/MentionTiny";
2import { ContentLayout, Notification } from "./Notification";
00034-export const DummyPostMentionNotification = (props: {}) => {
5- return (
6- <Notification
7- timestamp={""}
8- href="/"
9- icon={<MentionTiny />}
10- actionText={<>celine mentioned your post</>}
11- content={
12- <ContentLayout
13- postTitle={"Post Title Here"}
14- pubRecord={{ name: "My Publication" } as any}
15- >
16- I'm just gonna put the description here. The surrounding context is
17- just sort of a pain to figure out
18- <div className="border border-border-light rounded-md p-1 my-1 text-xs text-secondary">
19- <div className="font-bold">Title of the Mentioned Post</div>
20- <div className="text-tertiary">
21- And here is the description that follows it
22- </div>
23- </div>
24- </ContentLayout>
25- }
26- />
27- );
28-};
00000000000000000000002930-export const DummyUserMentionNotification = (props: {
31- cardBorderHidden: boolean;
32-}) => {
33 return (
34 <Notification
35- timestamp={""}
36- href="/"
37 icon={<MentionTiny />}
38- actionText={<>celine mentioned you</>}
39 content={
40- <ContentLayout
41- postTitle={"Post Title Here"}
42- pubRecord={{ name: "My Publication" } as any}
43- >
44- <div>
45- ...llo this is the content of a post or whatever here it comes{" "}
46- <span className="text-accent-contrast">@celine </span> and here it
47- was! ooooh heck yeah the high is unre...
48- </div>
49 </ContentLayout>
50 }
51 />
···1+import { Color } from "react-aria-components";
2+3+export function ColorToRGBA(color: Color) {
4+ if (!color)
5+ return {
6+ $type: "pub.leaflet.theme.color#rgba" as const,
7+ r: 0,
8+ g: 0,
9+ b: 0,
10+ a: 1,
11+ };
12+ let c = color.toFormat("rgba");
13+ const r = c.getChannelValue("red");
14+ const g = c.getChannelValue("green");
15+ const b = c.getChannelValue("blue");
16+ const a = c.getChannelValue("alpha");
17+ return {
18+ $type: "pub.leaflet.theme.color#rgba" as const,
19+ r: Math.round(r),
20+ g: Math.round(g),
21+ b: Math.round(b),
22+ a: Math.round(a * 100),
23+ };
24+}
25+26+export function ColorToRGB(color: Color) {
27+ if (!color)
28+ return {
29+ $type: "pub.leaflet.theme.color#rgb" as const,
30+ r: 0,
31+ g: 0,
32+ b: 0,
33+ };
34+ let c = color.toFormat("rgb");
35+ const r = c.getChannelValue("red");
36+ const g = c.getChannelValue("green");
37+ const b = c.getChannelValue("blue");
38+ return {
39+ $type: "pub.leaflet.theme.color#rgb" as const,
40+ r: Math.round(r),
41+ g: Math.round(g),
42+ b: Math.round(b),
43+ };
44+}
+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";
···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";
···6import { validate as _validate } from '../../../lexicons'
7import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
8import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef'
09import type * as PubLeafletPagesLinearDocument from './pages/linearDocument'
10import type * as PubLeafletPagesCanvas from './pages/canvas'
11···19 postRef?: ComAtprotoRepoStrongRef.Main
20 description?: string
21 publishedAt?: string
22- publication: string
23 author: string
0024 pages: (
25 | $Typed<PubLeafletPagesLinearDocument.Main>
26 | $Typed<PubLeafletPagesCanvas.Main>
···6import { validate as _validate } from '../../../lexicons'
7import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
8import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef'
9+import type * as PubLeafletPublication from './publication'
10import type * as PubLeafletPagesLinearDocument from './pages/linearDocument'
11import type * as PubLeafletPagesCanvas from './pages/canvas'
12···20 postRef?: ComAtprotoRepoStrongRef.Main
21 description?: string
22 publishedAt?: string
23+ publication?: string
24 author: string
25+ theme?: PubLeafletPublication.Theme
26+ tags?: string[]
27 pages: (
28 | $Typed<PubLeafletPagesLinearDocument.Main>
29 | $Typed<PubLeafletPagesCanvas.Main>
···1/// <reference types="next" />
2/// <reference types="next/image-types/global" />
3-/// <reference path="./.next/types/routes.d.ts" />
45// NOTE: This file should not be edited
6// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
···1/// <reference types="next" />
2/// <reference types="next/image-types/global" />
3+import "./.next/dev/types/routes.d.ts";
45// NOTE: This file should not be edited
6// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
···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 table "public"."leaflets_to_documents" (
2+ "leaflet" uuid not null,
3+ "document" text not null,
4+ "created_at" timestamp with time zone not null default now(),
5+ "title" text not null default ''::text,
6+ "description" text not null default ''::text
7+);
8+9+alter table "public"."leaflets_to_documents" enable row level security;
10+11+CREATE UNIQUE INDEX leaflets_to_documents_pkey ON public.leaflets_to_documents USING btree (leaflet, document);
12+13+alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_pkey" PRIMARY KEY using index "leaflets_to_documents_pkey";
14+15+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;
16+17+alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_document_fkey";
18+19+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;
20+21+alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_leaflet_fkey";
22+23+grant delete on table "public"."leaflets_to_documents" to "anon";
24+25+grant insert on table "public"."leaflets_to_documents" to "anon";
26+27+grant references on table "public"."leaflets_to_documents" to "anon";
28+29+grant select on table "public"."leaflets_to_documents" to "anon";
30+31+grant trigger on table "public"."leaflets_to_documents" to "anon";
32+33+grant truncate on table "public"."leaflets_to_documents" to "anon";
34+35+grant update on table "public"."leaflets_to_documents" to "anon";
36+37+grant delete on table "public"."leaflets_to_documents" to "authenticated";
38+39+grant insert on table "public"."leaflets_to_documents" to "authenticated";
40+41+grant references on table "public"."leaflets_to_documents" to "authenticated";
42+43+grant select on table "public"."leaflets_to_documents" to "authenticated";
44+45+grant trigger on table "public"."leaflets_to_documents" to "authenticated";
46+47+grant truncate on table "public"."leaflets_to_documents" to "authenticated";
48+49+grant update on table "public"."leaflets_to_documents" to "authenticated";
50+51+grant delete on table "public"."leaflets_to_documents" to "service_role";
52+53+grant insert on table "public"."leaflets_to_documents" to "service_role";
54+55+grant references on table "public"."leaflets_to_documents" to "service_role";
56+57+grant select on table "public"."leaflets_to_documents" to "service_role";
58+59+grant trigger on table "public"."leaflets_to_documents" to "service_role";
60+61+grant truncate on table "public"."leaflets_to_documents" to "service_role";
62+63+grant update on table "public"."leaflets_to_documents" to "service_role";
···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+;