From 82190946d5d9be722f5003d1bed3d5a79aa69aa2 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 20 May 2026 20:53:58 -0700 Subject: [PATCH] Feature/pub pages (#303) * add standard site post block * add posts-list block settings: view + highlight-first-post Adds a Settings popover to the on-block options strip on posts-list blocks, persisting `posts-list/view` (compact|full) and `posts-list/highlight-first-post` as block facts. Introduces PublicationPostItemSmall/Medium/Large variants and wires PublicationPostsList to render Large for the highlighted first post, Small for compact, and Medium for full. Co-Authored-By: Claude Opus 4.7 (1M context) * add basic publication pages implementation Introduces editable pages attached to a publication (separate from draft posts). Each page is backed by its own leaflet, listed under `publication_pages`, and edited through a new `/lish/[did]/[publication]/edit/[[...route]]` route with its own header/nav. PublicationContent is refactored to share PublicationHeader and PublicationPostsList. createNewLeaflet gains `firstBlocks` + `addToHomepage` so createPublicationPage can seed a page with a text + posts-list block without polluting the user's homepage. Page/Sidebar/PageOptions/PublicationMetadata hide post-only chrome when viewing a publication page, and the block command bar exposes Posts List + Subscribe Form via a new `publicationOnly` flag. Co-Authored-By: Claude Opus 4.7 (1M context) * wip publish publication pages * add standard-site-post block size setting: small/medium/large Co-Authored-By: Claude Opus 4.7 (1M context) * update many styles * add filtering by tag to post list block * change copy * simplify scroll stuff * de-duplicate things * add publication pages lexicon to the permission set * proper form for creating pub pages * some formatting WIP * fix standard.site resolution * pass down cover image from standard site post item * wrap up standard site post styling * add re-orderable tabs * use block size on initial placeholder * gate new features behind flag --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: celine --- actions/createNewLeaflet.ts | 92 +++++-- actions/createPublicationPage.ts | 57 ++++ actions/publishPublicationPages.ts | 146 ++++++++++ actions/reorderPublicationPages.ts | 29 ++ app/[leaflet_id]/Sidebar.tsx | 7 +- app/api/link_previews/route.ts | 17 +- app/api/oauth/[route]/route.ts | 19 +- app/api/rpc/[command]/get_publication_data.ts | 30 ++- .../rpc/[command]/get_standard_site_posts.ts | 122 +++++++++ app/api/rpc/[command]/route.ts | 2 + .../[publication]/PublicationContent.tsx | 248 +++++++---------- .../[did]/[publication]/PublicationHeader.tsx | 75 ++++++ .../[publication]/PublicationHomeLayout.tsx | 32 ++- .../[did]/[publication]/PublicationNav.tsx | 45 ++++ .../[publication]/PublicationPostItem.tsx | 183 +++++++++++++ .../[publication]/PublicationPostsList.tsx | 177 ++++++++++++ .../[publication]/PublicationStickyHeader.tsx | 80 ++++++ .../[rkey]/Blocks/PublishedPageBlock.tsx | 6 + .../[did]/[publication]/[rkey]/CanvasPage.tsx | 9 + .../[rkey]/DocumentPageRenderer.tsx | 144 ++-------- .../[rkey]/Interactions/Quotes.tsx | 1 + .../[rkey]/LinearDocumentPage.tsx | 2 + .../[publication]/[rkey]/PostContent.tsx | 80 ++++++ .../[did]/[publication]/[rkey]/PostPages.tsx | 5 + .../[rkey]/PublicationPageRenderer.tsx | 221 +++++++++++++++ .../[rkey]/collectAndFetchBlockResources.ts | 91 +++++++ .../[rkey]/extractBlocksByType.ts | 55 ++++ .../[publication]/[rkey]/getPostPageData.ts | 4 +- app/lish/[did]/[publication]/[rkey]/page.tsx | 53 +++- .../dashboard/EditPagesNavLink.tsx | 58 ++++ .../[did]/[publication]/dashboard/layout.tsx | 12 +- .../[[...route]]/PublicationEditHeader.tsx | 78 ++++++ .../[[...route]]/PublicationPageLeaflet.tsx | 52 ++++ .../edit/[[...route]]/PublicationPagesNav.tsx | 251 ++++++++++++++++++ .../edit/[[...route]]/layout.tsx | 126 +++++++++ .../[publication]/edit/[[...route]]/page.tsx | 85 ++++++ app/lish/[did]/[publication]/page.tsx | 9 + .../[publication]/sortPublicationPages.ts | 8 + .../theme-settings/PostPreview.tsx | 2 + .../tryRenderPublicationPage.tsx | 49 ++++ components/Blocks/Block.tsx | 23 +- components/Blocks/BlockCommandBar.tsx | 10 +- components/Blocks/BlockCommands.tsx | 25 ++ components/Blocks/EmbedBlock.tsx | 6 + components/Blocks/PostsListBlock.tsx | 205 ++++++++++++++ components/Blocks/SettingsTriggerButton.tsx | 18 ++ .../StandardSitePostItem.tsx | 181 +++++++++++++ .../Blocks/StandardSitePostBlock/index.tsx | 149 +++++++++++ .../parseStandardSitePostInput.ts | 23 ++ components/Icons/GoToArrowLined.tsx | 6 +- components/PageSWRDataProvider.tsx | 10 + components/Pages/Page.tsx | 9 +- components/Pages/PageOptions.tsx | 7 +- components/StandardSitePostDataProvider.tsx | 81 ++++++ drizzle/relations.ts | 36 +-- drizzle/schema.ts | 38 +-- lexicons/api/index.ts | 91 +++++++ lexicons/api/lexicons.ts | 89 +++++++ .../api/types/pub/leaflet/blocks/postsList.ts | 32 +++ .../pub/leaflet/blocks/standardSitePost.ts | 32 +++ .../api/types/pub/leaflet/pages/canvas.ts | 4 + .../types/pub/leaflet/pages/linearDocument.ts | 4 + .../api/types/pub/leaflet/publicationPage.ts | 32 +++ lexicons/build.ts | 2 + lexicons/pub/leaflet/authFullPermissions.json | 3 +- lexicons/pub/leaflet/blocks/postsList.json | 25 ++ .../pub/leaflet/blocks/standardSitePost.json | 29 ++ lexicons/pub/leaflet/pages/canvas.json | 4 +- .../pub/leaflet/pages/linearDocument.json | 4 +- lexicons/pub/leaflet/publicationPage.json | 40 +++ lexicons/src/authFullPermissions.ts | 2 + lexicons/src/blocks.ts | 34 +++ lexicons/src/publicationPage.ts | 26 ++ package-lock.json | 70 +++++ package.json | 5 +- src/replicache/attributes.ts | 36 ++- src/utils/addLinkBlock.ts | 25 ++ src/utils/factsToPagesRecord.ts | 28 ++ src/utils/getBlocksAsHTML.tsx | 6 + .../getPublicationMetadataFromLeafletData.ts | 80 ++++-- src/utils/leafletToPublicationPageRecord.ts | 66 +++++ src/utils/resolveStandardSitePostUrl.ts | 72 +++++ supabase/database.types.ts | 12 + 83 files changed, 4033 insertions(+), 409 deletions(-) create mode 100644 actions/createPublicationPage.ts create mode 100644 actions/publishPublicationPages.ts create mode 100644 actions/reorderPublicationPages.ts create mode 100644 app/api/rpc/[command]/get_standard_site_posts.ts create mode 100644 app/lish/[did]/[publication]/PublicationHeader.tsx create mode 100644 app/lish/[did]/[publication]/PublicationNav.tsx create mode 100644 app/lish/[did]/[publication]/PublicationPostItem.tsx create mode 100644 app/lish/[did]/[publication]/PublicationPostsList.tsx create mode 100644 app/lish/[did]/[publication]/PublicationStickyHeader.tsx create mode 100644 app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx create mode 100644 app/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts create mode 100644 app/lish/[did]/[publication]/[rkey]/extractBlocksByType.ts create mode 100644 app/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx create mode 100644 app/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx create mode 100644 app/lish/[did]/[publication]/edit/[[...route]]/PublicationPageLeaflet.tsx create mode 100644 app/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesNav.tsx create mode 100644 app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx create mode 100644 app/lish/[did]/[publication]/edit/[[...route]]/page.tsx create mode 100644 app/lish/[did]/[publication]/sortPublicationPages.ts create mode 100644 app/lish/[did]/[publication]/tryRenderPublicationPage.tsx create mode 100644 components/Blocks/PostsListBlock.tsx create mode 100644 components/Blocks/SettingsTriggerButton.tsx create mode 100644 components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx create mode 100644 components/Blocks/StandardSitePostBlock/index.tsx create mode 100644 components/Blocks/StandardSitePostBlock/parseStandardSitePostInput.ts create mode 100644 components/StandardSitePostDataProvider.tsx create mode 100644 lexicons/api/types/pub/leaflet/blocks/postsList.ts create mode 100644 lexicons/api/types/pub/leaflet/blocks/standardSitePost.ts create mode 100644 lexicons/api/types/pub/leaflet/publicationPage.ts create mode 100644 lexicons/pub/leaflet/blocks/postsList.json create mode 100644 lexicons/pub/leaflet/blocks/standardSitePost.json create mode 100644 lexicons/pub/leaflet/publicationPage.json create mode 100644 lexicons/src/publicationPage.ts create mode 100644 src/utils/leafletToPublicationPageRecord.ts create mode 100644 src/utils/resolveStandardSitePostUrl.ts 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..88cac009 --- /dev/null +++ b/actions/createPublicationPage.ts @@ -0,0 +1,57 @@ +"use server"; +import { generateKeyBetween } from "fractional-indexing"; +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; + sort_order?: string; + includePostsList?: boolean; +}) { + 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 sort_order = args.sort_order; + if (!sort_order) { + let { data: last } = await supabaseServerClient + .from("publication_pages") + .select("sort_order") + .eq("publication", args.publication_uri) + .order("sort_order", { ascending: false }) + .limit(1) + .maybeSingle(); + sort_order = generateKeyBetween(last?.sort_order ?? null, null); + } + + let leaflet_src = await createNewLeaflet({ + pageType: "doc", + redirectUser: false, + firstBlocks: args.includePostsList ? ["text", "posts-list"] : ["text"], + addToHomepage: false, + }); + + let { data: page } = await supabaseServerClient + .from("publication_pages") + .insert({ + publication: args.publication_uri, + leaflet_src, + path: args.path, + title: args.title ?? "", + sort_order, + }) + .select("*") + .single(); + + return page; +} diff --git a/actions/publishPublicationPages.ts b/actions/publishPublicationPages.ts new file mode 100644 index 00000000..96746b27 --- /dev/null +++ b/actions/publishPublicationPages.ts @@ -0,0 +1,146 @@ +"use server"; + +import { AtpBaseClient } from "lexicons/api"; +import { Json } from "supabase/database.types"; +import type { Fact } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { Lock } from "src/utils/lock"; +import { OAuthSessionError, restoreOAuthSession } from "src/atproto-oauth"; +import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; +import { getIdentityData } from "actions/getIdentityData"; +import { + leafletToPublicationPageRecord, + pathToRkey, +} from "src/utils/leafletToPublicationPageRecord"; +import { supabaseServerClient } from "supabase/serverClient"; + +type PublishPagesResult = + | { success: true; published: { id: number; uri: string }[] } + | { + success: false; + error: OAuthSessionError | { type: "other"; message: string }; + }; + +export async function publishPublicationPages({ + publication_uri, +}: { + publication_uri: string; +}): Promise { + const identity = await getIdentityData(); + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } + + const { data: publication } = await supabaseServerClient + .from("publications") + .select("uri, identity_did") + .eq("uri", publication_uri) + .single(); + if (!publication || publication.identity_did !== identity.atp_did) { + return { + success: false, + error: { type: "other", message: "Not the publication owner" }, + }; + } + + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + const credentialSession = sessionResult.value; + const agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + + const { data: pageRows } = await supabaseServerClient + .from("publication_pages") + .select("id, title, path, leaflet_src") + .eq("publication", publication_uri); + if (!pageRows || pageRows.length === 0) { + return { success: true, published: [] }; + } + + const uploadLock = new Lock(); + + const published: { id: number; uri: string }[] = []; + for (const page of pageRows) { + const path = page.path ?? "/"; + + const { result: leafletRes } = await get_leaflet_data.handler( + { token_id: page.leaflet_src }, + { supabase: supabaseServerClient }, + ); + const rootEntity = leafletRes.data?.root_entity; + if (!rootEntity) continue; + + const { data: factData } = await supabaseServerClient.rpc("get_facts", { + root: rootEntity, + }); + const facts = (factData as unknown as Fact[]) || []; + + const record = await leafletToPublicationPageRecord({ + facts, + root_entity: rootEntity, + publication_uri, + path, + title: page.title ?? "", + hooks: { + uploadImage: async (src: string) => { + const data = await fetch(src); + if (data.status !== 200) return; + const binary = await data.blob(); + return uploadLock.withLock(async () => { + const blob = await agent.com.atproto.repo.uploadBlob(binary, { + headers: { "Content-Type": binary.type }, + }); + return blob.data.blob; + }); + }, + uploadPoll: async (entityId, pollRecord) => { + const { data: pollResult } = await agent.com.atproto.repo.putRecord({ + rkey: entityId, + repo: credentialSession.did!, + collection: pollRecord.$type, + record: pollRecord, + validate: false, + }); + await supabaseServerClient.from("atp_poll_records").upsert({ + uri: pollResult.uri, + cid: pollResult.cid, + record: pollRecord as Json, + }); + return { uri: pollResult.uri, cid: pollResult.cid }; + }, + }, + }); + + const rkey = pathToRkey(path); + const { data: putResult } = await agent.com.atproto.repo.putRecord({ + rkey, + repo: credentialSession.did!, + collection: "pub.leaflet.publicationPage", + record, + validate: false, + }); + + await supabaseServerClient + .from("publication_pages") + .update({ + record: record as unknown as Json, + record_uri: putResult.uri, + }) + .eq("id", page.id) + .eq("publication", publication_uri); + + published.push({ id: page.id, uri: putResult.uri }); + } + + return { success: true, published }; +} diff --git a/actions/reorderPublicationPages.ts b/actions/reorderPublicationPages.ts new file mode 100644 index 00000000..f1f1621f --- /dev/null +++ b/actions/reorderPublicationPages.ts @@ -0,0 +1,29 @@ +"use server"; +import { getIdentityData } from "actions/getIdentityData"; +import { supabaseServerClient } from "supabase/serverClient"; + +export async function reorderPublicationPages(args: { + publication_uri: string; + page_id: number; + sort_order: string; +}): Promise<{ success: boolean }> { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return { success: false }; + + 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 { success: false }; + + let { error } = await supabaseServerClient + .from("publication_pages") + .update({ sort_order: args.sort_order }) + .eq("id", args.page_id) + .eq("publication", args.publication_uri); + if (error) return { success: false }; + + return { success: true }; +} diff --git a/app/[leaflet_id]/Sidebar.tsx b/app/[leaflet_id]/Sidebar.tsx index 26582bf0..d259b389 100644 --- a/app/[leaflet_id]/Sidebar.tsx +++ b/app/[leaflet_id]/Sidebar.tsx @@ -7,7 +7,10 @@ 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"; @@ -24,6 +27,7 @@ export function LeafletSidebar() { let { rootEntity } = useReplicache(); let { data: pub } = useLeafletPublicationData(); let { identity } = useIdentityData(); + let publicationPage = useLeafletPublicationPage(); let { permission_token } = useReplicache(); let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { fallbackData: [], @@ -37,6 +41,7 @@ export function LeafletSidebar() { !!pub?.publications && !!identity?.atp_did && pub.publications.identity_did === identity.atp_did; + if (publicationPage) return null; return ( diff --git a/app/api/link_previews/route.ts b/app/api/link_previews/route.ts index 465a3b41..91b38bef 100644 --- a/app/api/link_previews/route.ts +++ b/app/api/link_previews/route.ts @@ -9,6 +9,7 @@ import { getMicroLinkOgImage, getWebpageImage, } from "src/utils/getMicroLinkOgImage"; +import { resolveStandardSitePostUrl } from "src/utils/resolveStandardSitePostUrl"; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, process.env.SUPABASE_SERVICE_ROLE_KEY as string, @@ -19,15 +20,25 @@ export async function POST(req: NextRequest) { let body = (await req.json()) as LinkPreviewBody; let url = encodeURIComponent(body.url); if (body.type === "meta") { - let result = await get_link_metadata(url); - return Response.json(result); + let [iframely, leafletPost] = await Promise.all([ + get_link_metadata(url), + resolveStandardSitePostUrl(body.url, supabase).catch(() => null), + ]); + return Response.json({ + ...iframely, + leafletPost: leafletPost ? { uri: leafletPost } : null, + }); } else { let result = await get_link_image_preview(body.url); return Response.json(result); } } -export type LinkPreviewMetadataResult = ReturnType; +export type LinkPreviewMetadataResult = Promise< + Awaited> & { + leafletPost: { uri: string } | null; + } +>; export type LinkPreviewImageResult = ReturnType; async function get_link_image_preview(url: string) { diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts index c71fd7d5..0afbd016 100644 --- a/app/api/oauth/[route]/route.ts +++ b/app/api/oauth/[route]/route.ts @@ -86,7 +86,9 @@ export async function GET( .eq("atp_did", session.did) .single(); - const currentAuthToken = (await cookies()).get(AUTH_TOKEN_COOKIE)?.value; + const currentAuthToken = (await cookies()).get( + AUTH_TOKEN_COOKIE, + )?.value; let currentIdentity: { id: string; email: string | null; @@ -183,10 +185,7 @@ export async function GET( targetId: identity.id, }); if (!merged.ok) { - console.error( - "[oauth/callback] autoMerge failed:", - merged.error, - ); + console.error("[oauth/callback] autoMerge failed:", merged.error); await stagePendingMerge(identity.id, redirectPath); } } else { @@ -247,10 +246,7 @@ export async function GET( // success (via `redirect()`), so callers only reach the line after the call // when the token insert failed — at which point they should fall through to // the normal sign-in flow rather than blocking the user. -const stagePendingMerge = async ( - identityId: string, - redirectPath: string, -) => { +const stagePendingMerge = async (identityId: string, redirectPath: string) => { const { data: targetToken, error } = await supabaseServerClient .from("email_auth_tokens") .insert({ @@ -261,10 +257,7 @@ const stagePendingMerge = async ( .select("id") .single(); if (error) - console.error( - "[oauth/callback] pending merge token insert failed:", - error, - ); + console.error("[oauth/callback] pending merge token insert failed:", error); if (!targetToken) return; await setPendingMergeToken(targetToken.id); redirect(`/merge-accounts?redirect=${encodeURIComponent(redirectPath)}`); diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index 714ad992..e682963c 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,26 @@ 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, + record: p.record, + record_uri: p.record_uri, + leaflet_src: p.leaflet_src, + created_at: p.created_at, + sort_order: p.sort_order, + permission_tokens: p.permission_tokens, + })); + return { result: { publication, documents, drafts, + pages, leaflet_data: leaflet_data.result, }, }; diff --git a/app/api/rpc/[command]/get_standard_site_posts.ts b/app/api/rpc/[command]/get_standard_site_posts.ts new file mode 100644 index 00000000..5ff3b31f --- /dev/null +++ b/app/api/rpc/[command]/get_standard_site_posts.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; +import { AtUri } from "@atproto/syntax"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; + +export type StandardSitePostData = { + uri: string; + record: NormalizedDocument; + publication: { + uri: string; + record: NormalizedPublication | null; + } | null; + author: { + did: string; + handle: string | null; + displayName: string | null; + } | null; + commentsCount: number; + mentionsCount: number; + recommendsCount: number; +}; + +export type GetStandardSitePostsReturnType = Awaited< + ReturnType<(typeof get_standard_site_posts)["handler"]> +>; + +export const get_standard_site_posts = makeRoute({ + route: "get_standard_site_posts", + input: z.object({ + uris: z.array(z.string()).max(100), + }), + handler: async ({ uris }, { supabase }: Pick) => { + if (uris.length === 0) return { result: { posts: [] } }; + + const { data: documents } = await supabase + .from("documents") + .select( + ` + uri, + data, + comments_on_documents(count), + document_mentions_in_bsky(count), + recommends_on_documents(count), + documents_in_publications(publications(uri, record)) + `, + ) + .in("uri", uris); + + const dids = Array.from( + new Set( + (documents || []) + .map((d) => { + try { + return new AtUri(d.uri).host; + } catch { + return null; + } + }) + .filter((did): did is string => !!did), + ), + ); + + const { data: profiles } = dids.length + ? await supabase + .from("bsky_profiles") + .select("did, handle, record") + .in("did", dids) + : { data: [] as { did: string; handle: string | null; record: unknown }[] }; + + const profileByDid = new Map( + (profiles || []).map((p) => [p.did, p] as const), + ); + + const posts: StandardSitePostData[] = (documents || []) + .map((d): StandardSitePostData | null => { + const normalized = normalizeDocumentRecord(d.data, d.uri); + if (!normalized) return null; + + const pubRow = d.documents_in_publications?.[0]?.publications; + const publication = pubRow + ? { uri: pubRow.uri, record: normalizePublicationRecord(pubRow.record) } + : null; + + let did: string | null; + try { + did = new AtUri(d.uri).host; + } catch { + did = null; + } + const profile = did ? profileByDid.get(did) : undefined; + const profileRecord = (profile?.record ?? null) as + | { displayName?: string } + | null; + const author = did + ? { + did, + handle: profile?.handle ?? null, + displayName: profileRecord?.displayName ?? null, + } + : null; + + return { + uri: d.uri, + record: normalized, + publication, + author, + commentsCount: d.comments_on_documents?.[0]?.count || 0, + mentionsCount: d.document_mentions_in_bsky?.[0]?.count || 0, + recommendsCount: d.recommends_on_documents?.[0]?.count || 0, + }; + }) + .filter((p): p is StandardSitePostData => p !== null); + + return { result: { posts } }; + }, +}); diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts index 91f4bb74..eccbae45 100644 --- a/app/api/rpc/[command]/route.ts +++ b/app/api/rpc/[command]/route.ts @@ -11,6 +11,7 @@ import { } from "./domain_routes"; import { get_leaflet_data } from "./get_leaflet_data"; import { get_publication_data } from "./get_publication_data"; +import { get_standard_site_posts } from "./get_standard_site_posts"; import { search_publication_names } from "./search_publication_names"; import { search_publication_documents } from "./search_publication_documents"; import { get_profile_data } from "./get_profile_data"; @@ -45,6 +46,7 @@ let Routes = [ get_leaflet_subdomain_status, get_leaflet_data, get_publication_data, + get_standard_site_posts, search_publication_names, search_publication_documents, get_profile_data, diff --git a/app/lish/[did]/[publication]/PublicationContent.tsx b/app/lish/[did]/[publication]/PublicationContent.tsx index 0d2004a8..4d9ac966 100644 --- a/app/lish/[did]/[publication]/PublicationContent.tsx +++ b/app/lish/[did]/[publication]/PublicationContent.tsx @@ -1,24 +1,25 @@ 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 { PublicationNav } from "./PublicationNav"; +import { PublicationStickyHeader } from "./PublicationStickyHeader"; import { normalizePublicationRecord, normalizeDocumentRecord, } from "src/utils/normalizeRecords"; -import { getFirstParagraph } from "src/utils/getFirstParagraph"; import { FontLoader } from "components/FontLoader"; import { SpeedyLink } from "components/SpeedyLink"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 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, @@ -36,6 +37,13 @@ export const PublicationContent = ({ record: unknown; publication_subscriptions: { identity: string }[]; publication_newsletter_settings: { enabled: boolean } | null; + publication_pages?: { + id: number; + path: string | null; + title: string | null; + record_uri: string | null; + sort_order: string; + }[]; documents_in_publications: { documents: { uri: string; @@ -52,6 +60,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 ( <> - - ) : undefined - } - subscribeButton={ -
- p.record_uri, + )} + activePath="/" /> -
- } + } + > + + ) : undefined + } + subscribeButton={ +
+ +
+ } + /> + + } + > + -
- {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; description?: string; + author?: React.ReactNode; date?: React.ReactNode; interactions?: React.ReactNode; }) { @@ -228,6 +155,9 @@ export function PublicationPostItem(props: { ); + const hasAuthor = props.author !== undefined && props.author !== null; + const hasDate = props.date !== undefined && props.date !== null; + return ( <>
@@ -245,7 +175,11 @@ export function PublicationPostItem(props: { )}
-

{props.date}

+

+ {hasAuthor && {props.author}} + {hasAuthor && hasDate && |} + {hasDate && {props.date}} +

{props.interactions}
diff --git a/app/lish/[did]/[publication]/PublicationHeader.tsx b/app/lish/[did]/[publication]/PublicationHeader.tsx new file mode 100644 index 00000000..b2f3ec15 --- /dev/null +++ b/app/lish/[did]/[publication]/PublicationHeader.tsx @@ -0,0 +1,75 @@ +import React from "react"; + +export function PublicationHeader(props: { + iconUrl?: string; + publicationName: string; + description?: string; + author?: React.ReactNode; + subscribeButton?: React.ReactNode; + variant?: "stacked" | "inline"; +}) { + let variant = props.variant ?? "stacked"; + let icon = props.iconUrl ? ( +
+ ) : null; + let title = ( +

+ {props.publicationName} +

+ ); + + return ( +
+ {variant === "inline" ? ( +
+ {icon} + {title} +
+ ) : ( + <> + {icon} + {title} + + )} +
+ {props.description && ( +

{props.description}

+ )} + {props.author} + {props.subscribeButton && ( +
{props.subscribeButton}
+ )} +
+
+ ); +} diff --git a/app/lish/[did]/[publication]/PublicationHomeLayout.tsx b/app/lish/[did]/[publication]/PublicationHomeLayout.tsx index d22a13ae..96a61e7d 100644 --- a/app/lish/[did]/[publication]/PublicationHomeLayout.tsx +++ b/app/lish/[did]/[publication]/PublicationHomeLayout.tsx @@ -5,20 +5,36 @@ import { usePreserveScroll } from "src/hooks/usePreserveScroll"; export function PublicationHomeLayout(props: { uri: string; showPageBackground: boolean; + stickyHeader?: React.ReactNode; children: React.ReactNode; }) { let { ref } = usePreserveScroll(props.uri); + let inner = ( + <> + {props.stickyHeader} +
+ {props.children} +
+ + ); + if (props.showPageBackground) { + return ( +
+
+ {inner} +
+
+ ); + } return (
-
- {props.children} -
+ {inner}
); } diff --git a/app/lish/[did]/[publication]/PublicationNav.tsx b/app/lish/[did]/[publication]/PublicationNav.tsx new file mode 100644 index 00000000..44004175 --- /dev/null +++ b/app/lish/[did]/[publication]/PublicationNav.tsx @@ -0,0 +1,45 @@ +import { SpeedyLink } from "components/SpeedyLink"; +import { sortPublicationPages } from "./sortPublicationPages"; + +export type PublicationNavPage = { + id: number; + path: string | null; + title: string | null; + sort_order: string; +}; + +export function PublicationNav(props: { + publicationUrl: string; + pages: PublicationNavPage[]; + activePath: string | null; +}) { + if (props.pages.length === 0) return null; + + let sortedPages = sortPublicationPages(props.pages); + + return ( + + ); +} diff --git a/app/lish/[did]/[publication]/PublicationPostItem.tsx b/app/lish/[did]/[publication]/PublicationPostItem.tsx new file mode 100644 index 00000000..78028549 --- /dev/null +++ b/app/lish/[did]/[publication]/PublicationPostItem.tsx @@ -0,0 +1,183 @@ +import React from "react"; +import { SpeedyLink } from "components/SpeedyLink"; + +type CommonProps = { + href?: string; + title?: string; + description?: string; + author?: React.ReactNode; + date?: React.ReactNode; + interactions?: React.ReactNode; +}; + +type LargeProps = CommonProps & { + coverImageSrc?: string; + coverImageAlt?: string; + pageWidth?: number; +}; + +type MediumProps = CommonProps & { + coverImageSrc?: string; + coverImageAlt?: string; +}; + +function MetaRow({ + author, + date, + interactions, + textClassName, +}: { + author?: React.ReactNode; + date?: React.ReactNode; + interactions?: React.ReactNode; + textClassName: string; +}) { + const hasAuthor = author !== undefined && author !== null; + const hasDate = date !== undefined && date !== null; + return ( +
+

+ {hasAuthor && ( + + {author} + {hasDate && |} + + )} + {hasDate && {date}} +

+ {interactions} +
+ ); +} + +function PostLink({ + href, + children, +}: { + href?: string; + children: React.ReactNode; +}) { + if (href) { + return ( + + {children} + + ); + } + return ( +
{children}
+ ); +} + +export function PublicationPostItemSmall(props: CommonProps) { + return ( + <> +
+ + {props.title &&

{props.title}

} +
+ +
+
+ + ); +} + +export function PublicationPostItemMedium(props: MediumProps) { + const hasCoverImage = !!props.coverImageSrc; + return ( + <> +
+
+ + {props.title && ( +

{props.title}

+ )} +

+ {props.description} {props.description} +

+
+ +
+ {hasCoverImage && ( +
+ {props.coverImageAlt +
+ )} +
+
+ + ); +} + +export function PublicationPostItemLarge(props: LargeProps) { + const hasCoverImage = !!props.coverImageSrc; + const widePage = (props.pageWidth ?? 0) >= 768; + const body = ( +
+ + {props.title && ( +

+ {props.title} +

+ )} +

+ {props.description} +

+
+ +
+ ); + + return ( + <> +
+ {hasCoverImage && ( +
+ {props.coverImageAlt +
+ )} + {body} +
+
+ + ); +} diff --git a/app/lish/[did]/[publication]/PublicationPostsList.tsx b/app/lish/[did]/[publication]/PublicationPostsList.tsx new file mode 100644 index 00000000..7fc52f06 --- /dev/null +++ b/app/lish/[did]/[publication]/PublicationPostsList.tsx @@ -0,0 +1,177 @@ +import React from "react"; +import { AtUri } from "@atproto/api"; +import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; +import { InteractionPreview } from "components/InteractionsPreview"; +import { LocalizedDate } from "./LocalizedDate"; +import { PublicationPostItem } from "./PublicationContent"; +import { + PublicationPostItemSmall, + PublicationPostItemMedium, + PublicationPostItemLarge, +} from "./PublicationPostItem"; +import { + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +import { getFirstParagraph } from "src/utils/getFirstParagraph"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; + +export type PublicationPostsListPost = { + uri: string; + record: NormalizedDocument; + commentsCount: number; + mentionsCount: number; + recommendsCount: number; +}; + +export type PublicationPostsListFakePost = { + title: string; + description: string; + date: React.ReactNode; +}; + +type PublicationForURL = { + uri: string; + record: unknown; +}; + +export type PublicationPostsListView = "compact" | "full"; + +export function PublicationPostsList({ + publication, + publicationRecord, + posts, + fakePosts, + view = "full", + highlightFirstPost = false, +}: { + publication: PublicationForURL; + publicationRecord: NormalizedPublication | null; + posts?: PublicationPostsListPost[]; + fakePosts?: PublicationPostsListFakePost[]; + view?: PublicationPostsListView; + highlightFirstPost?: boolean; +}) { + return ( +
+ {fakePosts + ? fakePosts.map((post, i) => ( + + )) + : posts + ?.slice() + .sort((a, b) => { + const aDate = a.record.publishedAt + ? new Date(a.record.publishedAt) + : new Date(0); + const bDate = b.record.publishedAt + ? new Date(b.record.publishedAt) + : new Date(0); + return bDate.getTime() - aDate.getTime(); + }) + .map((post, index) => { + const doc_record = post.record; + const quotes = post.mentionsCount; + const comments = + publicationRecord?.preferences?.showComments === false + ? 0 + : post.commentsCount; + const recommends = post.recommendsCount; + const tags = doc_record.tags || []; + + const docUrl = getDocumentURL(doc_record, post.uri, publication); + const date = doc_record.publishedAt ? ( + + ) : undefined; + const interactions = ( + + ); + + const isHighlightedFirst = highlightFirstPost && index === 0; + const Variant = isHighlightedFirst + ? "large" + : view === "compact" + ? "small" + : "medium"; + + const postDid = new AtUri(post.uri).host; + const coverImageSrc = doc_record.coverImage + ? blobRefToSrc(doc_record.coverImage.ref, postDid) + : undefined; + + if (Variant === "large") { + return ( + + ); + } + + if (Variant === "small") { + return ( + + ); + } + + return ( + + ); + })} +
+ ); +} diff --git a/app/lish/[did]/[publication]/PublicationStickyHeader.tsx b/app/lish/[did]/[publication]/PublicationStickyHeader.tsx new file mode 100644 index 00000000..d2bef4ad --- /dev/null +++ b/app/lish/[did]/[publication]/PublicationStickyHeader.tsx @@ -0,0 +1,80 @@ +"use client"; +import { useEffect, useRef } from "react"; +import { usePathname } from "next/navigation"; + +export function PublicationStickyHeader(props: { + sticky?: boolean; + nav?: React.ReactNode; + children: React.ReactNode; +}) { + let ref = useRef(null); + let pathname = usePathname(); + let sticky = props.sticky ?? true; + + useEffect(() => { + let el = ref.current; + let parent = el?.parentElement; + if (!el || !parent) return; + + let rafId: number | null = null; + let writeProgress = (target: HTMLElement) => { + let p = Math.min(Math.max(target.scrollTop / 100, 0), 1); + el!.style.setProperty("--header-shrink", String(p)); + }; + let schedule = (target: HTMLElement) => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + writeProgress(target); + }); + }; + + let onScroll = (e: Event) => { + let t = e.target; + if (!(t instanceof HTMLElement)) return; + if (!t.matches(".publicationScrollContainer")) return; + schedule(t); + }; + parent.addEventListener("scroll", onScroll, { + passive: true, + capture: true, + }); + + let initial = ( + parent.matches(".publicationScrollContainer") + ? parent + : parent.querySelector(".publicationScrollContainer") + ) as HTMLElement | null; + if (initial) writeProgress(initial); + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + parent.removeEventListener("scroll", onScroll, true); + }; + }, []); + + useEffect(() => { + ref.current?.style.setProperty("--header-shrink", "0"); + }, [pathname]); + + return ( +
+
+ {props.children} +
+ {props.nav} +
+ ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx index fbe3dddd..4daca649 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx @@ -14,6 +14,7 @@ import { PubLeafletPublication, } from "lexicons/api"; import { AppBskyFeedDefs } from "@atproto/api"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; import { TextBlock } from "./TextBlock"; import { useDocument } from "contexts/DocumentContext"; import { openPage, useOpenPages } from "../postPageState"; @@ -35,6 +36,7 @@ export function PublishedPageLinkBlock(props: { className?: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; isCanvas?: boolean; pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) { @@ -70,6 +72,7 @@ export function PublishedPageLinkBlock(props: { did={props.did} pageId={props.pageId} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} pages={props.pages || []} /> ) : ( @@ -197,6 +200,7 @@ export function PagePreview(props: { blocks={props.blocks} preview bskyPostData={[]} + standardSitePostData={[]} />
@@ -269,6 +273,7 @@ const CanvasLinkBlock = (props: { did: string; pageId: string; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) => { let pageWidth = `var(--page-width-unitless)`; @@ -332,6 +337,7 @@ const CanvasLinkBlock = (props: { pageId={props.pageId} pages={props.pages} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} block={linearBlock} did={props.did} index={[index]} diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index 2b94be45..cc1c6f33 100644 --- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -22,6 +22,7 @@ import { PostHeader } from "./PostHeader/PostHeader"; import { useDrawerOpen } from "./Interactions/InteractionDrawer"; import { PollData } from "./fetchPollData"; import { SharedPageProps } from "./PostPages"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; import { useIsMobile } from "src/hooks/isMobile"; export function CanvasPage({ @@ -41,6 +42,7 @@ export function CanvasPage({ theme, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pollData, document_uri, pageId, @@ -78,6 +80,7 @@ export function CanvasPage({ did={did} prerenderedCodeBlocks={prerenderedCodeBlocks} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} pollData={pollData} pageId={pageId} pages={pages} @@ -91,6 +94,7 @@ function CanvasContent({ did, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pageId, pollData, pages, @@ -100,6 +104,7 @@ function CanvasContent({ prerenderedCodeBlocks?: Map; pollData: PollData[]; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pageId?: string; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) { @@ -132,6 +137,7 @@ function CanvasContent({ pollData={pollData} prerenderedCodeBlocks={prerenderedCodeBlocks} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} pageId={pageId} pages={pages} index={index} @@ -148,6 +154,7 @@ function CanvasBlock({ did, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pollData, pageId, pages, @@ -157,6 +164,7 @@ function CanvasBlock({ did: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; pageId?: string; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; @@ -187,6 +195,7 @@ function CanvasBlock({ pageId={pageId} pages={pages} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} block={linearBlock} did={did} index={[index]} diff --git a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx index 1f1c642c..df7fc566 100644 --- a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx @@ -1,14 +1,8 @@ import { AtpAgent } from "@atproto/api"; -import { ids } from "lexicons/api/lexicons"; import { - PubLeafletBlocksBskyPost, - PubLeafletBlocksOrderedList, - PubLeafletBlocksUnorderedList, PubLeafletPagesLinearDocument, PubLeafletPagesCanvas, - PubLeafletBlocksPoll, } from "lexicons/api"; -import { type $Typed } from "lexicons/api/util"; import { QuoteHandler } from "./QuoteHandler"; import { PublicationBackgroundProvider, @@ -16,17 +10,15 @@ import { } from "components/ThemeManager/PublicationThemeProvider"; import { getPostPageData } from "./getPostPageData"; import { PostPages } from "./PostPages"; -import { extractCodeBlocks } from "./extractCodeBlocks"; +import { collectAndFetchBlockResources } from "./collectAndFetchBlockResources"; import { LeafletLayout } from "components/LeafletLayout"; -import { fetchPollData } from "./fetchPollData"; -import { - getDocumentPages, - hasLeafletContent, -} from "src/utils/normalizeRecords"; +import { getDocumentPages } from "src/utils/normalizeRecords"; import { DocumentProvider } from "contexts/DocumentContext"; import { LeafletContentProvider } from "contexts/LeafletContentContext"; import { FontLoader } from "components/FontLoader"; import { mergePreferences } from "src/utils/mergePreferences"; +import { PublicationNav } from "../PublicationNav"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; export async function DocumentPageRenderer({ did, @@ -69,65 +61,24 @@ export async function DocumentPageRenderer({ ); - let bskyPosts = - pages.flatMap((p) => { - let page = p as PubLeafletPagesLinearDocument.Main; - return extractBlocksByType<$Typed>( - page.blocks || [], - 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 unknown 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 = pages.flatMap((p) => { - let page = p as PubLeafletPagesLinearDocument.Main; - return extractBlocksByType<$Typed>( - page.blocks || [], - ids.PubLeafletBlocksPoll, - ); + const { + bskyPostData, + standardSitePostData: standardSitePosts, + pollData, + prerenderedCodeBlocks, + } = await collectAndFetchBlockResources({ + agent, + pages: pages as ( + | PubLeafletPagesLinearDocument.Main + | PubLeafletPagesCanvas.Main + )[], }); - let pollData = await fetchPollData( - pollBlocks.map((b) => b.block.pollRef.uri), - ); const pubRecord = document.normalizedPublication; let pub_creator = document.publication?.identity_did || did; let isStandalone = !pubRecord; - let firstPage = pages[0]; - let firstPageBlocks = - ( - firstPage as - | PubLeafletPagesLinearDocument.Main - | PubLeafletPagesCanvas.Main - ).blocks || []; - let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); - return ( @@ -145,6 +96,13 @@ export async function DocumentPageRenderer({ pub_creator={pub_creator} > + {document.publication?.pages?.length ? ( + + ) : null} ); } - -function extractBlocksByType( - blocks: PubLeafletPagesLinearDocument.Block[], - type: string, -): { block: T }[] { - let results: { block: T }[] = []; - for (let b of blocks) { - if (b.block.$type === type) { - results.push(b as unknown as { block: T }); - } - if ( - b.block.$type === ids.PubLeafletBlocksOrderedList || - b.block.$type === ids.PubLeafletBlocksUnorderedList - ) { - let list = b.block as - | PubLeafletBlocksOrderedList.Main - | PubLeafletBlocksUnorderedList.Main; - extractFromListItems(list.children, type, results); - } - } - return results; -} - -function extractFromListItems( - items: - | PubLeafletBlocksOrderedList.ListItem[] - | PubLeafletBlocksUnorderedList.ListItem[], - type: string, - results: { block: T }[], -) { - for (let item of items) { - if ((item.content as { $type?: string })?.$type === type) { - results.push({ - block: item.content as unknown as T, - }); - } - if (item.children) { - extractFromListItems(item.children, type, results); - } - let orderedChildren = (item as PubLeafletBlocksUnorderedList.ListItem) - .orderedListChildren; - if (orderedChildren) { - extractFromListItems(orderedChildren.children, type, results); - } - let unorderedChildren = (item as PubLeafletBlocksOrderedList.ListItem) - .unorderedListChildren; - if (unorderedChildren) { - extractFromListItems(unorderedChildren.children, type, results); - } - } -} diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index c418d27d..fbb1c6f5 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -281,6 +281,7 @@ export const QuoteContent = (props: { pollData={[]} pages={[]} bskyPostData={[]} + standardSitePostData={[]} blocks={content} did={props.did} preview diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index 5ac6778d..9467c7a7 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -38,6 +38,7 @@ export function LinearDocumentPage({ preferences, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pollData, document_uri, pageId, @@ -85,6 +86,7 @@ export function LinearDocumentPage({ pages={pages as PubLeafletPagesLinearDocument.Main[]} pageId={pageId} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} blocks={blocks} did={did} prerenderedCodeBlocks={prerenderedCodeBlocks} diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index e6164175..fa40bf56 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -14,11 +14,18 @@ import { PubLeafletBlocksHorizontalRule, PubLeafletBlocksBlockquote, PubLeafletBlocksBskyPost, + PubLeafletBlocksStandardSitePost, PubLeafletBlocksIframe, PubLeafletBlocksPage, PubLeafletBlocksPoll, + PubLeafletBlocksPostsList, PubLeafletBlocksButton, } from "lexicons/api"; +import { + PublicationPostsList, + type PublicationPostsListPost, +} from "../PublicationPostsList"; +import type { NormalizedPublication } from "src/utils/normalizeRecords"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { TextBlock } from "./Blocks/TextBlock"; @@ -29,6 +36,8 @@ import { StaticMathBlock } from "./Blocks/StaticMathBlock"; import { PubCodeBlock } from "./Blocks/PubCodeBlock"; import { AppBskyFeedDefs } from "@atproto/api"; import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock"; +import { StandardSitePostItemView } from "components/Blocks/StandardSitePostBlock/StandardSitePostItem"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock"; import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; import { PollData } from "./fetchPollData"; @@ -43,6 +52,12 @@ import { openPage as openPageAction } from "./postPageState"; import { CheckboxChecked } from "components/Icons/CheckboxChecked"; import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; +export type PostsListData = { + publication: { uri: string; record: unknown }; + publicationRecord: NormalizedPublication | null; + posts: PublicationPostsListPost[]; +}; + export function PostContent({ blocks, did, @@ -50,10 +65,12 @@ export function PostContent({ className, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pageId, pages, pollData, footnoteIndexMap, + postsListData, }: { blocks: PubLeafletPagesLinearDocument.Block[]; pageId?: string; @@ -62,9 +79,11 @@ export function PostContent({ className?: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; footnoteIndexMap?: Map; + postsListData?: PostsListData; }) { return (
@@ -106,10 +127,12 @@ export let Block = ({ nextBlock, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pageId, pages, pollData, footnoteIndexMap, + postsListData, isFirst, isLast, }: { @@ -124,8 +147,10 @@ export let Block = ({ nextBlock?: PubLeafletPagesLinearDocument.Block; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; footnoteIndexMap?: Map; + postsListData?: PostsListData; isFirst?: boolean; isLast?: boolean; }) => { @@ -187,6 +212,7 @@ export let Block = ({ parentPageId={pageId} did={did} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} isCanvas={isCanvas} pages={pages} className={className} @@ -206,6 +232,28 @@ export let Block = ({ /> ); } + case PubLeafletBlocksStandardSitePost.isMain(b.block): { + let uri = b.block.uri; + let post = standardSitePostData.find((p) => p.uri === uri); + if (!post) { + return ( +
+

Post not found.

+
+ ); + } + let size: "large" | "medium" | "small" = + b.block.size === "large" + ? "large" + : b.block.size === "medium" + ? "medium" + : "small"; + return ( +
+ +
+ ); + } case PubLeafletBlocksIframe.isMain(b.block): { return ( ; } + case PubLeafletBlocksPostsList.isMain(b.block): { + if (!postsListData) return null; + const view: "compact" | "full" = + b.block.view === "compact" ? "compact" : "full"; + const filterByTag = b.block.filterByTag; + const posts = filterByTag + ? postsListData.posts.filter((p) => + p.record.tags?.includes(filterByTag), + ) + : postsListData.posts; + return ( +
+ +
+ ); + } case PubLeafletBlocksPoll.isMain(b.block): { let { cid, uri } = b.block.pollRef; const pollVoteData = pollData.find((p) => p.uri === uri && p.cid === cid); @@ -250,6 +320,7 @@ export let Block = ({ pollData={pollData} pages={pages} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} index={[...index, i]} item={child} did={did} @@ -271,6 +342,7 @@ export let Block = ({ pollData={pollData} pages={pages} bskyPostData={bskyPostData} + standardSitePostData={standardSitePostData} index={[...index, i]} item={child} did={did} @@ -547,6 +619,7 @@ function ListItem(props: { did: string; className?: string; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; pageId?: string; footnoteIndexMap?: Map; @@ -558,6 +631,7 @@ function ListItem(props: { pages={props.pages} pollData={props.pollData} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} index={[...props.index, index]} item={child} did={props.did} @@ -576,6 +650,7 @@ function ListItem(props: { pages={props.pages} pollData={props.pollData} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} index={[...props.index, index]} item={child} did={props.did} @@ -606,6 +681,7 @@ function ListItem(props: { pollData={props.pollData} pages={props.pages} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} block={{ block: props.item.content }} did={props.did} isList @@ -627,6 +703,7 @@ function OrderedListItem(props: { did: string; className?: string; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; pageId?: string; startIndex?: number; @@ -641,6 +718,7 @@ function OrderedListItem(props: { pages={props.pages} pollData={props.pollData} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} index={[...props.index, index]} item={child} did={props.did} @@ -660,6 +738,7 @@ function OrderedListItem(props: { pages={props.pages} pollData={props.pollData} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} index={[...props.index, index]} item={child} did={props.did} @@ -689,6 +768,7 @@ function OrderedListItem(props: { pollData={props.pollData} pages={props.pages} bskyPostData={props.bskyPostData} + standardSitePostData={props.standardSitePostData} block={{ block: props.item.content }} did={props.did} isList diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 85d8b2bb..903f2530 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -19,6 +19,7 @@ import { PageOptionButton } from "components/Pages/PageOptions"; import { CloseTiny } from "components/Icons/CloseTiny"; import { Fragment } from "react"; import { PollData } from "./fetchPollData"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; import { LinearDocumentPage } from "./LinearDocumentPage"; import { CanvasPage } from "./CanvasPage"; import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; @@ -62,6 +63,7 @@ export type SharedPageProps = { theme?: PubLeafletPublication.Theme | null; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; pollData: PollData[]; document_uri: string; fullPageScroll: boolean; @@ -107,6 +109,7 @@ export function PostPages({ pubRecord, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, document_uri, pollData, }: { @@ -117,6 +120,7 @@ export function PostPages({ did: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; preferences: { showComments?: boolean; showMentions?: boolean; @@ -153,6 +157,7 @@ export function PostPages({ theme, prerenderedCodeBlocks, bskyPostData, + standardSitePostData, pollData, document_uri, hasPageBackground, diff --git a/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx new file mode 100644 index 00000000..68e3a2ca --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx @@ -0,0 +1,221 @@ +import { + PubLeafletBlocksPostsList, + PubLeafletPagesLinearDocument, + PubLeafletPublicationPage, +} from "lexicons/api"; +import { AtpAgent } from "@atproto/api"; + +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; + +import { + PublicationBackgroundProvider, + PublicationThemeProvider, +} from "components/ThemeManager/PublicationThemeProvider"; +import { FontLoader } from "components/FontLoader"; +import { LeafletContentProvider } from "contexts/LeafletContentContext"; +import { DocumentProvider } from "contexts/DocumentContext"; +import type { DocumentContextValue } from "contexts/DocumentContext"; +import { + type PublicationPostsListPost, +} from "../PublicationPostsList"; +import { PublicationNav } from "../PublicationNav"; +import { PublicationHeader } from "../PublicationHeader"; +import { PublicationHomeLayout } from "../PublicationHomeLayout"; +import { PublicationStickyHeader } from "../PublicationStickyHeader"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; + +import { collectAndFetchBlockResources } from "./collectAndFetchBlockResources"; +import { PostContent } from "./PostContent"; + +export type PublicationPageRecord = PubLeafletPublicationPage.Record; + +type PublicationRow = { + uri: string; + name: string; + identity_did: string; + record: unknown; + publication_pages?: { + id: number; + path: string | null; + title: string; + record_uri: string | null; + sort_order: string; + }[]; + documents_in_publications?: { + documents: { + uri: string; + data: unknown; + comments_on_documents?: { count: number }[]; + document_mentions_in_bsky?: { count: number }[]; + recommends_on_documents?: { count: number }[]; + } | null; + }[]; +}; + +export async function PublicationPageRenderer({ + did, + page, + publication, +}: { + did: string; + page: { id: number; path: string; title: string | null; record: PublicationPageRecord }; + publication: PublicationRow; +}) { + const normalizedPublication = normalizePublicationRecord(publication.record); + const pages = page.record.content.pages || []; + const firstPage = pages[0]; + + const allBlocks: PubLeafletPagesLinearDocument.Block[] = + firstPage && firstPage.$type === "pub.leaflet.pages.linearDocument" + ? ((firstPage as PubLeafletPagesLinearDocument.Main).blocks ?? []) + : []; + + const agent = new AtpAgent({ + service: "https://public.api.bsky.app", + fetch: (...args) => + fetch(args[0], { + ...args[1], + next: { revalidate: 3600 }, + }), + }); + + const resourcePages = + firstPage && firstPage.$type === "pub.leaflet.pages.linearDocument" + ? [firstPage as PubLeafletPagesLinearDocument.Main] + : []; + + const { + bskyPostData, + standardSitePostData: standardSitePosts, + pollData, + prerenderedCodeBlocks, + } = await collectAndFetchBlockResources({ agent, pages: resourcePages }); + + const hasPostsList = allBlocks.some((b) => + PubLeafletBlocksPostsList.isMain(b.block), + ); + const postsListPosts: PublicationPostsListPost[] = hasPostsList + ? (publication.documents_in_publications ?? []) + .map((dip) => { + if (!dip.documents) return null; + const normalized = normalizeDocumentRecord( + dip.documents.data, + dip.documents.uri, + ); + 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) + : []; + + const postsListData = hasPostsList + ? { + publication: { uri: publication.uri, record: publication.record }, + publicationRecord: normalizedPublication as NormalizedPublication | null, + posts: postsListPosts, + } + : undefined; + + const theme = normalizedPublication?.theme; + const showPageBackground = !!theme?.showPageBackground; + + const documentContextValue: DocumentContextValue = { + uri: page.record.publication, + normalizedDocument: null as unknown as DocumentContextValue["normalizedDocument"], + normalizedPublication, + theme, + prevNext: undefined, + quotesAndMentions: [], + publication: { + uri: publication.uri, + name: publication.name, + identity_did: publication.identity_did, + record: publication.record as NonNullable< + DocumentContextValue["publication"] + >["record"], + publication_subscriptions: [], + newsletterMode: false, + pages: (publication.publication_pages ?? []).filter((p) => p.record_uri), + }, + comments: [], + mentions: [], + leafletId: null, + recommendsCount: 0, + }; + + return ( + + + + + + p.record_uri, + )} + activePath={page.path} + /> + } + > + + + } + > +
+ +
+
+
+
+
+
+ ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts b/app/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts new file mode 100644 index 00000000..1ce43d3c --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts @@ -0,0 +1,91 @@ +import { ids } from "lexicons/api/lexicons"; +import { + PubLeafletBlocksBskyPost, + PubLeafletBlocksPoll, + PubLeafletBlocksStandardSitePost, + PubLeafletPagesLinearDocument, + PubLeafletPagesCanvas, +} from "lexicons/api"; +import { type $Typed } from "lexicons/api/util"; +import { AtpAgent, AppBskyFeedDefs } from "@atproto/api"; +import { supabaseServerClient } from "supabase/serverClient"; +import { + get_standard_site_posts, + type StandardSitePostData, +} from "app/api/rpc/[command]/get_standard_site_posts"; +import { extractBlocksByType } from "./extractBlocksByType"; +import { extractCodeBlocks } from "./extractCodeBlocks"; +import { fetchPollData, type PollData } from "./fetchPollData"; + +type Page = + | PubLeafletPagesLinearDocument.Main + | PubLeafletPagesCanvas.Main; + +export async function collectAndFetchBlockResources({ + agent, + pages, +}: { + agent: AtpAgent; + pages: Page[]; +}): Promise<{ + bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; + pollData: PollData[]; + prerenderedCodeBlocks: Map; +}> { + const allBlocks: PubLeafletPagesLinearDocument.Block[] = pages.flatMap( + (p) => (p as PubLeafletPagesLinearDocument.Main).blocks ?? [], + ); + + const bskyPostBlocks = extractBlocksByType< + $Typed + >(allBlocks, ids.PubLeafletBlocksBskyPost); + const bskyPostBatches: typeof bskyPostBlocks[] = []; + for (let i = 0; i < bskyPostBlocks.length; i += 25) { + bskyPostBatches.push(bskyPostBlocks.slice(i, i + 25)); + } + const bskyPostResponses = await Promise.all( + bskyPostBatches.map((batch) => + agent.getPosts( + { uris: batch.map((p) => p.block.postRef.uri) }, + { headers: {} }, + ), + ), + ); + const bskyPostData = bskyPostResponses.flatMap((r) => r.data.posts); + + const standardSitePostUris = Array.from( + new Set( + extractBlocksByType<$Typed>( + allBlocks, + ids.PubLeafletBlocksStandardSitePost, + ).map((b) => b.block.uri), + ), + ); + const standardSitePostsResult = + standardSitePostUris.length > 0 + ? await get_standard_site_posts.handler( + { uris: standardSitePostUris }, + { supabase: supabaseServerClient }, + ) + : { result: { posts: [] } }; + const standardSitePostData = standardSitePostsResult.result.posts; + + const pollBlocks = extractBlocksByType<$Typed>( + allBlocks, + ids.PubLeafletBlocksPoll, + ); + const pollData = await fetchPollData( + pollBlocks.map((b) => b.block.pollRef.uri), + ); + + const firstPageBlocks = (pages[0] as Page | undefined)?.blocks ?? []; + const prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); + + return { + bskyPostData, + standardSitePostData, + pollData, + prerenderedCodeBlocks, + }; +} diff --git a/app/lish/[did]/[publication]/[rkey]/extractBlocksByType.ts b/app/lish/[did]/[publication]/[rkey]/extractBlocksByType.ts new file mode 100644 index 00000000..0dcc7827 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/extractBlocksByType.ts @@ -0,0 +1,55 @@ +import { ids } from "lexicons/api/lexicons"; +import { + PubLeafletBlocksOrderedList, + PubLeafletBlocksUnorderedList, + PubLeafletPagesLinearDocument, +} from "lexicons/api"; + +export function extractBlocksByType( + blocks: PubLeafletPagesLinearDocument.Block[], + type: string, +): { block: T }[] { + const results: { block: T }[] = []; + for (const b of blocks) { + if (b.block.$type === type) { + results.push(b as unknown as { block: T }); + } + if ( + b.block.$type === ids.PubLeafletBlocksOrderedList || + b.block.$type === ids.PubLeafletBlocksUnorderedList + ) { + const list = b.block as + | PubLeafletBlocksOrderedList.Main + | PubLeafletBlocksUnorderedList.Main; + extractFromListItems(list.children, type, results); + } + } + return results; +} + +function extractFromListItems( + items: + | PubLeafletBlocksOrderedList.ListItem[] + | PubLeafletBlocksUnorderedList.ListItem[], + type: string, + results: { block: T }[], +) { + for (const item of items) { + if ((item.content as { $type?: string })?.$type === type) { + results.push({ block: item.content as unknown as T }); + } + if (item.children) { + extractFromListItems(item.children, type, results); + } + const orderedChildren = (item as PubLeafletBlocksUnorderedList.ListItem) + .orderedListChildren; + if (orderedChildren) { + extractFromListItems(orderedChildren.children, type, results); + } + const unorderedChildren = (item as PubLeafletBlocksOrderedList.ListItem) + .unorderedListChildren; + if (unorderedChildren) { + extractFromListItems(unorderedChildren.children, type, results); + } + } +} diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts index 63c83829..2110fb31 100644 --- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -19,7 +19,8 @@ export async function getPostPageData(did: string, rkey: string) { documents_in_publications(publications(*, documents_in_publications(documents(uri, data)), publication_subscriptions(*), - publication_newsletter_settings(enabled)) + publication_newsletter_settings(enabled), + publication_pages(id, path, title, record_uri, sort_order)) ), document_mentions_in_bsky(*), leaflets_in_publications(*), @@ -143,6 +144,7 @@ export async function getPostPageData(did: string, rkey: string) { | null, publication_subscriptions: rawPub.publication_subscriptions || [], newsletterMode: !!rawPub.publication_newsletter_settings?.enabled, + pages: (rawPub.publication_pages || []).filter((p) => p.record_uri), } : null; const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0; diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index 6bce7d22..e941d6e3 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -1,16 +1,38 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Metadata } from "next"; import { DocumentPageRenderer } from "./DocumentPageRenderer"; +import { tryRenderPublicationPage } from "../tryRenderPublicationPage"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; -import { documentUriFilter } from "src/utils/uriHelpers"; +import { + documentUriFilter, + publicationNameOrUriFilter, +} from "src/utils/uriHelpers"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; }): Promise { let params = await props.params; let did = decodeURIComponent(params.did); + let publication_name = decodeURIComponent(params.publication); + let rkey = decodeURIComponent(params.rkey); if (!did) return { title: "Publication 404" }; + let { data: pubs } = await supabaseServerClient + .from("publications") + .select("name, publication_pages(path, title, record_uri)") + .eq("identity_did", did) + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1); + let matchingPage = pubs?.[0]?.publication_pages?.find( + (p) => p.path === "/" + rkey && p.record_uri, + ); + if (matchingPage) { + return { + title: `${matchingPage.title || matchingPage.path} - ${pubs?.[0]?.name}`, + }; + } + let [{ data: documents }] = await Promise.all([ supabaseServerClient .from("documents") @@ -55,6 +77,8 @@ export default async function Post(props: { }) { let params = await props.params; let did = decodeURIComponent(params.did); + let publication_name = decodeURIComponent(params.publication); + let rkey = decodeURIComponent(params.rkey); if (!did) return ( @@ -67,5 +91,30 @@ export default async function Post(props: {
); - return ; + let { data: publications } = await supabaseServerClient + .from("publications") + .select( + `uri, name, identity_did, record, + publication_pages(id, path, title, record, record_uri, sort_order), + documents_in_publications(documents(uri, data, + comments_on_documents(count), + document_mentions_in_bsky(count), + recommends_on_documents(count)))`, + ) + .eq("identity_did", did) + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1); + let publication = publications?.[0]; + + if (publication) { + const pageRender = tryRenderPublicationPage({ + did, + publication, + path: "/" + rkey, + }); + if (pageRender) return pageRender; + } + + return ; } diff --git a/app/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx b/app/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx new file mode 100644 index 00000000..2d087648 --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx @@ -0,0 +1,58 @@ +"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 { useHasEntitlement } from "src/hooks/useEntitlement"; +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 editPubPagesEnabled = useHasEntitlement("edit-pub-pages"); + + 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", + includePostsList: true, + }); + setLoading(false); + if (!created) return; + targetPath = created.path; + mutate(); + } + let routeSegment = targetPath && targetPath !== "/" ? targetPath : ""; + router.push( + `/lish/${props.did}/${props.publicationName}/edit${routeSegment}`, + ); + } + + if (!editPubPagesEnabled) return null; + + return ( + } + label={loading ? "Creating..." : "Edit Pages"} + onClick={handleClick} + className="w-full" + /> + ); +} diff --git a/app/lish/[did]/[publication]/dashboard/layout.tsx b/app/lish/[did]/[publication]/dashboard/layout.tsx index 0264fe50..0961f06d 100644 --- a/app/lish/[did]/[publication]/dashboard/layout.tsx +++ b/app/lish/[did]/[publication]/dashboard/layout.tsx @@ -11,6 +11,7 @@ import { SettingsSmall } from "components/Icons/SettingsSmall"; import { DashboardShell } from "components/PageLayouts/DashboardShell"; import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; import { Actions } from "./Actions"; +import { EditPagesNavLink } from "./EditPagesNavLink"; import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; import { AnalyticsSmall } from "components/Icons/AnalyticsSmall"; import { SubscribersSmall } from "components/Icons/SubscribersSmall"; @@ -98,7 +99,16 @@ export default async function PublicationDashboardLayout(props: { id={publication.uri} publication={publication.uri} pageTitle={} - actions={} + actions={ + <> + + + + } tabs={{ Drafts: { href: baseHref, icon: }, Posts: { href: `${baseHref}/posts`, icon: }, 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..99d20904 --- /dev/null +++ b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx @@ -0,0 +1,78 @@ +"use client"; +import { useState } from "react"; +import { SpeedyLink } from "components/SpeedyLink"; +import { GoToArrowLined } from "components/Icons/GoToArrowLined"; +import { publishPublicationPages } from "actions/publishPublicationPages"; +import { useToaster } from "components/Toast"; +import { usePublicationData } from "../../dashboard/PublicationSWRProvider"; + +type Status = "idle" | "publishing" | "success"; + +export function PublicationEditHeader(props: { + did: string; + publicationName: string; +}) { + let { data } = usePublicationData(); + let publicationUri = data?.publication?.uri; + let [status, setStatus] = useState("idle"); + let toaster = useToaster(); + + let dashboardHref = `/lish/${props.did}/${props.publicationName}/dashboard`; + + async function handlePublish() { + if (!publicationUri || status === "publishing") return; + setStatus("publishing"); + try { + const result = await publishPublicationPages({ + publication_uri: publicationUri, + }); + if (result.success) { + setStatus("success"); + setTimeout(() => setStatus("idle"), 2000); + } else { + setStatus("idle"); + toaster({ + type: "error", + content: + result.error.type === "oauth_session_expired" + ? "Sign in again to publish" + : result.error.message, + }); + } + } catch (e) { + setStatus("idle"); + toaster({ + type: "error", + content: e instanceof Error ? e.message : "Failed to publish", + }); + } + } + + let label = + status === "publishing" + ? "Publishing..." + : status === "success" + ? "Published!" + : "Update Publication"; + + 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..ea587740 --- /dev/null +++ b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesNav.tsx @@ -0,0 +1,251 @@ +"use client"; +import { useRouter, usePathname } from "next/navigation"; +import { useMemo, useState } from "react"; +import { + DndContext, + DragEndEvent, + MeasuringStrategy, + PointerSensor, + KeyboardSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + horizontalListSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; +import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; +import { generateKeyBetween } from "fractional-indexing"; +import { SpeedyLink } from "components/SpeedyLink"; +import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; +import { InputWithLabel } from "components/Input"; +import { Popover } from "components/Popover"; +import { AddTiny } from "components/Icons/AddTiny"; +import { createPublicationPage } from "actions/createPublicationPage"; +import { reorderPublicationPages } from "actions/reorderPublicationPages"; +import { + usePublicationData, + mutatePublicationData, +} from "../../dashboard/PublicationSWRProvider"; +import { sortPublicationPages } from "../../sortPublicationPages"; + +type SortablePage = { + id: number; + path: string | null; + title: string; + sort_order: string; +}; + +export function PublicationPagesNav(props: { + did: string; + publicationName: string; +}) { + let router = useRouter(); + let pathname = usePathname() ?? ""; + let { data, mutate } = usePublicationData(); + let [creating, setCreating] = useState(false); + let [open, setOpen] = useState(false); + let [name, setName] = useState(""); + let [path, setPath] = useState("/"); + + 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 handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (creating || !publicationUri) return; + let trimmedPath = path.trim(); + if (!trimmedPath) return; + if (!trimmedPath.startsWith("/")) trimmedPath = "/" + trimmedPath; + setCreating(true); + let created = await createPublicationPage({ + publication_uri: publicationUri, + path: trimmedPath, + title: name.trim() || undefined, + }); + setCreating(false); + if (!created) return; + setOpen(false); + setName(""); + setPath("/"); + await mutate(); + router.push(hrefForPath(created.path)); + } + + let sortedPages = useMemo(() => sortPublicationPages(pages), [pages]); + let sortableIds = useMemo(() => sortedPages.map((p) => p.id), [sortedPages]); + + let sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + function handleDragEnd(event: DragEndEvent) { + let { active, over } = event; + if (!over || active.id === over.id || !publicationUri) return; + + let activeId = Number(active.id); + let overId = Number(over.id); + let oldIndex = sortedPages.findIndex((p) => p.id === activeId); + let newIndex = sortedPages.findIndex((p) => p.id === overId); + if (oldIndex === -1 || newIndex === -1) return; + + let reordered = arrayMove(sortedPages, oldIndex, newIndex); + let before = reordered[newIndex - 1]?.sort_order ?? null; + let after = reordered[newIndex + 1]?.sort_order ?? null; + let newSortOrder = generateKeyBetween(before, after); + + mutatePublicationData(mutate, (draft) => { + let page = draft.publication?.publication_pages.find( + (p) => p.id === activeId, + ); + if (page) page.sort_order = newSortOrder; + }); + + reorderPublicationPages({ + publication_uri: publicationUri, + page_id: activeId, + sort_order: newSortOrder, + }).then((result) => { + if (!result.success) mutate(); + }); + } + + return ( + + ); +} + +function SortableTab(props: { + page: SortablePage; + href: string; + active: boolean; +}) { + let { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: props.page.id }); + + let style = { + transform: CSS.Translate.toString(transform), + transition, + zIndex: isDragging ? 1 : undefined, + }; + + 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..7e2b6e49 --- /dev/null +++ b/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx @@ -0,0 +1,126 @@ +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 { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { PublicationSWRDataProvider } from "../../dashboard/PublicationSWRProvider"; +import { PublicationPagesNav } from "./PublicationPagesNav"; +import { PublicationEditHeader } from "./PublicationEditHeader"; +import { PublicationHeader } from "../../PublicationHeader"; +import { PublicationStickyHeader } from "../../PublicationStickyHeader"; + +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 ; + + if (!identity.entitlements?.["edit-pub-pages"]) { + 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 ? blobRefToSrc(record.icon.ref, did) : 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/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx index 99c28fa0..e20844dc 100644 --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -9,6 +9,7 @@ import React from "react"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { PublicationContent } from "./PublicationContent"; +import { tryRenderPublicationPage } from "./tryRenderPublicationPage"; import { PublicationThemeProvider, PublicationBackgroundProvider, @@ -29,6 +30,7 @@ export default async function Publication(props: { `*, publication_subscriptions(*), publication_newsletter_settings(enabled), + publication_pages(id, path, title, record, record_uri, sort_order), documents_in_publications(documents( *, comments_on_documents(count), @@ -50,7 +52,14 @@ export default async function Publication(props: { const showPageBackground = record?.theme?.showPageBackground; if (!publication) return ; + try { + const homePageRender = tryRenderPublicationPage({ + did, + publication, + path: "/", + }); + if (homePageRender) return homePageRender; return ( (pages: T[]): T[] { + return [...pages].sort((a, b) => { + if (a.sort_order === b.sort_order) return a.id - b.id; + return a.sort_order.localeCompare(b.sort_order); + }); +} diff --git a/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx b/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx index d4f2637a..eea2b08d 100644 --- a/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx +++ b/app/lish/[did]/[publication]/theme-settings/PostPreview.tsx @@ -96,6 +96,7 @@ export function PostPreview(props: { })), newsletterMode: !!publication.publication_newsletter_settings?.enabled, + pages: [], } : null; @@ -139,6 +140,7 @@ export function PostPreview(props: { }} prerenderedCodeBlocks={new Map()} bskyPostData={[]} + standardSitePostData={[]} pollData={[]} document_uri={FAKE_DOC_URI} fullPageScroll={!props.showPageBackground} diff --git a/app/lish/[did]/[publication]/tryRenderPublicationPage.tsx b/app/lish/[did]/[publication]/tryRenderPublicationPage.tsx new file mode 100644 index 00000000..90f761dc --- /dev/null +++ b/app/lish/[did]/[publication]/tryRenderPublicationPage.tsx @@ -0,0 +1,49 @@ +import { + PublicationPageRenderer, + type PublicationPageRecord, +} from "./[rkey]/PublicationPageRenderer"; + +type RendererProps = Parameters[0]; + +type PublicationForRenderer = Omit< + RendererProps["publication"], + "publication_pages" +> & { + publication_pages?: { + id: number; + path: string | null; + title: string; + record: unknown; + record_uri: string | null; + sort_order: string; + }[]; +}; + +export function tryRenderPublicationPage({ + did, + publication, + path, +}: { + did: string; + publication: PublicationForRenderer; + path: string; +}) { + const matchingPage = publication.publication_pages?.find( + (p) => p.path === path && p.record_uri && p.record, + ); + if (!matchingPage || !matchingPage.record) return null; + const pageRecord = + matchingPage.record as unknown as PublicationPageRecord; + return ( + + ); +} diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 66606905..093603e3 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -25,11 +25,13 @@ import { elementId } from "src/utils/elementId"; import { ButtonBlock } from "./ButtonBlock"; import { PollBlock } from "./PollBlock"; import { BlueskyPostBlock } from "./BlueskyPostBlock"; +import { StandardSitePostBlock } from "./StandardSitePostBlock"; import { CheckboxChecked } from "components/Icons/CheckboxChecked"; import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; import { MathBlock } from "./MathBlock"; import { CodeBlock } from "./CodeBlock"; import { HorizontalRule } from "./HorizontalRule"; +import { PostsListBlock } from "./PostsListBlock"; import { deepEquals } from "src/utils/deepEquals"; import { isTextBlock } from "src/utils/isTextBlock"; import { DeleteTiny } from "components/Icons/DeleteTiny"; @@ -124,11 +126,7 @@ export const Block = memo(function Block( const bindSwipe = useDrag( ({ last, movement: [mx], event }) => { if (!last) return; - if ( - event && - "pointerType" in event && - event.pointerType !== "touch" - ) + if (event && "pointerType" in event && event.pointerType !== "touch") return; if (!rep || !props.listData || !entity_set.permissions.write) return; if (Math.abs(mx) < SWIPE_THRESHOLD) return; @@ -362,7 +360,9 @@ const BlockTypeComponents: { button: ButtonBlock, poll: PollBlock, "bluesky-post": BlueskyPostBlock, + "standard-site-post": StandardSitePostBlock, "horizontal-rule": HorizontalRule, + "posts-list": PostsListBlock, }; export const BlockMultiselectIndicator = (props: BlockProps) => { @@ -412,6 +412,7 @@ export const BlockLayout = (props: { hasAlignment?: boolean; areYouSure?: boolean; setAreYouSure?: (value: boolean) => void; + extraOptions?: React.ReactNode; }) => { // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete return ( @@ -439,6 +440,7 @@ export const BlockLayout = (props: { optionsClassName={props.optionsClassName} areYouSure={props.areYouSure} setAreYouSure={props.setAreYouSure} + extraOptions={props.extraOptions} /> )} @@ -451,6 +453,7 @@ const NonTextBlockOptions = (props: { areYouSure?: boolean; setAreYouSure?: (value: boolean) => void; optionsClassName?: string; + extraOptions?: React.ReactNode; }) => { let { rep } = useReplicache(); let entity_set = useEntitySetContext(); @@ -497,6 +500,12 @@ const NonTextBlockOptions = (props: { )} + {props.extraOptions && ( + <> + {props.extraOptions}{" "} + + + )} + ); + })} + + )} + + + + ); +} diff --git a/components/Blocks/SettingsTriggerButton.tsx b/components/Blocks/SettingsTriggerButton.tsx new file mode 100644 index 00000000..42d88436 --- /dev/null +++ b/components/Blocks/SettingsTriggerButton.tsx @@ -0,0 +1,18 @@ +import { forwardRef } from "react"; +import { SettingsTiny } from "components/Icons/SettingsTiny"; + +export const SettingsTriggerButton = forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>(({ "aria-label": ariaLabel = "Settings", ...props }, ref) => ( + +)); +SettingsTriggerButton.displayName = "SettingsTriggerButton"; diff --git a/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx b/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx new file mode 100644 index 00000000..a8ccd2cc --- /dev/null +++ b/components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx @@ -0,0 +1,181 @@ +"use client"; +import { AtUri } from "@atproto/api"; +import { + PublicationPostItemSmall, + PublicationPostItemMedium, + PublicationPostItemLarge, +} from "app/lish/[did]/[publication]/PublicationPostItem"; +import { LocalizedDate } from "app/lish/[did]/[publication]/LocalizedDate"; +import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; +import { getFirstParagraph } from "src/utils/getFirstParagraph"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { useStandardSitePost } from "components/StandardSitePostDataProvider"; +import { useEntity, useReplicache } from "src/replicache"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; + +export type StandardSitePostSize = "large" | "medium" | "small"; + +export function StandardSitePostItem({ + uri, + size = "medium", +}: { + uri: string; + size?: StandardSitePostSize; +}) { + const { data, isLoading } = useStandardSitePost(uri); + const { rootEntity } = useReplicache(); + const pageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; + + if (isLoading) { + return ; + } + + if (!data) { + return ( +

+ Post not found. +

+ ); + } + + return ; +} + +function StandardSitePostItemPlaceholder({ + size, + pageWidth, +}: { + size: StandardSitePostSize; + pageWidth?: number; +}) { + if (size === "small") { + return ( + <> +
+
+
+
+
+ + ); + } + + if (size === "medium") { + return ( + <> +
+
+
+
+
+
+
+
+
+
+
+
+ + ); + } + + const widePage = (pageWidth ?? 0) >= 768; + return ( + <> +
+
+
+
+
+
+
+
+
+
+
+
+ + ); +} + +export function StandardSitePostItemView({ + post, + size = "medium", +}: { + post: StandardSitePostData; + size?: StandardSitePostSize; +}) { + const docUrl = getDocumentURL( + post.record, + post.uri, + post.publication ?? undefined, + ); + const authorLabel = + post.author?.displayName || + (post.author?.handle ? `@${post.author.handle}` : undefined); + const date = post.record.publishedAt ? ( + + ) : undefined; + const description = post.record.description || getFirstParagraph(post.record); + + let postDid: string | undefined; + try { + postDid = new AtUri(post.uri).host; + } catch { + postDid = undefined; + } + const coverImageSrc = + post.record.coverImage && postDid + ? blobRefToSrc(post.record.coverImage.ref, postDid) + : undefined; + + const { rootEntity } = useReplicache(); + const pageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; + + const commonProps = { + href: docUrl, + title: post.record.title, + author: authorLabel, + date, + }; + + if (size === "large") { + return ( + + ); + } + if (size === "medium") { + return ( + + ); + } + return ; +} diff --git a/components/Blocks/StandardSitePostBlock/index.tsx b/components/Blocks/StandardSitePostBlock/index.tsx new file mode 100644 index 00000000..208787f4 --- /dev/null +++ b/components/Blocks/StandardSitePostBlock/index.tsx @@ -0,0 +1,149 @@ +import { BlockProps, BlockLayout } from "../Block"; +import { useEntity, useReplicache } from "src/replicache"; +import { useUIState } from "src/useUIState"; +import { Popover } from "components/Popover"; +import { SettingsTriggerButton } from "../SettingsTriggerButton"; +import { + StandardSitePostItem, + type StandardSitePostSize, +} from "./StandardSitePostItem"; + +export const StandardSitePostBlock = ( + props: BlockProps & { preview?: boolean }, +) => { + let isSelected = useUIState((s) => + s.selectedBlocks.find((b) => b.value === props.entityID), + ); + let uri = useEntity(props.entityID, "block/standard-site-post")?.data.value; + let sizeFact = useEntity(props.entityID, "standard-site-post/size"); + let size: StandardSitePostSize = sizeFact?.data.value ?? "medium"; + + if (!uri) return null; + + return ( + + } + > + + + ); +}; + +function StandardSitePostSettingsButton(props: { entityID: string }) { + let { rep } = useReplicache(); + let sizeFact = useEntity(props.entityID, "standard-site-post/size"); + let size: StandardSitePostSize = sizeFact?.data.value ?? "medium"; + + return ( + e.preventDefault()} + trigger={ + + } + > +

Post Size

+
+ {( + [ + { value: "small", Icon: SmallIcon }, + { value: "medium", Icon: MedIcon }, + { value: "large", Icon: LargeIcon }, + ] as { + value: StandardSitePostSize; + Icon: (props: { selected: boolean }) => React.ReactNode; + }[] + ).map((option) => { + let selected = + size === option.value || + (option.value === "medium" && size !== "small" && size !== "large"); + return ( + + ); + })} +
+
+ ); +} + +const SmallIcon = ({ selected }: { selected: boolean }) => { + return ( +
+
+
+ +
+
+
+
+
+
+ ); +}; + +const MedIcon = ({ selected }: { selected: boolean }) => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); +}; + +const LargeIcon = ({ selected }: { selected: boolean }) => { + return ( +
+
+ +
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/components/Blocks/StandardSitePostBlock/parseStandardSitePostInput.ts b/components/Blocks/StandardSitePostBlock/parseStandardSitePostInput.ts new file mode 100644 index 00000000..1d88624e --- /dev/null +++ b/components/Blocks/StandardSitePostBlock/parseStandardSitePostInput.ts @@ -0,0 +1,23 @@ +import { ids } from "lexicons/api/lexicons"; + +export function parseStandardSitePostInput(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + + // https://leaflet.pub/p/{did}/{rkey} + let m = url.pathname.match(/^\/p\/([^/]+)\/([^/]+)\/?$/); + if (m) return `at://${m[1]}/${ids.SiteStandardDocument}/${m[2]}`; + + // https://leaflet.pub/lish/{did}/{publication}/{rkey} + m = url.pathname.match(/^\/lish\/([^/]+)\/[^/]+\/([^/]+)\/?$/); + if (m) return `at://${m[1]}/${ids.SiteStandardDocument}/${m[2]}`; + + return null; +} diff --git a/components/Icons/GoToArrowLined.tsx b/components/Icons/GoToArrowLined.tsx index e0d5736a..a9fefda1 100644 --- a/components/Icons/GoToArrowLined.tsx +++ b/components/Icons/GoToArrowLined.tsx @@ -13,9 +13,9 @@ export const GoToArrowLined = (props: Props) => { ); 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..e314a5e4 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 && ( + )} @@ -129,6 +129,7 @@ export const PageWrapper = (props: { id={props.id} className={` pageScrollWrapper + publicationScrollContainer grow shrink-0 snap-center ${props.overflow === "hidden" ? "overflow-hidden" : "overflow-y-scroll"} 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 (
void; +let pendingURIs = new Set(); +let pendingResolvers = new Map(); +let flushScheduled = false; + +function scheduleFlush() { + if (flushScheduled) return; + flushScheduled = true; + queueMicrotask(async () => { + const uris = Array.from(pendingURIs); + pendingURIs = new Set(); + const resolvers = pendingResolvers; + pendingResolvers = new Map(); + flushScheduled = false; + if (uris.length === 0) return; + try { + const res = await callRPC("get_standard_site_posts", { uris }); + const byUri = new Map( + (res?.result.posts || []).map((p) => [p.uri, p] as const), + ); + for (const uri of uris) { + const post = byUri.get(uri) ?? null; + for (const r of resolvers.get(uri) || []) r(post); + } + } catch { + for (const uri of uris) { + for (const r of resolvers.get(uri) || []) r(null); + } + } + }); +} + +function batchFetchStandardSitePost( + uri: string, +): Promise { + return new Promise((resolve) => { + pendingURIs.add(uri); + const existing = pendingResolvers.get(uri) || []; + existing.push(resolve); + pendingResolvers.set(uri, existing); + scheduleFlush(); + }); +} + +export const StandardSitePostDataContext = createContext | null>(null); + +export function StandardSitePostDataProvider(props: { + posts: StandardSitePostData[]; + children: React.ReactNode; +}) { + const map: Record = {}; + for (const p of props.posts) map[p.uri] = p; + return ( + + {props.children} + + ); +} + +export function useStandardSitePost(uri: string | null | undefined) { + const ctx = useContext(StandardSitePostDataContext); + const fromContext = uri ? ctx?.[uri] : undefined; + const swr = useSWR( + !uri || fromContext ? null : `standard-site-post:${uri}`, + () => batchFetchStandardSitePost(uri!), + { revalidateOnFocus: false, revalidateIfStale: false }, + ); + return { + data: fromContext ?? swr.data, + isLoading: !fromContext && swr.isLoading, + }; +} diff --git a/drizzle/relations.ts b/drizzle/relations.ts index f298fbe1..0dce0573 100644 --- a/drizzle/relations.ts +++ b/drizzle/relations.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm/relations"; -import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, recommends_on_documents, poll_votes_on_entity, permission_tokens, user_subscriptions, phone_rsvps_to_entity, site_standard_publications, custom_domains, custom_domain_routes, site_standard_documents, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, publication_newsletter_settings, publication_email_subscribers, publication_email_subscriber_events, bsky_follows, site_standard_documents_in_publications, documents_in_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, publication_domains, publication_subscriptions, site_standard_subscriptions, user_entitlements, permission_token_rights, publication_post_sends, publication_pages, leaflets_to_documents, leaflets_in_publications } from "./schema"; +import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, recommends_on_documents, poll_votes_on_entity, permission_tokens, user_subscriptions, phone_rsvps_to_entity, site_standard_publications, custom_domains, custom_domain_routes, site_standard_documents, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, publication_newsletter_settings, publication_email_subscribers, publication_email_subscriber_events, bsky_follows, site_standard_documents_in_publications, documents_in_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, publication_domains, publication_subscriptions, site_standard_subscriptions, user_entitlements, permission_token_rights, publication_post_sends, leaflets_to_documents, leaflets_in_publications, publication_pages } from "./schema"; export const notificationsRelations = relations(notifications, ({one}) => ({ identity: one(identities, { @@ -53,8 +53,8 @@ export const publicationsRelations = relations(publications, ({one, many}) => ({ publication_domains: many(publication_domains), publication_subscriptions: many(publication_subscriptions), publication_post_sends: many(publication_post_sends), - publication_pages: many(publication_pages), leaflets_in_publications: many(leaflets_in_publications), + publication_pages: many(publication_pages), })); export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({ @@ -74,9 +74,9 @@ export const documentsRelations = relations(documents, ({many}) => ({ documents_in_publications: many(documents_in_publications), document_mentions_in_bskies: many(document_mentions_in_bsky), publication_post_sends: many(publication_post_sends), - publication_pages: many(publication_pages), leaflets_to_documents: many(leaflets_to_documents), leaflets_in_publications: many(leaflets_in_publications), + publication_pages: many(publication_pages), })); export const bsky_profilesRelations = relations(bsky_profiles, ({one, many}) => ({ @@ -162,9 +162,9 @@ export const permission_tokensRelations = relations(permission_tokens, ({one, ma email_subscriptions_to_entities: many(email_subscriptions_to_entity), permission_token_on_homepages: many(permission_token_on_homepage), permission_token_rights: many(permission_token_rights), - publication_pages: many(publication_pages), leaflets_to_documents: many(leaflets_to_documents), leaflets_in_publications: many(leaflets_in_publications), + publication_pages: many(publication_pages), })); export const user_subscriptionsRelations = relations(user_subscriptions, ({one}) => ({ @@ -409,43 +409,43 @@ export const publication_post_sendsRelations = relations(publication_post_sends, }), })); -export const publication_pagesRelations = relations(publication_pages, ({one}) => ({ +export const leaflets_to_documentsRelations = relations(leaflets_to_documents, ({one}) => ({ document: one(documents, { - fields: [publication_pages.document], + fields: [leaflets_to_documents.document], references: [documents.uri] }), permission_token: one(permission_tokens, { - fields: [publication_pages.leaflet_src], + fields: [leaflets_to_documents.leaflet], references: [permission_tokens.id] }), - publication: one(publications, { - fields: [publication_pages.publication], - references: [publications.uri] - }), })); -export const leaflets_to_documentsRelations = relations(leaflets_to_documents, ({one}) => ({ +export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ document: one(documents, { - fields: [leaflets_to_documents.document], + fields: [leaflets_in_publications.doc], references: [documents.uri] }), permission_token: one(permission_tokens, { - fields: [leaflets_to_documents.leaflet], + fields: [leaflets_in_publications.leaflet], references: [permission_tokens.id] }), + publication: one(publications, { + fields: [leaflets_in_publications.publication], + references: [publications.uri] + }), })); -export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ +export const publication_pagesRelations = relations(publication_pages, ({one}) => ({ document: one(documents, { - fields: [leaflets_in_publications.doc], + fields: [publication_pages.document], references: [documents.uri] }), permission_token: one(permission_tokens, { - fields: [leaflets_in_publications.leaflet], + fields: [publication_pages.leaflet_src], references: [permission_tokens.id] }), publication: one(publications, { - fields: [leaflets_in_publications.publication], + fields: [publication_pages.publication], references: [publications.uri] }), })); \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 3e28afeb..7f7cba42 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -353,6 +353,7 @@ export const publication_email_subscribers = pgTable("publication_email_subscrib created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), confirmed_at: timestamp("confirmed_at", { withTimezone: true, mode: 'string' }), unsubscribed_at: timestamp("unsubscribed_at", { withTimezone: true, mode: 'string' }), + metadata: jsonb("metadata"), }, (table) => { return { @@ -527,23 +528,6 @@ export const publication_post_sends = pgTable("publication_post_sends", { } }); -export const publication_pages = pgTable("publication_pages", { - // You can use { mode: "bigint" } if numbers are exceeding js number limitations - id: bigint("id", { mode: "number" }).notNull(), - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - leaflet_src: uuid("leaflet_src").notNull().references(() => permission_tokens.id), - document: text("document").references(() => documents.uri), - path: text("path"), - publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade", onUpdate: "cascade" } ), - title: text("title").default('').notNull(), - metadata: jsonb("metadata").default({}).notNull(), -}, -(table) => { - return { - publication_pages_pkey: primaryKey({ columns: [table.id, table.publication], name: "publication_pages_pkey"}), - } -}); - export const leaflets_to_documents = pgTable("leaflets_to_documents", { leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), @@ -582,4 +566,24 @@ export const leaflets_in_publications = pgTable("leaflets_in_publications", { doc_idx: index("leaflets_in_publications_doc_idx").on(table.doc), leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), } +}); + +export const publication_pages = pgTable("publication_pages", { + // You can use { mode: "bigint" } if numbers are exceeding js number limitations + id: bigint("id", { mode: "number" }).notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + leaflet_src: uuid("leaflet_src").notNull().references(() => permission_tokens.id), + document: text("document").references(() => documents.uri), + path: text("path"), + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade", onUpdate: "cascade" } ), + title: text("title").default('').notNull(), + metadata: jsonb("metadata").default({}).notNull(), + record: jsonb("record"), + record_uri: text("record_uri"), + sort_order: text("sort_order").notNull(), +}, +(table) => { + return { + publication_pages_pkey: primaryKey({ columns: [table.id, table.publication], name: "publication_pages_pkey"}), + } }); \ No newline at end of file diff --git a/lexicons/api/index.ts b/lexicons/api/index.ts index c1901c0a..df865afe 100644 --- a/lexicons/api/index.ts +++ b/lexicons/api/index.ts @@ -39,6 +39,8 @@ import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' import * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' +import * as PubLeafletBlocksPostsList from './types/pub/leaflet/blocks/postsList' +import * as PubLeafletBlocksStandardSitePost from './types/pub/leaflet/blocks/standardSitePost' import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' @@ -52,6 +54,7 @@ import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linear import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' import * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' import * as PubLeafletPublication from './types/pub/leaflet/publication' +import * as PubLeafletPublicationPage from './types/pub/leaflet/publicationPage' import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' import * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' @@ -92,6 +95,8 @@ export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' export * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' +export * as PubLeafletBlocksPostsList from './types/pub/leaflet/blocks/postsList' +export * as PubLeafletBlocksStandardSitePost from './types/pub/leaflet/blocks/standardSitePost' export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' @@ -105,6 +110,7 @@ export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linear export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' export * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' export * as PubLeafletPublication from './types/pub/leaflet/publication' +export * as PubLeafletPublicationPage from './types/pub/leaflet/publicationPage' export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' @@ -637,6 +643,7 @@ export class PubLeafletNS { comment: PubLeafletCommentRecord document: PubLeafletDocumentRecord publication: PubLeafletPublicationRecord + publicationPage: PubLeafletPublicationPageRecord blocks: PubLeafletBlocksNS graph: PubLeafletGraphNS interactions: PubLeafletInteractionsNS @@ -657,6 +664,7 @@ export class PubLeafletNS { this.comment = new PubLeafletCommentRecord(client) this.document = new PubLeafletDocumentRecord(client) this.publication = new PubLeafletPublicationRecord(client) + this.publicationPage = new PubLeafletPublicationPageRecord(client) } } @@ -1293,6 +1301,89 @@ export class PubLeafletPublicationRecord { } } +export class PubLeafletPublicationPageRecord { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } + + async list( + params: OmitKey, + ): Promise<{ + cursor?: string + records: { uri: string; value: PubLeafletPublicationPage.Record }[] + }> { + const res = await this._client.call('com.atproto.repo.listRecords', { + collection: 'pub.leaflet.publicationPage', + ...params, + }) + return res.data + } + + async get( + params: OmitKey, + ): Promise<{ + uri: string + cid: string + value: PubLeafletPublicationPage.Record + }> { + const res = await this._client.call('com.atproto.repo.getRecord', { + collection: 'pub.leaflet.publicationPage', + ...params, + }) + return res.data + } + + async create( + params: OmitKey< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.publicationPage' + const res = await this._client.call( + 'com.atproto.repo.createRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async put( + params: OmitKey< + ComAtprotoRepoPutRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.publicationPage' + const res = await this._client.call( + 'com.atproto.repo.putRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async delete( + params: OmitKey, + headers?: Record, + ): Promise { + await this._client.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'pub.leaflet.publicationPage', ...params }, + { headers }, + ) + } +} + export class SiteNS { _client: XrpcClient standard: SiteStandardNS diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 6a9da079..9bd49790 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1568,6 +1568,51 @@ export const schemaDict = { }, }, }, + PubLeafletBlocksPostsList: { + lexicon: 1, + id: 'pub.leaflet.blocks.postsList', + defs: { + main: { + type: 'object', + required: [], + properties: { + view: { + type: 'string', + knownValues: ['compact', 'full'], + }, + highlightFirstPost: { + type: 'boolean', + }, + filterByTag: { + type: 'string', + }, + }, + }, + }, + }, + PubLeafletBlocksStandardSitePost: { + lexicon: 1, + id: 'pub.leaflet.blocks.standardSitePost', + defs: { + main: { + type: 'object', + required: ['uri'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + }, + size: { + type: 'string', + knownValues: ['large', 'medium', 'small'], + }, + }, + }, + }, + }, PubLeafletBlocksText: { lexicon: 1, id: 'pub.leaflet.blocks.text', @@ -1948,9 +1993,11 @@ export const schemaDict = { 'lex:pub.leaflet.blocks.code', 'lex:pub.leaflet.blocks.horizontalRule', 'lex:pub.leaflet.blocks.bskyPost', + 'lex:pub.leaflet.blocks.standardSitePost', 'lex:pub.leaflet.blocks.page', 'lex:pub.leaflet.blocks.poll', 'lex:pub.leaflet.blocks.button', + 'lex:pub.leaflet.blocks.postsList', ], }, x: { @@ -2050,9 +2097,11 @@ export const schemaDict = { 'lex:pub.leaflet.blocks.code', 'lex:pub.leaflet.blocks.horizontalRule', 'lex:pub.leaflet.blocks.bskyPost', + 'lex:pub.leaflet.blocks.standardSitePost', 'lex:pub.leaflet.blocks.page', 'lex:pub.leaflet.blocks.poll', 'lex:pub.leaflet.blocks.button', + 'lex:pub.leaflet.blocks.postsList', ], }, alignment: { @@ -2306,6 +2355,43 @@ export const schemaDict = { }, }, }, + PubLeafletPublicationPage: { + lexicon: 1, + id: 'pub.leaflet.publicationPage', + defs: { + main: { + type: 'record', + key: 'any', + description: + 'A static page belonging to a publication (e.g. about / contact). The rkey is derived from the page path slug.', + record: { + type: 'object', + required: ['publication', 'path', 'content'], + properties: { + publication: { + type: 'string', + format: 'at-uri', + }, + path: { + type: 'string', + }, + title: { + type: 'string', + maxLength: 2000, + }, + publishedAt: { + type: 'string', + format: 'datetime', + }, + content: { + type: 'ref', + ref: 'lex:pub.leaflet.content', + }, + }, + }, + }, + }, + }, PubLeafletRichtextFacet: { lexicon: 1, id: 'pub.leaflet.richtext.facet', @@ -2883,6 +2969,8 @@ export const ids = { PubLeafletBlocksOrderedList: 'pub.leaflet.blocks.orderedList', PubLeafletBlocksPage: 'pub.leaflet.blocks.page', PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', + PubLeafletBlocksPostsList: 'pub.leaflet.blocks.postsList', + PubLeafletBlocksStandardSitePost: 'pub.leaflet.blocks.standardSitePost', PubLeafletBlocksText: 'pub.leaflet.blocks.text', PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', @@ -2896,6 +2984,7 @@ export const ids = { PubLeafletPollDefinition: 'pub.leaflet.poll.definition', PubLeafletPollVote: 'pub.leaflet.poll.vote', PubLeafletPublication: 'pub.leaflet.publication', + PubLeafletPublicationPage: 'pub.leaflet.publicationPage', PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage', PubLeafletThemeColor: 'pub.leaflet.theme.color', diff --git a/lexicons/api/types/pub/leaflet/blocks/postsList.ts b/lexicons/api/types/pub/leaflet/blocks/postsList.ts new file mode 100644 index 00000000..92376f62 --- /dev/null +++ b/lexicons/api/types/pub/leaflet/blocks/postsList.ts @@ -0,0 +1,32 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.blocks.postsList' + +export interface Main { + $type?: 'pub.leaflet.blocks.postsList' + view?: 'compact' | 'full' | (string & {}) + highlightFirstPost?: boolean + filterByTag?: string +} + +const hashMain = 'main' + +export function isMain(v: V) { + return is$typed(v, id, hashMain) +} + +export function validateMain(v: V) { + return validate
(v, id, hashMain) +} diff --git a/lexicons/api/types/pub/leaflet/blocks/standardSitePost.ts b/lexicons/api/types/pub/leaflet/blocks/standardSitePost.ts new file mode 100644 index 00000000..9f4d9e86 --- /dev/null +++ b/lexicons/api/types/pub/leaflet/blocks/standardSitePost.ts @@ -0,0 +1,32 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.blocks.standardSitePost' + +export interface Main { + $type?: 'pub.leaflet.blocks.standardSitePost' + uri: string + cid?: string + size?: 'large' | 'medium' | 'small' | (string & {}) +} + +const hashMain = 'main' + +export function isMain(v: V) { + return is$typed(v, id, hashMain) +} + +export function validateMain(v: V) { + return validate
(v, id, hashMain) +} diff --git a/lexicons/api/types/pub/leaflet/pages/canvas.ts b/lexicons/api/types/pub/leaflet/pages/canvas.ts index fc55c202..e18bc2ed 100644 --- a/lexicons/api/types/pub/leaflet/pages/canvas.ts +++ b/lexicons/api/types/pub/leaflet/pages/canvas.ts @@ -21,9 +21,11 @@ import type * as PubLeafletBlocksMath from '../blocks/math' import type * as PubLeafletBlocksCode from '../blocks/code' import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' +import type * as PubLeafletBlocksStandardSitePost from '../blocks/standardSitePost' import type * as PubLeafletBlocksPage from '../blocks/page' import type * as PubLeafletBlocksPoll from '../blocks/poll' import type * as PubLeafletBlocksButton from '../blocks/button' +import type * as PubLeafletBlocksPostsList from '../blocks/postsList' const is$typed = _is$typed, validate = _validate @@ -60,9 +62,11 @@ export interface Block { | $Typed | $Typed | $Typed + | $Typed | $Typed | $Typed | $Typed + | $Typed | { $type: string } x: number y: number diff --git a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts index 82dd1bf2..b35d8f98 100644 --- a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts +++ b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts @@ -21,9 +21,11 @@ import type * as PubLeafletBlocksMath from '../blocks/math' import type * as PubLeafletBlocksCode from '../blocks/code' import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' +import type * as PubLeafletBlocksStandardSitePost from '../blocks/standardSitePost' import type * as PubLeafletBlocksPage from '../blocks/page' import type * as PubLeafletBlocksPoll from '../blocks/poll' import type * as PubLeafletBlocksButton from '../blocks/button' +import type * as PubLeafletBlocksPostsList from '../blocks/postsList' const is$typed = _is$typed, validate = _validate @@ -60,9 +62,11 @@ export interface Block { | $Typed | $Typed | $Typed + | $Typed | $Typed | $Typed | $Typed + | $Typed | { $type: string } alignment?: | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft' diff --git a/lexicons/api/types/pub/leaflet/publicationPage.ts b/lexicons/api/types/pub/leaflet/publicationPage.ts new file mode 100644 index 00000000..d98d97dd --- /dev/null +++ b/lexicons/api/types/pub/leaflet/publicationPage.ts @@ -0,0 +1,32 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../lexicons' +import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' +import type * as PubLeafletContent from './content' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.publicationPage' + +export interface Record { + $type: 'pub.leaflet.publicationPage' + publication: string + path: string + title?: string + publishedAt?: string + content: PubLeafletContent.Main + [k: string]: unknown +} + +const hashRecord = 'main' + +export function isRecord(v: V) { + return is$typed(v, id, hashRecord) +} + +export function validateRecord(v: V) { + return validate(v, id, hashRecord, true) +} diff --git a/lexicons/build.ts b/lexicons/build.ts index b005bfa4..cdd4646b 100644 --- a/lexicons/build.ts +++ b/lexicons/build.ts @@ -12,6 +12,7 @@ import { PubLeafletRichTextFacet } from "./src/facet"; import { PubLeafletComment } from "./src/comment"; import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions"; import { PubLeafletContent } from "./src/content"; +import { PubLeafletPublicationPage } from "./src/publicationPage"; import * as MentionServiceLexicons from "./src/mentionService"; const outdir = path.join("lexicons", "pub", "leaflet"); @@ -24,6 +25,7 @@ fs.mkdirSync(outdir, { recursive: true }); const lexicons = [ PubLeafletDocument, PubLeafletContent, + PubLeafletPublicationPage, PubLeafletComment, PubLeafletRichTextFacet, PubLeafletAuthFullPermissions, diff --git a/lexicons/pub/leaflet/authFullPermissions.json b/lexicons/pub/leaflet/authFullPermissions.json index 4c5492c6..a0c1e114 100644 --- a/lexicons/pub/leaflet/authFullPermissions.json +++ b/lexicons/pub/leaflet/authFullPermissions.json @@ -22,7 +22,8 @@ "pub.leaflet.poll.definition", "pub.leaflet.poll.vote", "pub.leaflet.graph.subscription", - "pub.leaflet.interactions.recommend" + "pub.leaflet.interactions.recommend", + "pub.leaflet.publicationPage" ] } ] diff --git a/lexicons/pub/leaflet/blocks/postsList.json b/lexicons/pub/leaflet/blocks/postsList.json new file mode 100644 index 00000000..0bf357ad --- /dev/null +++ b/lexicons/pub/leaflet/blocks/postsList.json @@ -0,0 +1,25 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.postsList", + "defs": { + "main": { + "type": "object", + "required": [], + "properties": { + "view": { + "type": "string", + "knownValues": [ + "compact", + "full" + ] + }, + "highlightFirstPost": { + "type": "boolean" + }, + "filterByTag": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/pub/leaflet/blocks/standardSitePost.json b/lexicons/pub/leaflet/blocks/standardSitePost.json new file mode 100644 index 00000000..d88f57bb --- /dev/null +++ b/lexicons/pub/leaflet/blocks/standardSitePost.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.standardSitePost", + "defs": { + "main": { + "type": "object", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string" + }, + "size": { + "type": "string", + "knownValues": [ + "large", + "medium", + "small" + ] + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/pub/leaflet/pages/canvas.json b/lexicons/pub/leaflet/pages/canvas.json index 2efc04dd..275a367e 100644 --- a/lexicons/pub/leaflet/pages/canvas.json +++ b/lexicons/pub/leaflet/pages/canvas.json @@ -44,9 +44,11 @@ "pub.leaflet.blocks.code", "pub.leaflet.blocks.horizontalRule", "pub.leaflet.blocks.bskyPost", + "pub.leaflet.blocks.standardSitePost", "pub.leaflet.blocks.page", "pub.leaflet.blocks.poll", - "pub.leaflet.blocks.button" + "pub.leaflet.blocks.button", + "pub.leaflet.blocks.postsList" ] }, "x": { diff --git a/lexicons/pub/leaflet/pages/linearDocument.json b/lexicons/pub/leaflet/pages/linearDocument.json index 51007d09..29e5e200 100644 --- a/lexicons/pub/leaflet/pages/linearDocument.json +++ b/lexicons/pub/leaflet/pages/linearDocument.json @@ -41,9 +41,11 @@ "pub.leaflet.blocks.code", "pub.leaflet.blocks.horizontalRule", "pub.leaflet.blocks.bskyPost", + "pub.leaflet.blocks.standardSitePost", "pub.leaflet.blocks.page", "pub.leaflet.blocks.poll", - "pub.leaflet.blocks.button" + "pub.leaflet.blocks.button", + "pub.leaflet.blocks.postsList" ] }, "alignment": { diff --git a/lexicons/pub/leaflet/publicationPage.json b/lexicons/pub/leaflet/publicationPage.json new file mode 100644 index 00000000..a0095ec7 --- /dev/null +++ b/lexicons/pub/leaflet/publicationPage.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.publicationPage", + "defs": { + "main": { + "type": "record", + "key": "any", + "description": "A static page belonging to a publication (e.g. about / contact). The rkey is derived from the page path slug.", + "record": { + "type": "object", + "required": [ + "publication", + "path", + "content" + ], + "properties": { + "publication": { + "type": "string", + "format": "at-uri" + }, + "path": { + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 2000 + }, + "publishedAt": { + "type": "string", + "format": "datetime" + }, + "content": { + "type": "ref", + "ref": "pub.leaflet.content" + } + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/src/authFullPermissions.ts b/lexicons/src/authFullPermissions.ts index 68889ecc..2728509a 100644 --- a/lexicons/src/authFullPermissions.ts +++ b/lexicons/src/authFullPermissions.ts @@ -7,6 +7,7 @@ import { import { PubLeafletComment } from "./comment"; import { PubLeafletPollDefinition, PubLeafletPollVote } from "./polls"; import { PubLeafletInteractionsRecommend } from "./interactions"; +import { PubLeafletPublicationPage } from "./publicationPage"; export const PubLeafletAuthFullPermissions: LexiconDoc = { lexicon: 1, @@ -30,6 +31,7 @@ export const PubLeafletAuthFullPermissions: LexiconDoc = { PubLeafletPollVote.id, PubLeafletPublicationSubscription.id, PubLeafletInteractionsRecommend.id, + PubLeafletPublicationPage.id, ], }, ], diff --git a/lexicons/src/blocks.ts b/lexicons/src/blocks.ts index 198e1709..9ae64709 100644 --- a/lexicons/src/blocks.ts +++ b/lexicons/src/blocks.ts @@ -49,6 +49,22 @@ export const PubLeafletBlocksBskyPost: LexiconDoc = { }, }; +export const PubLeafletBlocksStandardSitePost: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.blocks.standardSitePost", + defs: { + main: { + type: "object", + required: ["uri"], + properties: { + uri: { type: "string", format: "at-uri" }, + cid: { type: "string" }, + size: { type: "string", knownValues: ["large", "medium", "small"] }, + }, + }, + }, +}; + export const PubLeafletBlocksBlockQuote: LexiconDoc = { lexicon: 1, id: "pub.leaflet.blocks.blockquote", @@ -349,6 +365,22 @@ export const PubLeafletBlocksButton: LexiconDoc = { }, }; +export const PubLeafletBlocksPostsList: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.blocks.postsList", + defs: { + main: { + type: "object", + required: [], + properties: { + view: { type: "string", knownValues: ["compact", "full"] }, + highlightFirstPost: { type: "boolean" }, + filterByTag: { type: "string" }, + }, + }, + }, +}; + export const BlockLexicons = [ PubLeafletBlocksIFrame, PubLeafletBlocksText, @@ -362,9 +394,11 @@ export const BlockLexicons = [ PubLeafletBlocksCode, PubLeafletBlocksHorizontalRule, PubLeafletBlocksBskyPost, + PubLeafletBlocksStandardSitePost, PubLeafletBlocksPage, PubLeafletBlocksPoll, PubLeafletBlocksButton, + PubLeafletBlocksPostsList, ]; export const BlockUnion: LexRefUnion = { type: "union", diff --git a/lexicons/src/publicationPage.ts b/lexicons/src/publicationPage.ts new file mode 100644 index 00000000..3992c8b7 --- /dev/null +++ b/lexicons/src/publicationPage.ts @@ -0,0 +1,26 @@ +import { LexiconDoc } from "@atproto/lexicon"; +import { PubLeafletContent } from "./content"; + +export const PubLeafletPublicationPage: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.publicationPage", + defs: { + main: { + type: "record", + key: "any", + description: + "A static page belonging to a publication (e.g. about / contact). The rkey is derived from the page path slug.", + record: { + type: "object", + required: ["publication", "path", "content"], + properties: { + publication: { type: "string", format: "at-uri" }, + path: { type: "string" }, + title: { type: "string", maxLength: 2000 }, + publishedAt: { type: "string", format: "datetime" }, + content: { type: "ref", ref: PubLeafletContent.id }, + }, + }, + }, + }, +}; diff --git a/package-lock.json b/package-lock.json index 95c18abb..49639b5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,9 @@ "@atproto/syntax": "^0.3.3", "@atproto/xrpc": "^0.7.5", "@atproto/xrpc-server": "^0.9.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@hono/node-server": "^1.14.3", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", @@ -1176,6 +1179,73 @@ "node": ">=10.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", diff --git a/package.json b/package.json index 676d0de6..c10059d6 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev": "TZ=UTC next dev --turbo", "publish-lexicons": "tsx lexicons/publish.ts", "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", - "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/content.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* ./lexicons/site/*/* ./lexicons/site/*/*/* ./lexicons/parts/*/* ./lexicons/parts/*/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api", + "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/publicationPage.json ./lexicons/pub/leaflet/content.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* ./lexicons/site/*/* ./lexicons/site/*/*/* ./lexicons/parts/*/* ./lexicons/parts/*/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api", "wrangler-dev": "wrangler dev", "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", @@ -30,6 +30,9 @@ "@atproto/syntax": "^0.3.3", "@atproto/xrpc": "^0.7.5", "@atproto/xrpc-server": "^0.9.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@hono/node-server": "^1.14.3", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", diff --git a/src/replicache/attributes.ts b/src/replicache/attributes.ts index eb59fa17..37182432 100644 --- a/src/replicache/attributes.ts +++ b/src/replicache/attributes.ts @@ -87,6 +87,14 @@ const BlockAttributes = { type: "bluesky-post", cardinality: "one", }, + "block/standard-site-post": { + type: "string", + cardinality: "one", + }, + "standard-site-post/size": { + type: "standard-site-post-size-union", + cardinality: "one", + }, "block/math": { type: "string", cardinality: "one", @@ -206,6 +214,21 @@ const PollBlockAttributes = { }, } as const; +const PostsListBlockAttributes = { + "posts-list/view": { + type: "posts-list-view-union", + cardinality: "one", + }, + "posts-list/highlight-first-post": { + type: "boolean", + cardinality: "one", + }, + "posts-list/filter-tag": { + type: "string", + cardinality: "one", + }, +} as const; + export const ThemeAttributes = { "theme/heading-font": { type: "string", @@ -297,6 +320,7 @@ export const Attributes = { ...ButtonBlockAttributes, ...ImageBlockAttributes, ...PollBlockAttributes, + ...PostsListBlockAttributes, }; export type Attributes = typeof Attributes; export type Attribute = keyof Attributes; @@ -370,10 +394,12 @@ export type Data = { | "button" | "poll" | "bluesky-post" + | "standard-site-post" | "math" | "code" | "blockquote" - | "horizontal-rule"; + | "horizontal-rule" + | "posts-list"; }; "canvas-pattern-union": { type: "canvas-pattern-union"; @@ -383,6 +409,14 @@ export type Data = { type: "list-style-union"; value: "ordered" | "unordered"; }; + "posts-list-view-union": { + type: "posts-list-view-union"; + value: "compact" | "full"; + }; + "standard-site-post-size-union": { + type: "standard-site-post-size-union"; + value: "large" | "medium" | "small"; + }; color: { type: "color"; value: string }; }[(typeof Attributes)[A]["type"]]; export type FilterAttributes> = diff --git a/src/utils/addLinkBlock.ts b/src/utils/addLinkBlock.ts index 36965e61..a5e29ea6 100644 --- a/src/utils/addLinkBlock.ts +++ b/src/utils/addLinkBlock.ts @@ -9,6 +9,25 @@ import { AtpAgent } from "@atproto/api"; import { v7 } from "uuid"; import { getAspectRatio } from "src/utils/aspectRatio"; +export async function assertStandardSitePostFacts( + rep: Replicache, + entityID: string, + uri: string, +) { + await rep.mutate.assertFact([ + { + entity: entityID, + attribute: "block/type", + data: { type: "block-type-union", value: "standard-site-post" }, + }, + { + entity: entityID, + attribute: "block/standard-site-post", + data: { type: "string", value: uri }, + }, + ]); +} + export async function addLinkBlock( url: string, entityID: string, @@ -40,6 +59,12 @@ export async function addLinkBlock( return; } let data = await (res.json() as LinkPreviewMetadataResult); + + if (data.leafletPost) { + await assertStandardSitePostFacts(rep, entityID, data.leafletPost.uri); + return; + } + if (!data.success) { await rep?.mutate.assertFact([ { diff --git a/src/utils/factsToPagesRecord.ts b/src/utils/factsToPagesRecord.ts index 89a3d328..8bc70422 100644 --- a/src/utils/factsToPagesRecord.ts +++ b/src/utils/factsToPagesRecord.ts @@ -6,6 +6,7 @@ import { BlobRef } from "@atproto/lexicon"; import { PubLeafletBlocksBlockquote, PubLeafletBlocksBskyPost, + PubLeafletBlocksStandardSitePost, PubLeafletBlocksButton, PubLeafletBlocksCode, PubLeafletBlocksHeader, @@ -16,6 +17,7 @@ import { PubLeafletBlocksOrderedList, PubLeafletBlocksPage, PubLeafletBlocksPoll, + PubLeafletBlocksPostsList, PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletBlocksWebsite, @@ -335,6 +337,17 @@ export async function processBlocksToPages(opts: { }; return block; } + if (b.type === "standard-site-post") { + const [uri] = scan.eav(b.value, "block/standard-site-post"); + if (!uri) return; + const [sizeFact] = scan.eav(b.value, "standard-site-post/size"); + const block: $Typed = { + $type: ids.PubLeafletBlocksStandardSitePost, + uri: uri.data.value, + ...(sizeFact && { size: sizeFact.data.value }), + }; + return block; + } if (b.type === "horizontal-rule") { const block: $Typed = { $type: ids.PubLeafletBlocksHorizontalRule, @@ -494,6 +507,21 @@ export async function processBlocksToPages(opts: { }; return block; } + if (b.type === "posts-list") { + const [viewFact] = scan.eav(b.value, "posts-list/view"); + const [highlightFact] = scan.eav( + b.value, + "posts-list/highlight-first-post", + ); + const [filterTagFact] = scan.eav(b.value, "posts-list/filter-tag"); + const block: $Typed = { + $type: "pub.leaflet.blocks.postsList", + ...(viewFact && { view: viewFact.data.value }), + ...(highlightFact && { highlightFirstPost: highlightFact.data.value }), + ...(filterTagFact && { filterByTag: filterTagFact.data.value }), + }; + return block; + } return; } diff --git a/src/utils/getBlocksAsHTML.tsx b/src/utils/getBlocksAsHTML.tsx index 182e7ac6..c205dc08 100644 --- a/src/utils/getBlocksAsHTML.tsx +++ b/src/utils/getBlocksAsHTML.tsx @@ -98,6 +98,11 @@ const BlockTypeToHTML: { /> ); }, + "standard-site-post": async (b, tx) => { + let [uri] = await scanIndex(tx).eav(b.value, "block/standard-site-post"); + if (!uri) return null; + return
; + }, math: async (b, tx, a) => { let [math] = await scanIndex(tx).eav(b.value, "block/math"); const html = Katex.renderToString(math?.data.value || "", { @@ -202,6 +207,7 @@ const BlockTypeToHTML: { /> ); }, + "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; } diff --git a/src/utils/leafletToPublicationPageRecord.ts b/src/utils/leafletToPublicationPageRecord.ts new file mode 100644 index 00000000..e854fcf5 --- /dev/null +++ b/src/utils/leafletToPublicationPageRecord.ts @@ -0,0 +1,66 @@ +import { + PubLeafletContent, + PubLeafletPagesCanvas, + PubLeafletPagesLinearDocument, + PubLeafletPublicationPage, +} from "lexicons/api"; + +import type { Fact } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { + processBlocksToPages, + type ProcessBlocksToPagesHooks, +} from "src/utils/factsToPagesRecord"; + +export async function leafletToPublicationPageRecord(opts: { + facts: Fact[]; + root_entity: string; + publication_uri: string; + path: string; + title?: string; + publishedAt?: string; + hooks: ProcessBlocksToPagesHooks; +}): Promise { + const { pages } = await processBlocksToPages({ + facts: opts.facts, + root_entity: opts.root_entity, + hooks: opts.hooks, + }); + + const pagesArray: PubLeafletContent.Main["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[], + }; + } + return { + $type: "pub.leaflet.pages.linearDocument" as const, + id: p.id, + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], + }; + }); + + return { + $type: "pub.leaflet.publicationPage", + publication: opts.publication_uri, + path: opts.path, + ...(opts.title !== undefined && { title: opts.title }), + publishedAt: opts.publishedAt ?? new Date().toISOString(), + content: { + $type: "pub.leaflet.content", + pages: pagesArray, + }, + }; +} + +export function pathToRkey(path: string): string { + let trimmed = path.replace(/^\/+/, "").replace(/\/+$/, ""); + if (trimmed === "") return "home"; + let slug = trimmed + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "home"; +} diff --git a/src/utils/resolveStandardSitePostUrl.ts b/src/utils/resolveStandardSitePostUrl.ts new file mode 100644 index 00000000..9885ad4d --- /dev/null +++ b/src/utils/resolveStandardSitePostUrl.ts @@ -0,0 +1,72 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database, Json } from "supabase/database.types"; +import { parseStandardSitePostInput } from "components/Blocks/StandardSitePostBlock/parseStandardSitePostInput"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; + +/** + * Resolve a URL or AT URI to a standard-site-post AT URI by checking our DB. + * Handles at:// URIs, leaflet.pub patterns synchronously, and custom-domain + * URLs via DB lookup against the publications + documents tables. + */ +export async function resolveStandardSitePostUrl( + input: string, + supabase: SupabaseClient, +): Promise { + const direct = parseStandardSitePostInput(input); + if (direct) { + const { data } = await supabase + .from("documents") + .select("uri") + .eq("uri", direct) + .maybeSingle(); + return data?.uri ?? null; + } + + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + const origin = `${url.protocol}//${url.host}`; + const pathOnly = url.pathname.replace(/\/$/, "") || "/"; + + // Find a publication whose URL matches this origin. Supports both + // pub.leaflet.publication (base_path) and site.standard.publication (url). + // Descending uri order prefers site.standard.publication over pub.leaflet.publication. + const { data: publication } = await supabase + .from("publications") + .select("uri") + .or( + `record->>base_path.eq.${url.host},record->>url.eq.${origin},record->>url.eq.${origin}/`, + ) + .order("uri", { ascending: false }) + .limit(1) + .maybeSingle(); + console.log(publication); + if (!publication) return null; + + // Find documents in that publication where data.path matches pathOnly + const { data: docsInPub } = await supabase + .from("documents_in_publications") + .select("documents(uri, data)") + .eq("publication", publication.uri); + + if (!docsInPub) return null; + + for (const row of docsInPub) { + const doc = row.documents; + if (!doc) continue; + const normalized = normalizeDocumentRecord(doc.data as Json, doc.uri); + if (!normalized) continue; + const rawPath = normalized.path + ? normalized.path.startsWith("/") + ? normalized.path + : `/${normalized.path}` + : null; + const docPath = rawPath ? rawPath.replace(/\/$/, "") || "/" : null; + console.log(docPath, pathOnly); + if (docPath === pathOnly) return doc.uri; + } + return null; +} diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 777384af..37fda631 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -1116,6 +1116,7 @@ export type Database = { email: string id: string identity_id: string | null + metadata: Json | null publication: string state: string unsubscribe_token: string @@ -1128,6 +1129,7 @@ export type Database = { email: string id?: string identity_id?: string | null + metadata?: Json | null publication: string state?: string unsubscribe_token?: string @@ -1140,6 +1142,7 @@ export type Database = { email?: string id?: string identity_id?: string | null + metadata?: Json | null publication?: string state?: string unsubscribe_token?: string @@ -1209,6 +1212,9 @@ export type Database = { metadata: Json path: string | null publication: string + record: Json | null + record_uri: string | null + sort_order: string title: string } Insert: { @@ -1219,6 +1225,9 @@ export type Database = { metadata?: Json path?: string | null publication: string + record?: Json | null + record_uri?: string | null + sort_order: string title?: string } Update: { @@ -1229,6 +1238,9 @@ export type Database = { metadata?: Json path?: string | null publication?: string + record?: Json | null + record_uri?: string | null + sort_order?: string title?: string } Relationships: [ -- 2.51.2