From 16ad54871c10ae09cd96b233a88b39eb937c22e3 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 25 Nov 2025 18:03:18 -0500 Subject: [PATCH] Feature/publish leaflets (#235) * reorganized Actions in leaflet a bit * started up on the publish button, WIP * added an icon for one-offs/looseleafs/whatever * adjusted popover to new one step design * wrap up styling tweaks to the publish popover * title input in publish post details form now pulls first block content as title * add a looseleaf button to the homepage * icon stuffffff * make save as draft and publish work to existing pub * make publication optional on documents * support publishing standalone leaflets * support adding theme to standalone published docs * render standalone published docs at /p/ * render standalone published docs properly on homepage * handle publishing doc theme * delete entiteis before publishing * tweak publish page styles * add published link for standaloen docs in share menu * style tweaks to make starting a new pub an external link rather than a redirect * tiny lil teaks to create pub form * fix type errors * added the looseleaf page * handle canvases better * some small tweaks * handle publishing canvas pages as first page * simplify page handling and support root canvas page * add basic looseleaf page * don't show looseleafs tab if none * add first page to start of array * fix metadata on read links * get theme from correct place and fix pub metadata * remove flex on published page link block content * fix imports and types * fix looseleaf titles --------- Co-authored-by: celine --- actions/createPublicationDraft.ts | 6 + actions/getIdentityData.ts | 1 + .../publications/moveLeafletToPublication.ts | 33 ++ .../updateLeafletDraftMetadata.ts | 26 -- actions/publishToPublication.ts | 253 ++++++++--- app/(home-pages)/discover/PubListing.tsx | 2 +- app/(home-pages)/home/Actions/Actions.tsx | 3 +- app/(home-pages)/home/HomeLayout.tsx | 23 +- .../home/LeafletList/LeafletInfo.tsx | 5 +- .../home/LeafletList/LeafletOptions.tsx | 2 +- app/(home-pages)/home/page.tsx | 3 +- .../looseleafs/LooseleafsLayout.tsx | 116 +++++ app/(home-pages)/looseleafs/page.tsx | 47 ++ app/(home-pages)/reader/ReaderContent.tsx | 2 +- app/[leaflet_id]/Actions.tsx | 98 ---- app/[leaflet_id]/Footer.tsx | 36 +- app/[leaflet_id]/Sidebar.tsx | 40 +- app/[leaflet_id]/actions/BackToPubButton.tsx | 27 ++ .../[leaflet_id]/actions/HelpButton.tsx | 12 +- .../[leaflet_id]/actions}/HomeButton.tsx | 37 +- app/[leaflet_id]/actions/PublishButton.tsx | 427 ++++++++++++++++++ .../actions}/ShareOptions/DomainOptions.tsx | 4 +- .../actions}/ShareOptions/getShareLink.ts | 0 .../actions}/ShareOptions/index.tsx | 17 +- app/[leaflet_id]/page.tsx | 7 +- app/[leaflet_id]/publish/PublishPost.tsx | 84 +++- app/[leaflet_id]/publish/page.tsx | 72 ++- app/api/rpc/[command]/get_leaflet_data.ts | 6 +- app/globals.css | 1 + .../[did]/[publication]/[rkey]/CanvasPage.tsx | 45 +- .../[rkey]/DocumentPageRenderer.tsx | 145 ++++++ .../[rkey]/LinearDocumentPage.tsx | 82 ++-- .../[rkey]/PostHeader/PostHeader.tsx | 35 +- .../[did]/[publication]/[rkey]/PostPages.tsx | 176 ++++---- .../[rkey]/PublishedPageBlock.tsx | 9 +- .../[publication]/[rkey]/extractCodeBlocks.ts | 5 +- .../[publication]/[rkey]/getPostPageData.ts | 9 +- app/lish/[did]/[publication]/[rkey]/page.tsx | 162 +------ .../dashboard/PublishedPostsLists.tsx | 2 +- app/lish/[did]/[publication]/page.tsx | 4 +- app/lish/createPub/CreatePubForm.tsx | 13 +- app/lish/createPub/UpdatePubForm.tsx | 6 +- app/lish/createPub/page.tsx | 2 +- app/login/LoginForm.tsx | 2 +- .../[rkey]/l-quote/[quote]/opengraph-image.ts | 19 + .../[rkey]/l-quote/[quote]/page.tsx | 8 + app/p/[didOrHandle]/[rkey]/page.tsx | 90 ++++ appview/index.ts | 37 +- components/ActionBar/Navigation.tsx | 17 +- components/ActionBar/Publications.tsx | 72 ++- components/Blocks/RSVPBlock/SendUpdate.tsx | 4 +- components/Buttons.tsx | 56 ++- components/Icons/LooseleafSmall.tsx | 19 + components/Input.tsx | 12 +- components/Layout.tsx | 1 + components/PageSWRDataProvider.tsx | 12 +- components/Pages/Page.tsx | 2 +- components/Pages/PageShareMenu.tsx | 7 +- components/Pages/PublicationMetadata.tsx | 32 +- components/ThemeManager/PubThemeSetter.tsx | 45 +- .../ThemeManager/PublicationThemeProvider.tsx | 49 +- components/ThemeManager/ThemeProvider.tsx | 6 +- components/ThemeManager/ThemeSetter.tsx | 4 +- components/ThemeManager/colorToLexicons.ts | 44 ++ lexicons/api/lexicons.ts | 6 +- lexicons/api/types/pub/leaflet/document.ts | 4 +- lexicons/pub/leaflet/document.json | 7 +- lexicons/src/document.ts | 3 +- .../getPublicationMetadataFromLeafletData.ts | 50 ++ 69 files changed, 1870 insertions(+), 823 deletions(-) create mode 100644 actions/publications/moveLeafletToPublication.ts delete mode 100644 actions/publications/updateLeafletDraftMetadata.ts create mode 100644 app/(home-pages)/looseleafs/LooseleafsLayout.tsx create mode 100644 app/(home-pages)/looseleafs/page.tsx delete mode 100644 app/[leaflet_id]/Actions.tsx create mode 100644 app/[leaflet_id]/actions/BackToPubButton.tsx rename components/HelpPopover.tsx => app/[leaflet_id]/actions/HelpButton.tsx (94%) rename {components => app/[leaflet_id]/actions}/HomeButton.tsx (63%) create mode 100644 app/[leaflet_id]/actions/PublishButton.tsx rename {components => app/[leaflet_id]/actions}/ShareOptions/DomainOptions.tsx (99%) rename {components => app/[leaflet_id]/actions}/ShareOptions/getShareLink.ts (100%) rename {components => app/[leaflet_id]/actions}/ShareOptions/index.tsx (95%) create mode 100644 app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx create mode 100644 app/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts create mode 100644 app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx create mode 100644 app/p/[didOrHandle]/[rkey]/page.tsx create mode 100644 components/Icons/LooseleafSmall.tsx create mode 100644 components/ThemeManager/colorToLexicons.ts create mode 100644 src/utils/getPublicationMetadataFromLeafletData.ts diff --git a/actions/createPublicationDraft.ts b/actions/createPublicationDraft.ts index 12d67938..164a91cb 100644 --- a/actions/createPublicationDraft.ts +++ b/actions/createPublicationDraft.ts @@ -11,6 +11,12 @@ export async function createPublicationDraft(publication_uri: string) { redirectUser: false, firstBlockType: "text", }); + let { data: publication } = await supabaseServerClient + .from("publications") + .select("*") + .eq("uri", publication_uri) + .single(); + if (publication?.identity_did !== identity.atp_did) return; await supabaseServerClient .from("leaflets_in_publications") diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts index 076bc760..b2d16130 100644 --- a/actions/getIdentityData.ts +++ b/actions/getIdentityData.ts @@ -30,6 +30,7 @@ export async function uncachedGetIdentityData() { id, root_entity, permission_token_rights(*), + leaflets_to_documents(*, documents(*)), leaflets_in_publications(*, publications(*), documents(*)) ) ) diff --git a/actions/publications/moveLeafletToPublication.ts b/actions/publications/moveLeafletToPublication.ts new file mode 100644 index 00000000..a3ee8c62 --- /dev/null +++ b/actions/publications/moveLeafletToPublication.ts @@ -0,0 +1,33 @@ +"use server"; + +import { getIdentityData } from "actions/getIdentityData"; +import { supabaseServerClient } from "supabase/serverClient"; + +export async function moveLeafletToPublication( + leaflet_id: string, + publication_uri: string, + metadata: { title: string; description: string }, + entitiesToDelete: string[], +) { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return null; + let { data: publication } = await supabaseServerClient + .from("publications") + .select("*") + .eq("uri", publication_uri) + .single(); + if (publication?.identity_did !== identity.atp_did) return; + + await supabaseServerClient.from("leaflets_in_publications").insert({ + publication: publication_uri, + leaflet: leaflet_id, + doc: null, + title: metadata.title, + description: metadata.description, + }); + + await supabaseServerClient + .from("entities") + .delete() + .in("id", entitiesToDelete); +} diff --git a/actions/publications/updateLeafletDraftMetadata.ts b/actions/publications/updateLeafletDraftMetadata.ts deleted file mode 100644 index c15fb1eb..00000000 --- a/actions/publications/updateLeafletDraftMetadata.ts +++ /dev/null @@ -1,26 +0,0 @@ -"use server"; - -import { getIdentityData } from "actions/getIdentityData"; -import { supabaseServerClient } from "supabase/serverClient"; - -export async function updateLeafletDraftMetadata( - leafletID: string, - publication_uri: string, - title: string, - description: string, -) { - let identity = await getIdentityData(); - if (!identity?.atp_did) return null; - let { data: publication } = await supabaseServerClient - .from("publications") - .select() - .eq("uri", publication_uri) - .single(); - if (!publication || publication.identity_did !== identity.atp_did) - return null; - await supabaseServerClient - .from("leaflets_in_publications") - .update({ title, description }) - .eq("leaflet", leafletID) - .eq("publication", publication_uri); -} diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index eb62eb97..946ed6ab 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -44,6 +44,12 @@ import { $Typed, UnicodeString } from "@atproto/api"; import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; import { Lock } from "src/utils/lock"; +import type { PubLeafletPublication } from "lexicons/api"; +import { + ColorToRGB, + ColorToRGBA, +} from "components/ThemeManager/colorToLexicons"; +import { parseColor } from "@react-stately/color"; export async function publishToPublication({ root_entity, @@ -51,12 +57,14 @@ export async function publishToPublication({ leaflet_id, title, description, + entitiesToDelete, }: { root_entity: string; - publication_uri: string; + publication_uri?: string; leaflet_id: string; title?: string; description?: string; + entitiesToDelete?: string[]; }) { const oauthClient = await createOauthClient(); let identity = await getIdentityData(); @@ -66,20 +74,49 @@ export async function publishToPublication({ let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); - let { data: draft } = await supabaseServerClient - .from("leaflets_in_publications") - .select("*, publications(*), documents(*)") - .eq("publication", publication_uri) - .eq("leaflet", leaflet_id) - .single(); - if (!draft || identity.atp_did !== draft?.publications?.identity_did) - throw new Error("No draft or not publisher"); + + // Check if we're publishing to a publication or standalone + let draft: any = null; + let existingDocUri: string | null = null; + + if (publication_uri) { + // Publishing to a publication - use leaflets_in_publications + let { data } = await supabaseServerClient + .from("leaflets_in_publications") + .select("*, publications(*), documents(*)") + .eq("publication", publication_uri) + .eq("leaflet", leaflet_id) + .single(); + if (!data || identity.atp_did !== data?.publications?.identity_did) + throw new Error("No draft or not publisher"); + draft = data; + existingDocUri = draft?.doc; + } else { + // Publishing standalone - use leaflets_to_documents + let { data } = await supabaseServerClient + .from("leaflets_to_documents") + .select("*, documents(*)") + .eq("leaflet", leaflet_id) + .single(); + draft = data; + existingDocUri = draft?.document; + } + + // Heuristic: Remove title entities if this is the first time publishing + // (when coming from a standalone leaflet with entitiesToDelete passed in) + if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { + await supabaseServerClient + .from("entities") + .delete() + .in("id", entitiesToDelete); + } + let { data } = await supabaseServerClient.rpc("get_facts", { root: root_entity, }); let facts = (data as unknown as Fact[]) || []; - let { firstPageBlocks, pages } = await processBlocksToPages( + let { pages } = await processBlocksToPages( facts, agent, root_entity, @@ -88,37 +125,41 @@ export async function publishToPublication({ let existingRecord = (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; + + // Extract theme for standalone documents (not for publications) + let theme: PubLeafletPublication.Theme | undefined; + if (!publication_uri) { + theme = await extractThemeFromFacts(facts, root_entity, agent); + } + let record: PubLeafletDocument.Record = { - $type: "pub.leaflet.document", - author: credentialSession.did!, - publication: publication_uri, publishedAt: new Date().toISOString(), ...existingRecord, + $type: "pub.leaflet.document", + author: credentialSession.did!, + ...(publication_uri && { publication: publication_uri }), + ...(theme && { theme }), title: title || "Untitled", description: description || "", - pages: [ - { - $type: "pub.leaflet.pages.linearDocument", - blocks: firstPageBlocks, - }, - ...pages.map((p) => { - if (p.type === "canvas") { - return { - $type: "pub.leaflet.pages.canvas" as const, - id: p.id, - blocks: p.blocks as PubLeafletPagesCanvas.Block[], - }; - } else { - return { - $type: "pub.leaflet.pages.linearDocument" as const, - id: p.id, - blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], - }; - } - }), - ], + pages: pages.map((p) => { + if (p.type === "canvas") { + return { + $type: "pub.leaflet.pages.canvas" as const, + id: p.id, + blocks: p.blocks as PubLeafletPagesCanvas.Block[], + }; + } else { + return { + $type: "pub.leaflet.pages.linearDocument" as const, + id: p.id, + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], + }; + } + }), }; - let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); + + // Keep the same rkey if updating an existing document + let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); let { data: result } = await agent.com.atproto.repo.putRecord({ rkey, repo: credentialSession.did!, @@ -127,24 +168,45 @@ export async function publishToPublication({ validate: false, //TODO publish the lexicon so we can validate! }); + // Optimistically create database entries await supabaseServerClient.from("documents").upsert({ uri: result.uri, data: record as Json, }); - await Promise.all([ - //Optimistically put these in! - supabaseServerClient.from("documents_in_publications").upsert({ - publication: record.publication, + + if (publication_uri) { + // Publishing to a publication - update both tables + await Promise.all([ + supabaseServerClient.from("documents_in_publications").upsert({ + publication: publication_uri, + document: result.uri, + }), + supabaseServerClient + .from("leaflets_in_publications") + .update({ + doc: result.uri, + }) + .eq("leaflet", leaflet_id) + .eq("publication", publication_uri), + ]); + } else { + // Publishing standalone - update leaflets_to_documents + await supabaseServerClient.from("leaflets_to_documents").upsert({ + leaflet: leaflet_id, document: result.uri, - }), - supabaseServerClient - .from("leaflets_in_publications") - .update({ - doc: result.uri, - }) - .eq("leaflet", leaflet_id) - .eq("publication", publication_uri), - ]); + title: title || "Untitled", + description: description || "", + }); + + // Heuristic: Remove title entities if this is the first time publishing standalone + // (when entitiesToDelete is provided and there's no existing document) + if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) { + await supabaseServerClient + .from("entities") + .delete() + .in("id", entitiesToDelete); + } + } return { rkey, record: JSON.parse(JSON.stringify(record)) }; } @@ -169,9 +231,30 @@ async function processBlocksToPages( let firstEntity = scan.eav(root_entity, "root/page")?.[0]; if (!firstEntity) throw new Error("No root page"); - let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); - let b = await blocksToRecord(blocks, did); - return { firstPageBlocks: b, pages }; + + // Check if the first page is a canvas or linear document + let [pageType] = scan.eav(firstEntity.data.value, "page/type"); + + if (pageType?.data.value === "canvas") { + // First page is a canvas + let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did); + pages.unshift({ + id: firstEntity.data.value, + blocks: canvasBlocks, + type: "canvas", + }); + } else { + // First page is a linear document + let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); + let b = await blocksToRecord(blocks, did); + pages.unshift({ + id: firstEntity.data.value, + blocks: b, + type: "doc", + }); + } + + return { pages }; async function uploadImage(src: string) { let data = await fetch(src); @@ -572,3 +655,71 @@ type ExcludeString = T extends string ? never : T /* maybe literal, not the whole `string` */ : T; /* not a string */ + +async function extractThemeFromFacts( + facts: Fact[], + root_entity: string, + agent: AtpBaseClient, +): Promise { + let scan = scanIndexLocal(facts); + let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data + .value; + let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data + .value; + let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value; + let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0] + ?.data.value; + let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value; + let showPageBackground = !scan.eav( + root_entity, + "theme/card-border-hidden", + )?.[0]?.data.value; + let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0]; + let backgroundImageRepeat = scan.eav( + root_entity, + "theme/background-image-repeat", + )?.[0]; + + let theme: PubLeafletPublication.Theme = { + showPageBackground: showPageBackground ?? true, + }; + + if (pageBackground) + theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`)); + if (cardBackground) + theme.pageBackground = ColorToRGBA(parseColor(`hsba(${cardBackground})`)); + if (primary) theme.primary = ColorToRGB(parseColor(`hsba(${primary})`)); + if (accentBackground) + theme.accentBackground = ColorToRGB( + parseColor(`hsba(${accentBackground})`), + ); + if (accentText) + theme.accentText = ColorToRGB(parseColor(`hsba(${accentText})`)); + + // Upload background image if present + if (backgroundImage?.data) { + let imageData = await fetch(backgroundImage.data.src); + if (imageData.status === 200) { + let binary = await imageData.blob(); + let blob = await agent.com.atproto.repo.uploadBlob(binary, { + headers: { "Content-Type": binary.type }, + }); + + theme.backgroundImage = { + $type: "pub.leaflet.theme.backgroundImage", + image: blob.data.blob, + repeat: backgroundImageRepeat?.data.value ? true : false, + ...(backgroundImageRepeat?.data.value && { + width: backgroundImageRepeat.data.value, + }), + }; + } + } + + // Only return theme if at least one property is set + if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) { + return theme; + } + + return undefined; +} diff --git a/app/(home-pages)/discover/PubListing.tsx b/app/(home-pages)/discover/PubListing.tsx index fae42889..285a36cd 100644 --- a/app/(home-pages)/discover/PubListing.tsx +++ b/app/(home-pages)/discover/PubListing.tsx @@ -16,7 +16,7 @@ export const PubListing = ( }, ) => { let record = props.record as PubLeafletPublication.Record; - let theme = usePubTheme(record); + let theme = usePubTheme(record.theme); let backgroundImage = record?.theme?.backgroundImage?.image?.ref ? blobRefToSrc( record?.theme?.backgroundImage?.image?.ref, diff --git a/app/(home-pages)/home/Actions/Actions.tsx b/app/(home-pages)/home/Actions/Actions.tsx index 5f99d3f6..20852f4d 100644 --- a/app/(home-pages)/home/Actions/Actions.tsx +++ b/app/(home-pages)/home/Actions/Actions.tsx @@ -1,7 +1,7 @@ "use client"; import { ThemePopover } from "components/ThemeManager/ThemeSetter"; import { CreateNewLeafletButton } from "./CreateNewButton"; -import { HelpPopover } from "components/HelpPopover"; +import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; import { AccountSettings } from "./AccountSettings"; import { useIdentityData } from "components/IdentityProvider"; import { useReplicache } from "src/replicache"; @@ -18,7 +18,6 @@ export const Actions = () => { ) : ( )} - ); }; diff --git a/app/(home-pages)/home/HomeLayout.tsx b/app/(home-pages)/home/HomeLayout.tsx index f1f84e4f..4ab12a44 100644 --- a/app/(home-pages)/home/HomeLayout.tsx +++ b/app/(home-pages)/home/HomeLayout.tsx @@ -30,7 +30,7 @@ import { PublicationBanner, } from "./HomeEmpty/HomeEmpty"; -type Leaflet = { +export type Leaflet = { added_at: string; archived?: boolean | null; token: PermissionToken & { @@ -38,6 +38,10 @@ type Leaflet = { GetLeafletDataReturnType["result"]["data"], null >["leaflets_in_publications"]; + leaflets_to_documents?: Exclude< + GetLeafletDataReturnType["result"]["data"], + null + >["leaflets_to_documents"]; }; }; @@ -130,7 +134,8 @@ export function HomeLeafletList(props: { ...identity.permission_token_on_homepage.reduce( (acc, tok) => { let title = - tok.permission_tokens.leaflets_in_publications[0]?.title; + tok.permission_tokens.leaflets_in_publications[0]?.title || + tok.permission_tokens.leaflets_to_documents[0]?.title; if (title) acc[tok.permission_tokens.root_entity] = title; return acc; }, @@ -222,6 +227,7 @@ export function LeafletList(props: { value={{ ...leaflet, leaflets_in_publications: leaflet.leaflets_in_publications || [], + leaflets_to_documents: leaflet.leaflets_to_documents || [], blocked_by_admin: null, custom_domain_routes: [], }} @@ -233,10 +239,15 @@ export function LeafletList(props: { draftInPublication={ leaflet.leaflets_in_publications?.[0]?.publication } - published={!!leaflet.leaflets_in_publications?.find((l) => l.doc)} + published={ + !!leaflet.leaflets_in_publications?.find((l) => l.doc) || + !!leaflet.leaflets_to_documents?.find((l) => !!l.documents) + } publishedAt={ leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents - ?.indexed_at + ?.indexed_at || + leaflet.leaflets_to_documents?.find((l) => !!l.documents) + ?.documents?.indexed_at } document_uri={ leaflet.leaflets_in_publications?.find((l) => l.doc)?.documents @@ -292,7 +303,9 @@ function useSearchedLeaflets( let filteredLeaflets = sortedLeaflets.filter( ({ token: leaflet, archived: archived }) => { - let published = !!leaflet.leaflets_in_publications?.find((l) => l.doc); + let published = + !!leaflet.leaflets_in_publications?.find((l) => l.doc) || + !!leaflet.leaflets_to_documents?.find((l) => l.document); let drafts = !!leaflet.leaflets_in_publications?.length && !published; let docs = !leaflet.leaflets_in_publications?.length && !archived; // If no filters are active, show all diff --git a/app/(home-pages)/home/LeafletList/LeafletInfo.tsx b/app/(home-pages)/home/LeafletList/LeafletInfo.tsx index f974b758..20004a7c 100644 --- a/app/(home-pages)/home/LeafletList/LeafletInfo.tsx +++ b/app/(home-pages)/home/LeafletList/LeafletInfo.tsx @@ -1,11 +1,8 @@ "use client"; import { PermissionToken, useEntity } from "src/replicache"; import { LeafletOptions } from "./LeafletOptions"; -import Link from "next/link"; -import { use, useState } from "react"; +import { useState } from "react"; import { timeAgo } from "src/utils/timeAgo"; -import { usePublishLink } from "components/ShareOptions"; -import { Separator } from "components/Layout"; import { usePageTitle } from "components/utils/UpdateLeafletTitle"; export const LeafletInfo = (props: { diff --git a/app/(home-pages)/home/LeafletList/LeafletOptions.tsx b/app/(home-pages)/home/LeafletList/LeafletOptions.tsx index 3df1f504..0e9b3db5 100644 --- a/app/(home-pages)/home/LeafletList/LeafletOptions.tsx +++ b/app/(home-pages)/home/LeafletList/LeafletOptions.tsx @@ -17,7 +17,6 @@ import { deletePost, unpublishPost, } from "app/lish/[did]/[publication]/dashboard/deletePost"; -import { ShareButton } from "components/ShareOptions"; import { ShareSmall } from "components/Icons/ShareSmall"; import { HideSmall } from "components/Icons/HideSmall"; import { hideDoc } from "../storage"; @@ -31,6 +30,7 @@ import { usePublicationData, mutatePublicationData, } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; +import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; export const LeafletOptions = (props: { leaflet: PermissionToken; diff --git a/app/(home-pages)/home/page.tsx b/app/(home-pages)/home/page.tsx index 32c8fd95..5408ab37 100644 --- a/app/(home-pages)/home/page.tsx +++ b/app/(home-pages)/home/page.tsx @@ -29,7 +29,8 @@ export default async function Home() { ...auth_res?.permission_token_on_homepage.reduce( (acc, tok) => { let title = - tok.permission_tokens.leaflets_in_publications[0]?.title; + tok.permission_tokens.leaflets_in_publications[0]?.title || + tok.permission_tokens.leaflets_to_documents[0]?.title; if (title) acc[tok.permission_tokens.root_entity] = title; return acc; }, diff --git a/app/(home-pages)/looseleafs/LooseleafsLayout.tsx b/app/(home-pages)/looseleafs/LooseleafsLayout.tsx new file mode 100644 index 00000000..d40d28d4 --- /dev/null +++ b/app/(home-pages)/looseleafs/LooseleafsLayout.tsx @@ -0,0 +1,116 @@ +"use client"; +import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; +import { useState } from "react"; +import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; +import { Fact, PermissionToken } from "src/replicache"; +import { Attribute } from "src/replicache/attributes"; +import { Actions } from "../home/Actions/Actions"; +import { callRPC } from "app/api/rpc/client"; +import { useIdentityData } from "components/IdentityProvider"; +import useSWR from "swr"; +import { getHomeDocs } from "../home/storage"; +import { Leaflet, LeafletList } from "../home/HomeLayout"; + +export const LooseleafsLayout = (props: { + entityID: string | null; + titles: { [root_entity: string]: string }; + initialFacts: { + [root_entity: string]: Fact[]; + }; +}) => { + let [searchValue, setSearchValue] = useState(""); + let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); + + useDebouncedEffect( + () => { + setDebouncedSearchValue(searchValue); + }, + 200, + [searchValue], + ); + + let cardBorderHidden = !!useCardBorderHidden(props.entityID); + return ( + } + tabs={{ + home: { + controls: null, + content: ( + + ), + }, + }} + /> + ); +}; + +export const LooseleafList = (props: { + titles: { [root_entity: string]: string }; + initialFacts: { + [root_entity: string]: Fact[]; + }; + searchValue: string; + cardBorderHidden: boolean; +}) => { + let { identity } = useIdentityData(); + let { data: initialFacts } = useSWR( + "home-leaflet-data", + async () => { + if (identity) { + let { result } = await callRPC("getFactsFromHomeLeaflets", { + tokens: identity.permission_token_on_homepage.map( + (ptrh) => ptrh.permission_tokens.root_entity, + ), + }); + let titles = { + ...result.titles, + ...identity.permission_token_on_homepage.reduce( + (acc, tok) => { + let title = + tok.permission_tokens.leaflets_in_publications[0]?.title || + tok.permission_tokens.leaflets_to_documents[0]?.title; + if (title) acc[tok.permission_tokens.root_entity] = title; + return acc; + }, + {} as { [k: string]: string }, + ), + }; + return { ...result, titles }; + } + }, + { fallbackData: { facts: props.initialFacts, titles: props.titles } }, + ); + + let leaflets: Leaflet[] = identity + ? identity.permission_token_on_homepage + .filter( + (ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0, + ) + .map((ptoh) => ({ + added_at: ptoh.created_at, + token: ptoh.permission_tokens as PermissionToken, + })) + : []; + return ( + + ); +}; diff --git a/app/(home-pages)/looseleafs/page.tsx b/app/(home-pages)/looseleafs/page.tsx new file mode 100644 index 00000000..9ae9f7b3 --- /dev/null +++ b/app/(home-pages)/looseleafs/page.tsx @@ -0,0 +1,47 @@ +import { getIdentityData } from "actions/getIdentityData"; +import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; +import { Actions } from "../home/Actions/Actions"; +import { Fact } from "src/replicache"; +import { Attribute } from "src/replicache/attributes"; +import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; +import { supabaseServerClient } from "supabase/serverClient"; +import { LooseleafsLayout } from "./LooseleafsLayout"; + +export default async function Home() { + let auth_res = await getIdentityData(); + + let [allLeafletFacts] = await Promise.all([ + auth_res + ? getFactsFromHomeLeaflets.handler( + { + tokens: auth_res.permission_token_on_homepage.map( + (r) => r.permission_tokens.root_entity, + ), + }, + { supabase: supabaseServerClient }, + ) + : undefined, + ]); + + let home_docs_initialFacts = allLeafletFacts?.result || {}; + + return ( + { + let title = + tok.permission_tokens.leaflets_in_publications[0]?.title || + tok.permission_tokens.leaflets_to_documents[0]?.title; + if (title) acc[tok.permission_tokens.root_entity] = title; + return acc; + }, + {} as { [k: string]: string }, + ), + }} + initialFacts={home_docs_initialFacts.facts || {}} + /> + ); +} diff --git a/app/(home-pages)/reader/ReaderContent.tsx b/app/(home-pages)/reader/ReaderContent.tsx index 11e49566..53b6edb5 100644 --- a/app/(home-pages)/reader/ReaderContent.tsx +++ b/app/(home-pages)/reader/ReaderContent.tsx @@ -102,7 +102,7 @@ const Post = (props: Post) => { let postRecord = props.documents.data as PubLeafletDocument.Record; let postUri = new AtUri(props.documents.uri); - let theme = usePubTheme(pubRecord); + let theme = usePubTheme(pubRecord?.theme); let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref ? blobRefToSrc( pubRecord?.theme?.backgroundImage?.image?.ref, diff --git a/app/[leaflet_id]/Actions.tsx b/app/[leaflet_id]/Actions.tsx deleted file mode 100644 index ebb6c89f..00000000 --- a/app/[leaflet_id]/Actions.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { publishToPublication } from "actions/publishToPublication"; -import { - getBasePublicationURL, - getPublicationURL, -} from "app/lish/createPub/getPublicationURL"; -import { ActionButton } from "components/ActionBar/ActionButton"; -import { GoBackSmall } from "components/Icons/GoBackSmall"; -import { PublishSmall } from "components/Icons/PublishSmall"; -import { useLeafletPublicationData } from "components/PageSWRDataProvider"; -import { SpeedyLink } from "components/SpeedyLink"; -import { useToaster } from "components/Toast"; -import { DotLoader } from "components/utils/DotLoader"; -import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; -import { useReplicache } from "src/replicache"; -import { Json } from "supabase/database.types"; - -export const BackToPubButton = (props: { - publication: { - identity_did: string; - indexed_at: string; - name: string; - record: Json; - uri: string; - }; -}) => { - return ( - - } - label="To Pub" - /> - - ); -}; - -export const PublishButton = () => { - let { data: pub } = useLeafletPublicationData(); - let params = useParams(); - let router = useRouter(); - if (!pub?.doc) - return ( - } - label={"Publish!"} - onClick={() => { - router.push(`/${params.leaflet_id}/publish`); - }} - /> - ); - - return ; -}; - -const UpdateButton = () => { - let [isLoading, setIsLoading] = useState(false); - let { data: pub, mutate } = useLeafletPublicationData(); - let { permission_token, rootEntity } = useReplicache(); - let toaster = useToaster(); - - return ( - } - label={isLoading ? : "Update!"} - onClick={async () => { - if (!pub || !pub.publications) return; - setIsLoading(true); - let doc = await publishToPublication({ - root_entity: rootEntity, - publication_uri: pub.publications.uri, - leaflet_id: permission_token.id, - title: pub.title, - description: pub.description, - }); - setIsLoading(false); - mutate(); - toaster({ - content: ( -
- {pub.doc ? "Updated! " : "Published! "} - - link - -
- ), - type: "success", - }); - }} - /> - ); -}; diff --git a/app/[leaflet_id]/Footer.tsx b/app/[leaflet_id]/Footer.tsx index d6eb1ff3..94a5a9a3 100644 --- a/app/[leaflet_id]/Footer.tsx +++ b/app/[leaflet_id]/Footer.tsx @@ -4,12 +4,13 @@ import { Footer as ActionFooter } from "components/ActionBar/Footer"; import { Media } from "components/Media"; import { ThemePopover } from "components/ThemeManager/ThemeSetter"; import { Toolbar } from "components/Toolbar"; -import { ShareOptions } from "components/ShareOptions"; -import { HomeButton } from "components/HomeButton"; +import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; +import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; +import { PublishButton } from "./actions/PublishButton"; import { useEntitySetContext } from "components/EntitySetProvider"; -import { HelpPopover } from "components/HelpPopover"; +import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; import { Watermark } from "components/Watermark"; -import { BackToPubButton, PublishButton } from "./Actions"; +import { BackToPubButton } from "./actions/BackToPubButton"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { useIdentityData } from "components/IdentityProvider"; @@ -36,24 +37,19 @@ export function LeafletFooter(props: { entityID: string }) { /> ) : entity_set.permissions.write ? ( - pub?.publications && - identity?.atp_did && - pub.publications.identity_did === identity.atp_did ? ( - + + {pub?.publications && + identity?.atp_did && + pub.publications.identity_did === identity.atp_did ? ( - - - - - - ) : ( - + ) : ( - - - - - ) + )} + + + + + ) : (
diff --git a/app/[leaflet_id]/Sidebar.tsx b/app/[leaflet_id]/Sidebar.tsx index edbdf086..568ed74d 100644 --- a/app/[leaflet_id]/Sidebar.tsx +++ b/app/[leaflet_id]/Sidebar.tsx @@ -1,16 +1,15 @@ "use client"; -import { ActionButton } from "components/ActionBar/ActionButton"; import { Sidebar } from "components/ActionBar/Sidebar"; import { useEntitySetContext } from "components/EntitySetProvider"; -import { HelpPopover } from "components/HelpPopover"; -import { HomeButton } from "components/HomeButton"; +import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; +import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; import { Media } from "components/Media"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; -import { ShareOptions } from "components/ShareOptions"; +import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; import { ThemePopover } from "components/ThemeManager/ThemeSetter"; +import { PublishButton } from "./actions/PublishButton"; import { Watermark } from "components/Watermark"; -import { useUIState } from "src/useUIState"; -import { BackToPubButton, PublishButton } from "./Actions"; +import { BackToPubButton } from "./actions/BackToPubButton"; import { useIdentityData } from "components/IdentityProvider"; import { useReplicache } from "src/replicache"; @@ -29,25 +28,17 @@ export function LeafletSidebar() {
{entity_set.permissions.write && ( + + + + +
{pub?.publications && identity?.atp_did && pub.publications.identity_did === identity.atp_did ? ( - <> - - - - -
- - + ) : ( - <> - - - -
- - + )}
)} @@ -59,10 +50,3 @@ export function LeafletSidebar() { ); } - -const blurPage = () => { - useUIState.setState(() => ({ - focusedEntity: null, - selectedBlocks: [], - })); -}; diff --git a/app/[leaflet_id]/actions/BackToPubButton.tsx b/app/[leaflet_id]/actions/BackToPubButton.tsx new file mode 100644 index 00000000..574eb57b --- /dev/null +++ b/app/[leaflet_id]/actions/BackToPubButton.tsx @@ -0,0 +1,27 @@ +import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; +import { ActionButton } from "components/ActionBar/ActionButton"; +import { GoBackSmall } from "components/Icons/GoBackSmall"; +import { SpeedyLink } from "components/SpeedyLink"; +import { Json } from "supabase/database.types"; + +export const BackToPubButton = (props: { + publication: { + identity_did: string; + indexed_at: string; + name: string; + record: Json; + uri: string; + }; +}) => { + return ( + + } + label="To Pub" + /> + + ); +}; diff --git a/components/HelpPopover.tsx b/app/[leaflet_id]/actions/HelpButton.tsx similarity index 94% rename from components/HelpPopover.tsx rename to app/[leaflet_id]/actions/HelpButton.tsx index 84623469..651a4b54 100644 --- a/components/HelpPopover.tsx +++ b/app/[leaflet_id]/actions/HelpButton.tsx @@ -1,16 +1,16 @@ "use client"; -import { ShortcutKey } from "./Layout"; -import { Media } from "./Media"; -import { Popover } from "./Popover"; +import { ShortcutKey } from "../../../components/Layout"; +import { Media } from "../../../components/Media"; +import { Popover } from "../../../components/Popover"; import { metaKey } from "src/utils/metaKey"; -import { useEntitySetContext } from "./EntitySetProvider"; +import { useEntitySetContext } from "../../../components/EntitySetProvider"; import { useState } from "react"; import { ActionButton } from "components/ActionBar/ActionButton"; -import { HelpSmall } from "./Icons/HelpSmall"; +import { HelpSmall } from "../../../components/Icons/HelpSmall"; import { isMac } from "src/utils/isDevice"; import { useIsMobile } from "src/hooks/isMobile"; -export const HelpPopover = (props: { noShortcuts?: boolean }) => { +export const HelpButton = (props: { noShortcuts?: boolean }) => { let entity_set = useEntitySetContext(); let isMobile = useIsMobile(); diff --git a/components/HomeButton.tsx b/app/[leaflet_id]/actions/HomeButton.tsx similarity index 63% rename from components/HomeButton.tsx rename to app/[leaflet_id]/actions/HomeButton.tsx index 43cf2d3e..54d54753 100644 --- a/components/HomeButton.tsx +++ b/app/[leaflet_id]/actions/HomeButton.tsx @@ -1,15 +1,15 @@ "use client"; import Link from "next/link"; -import { useEntitySetContext } from "./EntitySetProvider"; +import { useEntitySetContext } from "../../../components/EntitySetProvider"; import { ActionButton } from "components/ActionBar/ActionButton"; -import { useParams, useSearchParams } from "next/navigation"; -import { useIdentityData } from "./IdentityProvider"; +import { useSearchParams } from "next/navigation"; +import { useIdentityData } from "../../../components/IdentityProvider"; import { useReplicache } from "src/replicache"; import { addLeafletToHome } from "actions/addLeafletToHome"; -import { useSmoker } from "./Toast"; -import { AddToHomeSmall } from "./Icons/AddToHomeSmall"; -import { HomeSmall } from "./Icons/HomeSmall"; -import { permission } from "process"; +import { useSmoker } from "../../../components/Toast"; +import { AddToHomeSmall } from "../../../components/Icons/AddToHomeSmall"; +import { HomeSmall } from "../../../components/Icons/HomeSmall"; +import { produce } from "immer"; export function HomeButton() { let { permissions } = useEntitySetContext(); @@ -47,20 +47,17 @@ const AddToHomeButton = (props: {}) => { await addLeafletToHome(permission_token.id); mutate((identity) => { if (!identity) return; - return { - ...identity, - permission_token_on_homepage: [ - ...identity.permission_token_on_homepage, - { - archived: null, - created_at: new Date().toISOString(), - permission_tokens: { - ...permission_token, - leaflets_in_publications: [], - }, + return produce((draft) => { + draft.permission_token_on_homepage.push({ + created_at: new Date().toISOString(), + archived: null, + permission_tokens: { + ...permission_token, + leaflets_to_documents: [], + leaflets_in_publications: [], }, - ], - }; + }); + })(identity); }); smoker({ position: { diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx new file mode 100644 index 00000000..4098d1ff --- /dev/null +++ b/app/[leaflet_id]/actions/PublishButton.tsx @@ -0,0 +1,427 @@ +"use client"; +import { publishToPublication } from "actions/publishToPublication"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { ActionButton } from "components/ActionBar/ActionButton"; +import { + PubIcon, + PubListEmptyContent, + PubListEmptyIllo, +} from "components/ActionBar/Publications"; +import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; +import { AddSmall } from "components/Icons/AddSmall"; +import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; +import { PublishSmall } from "components/Icons/PublishSmall"; +import { useIdentityData } from "components/IdentityProvider"; +import { InputWithLabel } from "components/Input"; +import { Menu, MenuItem } from "components/Layout"; +import { + useLeafletDomains, + useLeafletPublicationData, +} from "components/PageSWRDataProvider"; +import { Popover } from "components/Popover"; +import { SpeedyLink } from "components/SpeedyLink"; +import { useToaster } from "components/Toast"; +import { DotLoader } from "components/utils/DotLoader"; +import { PubLeafletPublication } from "lexicons/api"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useState, useMemo } from "react"; +import { useIsMobile } from "src/hooks/isMobile"; +import { useReplicache, useEntity } from "src/replicache"; +import { Json } from "supabase/database.types"; +import { + useBlocks, + useCanvasBlocksWithType, +} from "src/hooks/queries/useBlocks"; +import * as Y from "yjs"; +import * as base64 from "base64-js"; +import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; +import { BlueskyLogin } from "app/login/LoginForm"; +import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; +import { AddTiny } from "components/Icons/AddTiny"; + +export const PublishButton = (props: { entityID: string }) => { + let { data: pub } = useLeafletPublicationData(); + let params = useParams(); + let router = useRouter(); + + if (!pub) return ; + if (!pub?.doc) + return ( + } + label={"Publish!"} + onClick={() => { + router.push(`/${params.leaflet_id}/publish`); + }} + /> + ); + + return ; +}; + +const UpdateButton = () => { + let [isLoading, setIsLoading] = useState(false); + let { data: pub, mutate } = useLeafletPublicationData(); + let { permission_token, rootEntity } = useReplicache(); + let { identity } = useIdentityData(); + let toaster = useToaster(); + + return ( + } + label={isLoading ? : "Update!"} + onClick={async () => { + if (!pub) return; + setIsLoading(true); + let doc = await publishToPublication({ + root_entity: rootEntity, + publication_uri: pub.publications?.uri, + leaflet_id: permission_token.id, + title: pub.title, + description: pub.description, + }); + setIsLoading(false); + mutate(); + + // Generate URL based on whether it's in a publication or standalone + let docUrl = pub.publications + ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` + : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; + + toaster({ + content: ( +
+ {pub.doc ? "Updated! " : "Published! "} + link +
+ ), + type: "success", + }); + }} + /> + ); +}; + +const PublishToPublicationButton = (props: { entityID: string }) => { + let { identity } = useIdentityData(); + let { permission_token } = useReplicache(); + let query = useSearchParams(); + console.log(query.get("publish")); + let [open, setOpen] = useState(query.get("publish") !== null); + + let isMobile = useIsMobile(); + identity && identity.atp_did && identity.publications.length > 0; + let [selectedPub, setSelectedPub] = useState(undefined); + let router = useRouter(); + let { title, entitiesToDelete } = useTitle(props.entityID); + let [description, setDescription] = useState(""); + + return ( + setOpen(o)} + side={isMobile ? "top" : "right"} + align={isMobile ? "center" : "start"} + className="sm:max-w-sm w-[1000px]" + trigger={ + } + label={"Publish on ATP"} + /> + } + > + {!identity || !identity.atp_did ? ( +
+
+
+ +
+
Publish on AT Proto
+ { + <> +
+ Link a Bluesky account to start
a publishing on AT + Proto +
+ + + + } +
+
+ ) : ( +
+ +
+
+ +
+
+ +
+ {selectedPub !== "looseleaf" && selectedPub && ( + + )} + { + if (!selectedPub) return; + e.preventDefault(); + if (selectedPub === "create") return; + + // For looseleaf, navigate without publication_uri + if (selectedPub === "looseleaf") { + router.push( + `${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`, + ); + } else { + router.push( + `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`, + ); + } + }} + > + Next{selectedPub === "create" && ": Create Pub!"} + +
+
+ )} +
+ ); +}; + +const SaveAsDraftButton = (props: { + selectedPub: string | undefined; + leafletId: string; + metadata: { title: string; description: string }; + entitiesToDelete: string[]; +}) => { + let { mutate } = useLeafletPublicationData(); + let { rep } = useReplicache(); + let [isLoading, setIsLoading] = useState(false); + + return ( + { + if (!props.selectedPub) return; + if (props.selectedPub === "create") return; + e.preventDefault(); + setIsLoading(true); + await moveLeafletToPublication( + props.leafletId, + props.selectedPub, + props.metadata, + props.entitiesToDelete, + ); + await Promise.all([rep?.pull(), mutate()]); + setIsLoading(false); + }} + > + {isLoading ? : "Save as Draft"} + + ); +}; + +const PostDetailsForm = (props: { + title: string; + description: string; + setDescription: (d: string) => void; +}) => { + return ( +
+
Post Details
+
+ + props.setDescription(e.currentTarget.value)} + /> +
+
+ ); +}; + +const PubSelector = (props: { + selectedPub: string | undefined; + setSelectedPub: (s: string) => void; + publications: { + identity_did: string; + indexed_at: string; + name: string; + record: Json | null; + uri: string; + }[]; +}) => { + // HEY STILL TO DO + // test out logged out, logged in but no pubs, and pubbed up flows + + return ( +
+
Publish to…
+ {props.publications.length === 0 || props.publications === undefined ? ( +
+
+ +
+
+ Publish as Looseleaf +
+
+ Publish this as a one off doc to AT Proto +
+
+
+
+ +
+
+ Publish to Publication +
+
+ Publish your writing to a blog on AT Proto +
+
+
+ You don't have any Publications yet.{" "} + + Create one + {" "} + to get started! +
+
+
+
+ ) : ( +
+ props.setSelectedPub("looseleaf")} + > + + Publish as Looseleaf + +
+ {props.publications.map((p) => { + let pubRecord = p.record as PubLeafletPublication.Record; + return ( + props.setSelectedPub(p.uri)} + > + <> + + {p.name} + + + ); + })} + +
+ )} +
+ ); +}; + +const PubOption = (props: { + selected: boolean; + onSelect: () => void; + children: React.ReactNode; +}) => { + return ( + + ); +}; + +let useTitle = (entityID: string) => { + let rootPage = useEntity(entityID, "root/page")[0].data.value; + let canvasBlocks = useCanvasBlocksWithType(rootPage).filter( + (b) => b.type === "text" || b.type === "heading", + ); + let blocks = useBlocks(rootPage).filter( + (b) => b.type === "text" || b.type === "heading", + ); + let firstBlock = canvasBlocks[0] || blocks[0]; + + let firstBlockText = useEntity(firstBlock?.value, "block/text")?.data.value; + + const leafletTitle = useMemo(() => { + if (!firstBlockText) return "Untitled"; + let doc = new Y.Doc(); + const update = base64.toByteArray(firstBlockText); + Y.applyUpdate(doc, update); + let nodes = doc.getXmlElement("prosemirror").toArray(); + return YJSFragmentToString(nodes[0]) || "Untitled"; + }, [firstBlockText]); + + // Only handle second block logic for linear documents, not canvas + let isCanvas = canvasBlocks.length > 0; + let secondBlock = !isCanvas ? blocks[1] : undefined; + let secondBlockTextValue = useEntity(secondBlock?.value || null, "block/text") + ?.data.value; + const secondBlockText = useMemo(() => { + if (!secondBlockTextValue) return ""; + let doc = new Y.Doc(); + const update = base64.toByteArray(secondBlockTextValue); + Y.applyUpdate(doc, update); + let nodes = doc.getXmlElement("prosemirror").toArray(); + return YJSFragmentToString(nodes[0]) || ""; + }, [secondBlockTextValue]); + + let entitiesToDelete = useMemo(() => { + let etod: string[] = []; + // Only delete first block if it's a heading type + if (firstBlock?.type === "heading") { + etod.push(firstBlock.value); + } + // Delete second block if it's empty text (only for linear documents) + if ( + !isCanvas && + secondBlockText.trim() === "" && + secondBlock?.type === "text" + ) { + etod.push(secondBlock.value); + } + return etod; + }, [firstBlock, secondBlockText, secondBlock, isCanvas]); + + return { title: leafletTitle, entitiesToDelete }; +}; diff --git a/components/ShareOptions/DomainOptions.tsx b/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx similarity index 99% rename from components/ShareOptions/DomainOptions.tsx rename to app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx index b579df46..1221c788 100644 --- a/components/ShareOptions/DomainOptions.tsx +++ b/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx @@ -8,7 +8,7 @@ import { useIdentityData } from "components/IdentityProvider"; import { addDomain } from "actions/domains/addDomain"; import { callRPC } from "app/api/rpc/client"; import { useLeafletDomains } from "components/PageSWRDataProvider"; -import { usePublishLink } from "."; +import { useReadOnlyShareLink } from "."; import { addDomainPath } from "actions/domains/addDomainPath"; import { useReplicache } from "src/replicache"; import { deleteDomain } from "actions/domains/deleteDomain"; @@ -74,7 +74,7 @@ export const DomainOptions = (props: { let toaster = useToaster(); let smoker = useSmoker(); - let publishLink = usePublishLink(); + let publishLink = useReadOnlyShareLink(); return (
diff --git a/components/ShareOptions/getShareLink.ts b/app/[leaflet_id]/actions/ShareOptions/getShareLink.ts similarity index 100% rename from components/ShareOptions/getShareLink.ts rename to app/[leaflet_id]/actions/ShareOptions/getShareLink.ts diff --git a/components/ShareOptions/index.tsx b/app/[leaflet_id]/actions/ShareOptions/index.tsx similarity index 95% rename from components/ShareOptions/index.tsx rename to app/[leaflet_id]/actions/ShareOptions/index.tsx index 34788ddb..7739150b 100644 --- a/components/ShareOptions/index.tsx +++ b/app/[leaflet_id]/actions/ShareOptions/index.tsx @@ -21,7 +21,7 @@ import { useIsMobile } from "src/hooks/isMobile"; export type ShareMenuStates = "default" | "login" | "domain"; -export let usePublishLink = () => { +export let useReadOnlyShareLink = () => { let { permission_token, rootEntity } = useReplicache(); let entity_set = useEntitySetContext(); let { data: publishLink } = useSWR( @@ -60,8 +60,7 @@ export function ShareOptions() { trigger={ - primary={!!!pub} - secondary={!!pub} + secondary label={`Share ${pub ? "Draft" : ""}`} /> } @@ -93,11 +92,13 @@ const ShareMenu = (props: { let record = pub?.documents?.data as PubLeafletDocument.Record | null; - let postLink = - pub?.publications && pub.documents - ? `${getPublicationURL(pub.publications)}/${new AtUri(pub?.documents.uri).rkey}` - : null; - let publishLink = usePublishLink(); + let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; + let postLink = !docURI + ? null + : pub?.publications + ? `${getPublicationURL(pub.publications)}/${docURI.rkey}` + : `p/${docURI.host}/${docURI.rkey}`; + let publishLink = useReadOnlyShareLink(); let [collabLink, setCollabLink] = useState(null); useEffect(() => { // strip leading '/' character from pathname diff --git a/app/[leaflet_id]/page.tsx b/app/[leaflet_id]/page.tsx index 81376965..44265a4e 100644 --- a/app/[leaflet_id]/page.tsx +++ b/app/[leaflet_id]/page.tsx @@ -13,6 +13,7 @@ import { getPollData } from "actions/pollActions"; import { supabaseServerClient } from "supabase/serverClient"; import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; export const preferredRegion = ["sfo1"]; export const dynamic = "force-dynamic"; @@ -70,11 +71,7 @@ export async function generateMetadata(props: Props): Promise { ); let rootEntity = res.data?.root_entity; if (!rootEntity || !res.data) return { title: "Leaflet not found" }; - let publication_data = - res.data?.leaflets_in_publications?.[0] || - res.data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( - (p) => p.leaflets_in_publications.length, - )?.leaflets_in_publications?.[0]; + let publication_data = getPublicationMetadataFromLeafletData(res.data); if (publication_data) { return { title: publication_data.title || "Untitled", diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx index 52ac63e9..3d5b459b 100644 --- a/app/[leaflet_id]/publish/PublishPost.tsx +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -18,6 +18,8 @@ import { editorStateToFacetedText, } from "./BskyPostEditorProsemirror"; import { EditorState } from "prosemirror-state"; +import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; +import { PubIcon } from "components/ActionBar/Publications"; type Props = { title: string; @@ -25,9 +27,10 @@ type Props = { root_entity: string; profile: ProfileViewDetailed; description: string; - publication_uri: string; + publication_uri?: string; record?: PubLeafletPublication.Record; posts_in_pub?: number; + entitiesToDelete?: string[]; }; export function PublishPost(props: Props) { @@ -72,10 +75,15 @@ const PublishPostForm = ( leaflet_id: props.leaflet_id, title: props.title, description: props.description, + entitiesToDelete: props.entitiesToDelete, }); if (!doc) return; - let post_url = `https://${props.record?.base_path}/${doc.rkey}`; + // Generate post URL based on whether it's in a publication or standalone + let post_url = props.record?.base_path + ? `https://${props.record.base_path}/${doc.rkey}` + : `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`; + let [text, facets] = editorStateRef.current ? editorStateToFacetedText(editorStateRef.current) : []; @@ -95,7 +103,6 @@ const PublishPostForm = ( return (
-

Publish Options

{ e.preventDefault(); @@ -103,6 +110,11 @@ const PublishPostForm = ( }} >
+ +
{ @@ -164,10 +176,14 @@ const PublishPostForm = (
{props.title}
{props.description}
-
-

- {props.record?.base_path} -

+ {props.record && ( + <> +
+

+ {props.record?.base_path} +

+ + )}
@@ -198,23 +214,59 @@ const PublishPostForm = ( ); }; +const PublishingTo = (props: { + publication_uri?: string; + record?: PubLeafletPublication.Record; +}) => { + if (props.publication_uri && props.record) { + return ( +
+

Publishing to

+
+ +
{props.record.name}
+
+
+ ); + } + + return ( +
+

Publishing as

+
+ +
Looseleaf
+
+
+ ); +}; + const PublishPostSuccess = (props: { post_url: string; - publication_uri: string; + publication_uri?: string; record: Props["record"]; posts_in_pub: number; }) => { - let uri = new AtUri(props.publication_uri); + let uri = props.publication_uri ? new AtUri(props.publication_uri) : null; return (
-

Published!

- - Back to Dashboard - +

Published!

+ {uri && props.record ? ( + + Back to Dashboard + + ) : ( + + Back to Home + + )} See published post
); diff --git a/app/[leaflet_id]/publish/page.tsx b/app/[leaflet_id]/publish/page.tsx index 578afb69..9ee5917c 100644 --- a/app/[leaflet_id]/publish/page.tsx +++ b/app/[leaflet_id]/publish/page.tsx @@ -13,6 +13,12 @@ export const fetchCache = "force-no-store"; type Props = { // this is now a token id not leaflet! Should probs rename params: Promise<{ leaflet_id: string }>; + searchParams: Promise<{ + publication_uri: string; + title: string; + description: string; + entitiesToDelete: string; + }>; }; export default async function PublishLeafletPage(props: Props) { let leaflet_id = (await props.params).leaflet_id; @@ -27,12 +33,36 @@ export default async function PublishLeafletPage(props: Props) { *, documents_in_publications(count) ), - documents(*))`, + documents(*)), + leaflets_to_documents( + *, + documents(*) + )`, ) .eq("id", leaflet_id) .single(); let rootEntity = data?.root_entity; - if (!data || !rootEntity || !data.leaflets_in_publications[0]) + + // Try to find publication from leaflets_in_publications first + let publication = data?.leaflets_in_publications[0]?.publications; + + // If not found, check if publication_uri is in searchParams + if (!publication) { + let pub_uri = (await props.searchParams).publication_uri; + if (pub_uri) { + console.log(decodeURIComponent(pub_uri)); + let { data: pubData, error } = await supabaseServerClient + .from("publications") + .select("*, documents_in_publications(count)") + .eq("uri", decodeURIComponent(pub_uri)) + .single(); + console.log(error); + publication = pubData; + } + } + + // Check basic data requirements + if (!data || !rootEntity) return (
missin something @@ -42,10 +72,33 @@ export default async function PublishLeafletPage(props: Props) { let identity = await getIdentityData(); if (!identity || !identity.atp_did) return null; - let pub = data.leaflets_in_publications[0]; - let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); + // Get title and description from either source + let title = + data.leaflets_in_publications[0]?.title || + data.leaflets_to_documents[0]?.title || + decodeURIComponent((await props.searchParams).title || ""); + let description = + data.leaflets_in_publications[0]?.description || + data.leaflets_to_documents[0]?.description || + decodeURIComponent((await props.searchParams).description || ""); + + let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); let profile = await agent.getProfile({ actor: identity.atp_did }); + + // Parse entitiesToDelete from URL params + let searchParams = await props.searchParams; + let entitiesToDelete: string[] = []; + try { + if (searchParams.entitiesToDelete) { + entitiesToDelete = JSON.parse( + decodeURIComponent(searchParams.entitiesToDelete), + ); + } + } catch (e) { + // If parsing fails, just use empty array + } + return ( ); diff --git a/app/api/rpc/[command]/get_leaflet_data.ts b/app/api/rpc/[command]/get_leaflet_data.ts index e8390b9e..19779b39 100644 --- a/app/api/rpc/[command]/get_leaflet_data.ts +++ b/app/api/rpc/[command]/get_leaflet_data.ts @@ -7,6 +7,7 @@ export type GetLeafletDataReturnType = Awaited< >; const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`; +const leaflets_to_documents_query = `leaflets_to_documents(*, documents(*))`; export const get_leaflet_data = makeRoute({ route: "get_leaflet_data", input: z.object({ @@ -18,9 +19,10 @@ export const get_leaflet_data = makeRoute({ .from("permission_tokens") .select( `*, - permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}))), + permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}, ${leaflets_to_documents_query}))), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), - ${leaflets_in_publications_query}`, + ${leaflets_in_publications_query}, + ${leaflets_to_documents_query}`, ) .eq("id", token_id) .single(); diff --git a/app/globals.css b/app/globals.css index 03008310..4193cdbd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -339,6 +339,7 @@ pre.shiki { @apply focus-within:outline-offset-1; @apply disabled:border-border-light; + @apply disabled:hover:border-border-light; @apply disabled:bg-border-light; @apply disabled:text-tertiary; } diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index 7602cdf3..32bccd2c 100644 --- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -21,41 +21,34 @@ import { InfoSmall } from "components/Icons/InfoSmall"; import { PostHeader } from "./PostHeader/PostHeader"; import { useDrawerOpen } from "./Interactions/InteractionDrawer"; import { PollData } from "./fetchPollData"; +import { SharedPageProps } from "./PostPages"; export function CanvasPage({ - document, blocks, - did, - profile, - preferences, - pubRecord, - prerenderedCodeBlocks, - bskyPostData, - pollData, - document_uri, - pageId, - pageOptions, - fullPageScroll, pages, -}: { - document_uri: string; - document: PostPageData; + ...props +}: Omit & { blocks: PubLeafletPagesCanvas.Block[]; - profile: ProfileViewDetailed; - pubRecord: PubLeafletPublication.Record; - did: string; - prerenderedCodeBlocks?: Map; - bskyPostData: AppBskyFeedDefs.PostView[]; - pollData: PollData[]; - preferences: { showComments?: boolean }; - pageId?: string; - pageOptions?: React.ReactNode; - fullPageScroll: boolean; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) { + const { + document, + did, + profile, + preferences, + pubRecord, + theme, + prerenderedCodeBlocks, + bskyPostData, + pollData, + document_uri, + pageId, + pageOptions, + fullPageScroll, + } = props; if (!document) return null; - let hasPageBackground = !!pubRecord.theme?.showPageBackground; + let hasPageBackground = !!theme?.showPageBackground; let isSubpage = !!pageId; let drawer = useDrawerOpen(document_uri); diff --git a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx new file mode 100644 index 00000000..b53b6383 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx @@ -0,0 +1,145 @@ +import { AtpAgent } from "@atproto/api"; +import { AtUri } from "@atproto/syntax"; +import { ids } from "lexicons/api/lexicons"; +import { + PubLeafletBlocksBskyPost, + PubLeafletDocument, + PubLeafletPagesLinearDocument, + PubLeafletPagesCanvas, + PubLeafletPublication, +} from "lexicons/api"; +import { QuoteHandler } from "./QuoteHandler"; +import { + PublicationBackgroundProvider, + PublicationThemeProvider, +} from "components/ThemeManager/PublicationThemeProvider"; +import { getPostPageData } from "./getPostPageData"; +import { PostPageContextProvider } from "./PostPageContext"; +import { PostPages } from "./PostPages"; +import { extractCodeBlocks } from "./extractCodeBlocks"; +import { LeafletLayout } from "components/LeafletLayout"; +import { fetchPollData } from "./fetchPollData"; + +export async function DocumentPageRenderer({ + did, + rkey, +}: { + did: string; + rkey: string; +}) { + let agent = new AtpAgent({ + service: "https://public.api.bsky.app", + fetch: (...args) => + fetch(args[0], { + ...args[1], + next: { revalidate: 3600 }, + }), + }); + + let [document, profile] = await Promise.all([ + getPostPageData(AtUri.make(did, ids.PubLeafletDocument, rkey).toString()), + agent.getProfile({ actor: did }), + ]); + + if (!document?.data) + return ( +
+
+
+

Sorry, post not found!

+

+ This may be a glitch on our end. If the issue persists please{" "} + send us a note. +

+
+
+
+ ); + + let record = document.data as PubLeafletDocument.Record; + let bskyPosts = + record.pages.flatMap((p) => { + let page = p as PubLeafletPagesLinearDocument.Main; + return page.blocks?.filter( + (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, + ); + }) || []; + + // Batch bsky posts into groups of 25 and fetch in parallel + let bskyPostBatches = []; + for (let i = 0; i < bskyPosts.length; i += 25) { + bskyPostBatches.push(bskyPosts.slice(i, i + 25)); + } + + let bskyPostResponses = await Promise.all( + bskyPostBatches.map((batch) => + agent.getPosts( + { + uris: batch.map((p) => { + let block = p?.block as PubLeafletBlocksBskyPost.Main; + return block.postRef.uri; + }), + }, + { headers: {} }, + ), + ), + ); + + let bskyPostData = + bskyPostResponses.length > 0 + ? bskyPostResponses.flatMap((response) => response.data.posts) + : []; + + // Extract poll blocks and fetch vote data + let pollBlocks = record.pages.flatMap((p) => { + let page = p as PubLeafletPagesLinearDocument.Main; + return ( + page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || + [] + ); + }); + let pollData = await fetchPollData( + pollBlocks.map((b) => (b.block as any).pollRef.uri), + ); + + // Get theme from publication or document (for standalone docs) + let pubRecord = document.documents_in_publications[0]?.publications + ?.record as PubLeafletPublication.Record | undefined; + let theme = pubRecord?.theme || record.theme || null; + let pub_creator = + document.documents_in_publications[0]?.publications?.identity_did || did; + + let firstPage = record.pages[0]; + + let firstPageBlocks = + ( + firstPage as + | PubLeafletPagesLinearDocument.Main + | PubLeafletPagesCanvas.Main + ).blocks || []; + let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); + + return ( + + + + + + + + + + + + ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index ffa27a28..18e4fdd0 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -23,43 +23,35 @@ import { useDrawerOpen } from "./Interactions/InteractionDrawer"; import { PageWrapper } from "components/Pages/Page"; import { decodeQuotePosition } from "./quotePosition"; import { PollData } from "./fetchPollData"; +import { SharedPageProps } from "./PostPages"; export function LinearDocumentPage({ - document, blocks, - did, - profile, - preferences, - pubRecord, - prerenderedCodeBlocks, - bskyPostData, - document_uri, - pageId, - pageOptions, - pollData, - fullPageScroll, -}: { - document_uri: string; - document: PostPageData; + ...props +}: Omit & { blocks: PubLeafletPagesLinearDocument.Block[]; - profile?: ProfileViewDetailed; - pubRecord: PubLeafletPublication.Record; - did: string; - prerenderedCodeBlocks?: Map; - bskyPostData: AppBskyFeedDefs.PostView[]; - pollData: PollData[]; - preferences: { showComments?: boolean }; - pageId?: string; - pageOptions?: React.ReactNode; - fullPageScroll: boolean; }) { + const { + document, + did, + profile, + preferences, + pubRecord, + theme, + prerenderedCodeBlocks, + bskyPostData, + pollData, + document_uri, + pageId, + pageOptions, + fullPageScroll, + } = props; let { identity } = useIdentityData(); let drawer = useDrawerOpen(document_uri); - if (!document || !document.documents_in_publications[0].publications) - return null; + if (!document) return null; - let hasPageBackground = !!pubRecord.theme?.showPageBackground; + let hasPageBackground = !!theme?.showPageBackground; let record = document.data as PubLeafletDocument.Record; const isSubpage = !!pageId; @@ -114,22 +106,24 @@ export function LinearDocumentPage({ Edit Post ) : ( - + document.documents_in_publications[0]?.publications && ( + + ) )}
diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index ebea43ff..1ad7ed76 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -27,8 +27,7 @@ export function PostHeader(props: { let record = document?.data as PubLeafletDocument.Record; let profile = props.profile; - let pub = props.data?.documents_in_publications[0].publications; - let pubRecord = pub?.record as PubLeafletPublication.Record; + let pub = props.data?.documents_in_publications[0]?.publications; const formattedDate = useLocalizedDate( record.publishedAt || new Date().toISOString(), @@ -36,11 +35,10 @@ export function PostHeader(props: { year: "numeric", month: "long", day: "2-digit", - } + }, ); - if (!document?.data || !document.documents_in_publications[0].publications) - return; + if (!document?.data) return; return (
- - {pub?.name} - + {pub && ( + + {pub?.name} + + )} {identity && - identity.atp_did === - document.documents_in_publications[0]?.publications - .identity_did && + pub && + identity.atp_did === pub.identity_did && document.leaflets_in_publications[0] && ( - | -

{formattedDate}

+ |

{formattedDate}

) : null} |{" "} diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 00b061b2..e599e241 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -98,9 +98,53 @@ export const closePage = (page: string) => }; }); +// Shared props type for both page components +export type SharedPageProps = { + document: PostPageData; + did: string; + profile: ProfileViewDetailed; + preferences: { showComments?: boolean }; + pubRecord?: PubLeafletPublication.Record; + theme?: PubLeafletPublication.Theme | null; + prerenderedCodeBlocks?: Map; + bskyPostData: AppBskyFeedDefs.PostView[]; + pollData: PollData[]; + document_uri: string; + fullPageScroll: boolean; + pageId?: string; + pageOptions?: React.ReactNode; + allPages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; +}; + +// Component that renders either Canvas or Linear page based on page type +function PageRenderer({ + page, + ...sharedProps +}: { + page: PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main; +} & SharedPageProps) { + const isCanvas = PubLeafletPagesCanvas.isMain(page); + + if (isCanvas) { + return ( + + ); + } + + return ( + + ); +} + export function PostPages({ document, - blocks, did, profile, preferences, @@ -112,9 +156,8 @@ export function PostPages({ }: { document_uri: string; document: PostPageData; - blocks: PubLeafletPagesLinearDocument.Block[]; profile: ProfileViewDetailed; - pubRecord: PubLeafletPublication.Record; + pubRecord?: PubLeafletPublication.Record; did: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; @@ -123,37 +166,48 @@ export function PostPages({ }) { let drawer = useDrawerOpen(document_uri); useInitializeOpenPages(); - let pages = useOpenPages(); - if (!document || !document.documents_in_publications[0].publications) - return null; + let openPageIds = useOpenPages(); + if (!document) return null; - let hasPageBackground = !!pubRecord.theme?.showPageBackground; let record = document.data as PubLeafletDocument.Record; + let theme = pubRecord?.theme || record.theme || null; + let hasPageBackground = !!theme?.showPageBackground; let quotesAndMentions = document.quotesAndMentions; - let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; + let firstPage = record.pages[0] as + | PubLeafletPagesLinearDocument.Main + | PubLeafletPagesCanvas.Main; + + // Shared props used for all pages + const sharedProps: SharedPageProps = { + document, + did, + profile, + preferences, + pubRecord, + theme, + prerenderedCodeBlocks, + bskyPostData, + pollData, + document_uri, + allPages: record.pages as ( + | PubLeafletPagesLinearDocument.Main + | PubLeafletPagesCanvas.Main + )[], + fullPageScroll: !hasPageBackground && !drawer && openPageIds.length === 0, + }; + return ( <> - {!fullPageScroll && } - + {!sharedProps.fullPageScroll && } + + {drawer && !drawer.pageId && ( )} - {pages.map((p) => { + {openPageIds.map((pageId) => { let page = record.pages.find( - (page) => - ( - page as - | PubLeafletPagesLinearDocument.Main - | PubLeafletPagesCanvas.Main - ).id === p, + (p) => + (p as PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main) + .id === pageId, ) as | PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main | undefined; - if (!page) return null; - const isCanvas = PubLeafletPagesCanvas.isMain(page); + if (!page) return null; return ( - + - {isCanvas ? ( - closePage(page?.id!)} - hasPageBackground={hasPageBackground} - /> - } - /> - ) : ( - closePage(page?.id!)} - hasPageBackground={hasPageBackground} - /> - } - /> - )} + closePage(page.id!)} + hasPageBackground={hasPageBackground} + /> + } + /> {drawer && drawer.pageId === page.id && ( ); })} - {!fullPageScroll && } + + {!sharedProps.fullPageScroll && } ); } diff --git a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx index aad2a8d5..a671285c 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx @@ -106,7 +106,7 @@ export function DocLinkBlock(props: {
{title && (
(null); let { rootEntity } = useReplicache(); let data = useContext(PostPageContext); - let theme = data?.documents_in_publications[0]?.publications - ?.record as PubLeafletPublication.Record; + let theme = data?.theme; let pageWidth = `var(--page-width-unitless)`; - let cardBorderHidden = !theme.theme?.showPageBackground; + let cardBorderHidden = !theme?.showPageBackground; return (
> { const codeBlocks = new Map(); - // Process all pages in the document + // Process all blocks (works for both linear and canvas) for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; const currentIndex = [i]; diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts index 2c004601..7f26cd9f 100644 --- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -1,6 +1,6 @@ import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletPublication } from "lexicons/api"; +import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; export async function getPostPageData(uri: string) { let { data: document } = await supabaseServerClient @@ -43,9 +43,16 @@ export async function getPostPageData(uri: string) { ...uniqueBacklinks, ]; + let theme = + ( + document?.documents_in_publications[0]?.publications + ?.record as PubLeafletPublication.Record + )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; + return { ...document, quotesAndMentions, + theme, }; } diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index f791d173..0d7f22e5 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -1,26 +1,9 @@ import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; import { ids } from "lexicons/api/lexicons"; -import { - PubLeafletBlocksBskyPost, - PubLeafletDocument, - PubLeafletPagesLinearDocument, - PubLeafletPublication, -} from "lexicons/api"; +import { PubLeafletDocument } from "lexicons/api"; import { Metadata } from "next"; -import { AtpAgent } from "@atproto/api"; -import { QuoteHandler } from "./QuoteHandler"; -import { InteractionDrawer } from "./Interactions/InteractionDrawer"; -import { - PublicationBackgroundProvider, - PublicationThemeProvider, -} from "components/ThemeManager/PublicationThemeProvider"; -import { getPostPageData } from "./getPostPageData"; -import { PostPageContextProvider } from "./PostPageContext"; -import { PostPages } from "./PostPages"; -import { extractCodeBlocks } from "./extractCodeBlocks"; -import { LeafletLayout } from "components/LeafletLayout"; -import { fetchPollData } from "./fetchPollData"; +import { DocumentPageRenderer } from "./DocumentPageRenderer"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; @@ -57,7 +40,9 @@ export async function generateMetadata(props: { export default async function Post(props: { params: Promise<{ publication: string; did: string; rkey: string }>; }) { - let did = decodeURIComponent((await props.params).did); + let params = await props.params; + let did = decodeURIComponent(params.did); + if (!did) return (
@@ -68,141 +53,6 @@ export default async function Post(props: {

); - let agent = new AtpAgent({ - service: "https://public.api.bsky.app", - fetch: (...args) => - fetch(args[0], { - ...args[1], - next: { revalidate: 3600 }, - }), - }); - let [document, profile] = await Promise.all([ - getPostPageData( - AtUri.make( - did, - ids.PubLeafletDocument, - (await props.params).rkey, - ).toString(), - ), - agent.getProfile({ actor: did }), - ]); - if (!document?.data || !document.documents_in_publications[0].publications) - return ( -
- ); - let record = document.data as PubLeafletDocument.Record; - let bskyPosts = - record.pages.flatMap((p) => { - let page = p as PubLeafletPagesLinearDocument.Main; - return page.blocks?.filter( - (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, - ); - }) || []; - - // Batch bsky posts into groups of 25 and fetch in parallel - let bskyPostBatches = []; - for (let i = 0; i < bskyPosts.length; i += 25) { - bskyPostBatches.push(bskyPosts.slice(i, i + 25)); - } - - let bskyPostResponses = await Promise.all( - bskyPostBatches.map((batch) => - agent.getPosts( - { - uris: batch.map((p) => { - let block = p?.block as PubLeafletBlocksBskyPost.Main; - return block.postRef.uri; - }), - }, - { headers: {} }, - ), - ), - ); - - let bskyPostData = - bskyPostResponses.length > 0 - ? bskyPostResponses.flatMap((response) => response.data.posts) - : []; - - // Extract poll blocks and fetch vote data - let pollBlocks = record.pages.flatMap((p) => { - let page = p as PubLeafletPagesLinearDocument.Main; - return ( - page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || - [] - ); - }); - let pollData = await fetchPollData( - pollBlocks.map((b) => (b.block as any).pollRef.uri), - ); - - let pubRecord = document.documents_in_publications[0]?.publications - .record as PubLeafletPublication.Record; - - let firstPage = record.pages[0]; - let blocks: PubLeafletPagesLinearDocument.Block[] = []; - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { - blocks = firstPage.blocks || []; - } - - let prerenderedCodeBlocks = await extractCodeBlocks(blocks); - - return ( - - - - {/* - TODO: SCROLL PAGE TO FIT DRAWER - If the drawer fits without scrolling, dont scroll - If both drawer and page fit if you scrolled it, scroll it all into the center - If the drawer and pafe doesn't all fit, scroll to drawer - - TODO: SROLL BAR - If there is no drawer && there is no page bg, scroll the entire page - If there is either a drawer open OR a page background, scroll just the post content - - TODO: HIGHLIGHTING BORKED - on chrome, if you scroll backward, things stop working - seems like if you use an older browser, sel direction is not a thing yet - */} - - - - - - - - ); + return ; } diff --git a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx index 12e2cc15..38cf82d3 100644 --- a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx @@ -13,7 +13,7 @@ import { ButtonPrimary } from "components/Buttons"; import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; import { DeleteSmall } from "components/Icons/DeleteSmall"; import { ShareSmall } from "components/Icons/ShareSmall"; -import { ShareButton } from "components/ShareOptions"; +import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; import { SpeedyLink } from "components/SpeedyLink"; import { QuoteTiny } from "components/Icons/QuoteTiny"; import { CommentTiny } from "components/Icons/CommentTiny"; diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx index aa1ac273..cf5d50af 100644 --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -60,11 +60,11 @@ export default async function Publication(props: { try { return ( { onChange={(e) => setShowInDiscover(e.target.checked)} >
-

- Show In{" "} +

Show In Discover

+

+ Your posts will appear on our{" "} Discover - -

-

- You'll be able to change this later! + {" "} + page. You can change this at any time!


-
+

- This publication will appear on our public Discover page + Your posts will appear on our{" "} + + Discover + {" "} + page. You can change this at any time!

diff --git a/app/lish/createPub/page.tsx b/app/lish/createPub/page.tsx index 848dde28..f22b8b67 100644 --- a/app/lish/createPub/page.tsx +++ b/app/lish/createPub/page.tsx @@ -26,7 +26,7 @@ export default async function CreatePub() {

Create Your Publication!

-
+
diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index 13a8e228..a9ffa1ba 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -213,7 +213,7 @@ export function BlueskyLogin(props: { @@ -92,21 +93,34 @@ export const ButtonTertiary = forwardRef< HTMLButtonElement, { fullWidth?: boolean; + fullWidthOnMobile?: boolean; children: React.ReactNode; compact?: boolean; } & ButtonProps >((props, ref) => { - let { fullWidth, children, compact, ...buttonProps } = props; + let { + className, + fullWidth, + fullWidthOnMobile, + compact, + children, + ...buttonProps + } = props; return ( diff --git a/components/Icons/LooseleafSmall.tsx b/components/Icons/LooseleafSmall.tsx new file mode 100644 index 00000000..7d2599be --- /dev/null +++ b/components/Icons/LooseleafSmall.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const LooseLeafSmall = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Input.tsx b/components/Input.tsx index 97213a69..5666bb01 100644 --- a/components/Input.tsx +++ b/components/Input.tsx @@ -100,9 +100,17 @@ export const InputWithLabel = ( JSX.IntrinsicElements["textarea"], ) => { let { label, textarea, ...inputProps } = props; - let style = `appearance-none w-full font-normal not-italic bg-transparent text-base text-primary focus:outline-0 ${props.className} outline-hidden resize-none`; + let style = ` + appearance-none resize-none w-full + bg-transparent + outline-hidden focus:outline-0 + font-normal not-italic text-base text-primary disabled:text-tertiary + disabled:cursor-not-allowed + ${props.className}`; return ( -