diff --git a/actions/createNewLeaflet.ts b/actions/createNewLeaflet.ts index 29e2f840..cb13e81f 100644 --- a/actions/createNewLeaflet.ts +++ b/actions/createNewLeaflet.ts @@ -14,17 +14,24 @@ import { import { redirect } from "next/navigation"; import { v7 } from "uuid"; import { sql, eq, and } from "drizzle-orm"; +import { generateKeyBetween } from "fractional-indexing"; import { cookies } from "next/headers"; import { pool } from "supabase/pool"; +type DefaultBlockType = "h1" | "text" | "posts-list"; + export async function createNewLeaflet({ pageType, redirectUser, firstBlockType, + firstBlocks, + addToHomepage = true, }: { pageType: "canvas" | "doc"; redirectUser: boolean; firstBlockType?: "h1" | "text"; + firstBlocks?: DefaultBlockType[]; + addToHomepage?: boolean; }) { let auth_token = (await cookies()).get("auth_token")?.value; const client = await pool.connect(); @@ -105,39 +112,66 @@ export async function createNewLeaflet({ }, ]); } else { - await tx.insert(facts).values([ - { + let blockSpecs: DefaultBlockType[] = + firstBlocks ?? [firstBlockType === "text" ? "text" : "h1"]; + + // Reuse the pre-created blockEntity for the first block so the rest of + // the flow (e.g. focusFirstBlock) still has an entity to target. + let blockEntities = blockSpecs.map((_, i) => + i === 0 ? blockEntity.id : v7(), + ); + + if (blockEntities.length > 1) { + await tx + .insert(entities) + .values(blockEntities.slice(1).map((id) => ({ set: entity_set.id, id }))); + } + + let blockFacts: Array<{ + id: string; + entity: string; + attribute: string; + data: ReturnType; + }> = []; + let prevPosition: string | null = null; + blockSpecs.forEach((spec, i) => { + let entity = blockEntities[i]; + let position = generateKeyBetween(prevPosition, null); + prevPosition = position; + blockFacts.push({ id: v7(), entity: first_page.id, attribute: "card/block", - data: sql`${{ type: "ordered-reference", value: blockEntity.id, position: "a0" }}::jsonb`, - }, - ...(firstBlockType === "text" - ? [ - { - id: v7(), - entity: blockEntity.id, - attribute: "block/type", - data: sql`${{ type: "block-type-union", value: "text" }}::jsonb`, - }, - ] - : [ - { - id: v7(), - entity: blockEntity.id, - attribute: "block/type", - data: sql`${{ type: "block-type-union", value: "heading" }}::jsonb`, - }, - { - id: v7(), - entity: blockEntity.id, - attribute: "block/heading-level", - data: sql`${{ type: "number", value: 1 }}::jsonb`, - }, - ]), - ]); + data: sql`${{ type: "ordered-reference", value: entity, position }}::jsonb`, + }); + if (spec === "h1") { + blockFacts.push( + { + id: v7(), + entity, + attribute: "block/type", + data: sql`${{ type: "block-type-union", value: "heading" }}::jsonb`, + }, + { + id: v7(), + entity, + attribute: "block/heading-level", + data: sql`${{ type: "number", value: 1 }}::jsonb`, + }, + ); + } else { + blockFacts.push({ + id: v7(), + entity, + attribute: "block/type", + data: sql`${{ type: "block-type-union", value: spec }}::jsonb`, + }); + } + }); + + await tx.insert(facts).values(blockFacts); } - if (auth_token) { + if (auth_token && addToHomepage) { await tx.execute(sql` WITH auth_token AS ( SELECT identities.id as identity_id diff --git a/actions/createPublicationPage.ts b/actions/createPublicationPage.ts new file mode 100644 index 00000000..4529652f --- /dev/null +++ b/actions/createPublicationPage.ts @@ -0,0 +1,41 @@ +"use server"; +import { getIdentityData } from "actions/getIdentityData"; +import { createNewLeaflet } from "./createNewLeaflet"; +import { supabaseServerClient } from "supabase/serverClient"; + +export async function createPublicationPage(args: { + publication_uri: string; + path: string; + title?: string; +}) { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return null; + + let { data: publication } = await supabaseServerClient + .from("publications") + .select("uri, identity_did") + .eq("uri", args.publication_uri) + .single(); + if (!publication || publication.identity_did !== identity.atp_did) + return null; + + let leaflet_src = await createNewLeaflet({ + pageType: "doc", + redirectUser: false, + firstBlocks: ["text", "posts-list"], + addToHomepage: false, + }); + + let { data: page } = await supabaseServerClient + .from("publication_pages") + .insert({ + publication: args.publication_uri, + leaflet_src, + path: args.path, + title: args.title ?? "", + }) + .select("*") + .single(); + + return page; +} diff --git a/app/[leaflet_id]/Sidebar.tsx b/app/[leaflet_id]/Sidebar.tsx index 14ac86cc..94d8d23e 100644 --- a/app/[leaflet_id]/Sidebar.tsx +++ b/app/[leaflet_id]/Sidebar.tsx @@ -4,7 +4,10 @@ import { useEntitySetContext } from "components/EntitySetProvider"; 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 { + useLeafletPublicationData, + useLeafletPublicationPage, +} from "components/PageSWRDataProvider"; import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; import { ThemePopover } from "components/ThemeManager/ThemeSetter"; import { PublishButton } from "./actions/PublishButton"; @@ -19,6 +22,8 @@ export function LeafletSidebar() { let { rootEntity } = useReplicache(); let { data: pub } = useLeafletPublicationData(); let { identity } = useIdentityData(); + let publicationPage = useLeafletPublicationPage(); + if (publicationPage) return null; return ( diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index 714ad992..b3f023b5 100644 --- a/app/api/rpc/[command]/get_publication_data.ts +++ b/app/api/rpc/[command]/get_publication_data.ts @@ -54,6 +54,11 @@ export const get_publication_data = makeRoute({ permission_token_rights(*), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*) ) + ), + publication_pages(*, + permission_tokens!publication_pages_leaflet_src_fkey(*, + permission_token_rights(*) + ) )`, ) .or( @@ -66,10 +71,14 @@ export const get_publication_data = makeRoute({ let leaflet_data = await getFactsFromHomeLeaflets.handler( { - tokens: - publication?.leaflets_in_publications.map( + tokens: [ + ...(publication?.leaflets_in_publications.map( (l) => l.permission_tokens?.root_entity!, - ) || [], + ) || []), + ...(publication?.publication_pages.map( + (p) => p.permission_tokens?.root_entity!, + ) || []), + ].filter(Boolean), }, { supabase }, ); @@ -111,11 +120,23 @@ export const get_publication_data = makeRoute({ _raw: l, })); + const pages = (publication?.publication_pages || []).map((p) => ({ + id: p.id, + title: p.title, + path: p.path, + document: p.document, + metadata: p.metadata, + leaflet_src: p.leaflet_src, + created_at: p.created_at, + permission_tokens: p.permission_tokens, + })); + return { result: { publication, documents, drafts, + pages, leaflet_data: leaflet_data.result, }, }; diff --git a/app/lish/[did]/[publication]/PublicationContent.tsx b/app/lish/[did]/[publication]/PublicationContent.tsx index 8e23253e..b22ea9a0 100644 --- a/app/lish/[did]/[publication]/PublicationContent.tsx +++ b/app/lish/[did]/[publication]/PublicationContent.tsx @@ -1,24 +1,21 @@ import React from "react"; -import { AtUri } from "@atproto/syntax"; -import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; -import { InteractionPreview } from "components/InteractionsPreview"; -import { LocalizedDate } from "./LocalizedDate"; import { PublicationHomeLayout } from "./PublicationHomeLayout"; import { PublicationAuthor } from "./PublicationAuthor"; +import { PublicationHeader } from "./PublicationHeader"; import { normalizePublicationRecord, normalizeDocumentRecord, } from "src/utils/normalizeRecords"; -import { getFirstParagraph } from "src/utils/getFirstParagraph"; import { FontLoader } from "components/FontLoader"; import { SpeedyLink } from "components/SpeedyLink"; import { SubscribeInput } from "components/Subscribe/SubscribeButton"; +import { + PublicationPostsList, + type PublicationPostsListPost, + type PublicationPostsListFakePost, +} from "./PublicationPostsList"; -type FakePost = { - title: string; - description: string; - date: React.ReactNode; -}; +type FakePost = PublicationPostsListFakePost; export const PublicationContent = ({ record, @@ -52,6 +49,24 @@ export const PublicationContent = ({ fakePosts?: FakePost[]; }) => { const newsletterMode = !!publication.publication_newsletter_settings?.enabled; + const posts: PublicationPostsListPost[] = fakePosts + ? [] + : publication.documents_in_publications + .map((dip) => { + if (!dip.documents) return null; + const normalized = normalizeDocumentRecord(dip.documents.data); + if (!normalized) return null; + return { + uri: dip.documents.uri, + record: normalized, + commentsCount: dip.documents.comments_on_documents[0]?.count || 0, + mentionsCount: + dip.documents.document_mentions_in_bsky[0]?.count || 0, + recommendsCount: + dip.documents.recommends_on_documents?.[0]?.count || 0, + }; + }) + .filter((p): p is PublicationPostsListPost => p !== null); return ( <> } /> -
- {fakePosts && - fakePosts.map((post, i) => ( - - ))} - {!fakePosts && - publication.documents_in_publications - .filter((d) => !!d?.documents) - .sort((a, b) => { - const aRecord = normalizeDocumentRecord(a.documents?.data); - const bRecord = normalizeDocumentRecord(b.documents?.data); - const aDate = aRecord?.publishedAt - ? new Date(aRecord.publishedAt) - : new Date(0); - const bDate = bRecord?.publishedAt - ? new Date(bRecord.publishedAt) - : new Date(0); - return bDate.getTime() - aDate.getTime(); // Sort by most recent first - }) - .map((doc) => { - if (!doc.documents) return null; - const doc_record = normalizeDocumentRecord(doc.documents.data); - if (!doc_record) return null; - let uri = new AtUri(doc.documents.uri); - let quotes = - doc.documents.document_mentions_in_bsky[0].count || 0; - let comments = - record?.preferences?.showComments === false - ? 0 - : doc.documents.comments_on_documents[0].count || 0; - let recommends = - doc.documents.recommends_on_documents?.[0]?.count || 0; - let tags = doc_record.tags || []; - - const docUrl = getDocumentURL( - doc_record, - doc.documents.uri, - publication, - ); - return ( - - - ) : undefined - } - interactions={ - - } - /> - - ); - })} -
+ ); }; -export function PublicationHeader(props: { - iconUrl?: string; - publicationName: string; - description?: string; - author?: React.ReactNode; - subscribeButton?: React.ReactNode; -}) { - return ( -
- {props.iconUrl && ( -
- )} -

- {props.publicationName} -

-

{props.description}

- {props.author} -
{props.subscribeButton}
-
- ); -} - export function PublicationPostItem(props: { href?: string; title?: string; diff --git a/app/lish/[did]/[publication]/PublicationHeader.tsx b/app/lish/[did]/[publication]/PublicationHeader.tsx new file mode 100644 index 00000000..6a9aab0c --- /dev/null +++ b/app/lish/[did]/[publication]/PublicationHeader.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +export function PublicationHeader(props: { + iconUrl?: string; + publicationName: string; + description?: string; + author?: React.ReactNode; + subscribeButton?: React.ReactNode; +}) { + return ( +
+ {props.iconUrl && ( +
+ )} +

+ {props.publicationName} +

+

{props.description}

+ {props.author} +
{props.subscribeButton}
+
+ ); +} diff --git a/app/lish/[did]/[publication]/PublicationHeaderEditor.tsx b/app/lish/[did]/[publication]/PublicationHeaderEditor.tsx new file mode 100644 index 00000000..8c01857a --- /dev/null +++ b/app/lish/[did]/[publication]/PublicationHeaderEditor.tsx @@ -0,0 +1,29 @@ +export function PublicationHeaderEditor(props: { + iconUrl?: string; + publicationName: string; + description?: string; +}) { + return ( +
+
+ {props.iconUrl && ( +
+ )} +

+ {props.publicationName} +

+
+ {props.description && ( +

{props.description}

+ )} +
+ ); +} diff --git a/app/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx b/app/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx new file mode 100644 index 00000000..c74cc165 --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx @@ -0,0 +1,53 @@ +"use client"; +import { ActionButton } from "components/ActionBar/ActionButton"; +import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { createPublicationPage } from "actions/createPublicationPage"; +import { usePublicationData } from "./PublicationSWRProvider"; + +export function EditPagesNavLink(props: { + publication: string; + did: string; + publicationName: string; +}) { + let router = useRouter(); + let { data, mutate } = usePublicationData(); + let [loading, setLoading] = useState(false); + + let pages = data?.publication?.publication_pages || []; + + async function handleClick() { + if (loading) return; + let targetPath: string | null; + let existing = pages[0]; + if (existing) { + targetPath = existing.path; + } else { + setLoading(true); + let created = await createPublicationPage({ + publication_uri: props.publication, + path: "/", + title: "home", + }); + setLoading(false); + if (!created) return; + targetPath = created.path; + mutate(); + } + let routeSegment = targetPath && targetPath !== "/" ? targetPath : ""; + router.push( + `/lish/${props.did}/${props.publicationName}/edit${routeSegment}`, + ); + } + + return ( + } + label={loading ? "Creating..." : "Editing Pages"} + onClick={handleClick} + className="w-full" + /> + ); +} diff --git a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx index db4eac7e..d00fd03c 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx @@ -3,6 +3,8 @@ import { DraftList } from "./DraftList"; import { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; import { Actions } from "./Actions"; +import { EditPagesNavLink } from "./EditPagesNavLink"; +import { AtUri } from "@atproto/syntax"; import React, { useState } from "react"; import { PublishedPostsList } from "./PublishedPostsLists"; import { PublicationSubscribers } from "./PublicationSubscribers"; @@ -31,6 +33,7 @@ export default function PublicationDashboard({ let canSeePro = useCanSeePro(); let [searchValue, setSearchValue] = useState(""); let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); + let pubUri = new AtUri(publication.uri); useDebouncedEffect( () => { @@ -100,7 +103,16 @@ export default function PublicationDashboard({ controls: null, }, }} - actions={} + actions={ + <> + + + + } currentPage="pub" publication={publication.uri} pageTitle={record.name} diff --git a/app/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx new file mode 100644 index 00000000..615bba95 --- /dev/null +++ b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx @@ -0,0 +1,32 @@ +"use client"; +import { SpeedyLink } from "components/SpeedyLink"; +import { GoToArrowLined } from "components/Icons/GoToArrowLined"; + +export function PublicationEditHeader(props: { + did: string; + publicationName: string; +}) { + let dashboardHref = `/lish/${props.did}/${props.publicationName}/dashboard`; + + return ( +
+ + + Back to Dashboard + + +
+ ); +} diff --git a/app/lish/[did]/[publication]/edit/[[...route]]/PublicationPageLeaflet.tsx b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationPageLeaflet.tsx new file mode 100644 index 00000000..1247680a --- /dev/null +++ b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationPageLeaflet.tsx @@ -0,0 +1,52 @@ +"use client"; +import { Fact, PermissionToken, ReplicacheProvider } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { SelectionManager } from "components/SelectionManager"; +import { Pages } from "components/Pages"; +import { + PublicationThemeProvider, + PublicationBackgroundProvider, +} from "components/ThemeManager/PublicationThemeProvider"; +import { EntitySetProvider } from "components/EntitySetProvider"; +import { UpdateLeafletTitle } from "components/utils/UpdateLeafletTitle"; +import { LeafletLayout } from "components/LeafletLayout"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { Json } from "supabase/database.types"; + +export function PublicationPageLeaflet(props: { + token: PermissionToken; + initialFacts: Fact[]; + leaflet_id: string; + publicationRecord: Json | null; + publicationCreator: string; +}) { + let normalizedPub = normalizePublicationRecord(props.publicationRecord); + return ( + + + + + + + + + + + + + + ); +} diff --git a/app/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesNav.tsx b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesNav.tsx new file mode 100644 index 00000000..997749ef --- /dev/null +++ b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesNav.tsx @@ -0,0 +1,82 @@ +"use client"; +import { useRouter, usePathname } from "next/navigation"; +import { useState } from "react"; +import { SpeedyLink } from "components/SpeedyLink"; +import { ButtonSecondary } from "components/Buttons"; +import { AddTiny } from "components/Icons/AddTiny"; +import { createPublicationPage } from "actions/createPublicationPage"; +import { usePublicationData } from "../../dashboard/PublicationSWRProvider"; + +export function PublicationPagesNav(props: { + did: string; + publicationName: string; +}) { + let router = useRouter(); + let pathname = usePathname(); + let { data, mutate } = usePublicationData(); + let [creating, setCreating] = useState(false); + + let pages = data?.publication?.publication_pages ?? []; + let publicationUri = data?.publication?.uri; + + let baseHref = `/lish/${props.did}/${props.publicationName}`; + + function hrefForPath(path: string | null) { + let segment = path && path !== "/" ? path : ""; + return `${baseHref}/edit${segment}`; + } + + async function handleCreate() { + if (creating || !publicationUri) return; + let path = window.prompt("New page path (e.g. /about):", "/"); + if (!path) return; + if (!path.startsWith("/")) path = "/" + path; + setCreating(true); + let created = await createPublicationPage({ + publication_uri: publicationUri, + path, + }); + setCreating(false); + if (!created) return; + await mutate(); + router.push(hrefForPath(created.path)); + } + + let sortedPages = [...pages].sort((a, b) => { + let ap = a.path === "/" ? 0 : 1; + let bp = b.path === "/" ? 0 : 1; + if (ap !== bp) return ap - bp; + return (a.path ?? "").localeCompare(b.path ?? ""); + }); + + return ( + + ); +} diff --git a/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx b/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx new file mode 100644 index 00000000..2cab1a07 --- /dev/null +++ b/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx @@ -0,0 +1,121 @@ +import { supabaseServerClient } from "supabase/serverClient"; +import { Metadata } from "next"; +import { getIdentityData } from "actions/getIdentityData"; +import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { LoginModal } from "components/LoginButton"; +import { AtUri } from "@atproto/syntax"; +import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; +import { PublicationSWRDataProvider } from "../../dashboard/PublicationSWRProvider"; +import { PublicationPagesNav } from "./PublicationPagesNav"; +import { PublicationEditHeader } from "./PublicationEditHeader"; +import { PublicationHeaderEditor } from "../../PublicationHeaderEditor"; + +export async function generateMetadata(props: { + params: Promise<{ publication: string; did: string }>; +}): Promise { + let did = decodeURIComponent((await props.params).did); + if (!did) return { title: "Publication 404" }; + + let { result: publication_data } = await get_publication_data.handler( + { + did, + publication_name: decodeURIComponent((await props.params).publication), + }, + { supabase: supabaseServerClient }, + ); + let { publication } = publication_data; + const record = normalizePublicationRecord(publication?.record); + if (!publication) return { title: "404 Publication" }; + return { title: `Edit Pages — ${record?.name || "Untitled Publication"}` }; +} + +export default async function PublicationEditLayout(props: { + children: React.ReactNode; + params: Promise<{ publication: string; did: string }>; +}) { + let params = await props.params; + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) { + return ( + +

+ Looks like you're not logged in.{" "} + Log in here

+ } + /> + ! +

+ + ); + } + + let did = decodeURIComponent(params.did); + if (!did) return ; + + let { result: publication_data } = await get_publication_data.handler( + { + did, + publication_name: decodeURIComponent(params.publication), + }, + { supabase: supabaseServerClient }, + ); + let { publication } = publication_data; + const record = normalizePublicationRecord(publication?.record); + + if ( + !publication || + identity.atp_did !== publication.identity_did || + !record + ) { + return ; + } + + let uri = new AtUri(publication.uri); + const iconUrl = record?.icon + ? `/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}` + : undefined; + + return ( + + +
+ +
+
+ +
+ +
{props.children}
+
+
+
+
+ ); +} + +const PubNotFound = () => { + return ( + +

Sorry, we can't find this publication!

+
+ ); +}; diff --git a/app/lish/[did]/[publication]/edit/[[...route]]/page.tsx b/app/lish/[did]/[publication]/edit/[[...route]]/page.tsx new file mode 100644 index 00000000..d95c482c --- /dev/null +++ b/app/lish/[did]/[publication]/edit/[[...route]]/page.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import type { Fact } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { PublicationPageLeaflet } from "./PublicationPageLeaflet"; +import { PageSWRDataProvider } from "components/PageSWRDataProvider"; +import { getPollData } from "actions/pollActions"; +import { supabaseServerClient } from "supabase/serverClient"; +import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; +import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; +import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { FontLoader, extractFontsFromFacts } from "components/FontLoader"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; + +export const preferredRegion = ["sfo1"]; +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; + +type Props = { + params: Promise<{ did: string; publication: string; route?: string[] }>; +}; + +export default async function PublicationEditPage(props: Props) { + let params = await props.params; + let did = decodeURIComponent(params.did); + let publicationName = decodeURIComponent(params.publication); + let path = "/" + (params.route?.map(decodeURIComponent).join("/") ?? ""); + + let { result: publication_data } = await get_publication_data.handler( + { did, publication_name: publicationName }, + { supabase: supabaseServerClient }, + ); + let publication = publication_data.publication; + if (!publication) return ; + + let page = publication.publication_pages?.find((p) => p.path === path); + if (!page) return ; + + let { result: res } = await get_leaflet_data.handler( + { token_id: page.leaflet_src }, + { supabase: supabaseServerClient }, + ); + let rootEntity = res.data?.root_entity; + if (!rootEntity || !res.data || res.data.blocked_by_admin) + return ; + + let [{ data }, poll_data] = await Promise.all([ + supabaseServerClient.rpc("get_facts", { root: rootEntity }), + getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), + ]); + let initialFacts = (data as unknown as Fact[]) || []; + + let pubRecord = normalizePublicationRecord(publication.record); + const headingFontId = pubRecord?.theme?.headingFont; + const bodyFontId = pubRecord?.theme?.bodyFont; + const fallbackFonts = extractFontsFromFacts(initialFacts as any, rootEntity); + + return ( + + + + + + + ); +} + +const PageNotFound = (props: { path: string }) => ( + +

No publication page at {props.path}

+

Use the Editing Pages button to create one.

+
+); diff --git a/components/Blocks/BlockCommandBar.tsx b/components/Blocks/BlockCommandBar.tsx index 7a302746..2cd55488 100644 --- a/components/Blocks/BlockCommandBar.tsx +++ b/components/Blocks/BlockCommandBar.tsx @@ -2,7 +2,10 @@ import { useState } from "react"; import { blockCommands } from "./BlockCommands"; import { useReplicache } from "src/replicache"; import { useEntitySetContext } from "components/EntitySetProvider"; -import { useLeafletPublicationData } from "components/PageSWRDataProvider"; +import { + useLeafletPublicationData, + useLeafletPublicationPage, +} from "components/PageSWRDataProvider"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; import { Combobox, ComboboxResult } from "components/Combobox"; @@ -28,6 +31,8 @@ export const BlockCommandBar = ({ let { rep, undoManager } = useReplicache(); let entity_set = useEntitySetContext(); let { data: pub } = useLeafletPublicationData(); + let publicationPage = useLeafletPublicationPage(); + let inPublicationEdit = !!publicationPage; // This clears '/' AND anything typed after it const clearCommandSearchText = () => { @@ -53,7 +58,8 @@ export const BlockCommandBar = ({ ) ?? false; const matchesSearch = matchesName || matchesAlternate; const isVisible = !pub || !command.hiddenInPublication; - return matchesSearch && isVisible; + const allowedInContext = !command.publicationOnly || inPublicationEdit; + return matchesSearch && isVisible && allowedInContext; }); return ( diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx index d1f9484d..0a7dcc82 100644 --- a/components/Blocks/BlockCommands.tsx +++ b/components/Blocks/BlockCommands.tsx @@ -108,6 +108,7 @@ type Command = { type: string; alternateNames?: string[]; hiddenInPublication?: boolean; + publicationOnly?: boolean; onSelect: ( rep: Replicache, props: Props & { entity_set: string }, @@ -440,6 +441,30 @@ export const blockCommands: Command[] = [ }); }, }, + + // PUBLICATION BLOCKS — only shown when editing a publication page + { + name: "Posts List", + icon: , + type: "publication", + alternateNames: ["posts", "archive", "feed", "listing"], + publicationOnly: true, + onSelect: async (rep, props) => { + props.entityID && clearCommandSearchText(props.entityID); + await createBlockWithType(rep, props, "posts-list"); + }, + }, + { + name: "Subscribe Form", + icon: , + type: "publication", + alternateNames: ["subscribe", "newsletter", "email"], + publicationOnly: true, + onSelect: async (rep, props) => { + props.entityID && clearCommandSearchText(props.entityID); + await createBlockWithType(rep, props, "text"); + }, + }, ]; async function setHeaderCommand( diff --git a/components/Icons/GoToArrowLined.tsx b/components/Icons/GoToArrowLined.tsx new file mode 100644 index 00000000..a9fefda1 --- /dev/null +++ b/components/Icons/GoToArrowLined.tsx @@ -0,0 +1,22 @@ +import { Props } from "./Props"; + +export const GoToArrowLined = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/PageSWRDataProvider.tsx b/components/PageSWRDataProvider.tsx index 39e52d59..d97b4b08 100644 --- a/components/PageSWRDataProvider.tsx +++ b/components/PageSWRDataProvider.tsx @@ -94,6 +94,16 @@ export function useLeafletDomains() { return { data: data?.custom_domain_routes, mutate: mutate }; } +/** + * Returns the publication_pages row this leaflet is the source of, if any. + * Mirrors `draftInPublication` from useLeafletPublicationStatus, but for + * leaflets that back a publication page rather than a draft post. + */ +export function useLeafletPublicationPage() { + let { data } = useLeafletData(); + return data?.publication_pages?.[0] ?? null; +} + export function useLeafletPublicationStatus() { const data = useContext(StaticLeafletDataContext); if (!data) return null; diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index ef0d43a8..39363a9c 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -11,6 +11,7 @@ import { DesktopPageFooter } from "../DesktopFooter"; import { Canvas } from "../Canvas"; import { Blocks } from "components/Blocks"; import { PublicationMetadata } from "./PublicationMetadata"; +import { useLeafletPublicationPage } from "components/PageSWRDataProvider"; import { useCardBorderHidden } from "./useCardBorderHidden"; import { focusPage } from "src/utils/focusPage"; import { PageOptions } from "./PageOptions"; @@ -30,6 +31,7 @@ export function Page(props: { fullPageScroll: boolean; }) { let { rep } = useReplicache(); + let publicationPage = useLeafletPublicationPage(); let isFocused = useUIState((s) => { let focusedElement = s.focusedEntity; @@ -81,10 +83,8 @@ export function Page(props: { /> } > - {props.first && pageType === "doc" && ( - <> - - + {props.first && pageType === "doc" && !publicationPage && ( + )} diff --git a/components/Pages/PageOptions.tsx b/components/Pages/PageOptions.tsx index 561d1cf0..8cc15725 100644 --- a/components/Pages/PageOptions.tsx +++ b/components/Pages/PageOptions.tsx @@ -16,7 +16,10 @@ import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny"; import { PaintSmall } from "components/Icons/PaintSmall"; import { ShareSmall } from "components/Icons/ShareSmall"; import { useCardBorderHidden } from "./useCardBorderHidden"; -import { useLeafletPublicationData } from "components/PageSWRDataProvider"; +import { + useLeafletPublicationData, + useLeafletPublicationPage, +} from "components/PageSWRDataProvider"; export const PageOptionButton = ({ children, @@ -57,6 +60,8 @@ export const PageOptions = (props: { first: boolean | undefined; isFocused: boolean; }) => { + let publicationPage = useLeafletPublicationPage(); + if (publicationPage) return null; return (
); }, + "posts-list": async () => null, }; async function renderBlock(b: Block, tx: ReadTransaction) { diff --git a/src/utils/getPublicationMetadataFromLeafletData.ts b/src/utils/getPublicationMetadataFromLeafletData.ts index e0220520..3e3666b5 100644 --- a/src/utils/getPublicationMetadataFromLeafletData.ts +++ b/src/utils/getPublicationMetadataFromLeafletData.ts @@ -7,6 +7,14 @@ import { Json } from "supabase/database.types"; * Consumers should use `normalizePublicationRecord()` and `normalizeDocumentRecord()` * from `src/utils/normalizeRecords` to get properly typed data. */ +export type PublicationPageMetadataEntry = { + id: number; + title: string; + path: string | null; + document: string | null; + metadata: Json; +}; + export type PublicationMetadata = { description: string; title: string; @@ -26,6 +34,8 @@ export type PublicationMetadata = { indexed_at: string; uri: string; } | null; + /** Set when this leaflet is the source of a publication_pages row. */ + page?: PublicationPageMetadataEntry; } | null; export function getPublicationMetadataFromLeafletData( @@ -33,28 +43,60 @@ export function getPublicationMetadataFromLeafletData( ): PublicationMetadata { if (!data) return null; - let pubData: - | NonNullable - | undefined - | null = - data?.leaflets_in_publications?.[0] || - data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( - (p) => p.leaflets_in_publications?.length, - )?.leaflets_in_publications?.[0]; - - // If not found, check for standalone documents - let standaloneDoc = - data?.leaflets_to_documents?.[0] || - data?.permission_token_rights[0].entity_sets?.permission_tokens.find( - (p) => p.leaflets_to_documents?.length, - )?.leaflets_to_documents?.[0]; - if (!pubData && standaloneDoc) { - // Transform standalone document data to match the expected format + // Look across the leaflet's own associations and any sibling tokens that share + // its entity set. Each association is checked in a single pass. + let leafletInPub = data.leaflets_in_publications?.[0]; + let standaloneDoc = data.leaflets_to_documents?.[0]; + let pageRow = data.publication_pages?.[0]; + + if (!leafletInPub || !standaloneDoc || !pageRow) { + let siblingTokens = + data.permission_token_rights[0].entity_sets?.permission_tokens || []; + for (let token of siblingTokens) { + if (!leafletInPub && token.leaflets_in_publications?.length) + leafletInPub = token.leaflets_in_publications[0]; + if (!standaloneDoc && token.leaflets_to_documents?.length) + standaloneDoc = token.leaflets_to_documents[0]; + if (!pageRow && token.publication_pages?.length) + pageRow = token.publication_pages[0]; + if (leafletInPub && standaloneDoc && pageRow) break; + } + } + + let pubData: NonNullable | null = null; + + if (leafletInPub) { + pubData = leafletInPub; + } else if (standaloneDoc) { pubData = { ...standaloneDoc, - publications: null, // No publication for standalone docs + publications: null, doc: standaloneDoc.document, }; + } else if (pageRow && pageRow.publications) { + pubData = { + title: pageRow.title || "", + description: "", + leaflet: data.id, + doc: pageRow.document, + publications: pageRow.publications, + documents: null, + }; + } + + if (pageRow) { + if (!pubData) return null; + pubData = { + ...pubData, + page: { + id: pageRow.id, + title: pageRow.title, + path: pageRow.path, + document: pageRow.document, + metadata: pageRow.metadata, + }, + }; } - return pubData || null; + + return pubData; }