···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>(
···12import { AddLeafletToHomepage } from "components/utils/AddLeafletToHomepage";
13import { UpdateLeafletTitle } from "components/utils/UpdateLeafletTitle";
14import { useUIState } from "src/useUIState";
15-import { LeafletSidebar } from "./Sidebar";
1617export function Leaflet(props: {
18 token: PermissionToken;
···36 <SelectionManager />
37 {/* we need the padding bottom here because if we don't have it the mobile footer will cut off...
38 the dropshadow on the page... the padding is compensated by a negative top margin in mobile footer */}
39- <div
40- className="leafletContentWrapper w-full relative overflow-x-scroll snap-x snap-mandatory no-scrollbar grow items-stretch flex h-full pb-4 pwa-padding"
41- id="page-carousel"
42- >
43- {/* if you adjust this padding, remember to adjust the negative margins on page in Pages/index when card borders are hidden (also applies for the pb in the parent div)*/}
44- <div
45- id="pages"
46- className="pages flex pt-2 pb-1 sm:pb-8 sm:pt-6"
47- onClick={(e) => {
48- e.currentTarget === e.target && blurPage();
49- }}
50- >
51- <LeafletSidebar leaflet_id={props.leaflet_id} />
52- <Pages rootPage={props.leaflet_id} />
53- </div>
54- </div>
55 <LeafletFooter entityID={props.leaflet_id} />
56 </ThemeBackgroundProvider>
57 </ThemeProvider>
···59 </ReplicacheProvider>
60 );
61}
62-63-const blurPage = () => {
64- useUIState.setState(() => ({
65- focusedEntity: null,
66- selectedBlocks: [],
67- }));
68-};
···12import { AddLeafletToHomepage } from "components/utils/AddLeafletToHomepage";
13import { UpdateLeafletTitle } from "components/utils/UpdateLeafletTitle";
14import { useUIState } from "src/useUIState";
15+import { LeafletLayout } from "components/LeafletLayout";
1617export function Leaflet(props: {
18 token: PermissionToken;
···36 <SelectionManager />
37 {/* we need the padding bottom here because if we don't have it the mobile footer will cut off...
38 the dropshadow on the page... the padding is compensated by a negative top margin in mobile footer */}
39+ <LeafletLayout className="!pb-[64px] sm:!pb-6">
40+ <Pages rootPage={props.leaflet_id} />
41+ </LeafletLayout>
000000000000042 <LeafletFooter entityID={props.leaflet_id} />
43 </ThemeBackgroundProvider>
44 </ThemeProvider>
···46 </ReplicacheProvider>
47 );
48}
0000000
···13type Props = {
14 // this is now a token id not leaflet! Should probs rename
15 params: Promise<{ leaflet_id: string }>;
00000016};
17export default async function PublishLeafletPage(props: Props) {
18 let leaflet_id = (await props.params).leaflet_id;
···27 *,
28 documents_in_publications(count)
29 ),
30- documents(*))`,
000031 )
32 .eq("id", leaflet_id)
33 .single();
34 let rootEntity = data?.root_entity;
35- if (!data || !rootEntity || !data.leaflets_in_publications[0])
0000000000000000000036 return (
37 <div>
38 missin something
···4243 let identity = await getIdentityData();
44 if (!identity || !identity.atp_did) return null;
45- let pub = data.leaflets_in_publications[0];
46- let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
470000000000048 let profile = await agent.getProfile({ actor: identity.atp_did });
000000000000000000049 return (
50 <ReplicacheProvider
51 rootEntity={rootEntity}
···57 leaflet_id={leaflet_id}
58 root_entity={rootEntity}
59 profile={profile.data}
60- title={pub.title}
61- publication_uri={pub.publication}
62- description={pub.description}
63- record={pub.publications?.record as PubLeafletPublication.Record}
64- posts_in_pub={pub.publications?.documents_in_publications[0].count}
0065 />
66 </ReplicacheProvider>
67 );
···13type Props = {
14 // this is now a token id not leaflet! Should probs rename
15 params: Promise<{ leaflet_id: string }>;
16+ searchParams: Promise<{
17+ publication_uri: string;
18+ title: string;
19+ description: string;
20+ entitiesToDelete: string;
21+ }>;
22};
23export default async function PublishLeafletPage(props: Props) {
24 let leaflet_id = (await props.params).leaflet_id;
···33 *,
34 documents_in_publications(count)
35 ),
36+ documents(*)),
37+ leaflets_to_documents(
38+ *,
39+ documents(*)
40+ )`,
41 )
42 .eq("id", leaflet_id)
43 .single();
44 let rootEntity = data?.root_entity;
45+46+ // Try to find publication from leaflets_in_publications first
47+ let publication = data?.leaflets_in_publications[0]?.publications;
48+49+ // If not found, check if publication_uri is in searchParams
50+ if (!publication) {
51+ let pub_uri = (await props.searchParams).publication_uri;
52+ if (pub_uri) {
53+ console.log(decodeURIComponent(pub_uri));
54+ let { data: pubData, error } = await supabaseServerClient
55+ .from("publications")
56+ .select("*, documents_in_publications(count)")
57+ .eq("uri", decodeURIComponent(pub_uri))
58+ .single();
59+ console.log(error);
60+ publication = pubData;
61+ }
62+ }
63+64+ // Check basic data requirements
65+ if (!data || !rootEntity)
66 return (
67 <div>
68 missin something
···7273 let identity = await getIdentityData();
74 if (!identity || !identity.atp_did) return null;
007576+ // Get title and description from either source
77+ let title =
78+ data.leaflets_in_publications[0]?.title ||
79+ data.leaflets_to_documents[0]?.title ||
80+ decodeURIComponent((await props.searchParams).title || "");
81+ let description =
82+ data.leaflets_in_publications[0]?.description ||
83+ data.leaflets_to_documents[0]?.description ||
84+ decodeURIComponent((await props.searchParams).description || "");
85+86+ let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
87 let profile = await agent.getProfile({ actor: identity.atp_did });
88+89+ // Parse entitiesToDelete from URL params
90+ let searchParams = await props.searchParams;
91+ let entitiesToDelete: string[] = [];
92+ try {
93+ if (searchParams.entitiesToDelete) {
94+ entitiesToDelete = JSON.parse(
95+ decodeURIComponent(searchParams.entitiesToDelete),
96+ );
97+ }
98+ } catch (e) {
99+ // If parsing fails, just use empty array
100+ }
101+102+ // Check if a draft record exists (either in a publication or standalone)
103+ let hasDraft =
104+ data.leaflets_in_publications.length > 0 ||
105+ data.leaflets_to_documents.length > 0;
106+107 return (
108 <ReplicacheProvider
109 rootEntity={rootEntity}
···115 leaflet_id={leaflet_id}
116 root_entity={rootEntity}
117 profile={profile.data}
118+ title={title}
119+ description={description}
120+ publication_uri={publication?.uri}
121+ record={publication?.record as PubLeafletPublication.Record | undefined}
122+ posts_in_pub={publication?.documents_in_publications[0]?.count}
123+ entitiesToDelete={entitiesToDelete}
124+ hasDraft={hasDraft}
125 />
126 </ReplicacheProvider>
127 );
+9-8
app/[leaflet_id]/publish/publishBskyPost.ts
···12import { createOauthClient } from "src/atproto-oauth";
13import { supabaseServerClient } from "supabase/serverClient";
14import { Json } from "supabase/database.types";
00001516export async function publishPostToBsky(args: {
17 text: string;
···31 credentialSession.fetchHandler.bind(credentialSession),
32 );
33 let newPostUrl = args.url;
34- let preview_image = await fetch(
35- `https://pro.microlink.io/?url=${newPostUrl}&screenshot=true&viewport.width=1400&viewport.height=733&meta=false&embed=screenshot.url&force=true`,
36- {
37- headers: {
38- "x-api-key": process.env.MICROLINK_API_KEY!,
39- },
40- },
41- );
4243 let binary = await preview_image.blob();
44 let resized_preview_image = await sharp(await binary.arrayBuffer())
···12import { createOauthClient } from "src/atproto-oauth";
13import { supabaseServerClient } from "supabase/serverClient";
14import { Json } from "supabase/database.types";
15+import {
16+ getMicroLinkOgImage,
17+ getWebpageImage,
18+} from "src/utils/getMicroLinkOgImage";
1920export async function publishPostToBsky(args: {
21 text: string;
···35 credentialSession.fetchHandler.bind(credentialSession),
36 );
37 let newPostUrl = args.url;
38+ let preview_image = await getWebpageImage(newPostUrl, {
39+ width: 1400,
40+ height: 733,
41+ noCache: true,
42+ });
0004344 let binary = await preview_image.blob();
45 let resized_preview_image = await sharp(await binary.arrayBuffer())
-30
app/about/page.tsx
···1-import { LegalContent } from "app/legal/content";
2-import Link from "next/link";
3-4-export default function AboutPage() {
5- return (
6- <div className="flex flex-col gap-2">
7- <div className="flex flex-col h-[80vh] mx-auto sm:px-4 px-3 sm:py-6 py-4 max-w-prose gap-4 text-lg">
8- <p>
9- Leaflet.pub is a web app for instantly creating and collaborating on
10- documents.{" "}
11- <Link href="/" prefetch={false}>
12- Click here
13- </Link>{" "}
14- to create one and get started!
15- </p>
16-17- <p>
18- Leaflet is made by Learning Futures Inc. Previously we built{" "}
19- <a href="https://hyperlink.academy">hyperlink.academy</a>
20- </p>
21- <p>
22- You can find us on{" "}
23- <a href="https://bsky.app/profile/leaflet.pub">Bluesky</a> or email as
24- at <a href="mailto:contact@leaflet.pub">contact@leaflet.pub</a>
25- </p>
26- </div>
27- <LegalContent />
28- </div>
29- );
30-}
···000000000000000000000000000000
+6-1
app/api/atproto_images/route.ts
···16 if (!service) return new NextResponse(null, { status: 404 });
17 const response = await fetch(
18 `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${params.did}&cid=${params.cid}`,
0000019 );
2021 // Clone the response to modify headers
···24 // Set cache-control header to cache indefinitely
25 cachedResponse.headers.set(
26 "Cache-Control",
27- "public, max-age=31536000, immutable",
28 );
29 cachedResponse.headers.set(
30 "CDN-Cache-Control",
···3import { AtpAgent, AtUri } from "@atproto/api";
4import { Json } from "supabase/database.types";
5import { ids } from "lexicons/api/lexicons";
6+import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
7+import { v7 } from "uuid";
8+import { idResolver } from "app/(home-pages)/reader/idResolver";
910export const index_post_mention = inngest.createFunction(
11 { id: "index_post_mention" },
···14 let url = new URL(event.data.document_link);
15 let path = url.pathname.split("/").filter(Boolean);
1617+ // Check if this is a standalone document URL (/p/didOrHandle/rkey/...)
18+ const isStandaloneDoc = path[0] === "p" && path.length >= 3;
19+20+ let documentUri: string;
21+ let authorDid: string;
22+23+ if (isStandaloneDoc) {
24+ // Standalone doc: /p/didOrHandle/rkey/l-quote/...
25+ const didOrHandle = decodeURIComponent(path[1]);
26+ const rkey = path[2];
27+28+ // Resolve handle to DID if necessary
29+ let did = didOrHandle;
30+ if (!didOrHandle.startsWith("did:")) {
31+ const resolved = await step.run("resolve-handle", async () => {
32+ return idResolver.handle.resolve(didOrHandle);
33+ });
34+ if (!resolved) {
35+ return { message: `Could not resolve handle: ${didOrHandle}` };
36+ }
37+ did = resolved;
38+ }
3940+ documentUri = AtUri.make(did, ids.PubLeafletDocument, rkey).toString();
41+ authorDid = did;
42+ } else {
43+ // Publication post: look up by custom domain
44+ let { data: pub, error } = await supabaseServerClient
45+ .from("publications")
46+ .select("*")
47+ .eq("record->>base_path", url.host)
48+ .single();
49+50+ if (!pub) {
51+ return {
52+ message: `No publication found for ${url.host}/${path[0]}`,
53+ error,
54+ };
55+ }
56+57+ documentUri = AtUri.make(
58+ pub.identity_did,
59+ ids.PubLeafletDocument,
60+ path[0],
61+ ).toString();
62+ authorDid = pub.identity_did;
63 }
6465 let bsky_post = await step.run("get-bsky-post-data", async () => {
···76 }
7778 await step.run("index-bsky-post", async () => {
79+ await supabaseServerClient.from("bsky_posts").upsert({
80 uri: bsky_post.uri,
81 cid: bsky_post.cid,
82 post_view: bsky_post as Json,
83 });
84+ await supabaseServerClient.from("document_mentions_in_bsky").upsert({
85 uri: bsky_post.uri,
86+ document: documentUri,
000087 link: event.data.document_link,
88 });
89+ });
90+91+ await step.run("create-notification", async () => {
92+ // Only create notification if the quote is from someone other than the author
93+ if (bsky_post.author.did !== authorDid) {
94+ // Check if a notification already exists for this post and recipient
95+ const { data: existingNotification } = await supabaseServerClient
96+ .from("notifications")
97+ .select("id")
98+ .eq("recipient", authorDid)
99+ .eq("data->>type", "quote")
100+ .eq("data->>bsky_post_uri", bsky_post.uri)
101+ .eq("data->>document_uri", documentUri)
102+ .single();
103+104+ if (!existingNotification) {
105+ const notification: Notification = {
106+ id: v7(),
107+ recipient: authorDid,
108+ data: {
109+ type: "quote",
110+ bsky_post_uri: bsky_post.uri,
111+ document_uri: documentUri,
112+ },
113+ };
114+ await supabaseServerClient.from("notifications").insert(notification);
115+ await pingIdentityToUpdateNotification(authorDid);
116+ }
117+ }
118 });
119 },
120);
+7-2
app/api/inngest/route.tsx
···3import { index_post_mention } from "./functions/index_post_mention";
4import { come_online } from "./functions/come_online";
5import { batched_update_profiles } from "./functions/batched_update_profiles";
067-// Create an API that serves zero functions
8export const { GET, POST, PUT } = serve({
9 client: inngest,
10- functions: [index_post_mention, come_online, batched_update_profiles],
0000011});
···3import { index_post_mention } from "./functions/index_post_mention";
4import { come_online } from "./functions/come_online";
5import { batched_update_profiles } from "./functions/batched_update_profiles";
6+import { index_follows } from "./functions/index_follows";
708export const { GET, POST, PUT } = serve({
9 client: inngest,
10+ functions: [
11+ index_post_mention,
12+ come_online,
13+ batched_update_profiles,
14+ index_follows,
15+ ],
16});
+6-12
app/api/link_previews/route.ts
···5import * as z from "zod";
6import { createClient } from "@supabase/supabase-js";
7import { Database } from "supabase/database.types";
00008let supabase = createClient<Database>(
9 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
10 process.env.SUPABASE_SERVICE_ROLE_KEY as string,
···18 let result = await get_link_metadata(url);
19 return Response.json(result);
20 } else {
21- let result = await get_link_image_preview(url);
22 return Response.json(result);
23 }
24}
···27export type LinkPreviewImageResult = ReturnType<typeof get_link_image_preview>;
2829async function get_link_image_preview(url: string) {
30- let image = await fetch(
31- `https://pro.microlink.io/?url=${url}&screenshot&viewport.width=1400&viewport.height=1213&embed=screenshot.url&meta=false&force=true`,
32- {
33- headers: {
34- "x-api-key": process.env.MICROLINK_API_KEY!,
35- },
36- next: {
37- revalidate: 600,
38- },
39- },
40- );
41 let key = await hash(url);
42 if (image.status === 200) {
43 await supabase.storage
···5import * as z from "zod";
6import { createClient } from "@supabase/supabase-js";
7import { Database } from "supabase/database.types";
8+import {
9+ getMicroLinkOgImage,
10+ getWebpageImage,
11+} from "src/utils/getMicroLinkOgImage";
12let supabase = createClient<Database>(
13 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
14 process.env.SUPABASE_SERVICE_ROLE_KEY as string,
···22 let result = await get_link_metadata(url);
23 return Response.json(result);
24 } else {
25+ let result = await get_link_image_preview(body.url);
26 return Response.json(result);
27 }
28}
···31export type LinkPreviewImageResult = ReturnType<typeof get_link_image_preview>;
3233async function get_link_image_preview(url: string) {
34+ let image = await getWebpageImage(url, { width: 1400, height: 1213 });
000000000035 let key = await hash(url);
36 if (image.status === 200) {
37 await supabase.storage
···1+import { useUIState } from "src/useUIState";
2+import { BlockProps } from "./Block";
3+import { useMemo } from "react";
4+import { AsyncValueInput } from "components/Input";
5+import { focusElement } from "src/utils/focusElement";
6+import { useEntitySetContext } from "components/EntitySetProvider";
7+import { useEntity, useReplicache } from "src/replicache";
8+import { v7 } from "uuid";
9+import { elementId } from "src/utils/elementId";
10+import { CloseTiny } from "components/Icons/CloseTiny";
11+import { useLeafletPublicationData } from "components/PageSWRDataProvider";
12+import {
13+ PubLeafletBlocksPoll,
14+ PubLeafletDocument,
15+ PubLeafletPagesLinearDocument,
16+} from "lexicons/api";
17+import { ids } from "lexicons/api/lexicons";
18+19+/**
20+ * PublicationPollBlock is used for editing polls in publication documents.
21+ * It allows adding/editing options when the poll hasn't been published yet,
22+ * but disables adding new options once the poll record exists (indicated by pollUri).
23+ */
24+export const PublicationPollBlock = (props: BlockProps) => {
25+ let { data: publicationData } = useLeafletPublicationData();
26+ let isSelected = useUIState((s) =>
27+ s.selectedBlocks.find((b) => b.value === props.entityID),
28+ );
29+ // Check if this poll has been published in a publication document
30+ const isPublished = useMemo(() => {
31+ if (!publicationData?.documents?.data) return false;
32+33+ const docRecord = publicationData.documents
34+ .data as PubLeafletDocument.Record;
35+36+ // Search through all pages and blocks to find if this poll entity has been published
37+ for (const page of docRecord.pages || []) {
38+ if (page.$type === "pub.leaflet.pages.linearDocument") {
39+ const linearPage = page as PubLeafletPagesLinearDocument.Main;
40+ for (const blockWrapper of linearPage.blocks || []) {
41+ if (blockWrapper.block?.$type === ids.PubLeafletBlocksPoll) {
42+ const pollBlock = blockWrapper.block as PubLeafletBlocksPoll.Main;
43+ // Check if this poll's rkey matches our entity ID
44+ const rkey = pollBlock.pollRef.uri.split("/").pop();
45+ if (rkey === props.entityID) {
46+ return true;
47+ }
48+ }
49+ }
50+ }
51+ }
52+ return false;
53+ }, [publicationData, props.entityID]);
54+55+ return (
56+ <div
57+ className={`poll flex flex-col gap-2 p-3 w-full
58+ ${isSelected ? "block-border-selected " : "block-border"}`}
59+ style={{
60+ backgroundColor:
61+ "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
62+ }}
63+ >
64+ <EditPollForPublication
65+ entityID={props.entityID}
66+ isPublished={isPublished}
67+ />
68+ </div>
69+ );
70+};
71+72+const EditPollForPublication = (props: {
73+ entityID: string;
74+ isPublished: boolean;
75+}) => {
76+ let pollOptions = useEntity(props.entityID, "poll/options");
77+ let { rep } = useReplicache();
78+ let permission_set = useEntitySetContext();
79+80+ return (
81+ <>
82+ {props.isPublished && (
83+ <div className="text-sm italic text-tertiary">
84+ This poll has been published. You can't edit the options.
85+ </div>
86+ )}
87+88+ {pollOptions.length === 0 && !props.isPublished && (
89+ <div className="text-center italic text-tertiary text-sm">
90+ no options yet...
91+ </div>
92+ )}
93+94+ {pollOptions.map((p) => (
95+ <EditPollOptionForPublication
96+ key={p.id}
97+ entityID={p.data.value}
98+ pollEntity={props.entityID}
99+ disabled={props.isPublished}
100+ canDelete={!props.isPublished}
101+ />
102+ ))}
103+104+ {!props.isPublished && permission_set.permissions.write && (
105+ <button
106+ className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
107+ onClick={async () => {
108+ let pollOptionEntity = v7();
109+ await rep?.mutate.addPollOption({
110+ pollEntity: props.entityID,
111+ pollOptionEntity,
112+ pollOptionName: "",
113+ permission_set: permission_set.set,
114+ factID: v7(),
115+ });
116+117+ focusElement(
118+ document.getElementById(
119+ elementId.block(props.entityID).pollInput(pollOptionEntity),
120+ ) as HTMLInputElement | null,
121+ );
122+ }}
123+ >
124+ Add an Option
125+ </button>
126+ )}
127+ </>
128+ );
129+};
130+131+const EditPollOptionForPublication = (props: {
132+ entityID: string;
133+ pollEntity: string;
134+ disabled: boolean;
135+ canDelete: boolean;
136+}) => {
137+ let { rep } = useReplicache();
138+ let { permissions } = useEntitySetContext();
139+ let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
140+141+ return (
142+ <div className="flex gap-2 items-center">
143+ <AsyncValueInput
144+ id={elementId.block(props.pollEntity).pollInput(props.entityID)}
145+ type="text"
146+ className="pollOptionInput w-full input-with-border"
147+ placeholder="Option here..."
148+ disabled={props.disabled || !permissions.write}
149+ value={optionName || ""}
150+ onChange={async (e) => {
151+ await rep?.mutate.assertFact([
152+ {
153+ entity: props.entityID,
154+ attribute: "poll-option/name",
155+ data: { type: "string", value: e.currentTarget.value },
156+ },
157+ ]);
158+ }}
159+ onKeyDown={(e) => {
160+ if (
161+ props.canDelete &&
162+ e.key === "Backspace" &&
163+ !e.currentTarget.value
164+ ) {
165+ e.preventDefault();
166+ rep?.mutate.removePollOption({ optionEntity: props.entityID });
167+ }
168+ }}
169+ />
170+171+ {permissions.write && props.canDelete && (
172+ <button
173+ tabIndex={-1}
174+ className="text-accent-contrast"
175+ onMouseDown={async () => {
176+ await rep?.mutate.removePollOption({
177+ optionEntity: props.entityID,
178+ });
179+ }}
180+ >
181+ <CloseTiny />
182+ </button>
183+ )}
184+ </div>
185+ );
186+};
-59
components/Blocks/QuoteEmbedBlock.tsx
···1-import { GoToArrow } from "components/Icons/GoToArrow";
2-import { ExternalLinkBlock } from "./ExternalLinkBlock";
3-import { Separator } from "components/Layout";
4-5-export const QuoteEmbedBlockLine = () => {
6- return (
7- <div className="quoteEmbedBlock flex sm:mx-4 mx-3 my-3 sm:my-4 text-secondary text-sm italic">
8- <div className="w-2 h-full bg-border" />
9- <div className="flex flex-col pl-4">
10- <div className="quoteEmbedContent ">
11- Hello, this is a long quote that I am writing to you! I am so excited
12- that you decided to quote my stuff. I would love to take a moments and
13- just say whatever the heck i feel like. Unforunately for you, it is a
14- rather boring todo list. I need to add an author and pub name, i need
15- to add a back link, and i need to link about text formatting, if we
16- want to handle it.
17- </div>
18- <div className="quoteEmbedFooter flex gap-2 pt-2 ">
19- <div className="flex flex-col leading-tight grow">
20- <div className="font-bold ">This was made to be quoted</div>
21- <div className="text-tertiary text-xs">celine</div>
22- </div>
23- </div>
24- </div>
25- </div>
26- );
27-};
28-29-export const QuoteEmbedBlock = () => {
30- return (
31- <div className="quoteEmbedBlock transparent-container sm:mx-4 mx-3 my-3 sm:my-4 text-secondary text-sm">
32- <div className="quoteEmbedContent p-3">
33- Hello, this is a long quote that I am writing to you! I am so excited
34- that you decided to quote my stuff. I would love to take a moments and
35- just say whatever the heck i feel like. Unforunately for you, it is a
36- rather boring todo list. I need to add an author and pub name, i need to
37- add a back link, and i need to link about text formatting, if we want to
38- handle it.
39- </div>
40- <hr className="border-border-light" />
41- <a
42- className="quoteEmbedFooter flex max-w-full gap-2 px-3 py-2 hover:no-underline! text-secondary"
43- href="#"
44- >
45- <div className="flex flex-col w-[calc(100%-28px)] grow">
46- <div className="font-bold w-full truncate">
47- This was made to be quoted and if it's very long, to truncate
48- </div>
49- <div className="flex gap-[6px] text-tertiary text-xs items-center">
50- <div className="underline">lab.leaflet.pub</div>
51- <Separator classname="h-2" />
52- <div>celine</div>
53- </div>
54- </div>
55- <div className=" shrink-0 pt-px bg-test w-5 h-5 rounded-full"></div>
56- </a>
57- </div>
58- );
59-};
···389 let oldEntityID = child.getAttribute("data-entityid") as string;
390 let factsData = child.getAttribute("data-facts");
391 if (factsData) {
392- let facts = JSON.parse(atob(factsData)) as Fact<any>[];
393394 let oldEntityIDToNewID = {} as { [k: string]: string };
395 let oldEntities = facts.reduce((acc, f) => {
···389 let oldEntityID = child.getAttribute("data-entityid") as string;
390 let factsData = child.getAttribute("data-facts");
391 if (factsData) {
392+ let facts = JSON.parse(factsData) as Fact<any>[];
393394 let oldEntityIDToNewID = {} as { [k: string]: string };
395 let oldEntities = facts.reduce((acc, f) => {
+11-1
components/Blocks/index.tsx
···16import { Block } from "./Block";
17import { useEffect } from "react";
18import { addShortcut } from "src/shortcuts";
19-import { QuoteEmbedBlock } from "./QuoteEmbedBlock";
2021export function Blocks(props: { entityID: string }) {
22 let rep = useReplicache();
···231}) => {
232 let { rep } = useReplicache();
233 let entity_set = useEntitySetContext();
00000234235 if (!entity_set.permissions.write) return;
236 return (
···267 }, 10);
268 }
269 }}
00000270 />
271 );
272};
···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,
···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/AddLeafletToHomepage.tsx
···1"use client";
23-import { addDocToHome } from "app/home/storage";
4import { useIdentityData } from "components/IdentityProvider";
5import { useEffect } from "react";
6import { useReplicache } from "src/replicache";
···1"use client";
23+import { addDocToHome } from "app/(home-pages)/home/storage";
4import { useIdentityData } from "components/IdentityProvider";
5import { useEffect } from "react";
6import { useReplicache } from "src/replicache";
+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";
···25import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob'
26import * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote'
27import * as PubLeafletBlocksBskyPost from './types/pub/leaflet/blocks/bskyPost'
028import * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code'
29import * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header'
30import * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/horizontalRule'
31import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe'
32import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image'
33import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math'
0034import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
35import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList'
36import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website'
37import * as PubLeafletComment from './types/pub/leaflet/comment'
38import * as PubLeafletDocument from './types/pub/leaflet/document'
39import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription'
040import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument'
0041import * as PubLeafletPublication from './types/pub/leaflet/publication'
42import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet'
43import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage'
···59export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob'
60export * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote'
61export * as PubLeafletBlocksBskyPost from './types/pub/leaflet/blocks/bskyPost'
062export * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code'
63export * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header'
64export * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/horizontalRule'
65export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe'
66export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image'
67export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math'
0068export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
69export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList'
70export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website'
71export * as PubLeafletComment from './types/pub/leaflet/comment'
72export * as PubLeafletDocument from './types/pub/leaflet/document'
73export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription'
074export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument'
0075export * as PubLeafletPublication from './types/pub/leaflet/publication'
76export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet'
77export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage'
78export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color'
7980export const PUB_LEAFLET_PAGES = {
00081 LinearDocumentTextAlignLeft: 'pub.leaflet.pages.linearDocument#textAlignLeft',
82 LinearDocumentTextAlignCenter:
83 'pub.leaflet.pages.linearDocument#textAlignCenter',
84 LinearDocumentTextAlignRight:
85 'pub.leaflet.pages.linearDocument#textAlignRight',
0086}
8788export class AtpBaseClient extends XrpcClient {
···378 blocks: PubLeafletBlocksNS
379 graph: PubLeafletGraphNS
380 pages: PubLeafletPagesNS
0381 richtext: PubLeafletRichtextNS
382 theme: PubLeafletThemeNS
383···386 this.blocks = new PubLeafletBlocksNS(client)
387 this.graph = new PubLeafletGraphNS(client)
388 this.pages = new PubLeafletPagesNS(client)
0389 this.richtext = new PubLeafletRichtextNS(client)
390 this.theme = new PubLeafletThemeNS(client)
391 this.comment = new PubLeafletCommentRecord(client)
···500501 constructor(client: XrpcClient) {
502 this._client = client
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000503 }
504}
505
···25import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob'
26import * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote'
27import * as PubLeafletBlocksBskyPost from './types/pub/leaflet/blocks/bskyPost'
28+import * as PubLeafletBlocksButton from './types/pub/leaflet/blocks/button'
29import * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code'
30import * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header'
31import * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/horizontalRule'
32import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe'
33import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image'
34import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math'
35+import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page'
36+import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll'
37import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
38import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList'
39import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website'
40import * as PubLeafletComment from './types/pub/leaflet/comment'
41import * as PubLeafletDocument from './types/pub/leaflet/document'
42import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription'
43+import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas'
44import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument'
45+import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition'
46+import * as PubLeafletPollVote from './types/pub/leaflet/poll/vote'
47import * as PubLeafletPublication from './types/pub/leaflet/publication'
48import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet'
49import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage'
···65export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob'
66export * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote'
67export * as PubLeafletBlocksBskyPost from './types/pub/leaflet/blocks/bskyPost'
68+export * as PubLeafletBlocksButton from './types/pub/leaflet/blocks/button'
69export * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code'
70export * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header'
71export * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/horizontalRule'
72export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe'
73export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image'
74export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math'
75+export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page'
76+export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll'
77export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
78export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList'
79export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website'
80export * as PubLeafletComment from './types/pub/leaflet/comment'
81export * as PubLeafletDocument from './types/pub/leaflet/document'
82export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription'
83+export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas'
84export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument'
85+export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition'
86+export * as PubLeafletPollVote from './types/pub/leaflet/poll/vote'
87export * as PubLeafletPublication from './types/pub/leaflet/publication'
88export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet'
89export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage'
90export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color'
9192export const PUB_LEAFLET_PAGES = {
93+ CanvasTextAlignLeft: 'pub.leaflet.pages.canvas#textAlignLeft',
94+ CanvasTextAlignCenter: 'pub.leaflet.pages.canvas#textAlignCenter',
95+ CanvasTextAlignRight: 'pub.leaflet.pages.canvas#textAlignRight',
96 LinearDocumentTextAlignLeft: 'pub.leaflet.pages.linearDocument#textAlignLeft',
97 LinearDocumentTextAlignCenter:
98 'pub.leaflet.pages.linearDocument#textAlignCenter',
99 LinearDocumentTextAlignRight:
100 'pub.leaflet.pages.linearDocument#textAlignRight',
101+ LinearDocumentTextAlignJustify:
102+ 'pub.leaflet.pages.linearDocument#textAlignJustify',
103}
104105export class AtpBaseClient extends XrpcClient {
···395 blocks: PubLeafletBlocksNS
396 graph: PubLeafletGraphNS
397 pages: PubLeafletPagesNS
398+ poll: PubLeafletPollNS
399 richtext: PubLeafletRichtextNS
400 theme: PubLeafletThemeNS
401···404 this.blocks = new PubLeafletBlocksNS(client)
405 this.graph = new PubLeafletGraphNS(client)
406 this.pages = new PubLeafletPagesNS(client)
407+ this.poll = new PubLeafletPollNS(client)
408 this.richtext = new PubLeafletRichtextNS(client)
409 this.theme = new PubLeafletThemeNS(client)
410 this.comment = new PubLeafletCommentRecord(client)
···519520 constructor(client: XrpcClient) {
521 this._client = client
522+ }
523+}
524+525+export class PubLeafletPollNS {
526+ _client: XrpcClient
527+ definition: PubLeafletPollDefinitionRecord
528+ vote: PubLeafletPollVoteRecord
529+530+ constructor(client: XrpcClient) {
531+ this._client = client
532+ this.definition = new PubLeafletPollDefinitionRecord(client)
533+ this.vote = new PubLeafletPollVoteRecord(client)
534+ }
535+}
536+537+export class PubLeafletPollDefinitionRecord {
538+ _client: XrpcClient
539+540+ constructor(client: XrpcClient) {
541+ this._client = client
542+ }
543+544+ async list(
545+ params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>,
546+ ): Promise<{
547+ cursor?: string
548+ records: { uri: string; value: PubLeafletPollDefinition.Record }[]
549+ }> {
550+ const res = await this._client.call('com.atproto.repo.listRecords', {
551+ collection: 'pub.leaflet.poll.definition',
552+ ...params,
553+ })
554+ return res.data
555+ }
556+557+ async get(
558+ params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>,
559+ ): Promise<{
560+ uri: string
561+ cid: string
562+ value: PubLeafletPollDefinition.Record
563+ }> {
564+ const res = await this._client.call('com.atproto.repo.getRecord', {
565+ collection: 'pub.leaflet.poll.definition',
566+ ...params,
567+ })
568+ return res.data
569+ }
570+571+ async create(
572+ params: OmitKey<
573+ ComAtprotoRepoCreateRecord.InputSchema,
574+ 'collection' | 'record'
575+ >,
576+ record: Un$Typed<PubLeafletPollDefinition.Record>,
577+ headers?: Record<string, string>,
578+ ): Promise<{ uri: string; cid: string }> {
579+ const collection = 'pub.leaflet.poll.definition'
580+ const res = await this._client.call(
581+ 'com.atproto.repo.createRecord',
582+ undefined,
583+ { collection, ...params, record: { ...record, $type: collection } },
584+ { encoding: 'application/json', headers },
585+ )
586+ return res.data
587+ }
588+589+ async put(
590+ params: OmitKey<
591+ ComAtprotoRepoPutRecord.InputSchema,
592+ 'collection' | 'record'
593+ >,
594+ record: Un$Typed<PubLeafletPollDefinition.Record>,
595+ headers?: Record<string, string>,
596+ ): Promise<{ uri: string; cid: string }> {
597+ const collection = 'pub.leaflet.poll.definition'
598+ const res = await this._client.call(
599+ 'com.atproto.repo.putRecord',
600+ undefined,
601+ { collection, ...params, record: { ...record, $type: collection } },
602+ { encoding: 'application/json', headers },
603+ )
604+ return res.data
605+ }
606+607+ async delete(
608+ params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>,
609+ headers?: Record<string, string>,
610+ ): Promise<void> {
611+ await this._client.call(
612+ 'com.atproto.repo.deleteRecord',
613+ undefined,
614+ { collection: 'pub.leaflet.poll.definition', ...params },
615+ { headers },
616+ )
617+ }
618+}
619+620+export class PubLeafletPollVoteRecord {
621+ _client: XrpcClient
622+623+ constructor(client: XrpcClient) {
624+ this._client = client
625+ }
626+627+ async list(
628+ params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>,
629+ ): Promise<{
630+ cursor?: string
631+ records: { uri: string; value: PubLeafletPollVote.Record }[]
632+ }> {
633+ const res = await this._client.call('com.atproto.repo.listRecords', {
634+ collection: 'pub.leaflet.poll.vote',
635+ ...params,
636+ })
637+ return res.data
638+ }
639+640+ async get(
641+ params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>,
642+ ): Promise<{ uri: string; cid: string; value: PubLeafletPollVote.Record }> {
643+ const res = await this._client.call('com.atproto.repo.getRecord', {
644+ collection: 'pub.leaflet.poll.vote',
645+ ...params,
646+ })
647+ return res.data
648+ }
649+650+ async create(
651+ params: OmitKey<
652+ ComAtprotoRepoCreateRecord.InputSchema,
653+ 'collection' | 'record'
654+ >,
655+ record: Un$Typed<PubLeafletPollVote.Record>,
656+ headers?: Record<string, string>,
657+ ): Promise<{ uri: string; cid: string }> {
658+ const collection = 'pub.leaflet.poll.vote'
659+ const res = await this._client.call(
660+ 'com.atproto.repo.createRecord',
661+ undefined,
662+ { collection, ...params, record: { ...record, $type: collection } },
663+ { encoding: 'application/json', headers },
664+ )
665+ return res.data
666+ }
667+668+ async put(
669+ params: OmitKey<
670+ ComAtprotoRepoPutRecord.InputSchema,
671+ 'collection' | 'record'
672+ >,
673+ record: Un$Typed<PubLeafletPollVote.Record>,
674+ headers?: Record<string, string>,
675+ ): Promise<{ uri: string; cid: string }> {
676+ const collection = 'pub.leaflet.poll.vote'
677+ const res = await this._client.call(
678+ 'com.atproto.repo.putRecord',
679+ undefined,
680+ { collection, ...params, record: { ...record, $type: collection } },
681+ { encoding: 'application/json', headers },
682+ )
683+ return res.data
684+ }
685+686+ async delete(
687+ params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>,
688+ headers?: Record<string, string>,
689+ ): Promise<void> {
690+ await this._client.call(
691+ 'com.atproto.repo.deleteRecord',
692+ undefined,
693+ { collection: 'pub.leaflet.poll.vote', ...params },
694+ { headers },
695+ )
696 }
697}
698
···2import { BlockLexicons } from "./src/blocks";
3import { PubLeafletDocument } from "./src/document";
4import * as PublicationLexicons from "./src/publication";
05import { ThemeLexicons } from "./src/theme";
67import * as fs from "fs";
···21 PubLeafletComment,
22 PubLeafletRichTextFacet,
23 PageLexicons.PubLeafletPagesLinearDocument,
024 ...ThemeLexicons,
25 ...BlockLexicons,
26 ...Object.values(PublicationLexicons),
027];
2829// Write each lexicon to a file
···2import { BlockLexicons } from "./src/blocks";
3import { PubLeafletDocument } from "./src/document";
4import * as PublicationLexicons from "./src/publication";
5+import * as PollLexicons from "./src/polls";
6import { ThemeLexicons } from "./src/theme";
78import * as fs from "fs";
···22 PubLeafletComment,
23 PubLeafletRichTextFacet,
24 PageLexicons.PubLeafletPagesLinearDocument,
25+ PageLexicons.PubLeafletPagesCanvasDocument,
26 ...ThemeLexicons,
27 ...BlockLexicons,
28 ...Object.values(PublicationLexicons),
29+ ...Object.values(PollLexicons),
30];
3132// Write each lexicon to a file
···83 let aturi = new AtUri(pub?.uri);
84 return NextResponse.rewrite(
85 new URL(
86- `/lish/${aturi.host}/${encodeURIComponent(pub.name)}${req.nextUrl.pathname}`,
87 req.url,
88 ),
89 );
···83 let aturi = new AtUri(pub?.uri);
84 return NextResponse.rewrite(
85 new URL(
86+ `/lish/${aturi.host}/${aturi.rkey}${req.nextUrl.pathname}`,
87 req.url,
88 ),
89 );
+1-1
next-env.d.ts
···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+}
+10
src/utils/scrollIntoView.ts
···0000000000
···1+import { scrollIntoViewIfNeeded } from "./scrollIntoViewIfNeeded";
2+3+export function scrollIntoView(
4+ elementId: string,
5+ scrollContainerId: string = "pages",
6+ threshold: number = 0.9,
7+) {
8+ const element = document.getElementById(elementId);
9+ scrollIntoViewIfNeeded(element, false, "smooth");
10+}
···1+create table "public"."bsky_follows" (
2+ "identity" text not null,
3+ "follows" text not null
4+);
5+6+alter table "public"."bsky_follows" enable row level security;
7+8+CREATE UNIQUE INDEX bsky_follows_pkey ON public.bsky_follows USING btree (identity, follows);
9+10+CREATE INDEX facts_reference_idx ON public.facts USING btree (((data ->> 'value'::text))) WHERE (((data ->> 'type'::text) = 'reference'::text) OR ((data ->> 'type'::text) = 'ordered-reference'::text));
11+12+alter table "public"."bsky_follows" add constraint "bsky_follows_pkey" PRIMARY KEY using index "bsky_follows_pkey";
13+14+alter table "public"."bsky_follows" add constraint "bsky_follows_follows_fkey" FOREIGN KEY (follows) REFERENCES identities(atp_did) ON DELETE CASCADE not valid;
15+16+alter table "public"."bsky_follows" validate constraint "bsky_follows_follows_fkey";
17+18+alter table "public"."bsky_follows" add constraint "bsky_follows_identity_fkey" FOREIGN KEY (identity) REFERENCES identities(atp_did) ON DELETE CASCADE not valid;
19+20+alter table "public"."bsky_follows" validate constraint "bsky_follows_identity_fkey";
21+22+grant delete on table "public"."bsky_follows" to "anon";
23+24+grant insert on table "public"."bsky_follows" to "anon";
25+26+grant references on table "public"."bsky_follows" to "anon";
27+28+grant select on table "public"."bsky_follows" to "anon";
29+30+grant trigger on table "public"."bsky_follows" to "anon";
31+32+grant truncate on table "public"."bsky_follows" to "anon";
33+34+grant update on table "public"."bsky_follows" to "anon";
35+36+grant delete on table "public"."bsky_follows" to "authenticated";
37+38+grant insert on table "public"."bsky_follows" to "authenticated";
39+40+grant references on table "public"."bsky_follows" to "authenticated";
41+42+grant select on table "public"."bsky_follows" to "authenticated";
43+44+grant trigger on table "public"."bsky_follows" to "authenticated";
45+46+grant truncate on table "public"."bsky_follows" to "authenticated";
47+48+grant update on table "public"."bsky_follows" to "authenticated";
49+50+grant delete on table "public"."bsky_follows" to "service_role";
51+52+grant insert on table "public"."bsky_follows" to "service_role";
53+54+grant references on table "public"."bsky_follows" to "service_role";
55+56+grant select on table "public"."bsky_follows" to "service_role";
57+58+grant trigger on table "public"."bsky_follows" to "service_role";
59+60+grant truncate on table "public"."bsky_follows" to "service_role";
61+62+grant update on table "public"."bsky_follows" to "service_role";
···1+create table "public"."atp_poll_votes" (
2+ "uri" text not null,
3+ "record" jsonb not null,
4+ "voter_did" text not null,
5+ "poll_uri" text not null,
6+ "poll_cid" text not null,
7+ "option" text not null,
8+ "indexed_at" timestamp with time zone not null default now()
9+);
10+11+alter table "public"."atp_poll_votes" enable row level security;
12+13+CREATE UNIQUE INDEX atp_poll_votes_pkey ON public.atp_poll_votes USING btree (uri);
14+15+alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_pkey" PRIMARY KEY using index "atp_poll_votes_pkey";
16+17+CREATE INDEX atp_poll_votes_poll_uri_idx ON public.atp_poll_votes USING btree (poll_uri);
18+19+CREATE INDEX atp_poll_votes_voter_did_idx ON public.atp_poll_votes USING btree (voter_did);
20+21+grant delete on table "public"."atp_poll_votes" to "anon";
22+23+grant insert on table "public"."atp_poll_votes" to "anon";
24+25+grant references on table "public"."atp_poll_votes" to "anon";
26+27+grant select on table "public"."atp_poll_votes" to "anon";
28+29+grant trigger on table "public"."atp_poll_votes" to "anon";
30+31+grant truncate on table "public"."atp_poll_votes" to "anon";
32+33+grant update on table "public"."atp_poll_votes" to "anon";
34+35+grant delete on table "public"."atp_poll_votes" to "authenticated";
36+37+grant insert on table "public"."atp_poll_votes" to "authenticated";
38+39+grant references on table "public"."atp_poll_votes" to "authenticated";
40+41+grant select on table "public"."atp_poll_votes" to "authenticated";
42+43+grant trigger on table "public"."atp_poll_votes" to "authenticated";
44+45+grant truncate on table "public"."atp_poll_votes" to "authenticated";
46+47+grant update on table "public"."atp_poll_votes" to "authenticated";
48+49+grant delete on table "public"."atp_poll_votes" to "service_role";
50+51+grant insert on table "public"."atp_poll_votes" to "service_role";
52+53+grant references on table "public"."atp_poll_votes" to "service_role";
54+55+grant select on table "public"."atp_poll_votes" to "service_role";
56+57+grant trigger on table "public"."atp_poll_votes" to "service_role";
58+59+grant truncate on table "public"."atp_poll_votes" to "service_role";
60+61+grant update on table "public"."atp_poll_votes" to "service_role";
62+63+create table "public"."atp_poll_records" (
64+ "uri" text not null,
65+ "cid" text not null,
66+ "record" jsonb not null,
67+ "created_at" timestamp with time zone not null default now()
68+);
69+70+71+alter table "public"."atp_poll_records" enable row level security;
72+73+alter table "public"."bsky_follows" alter column "identity" set default ''::text;
74+75+CREATE UNIQUE INDEX atp_poll_records_pkey ON public.atp_poll_records USING btree (uri);
76+77+alter table "public"."atp_poll_records" add constraint "atp_poll_records_pkey" PRIMARY KEY using index "atp_poll_records_pkey";
78+79+alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_poll_uri_fkey" FOREIGN KEY (poll_uri) REFERENCES atp_poll_records(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid;
80+81+alter table "public"."atp_poll_votes" validate constraint "atp_poll_votes_poll_uri_fkey";
82+83+grant delete on table "public"."atp_poll_records" to "anon";
84+85+grant insert on table "public"."atp_poll_records" to "anon";
86+87+grant references on table "public"."atp_poll_records" to "anon";
88+89+grant select on table "public"."atp_poll_records" to "anon";
90+91+grant trigger on table "public"."atp_poll_records" to "anon";
92+93+grant truncate on table "public"."atp_poll_records" to "anon";
94+95+grant update on table "public"."atp_poll_records" to "anon";
96+97+grant delete on table "public"."atp_poll_records" to "authenticated";
98+99+grant insert on table "public"."atp_poll_records" to "authenticated";
100+101+grant references on table "public"."atp_poll_records" to "authenticated";
102+103+grant select on table "public"."atp_poll_records" to "authenticated";
104+105+grant trigger on table "public"."atp_poll_records" to "authenticated";
106+107+grant truncate on table "public"."atp_poll_records" to "authenticated";
108+109+grant update on table "public"."atp_poll_records" to "authenticated";
110+111+grant delete on table "public"."atp_poll_records" to "service_role";
112+113+grant insert on table "public"."atp_poll_records" to "service_role";
114+115+grant references on table "public"."atp_poll_records" to "service_role";
116+117+grant select on table "public"."atp_poll_records" to "service_role";
118+119+grant trigger on table "public"."atp_poll_records" to "service_role";
120+121+grant truncate on table "public"."atp_poll_records" to "service_role";
122+123+grant update on table "public"."atp_poll_records" to "service_role";
···1+create table "public"."notifications" (
2+ "recipient" text not null,
3+ "created_at" timestamp with time zone not null default now(),
4+ "read" boolean not null default false,
5+ "data" jsonb not null,
6+ "id" uuid not null
7+);
8+9+10+alter table "public"."notifications" enable row level security;
11+12+CREATE UNIQUE INDEX notifications_pkey ON public.notifications USING btree (id);
13+14+alter table "public"."notifications" add constraint "notifications_pkey" PRIMARY KEY using index "notifications_pkey";
15+16+alter table "public"."notifications" add constraint "notifications_recipient_fkey" FOREIGN KEY (recipient) REFERENCES identities(atp_did) ON UPDATE CASCADE ON DELETE CASCADE not valid;
17+18+alter table "public"."notifications" validate constraint "notifications_recipient_fkey";
19+20+grant delete on table "public"."notifications" to "anon";
21+22+grant insert on table "public"."notifications" to "anon";
23+24+grant references on table "public"."notifications" to "anon";
25+26+grant select on table "public"."notifications" to "anon";
27+28+grant trigger on table "public"."notifications" to "anon";
29+30+grant truncate on table "public"."notifications" to "anon";
31+32+grant update on table "public"."notifications" to "anon";
33+34+grant delete on table "public"."notifications" to "authenticated";
35+36+grant insert on table "public"."notifications" to "authenticated";
37+38+grant references on table "public"."notifications" to "authenticated";
39+40+grant select on table "public"."notifications" to "authenticated";
41+42+grant trigger on table "public"."notifications" to "authenticated";
43+44+grant truncate on table "public"."notifications" to "authenticated";
45+46+grant update on table "public"."notifications" to "authenticated";
47+48+grant delete on table "public"."notifications" to "service_role";
49+50+grant insert on table "public"."notifications" to "service_role";
51+52+grant references on table "public"."notifications" to "service_role";
53+54+grant select on table "public"."notifications" to "service_role";
55+56+grant trigger on table "public"."notifications" to "service_role";
57+58+grant truncate on table "public"."notifications" to "service_role";
59+60+grant update on table "public"."notifications" to "service_role";
···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+;