diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts index 0cc7ff97..19352ac3 100644 --- a/app/api/inngest/client.ts +++ b/app/api/inngest/client.ts @@ -42,7 +42,11 @@ export const events = { schema: staticSchema<{ documentUris?: string[] }>(), }), appviewSyncDocumentMetadata: eventType("appview/sync-document-metadata", { - schema: staticSchema<{ document_uri: string; bsky_post_uri?: string }>(), + schema: staticSchema<{ + document_uri: string; + bsky_post_uri?: string; + event_type?: "create" | "update"; + }>(), }), userWriteRecordsToPds: eventType("user/write-records-to-pds", { schema: staticSchema<{ diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts index 8a0348bd..1ab7965f 100644 --- a/app/api/inngest/functions/sync_document_metadata.ts +++ b/app/api/inngest/functions/sync_document_metadata.ts @@ -20,7 +20,7 @@ export const sync_document_metadata = inngest.createFunction( triggers: [events.appviewSyncDocumentMetadata], }, async ({ event, step }) => { - const { document_uri, bsky_post_uri } = event.data; + const { document_uri, bsky_post_uri, event_type } = event.data; const did = new AtUri(document_uri).host; @@ -57,55 +57,58 @@ export const sync_document_metadata = inngest.createFunction( .select(); }); - const broadcast = await step.run( - "maybe-claim-newsletter-broadcast", - async () => { - const { data: docInPub } = await supabaseServerClient - .from("documents_in_publications") - .select("publication") - .eq("document", document_uri) - .maybeSingle(); - const publication_uri = docInPub?.publication; - if (!publication_uri) return { skipped: "no_publication" as const }; - - const { data: settings } = await supabaseServerClient - .from("publication_newsletter_settings") - .select("enabled") - .eq("publication", publication_uri) - .maybeSingle(); - if (!settings?.enabled) { - return { skipped: "newsletter_not_enabled" as const }; - } - - // Composite PK on (publication, document) is the idempotency guard: - // re-runs on document updates and races with publishToPublication - // both no-op here. - const { data: inserted } = await supabaseServerClient - .from("publication_post_sends") - .upsert( - { - publication: publication_uri, - document: document_uri, - status: "pending", - }, - { onConflict: "publication,document", ignoreDuplicates: true }, - ) - .select(); - if (!inserted || inserted.length === 0) { - return { skipped: "already_sent_or_pending" as const }; - } - return { claimed: true as const, publication_uri }; - }, - ); - - if ("claimed" in broadcast) { - await step.sendEvent("send-newsletter-broadcast", { - name: "newsletter/post.send.requested", - data: { - publication_uri: broadcast.publication_uri, - document_uri, + // Only fire newsletter broadcasts on first-time document creation. An + // update to an existing post must never trigger a send — otherwise editing + // an old post after newsletters were enabled would mail subscribers a post + // they never signed up for. + if (event_type === "create") { + const broadcast = await step.run( + "maybe-claim-newsletter-broadcast", + async () => { + const { data: docInPub } = await supabaseServerClient + .from("documents_in_publications") + .select("publication") + .eq("document", document_uri) + .maybeSingle(); + const publication_uri = docInPub?.publication; + if (!publication_uri) return { skipped: "no_publication" as const }; + + const { data: settings } = await supabaseServerClient + .from("publication_newsletter_settings") + .select("enabled") + .eq("publication", publication_uri) + .maybeSingle(); + if (!settings?.enabled) { + return { skipped: "newsletter_not_enabled" as const }; + } + + const { data: inserted } = await supabaseServerClient + .from("publication_post_sends") + .upsert( + { + publication: publication_uri, + document: document_uri, + status: "pending", + }, + { onConflict: "publication,document", ignoreDuplicates: true }, + ) + .select(); + if (!inserted || inserted.length === 0) { + return { skipped: "already_sent_or_pending" as const }; + } + return { claimed: true as const, publication_uri }; }, - }); + ); + + if ("claimed" in broadcast) { + await step.sendEvent("send-newsletter-broadcast", { + name: "newsletter/post.send.requested", + data: { + publication_uri: broadcast.publication_uri, + document_uri, + }, + }); + } } if (!bsky_post_uri) { diff --git a/appview/index.ts b/appview/index.ts index 782ad144..5b9b0fc5 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -118,6 +118,7 @@ async function handleEvent(evt: Event) { data: { document_uri: evt.uri.toString(), bsky_post_uri: record.value.postRef?.uri, + event_type: evt.event, }, }); if (record.value.publication) { @@ -285,6 +286,7 @@ async function handleEvent(evt: Event) { data: { document_uri: evt.uri.toString(), bsky_post_uri: record.value.bskyPostRef?.uri, + event_type: evt.event, }, }); -- 2.51.2 From 9f344c8258dafd51d54059fad3316fd8c95c0705 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 19 May 2026 18:48:24 -0400 Subject: [PATCH 02/10] add migration to add publication page records to table --- .../migrations/20260519000000_add_publication_pages_record.sql | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 supabase/migrations/20260519000000_add_publication_pages_record.sql diff --git a/supabase/migrations/20260519000000_add_publication_pages_record.sql b/supabase/migrations/20260519000000_add_publication_pages_record.sql new file mode 100644 index 00000000..5e0bbf80 --- /dev/null +++ b/supabase/migrations/20260519000000_add_publication_pages_record.sql @@ -0,0 +1,3 @@ +alter table "public"."publication_pages" + add column "record" jsonb, + add column "record_uri" text; -- 2.51.2 From 0e914bdf28143251e3b06d8c9980b2097d7ed0c5 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 20 May 2026 11:59:53 -0400 Subject: [PATCH 03/10] migration to add sort order to publication pages --- .../20260520000000_add_sort_order_to_publication_pages.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 supabase/migrations/20260520000000_add_sort_order_to_publication_pages.sql diff --git a/supabase/migrations/20260520000000_add_sort_order_to_publication_pages.sql b/supabase/migrations/20260520000000_add_sort_order_to_publication_pages.sql new file mode 100644 index 00000000..86307a28 --- /dev/null +++ b/supabase/migrations/20260520000000_add_sort_order_to_publication_pages.sql @@ -0,0 +1,2 @@ +alter table "public"."publication_pages" + add column "sort_order" text not null; -- 2.51.2 From 7183f0e153bad091f914730baccd42757de73c49 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 20 May 2026 22:53:03 -0400 Subject: [PATCH 04/10] handle large posts with blob content --- actions/publishToPublication.ts | 57 ++++++++++++++++- .../functions/sync_document_metadata.ts | 64 +++++++++++++++++++ appview/index.ts | 20 ++++-- lexicons/api/lexicons.ts | 17 +++++ lexicons/api/types/pub/leaflet/content.ts | 4 ++ lexicons/pub/leaflet/content.json | 19 ++++++ lexicons/src/content.ts | 17 +++++ 7 files changed, 191 insertions(+), 7 deletions(-) diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 1755090b..1bf07483 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -271,6 +271,9 @@ export async function publishToPublication({ // Create record based on the document type let record: PubLeafletDocument.Record | SiteStandardDocument.Record; + // The record we persist locally always holds the fully-inflated content; when + // we offload pages to a blob, recordForPDS gets a shrunk copy below. + let recordForPDS: PubLeafletDocument.Record | SiteStandardDocument.Record; if (documentType === "site.standard.document") { // site.standard.document format @@ -305,6 +308,34 @@ export async function publishToPublication({ pages: pagesArray, }, } satisfies SiteStandardDocument.Record; + + // If the inline pages would push the record past the PDS's per-record size + // limits, offload them to a JSON blob and reference it via blobPages. We + // also lift every BlobRef found inside the pages onto a top-level `blobs` + // array — the PDS only scans the record itself for blob references when + // deciding what to garbage-collect, so any image/etc. blob that now lives + // inside the opaque JSON blob would otherwise look orphaned. + const CONTENT_BLOB_THRESHOLD = 100 * 1024; + const inlinePagesJson = JSON.stringify(pagesArray); + const inlinePagesBytes = Buffer.byteLength(inlinePagesJson, "utf8"); + if (inlinePagesBytes > CONTENT_BLOB_THRESHOLD) { + const pagesBlob = await agent.com.atproto.repo.uploadBlob( + new Blob([inlinePagesJson], { type: "application/json" }), + { headers: { "Content-Type": "application/json" } }, + ); + const referencedBlobs = collectBlobRefs(pagesArray); + recordForPDS = { + ...record, + content: { + $type: "pub.leaflet.content" as const, + pages: [], + blobPages: pagesBlob.data.blob, + ...(referencedBlobs.length > 0 && { blobs: referencedBlobs }), + }, + }; + } else { + recordForPDS = record; + } } else { // pub.leaflet.document format (legacy) record = { @@ -328,13 +359,14 @@ export async function publishToPublication({ pages: pagesArray, publishedAt: resolvedPublishedAt, } satisfies PubLeafletDocument.Record; + recordForPDS = record; } let { data: result } = await agent.com.atproto.repo.putRecord({ rkey, repo: credentialSession.did!, - collection: record.$type, - record, + collection: recordForPDS.$type, + record: recordForPDS, validate: false, //TODO publish the lexicon so we can validate! }); @@ -429,6 +461,27 @@ export async function publishToPublication({ return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; } +// Walks an arbitrary value and returns every BlobRef instance reachable from +// it. Used to hoist image/etc. blob refs out of pages content so they remain +// referenced by the record after pages are offloaded to a JSON blob. +function collectBlobRefs(value: unknown): BlobRef[] { + const out: BlobRef[] = []; + const visit = (v: unknown) => { + if (v instanceof BlobRef) { + out.push(v); + return; + } + if (Array.isArray(v)) { + for (const item of v) visit(item); + return; + } + if (v && typeof v === "object") { + for (const item of Object.values(v)) visit(item); + } + }; + visit(value); + return out; +} async function extractThemeFromFacts( facts: Fact[], diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts index 1ab7965f..7765faa9 100644 --- a/app/api/inngest/functions/sync_document_metadata.ts +++ b/app/api/inngest/functions/sync_document_metadata.ts @@ -2,6 +2,7 @@ import { inngest, events } from "../client"; import { supabaseServerClient } from "supabase/serverClient"; import { AtpAgent, AtUri } from "@atproto/api"; import { idResolver } from "app/(home-pages)/reader/idResolver"; +import type { Json } from "supabase/database.types"; // 1m, 2m, 4m, 8m, 16m, 32m, 1h, 2h, 4h, 8h, 8h, 8h (~37h total) const SLEEP_INTERVALS = [ @@ -49,6 +50,69 @@ export const sync_document_metadata = inngest.createFunction( return { handle: handleResult.handle, deleted: true }; } + await step.run("inflate-blob-pages", async () => { + // If the publisher offloaded pages to a JSON blob (see + // publishToPublication.ts), fetch the blob, splice it back into + // content.pages, and drop blobPages so downstream readers can treat + // documents.data as fully inflated. + const { data: doc } = await supabaseServerClient + .from("documents") + .select("data") + .eq("uri", document_uri) + .single(); + if (!doc?.data || typeof doc.data !== "object") return { skipped: true }; + + const record = doc.data as Record; + if (record.$type !== "site.standard.document") return { skipped: true }; + const content = record.content as Record | undefined; + const blobPages = content?.blobPages as + | { ref?: { $link?: string }; mimeType?: string } + | undefined; + if (!content || !blobPages) return { skipped: true }; + + const cid = blobPages.ref?.$link; + if (!cid) return { skipped: true }; + + const service = handleResult.doc?.service?.find( + (f) => f.id === "#atproto_pds", + ); + if (!service || typeof service.serviceEndpoint !== "string") { + return { skipped: "no_pds" as const }; + } + + const pdsAgent = new AtpAgent({ service: service.serviceEndpoint }); + const blobResponse = await pdsAgent.com.atproto.sync.getBlob({ + did, + cid, + }); + const inflatedPages = JSON.parse( + new TextDecoder().decode(blobResponse.data), + ); + if (!Array.isArray(inflatedPages)) { + return { skipped: "blob_not_array" as const }; + } + + // Strip both blobPages and the top-level `blobs` mirror — after inflation + // the blob refs live back inside pages, so the mirror would just be + // duplicate data. + const { blobPages: _blobPages, blobs: _blobs, ...contentWithoutBlob } = + content; + const inflatedRecord = { + ...record, + content: { + ...contentWithoutBlob, + pages: inflatedPages, + }, + }; + + await supabaseServerClient + .from("documents") + .update({ data: inflatedRecord as Json }) + .eq("uri", document_uri); + + return { inflated: true, pageCount: inflatedPages.length }; + }); + await step.run("set-indexed", async () => { return await supabaseServerClient .from("documents") diff --git a/appview/index.ts b/appview/index.ts index 5b9b0fc5..992b63b7 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -5,6 +5,7 @@ const idResolver = new IdResolver(); import { Firehose, MemoryRunner, Event } from "@atproto/sync"; import { ids } from "lexicons/api/lexicons"; import { + PubLeafletContent, PubLeafletDocument, PubLeafletGraphSubscription, PubLeafletPublication, @@ -276,11 +277,20 @@ async function handleEvent(evt: Event) { console.log(record.error); return; } - let docResult = await supabase.from("documents").upsert({ - uri: evt.uri.toString(), - data: record.value as Json, - }); - if (docResult.error) console.log(docResult.error); + // When the record offloads pages to a blob, skip the documents upsert — + // the firehose record has `pages: []` and would clobber the fully + // inflated copy that publishToPublication wrote optimistically. The + // inngest sync_document_metadata function is the writer in that case. + const hasBlobPages = + PubLeafletContent.isMain(record.value.content) && + !!record.value.content.blobPages; + if (!hasBlobPages) { + let docResult = await supabase.from("documents").upsert({ + uri: evt.uri.toString(), + data: record.value as Json, + }); + if (docResult.error) console.log(docResult.error); + } await inngest.send({ name: "appview/sync-document-metadata", data: { diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 38a10025..6a9da079 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1767,6 +1767,23 @@ export const schemaDict = { ], }, }, + blobPages: { + type: 'blob', + accept: ['application/json'], + maxSize: 5000000, + description: + 'JSON-encoded array of pages. When the inline pages array would be too large to store on the PDS, the pages are uploaded as a blob and referenced here. When set, consumers MUST ignore `pages` and use the decoded blob contents as the page array; the inline `pages` field will be empty or a stub.', + }, + blobs: { + type: 'array', + description: + 'Blobs referenced inside `blobPages`. Load-bearing when `blobPages` is set: the PDS only scans the top level of a record for blob references when deciding what to garbage-collect, so any image/etc. blob now living inside the opaque JSON blob must be mirrored here to remain referenced.', + items: { + type: 'blob', + accept: ['*/*'], + maxSize: 10000000, + }, + }, }, }, }, diff --git a/lexicons/api/types/pub/leaflet/content.ts b/lexicons/api/types/pub/leaflet/content.ts index 1dba8ce6..3f8784a5 100644 --- a/lexicons/api/types/pub/leaflet/content.ts +++ b/lexicons/api/types/pub/leaflet/content.ts @@ -20,6 +20,10 @@ export interface Main { | $Typed | { $type: string } )[] + /** JSON-encoded array of pages. When the inline pages array would be too large to store on the PDS, the pages are uploaded as a blob and referenced here. When set, consumers MUST ignore `pages` and use the decoded blob contents as the page array; the inline `pages` field will be empty or a stub. */ + blobPages?: BlobRef + /** Blobs referenced inside `blobPages`. Load-bearing when `blobPages` is set: the PDS only scans the top level of a record for blob references when deciding what to garbage-collect, so any image/etc. blob now living inside the opaque JSON blob must be mirrored here to remain referenced. */ + blobs?: BlobRef[] } const hashMain = 'main' diff --git a/lexicons/pub/leaflet/content.json b/lexicons/pub/leaflet/content.json index 7978c068..03596ffb 100644 --- a/lexicons/pub/leaflet/content.json +++ b/lexicons/pub/leaflet/content.json @@ -20,6 +20,25 @@ "pub.leaflet.pages.canvas" ] } + }, + "blobPages": { + "type": "blob", + "accept": [ + "application/json" + ], + "maxSize": 5000000, + "description": "JSON-encoded array of pages. When the inline pages array would be too large to store on the PDS, the pages are uploaded as a blob and referenced here. When set, consumers MUST ignore `pages` and use the decoded blob contents as the page array; the inline `pages` field will be empty or a stub." + }, + "blobs": { + "type": "array", + "description": "Blobs referenced inside `blobPages`. Load-bearing when `blobPages` is set: the PDS only scans the top level of a record for blob references when deciding what to garbage-collect, so any image/etc. blob now living inside the opaque JSON blob must be mirrored here to remain referenced.", + "items": { + "type": "blob", + "accept": [ + "*/*" + ], + "maxSize": 10000000 + } } } } diff --git a/lexicons/src/content.ts b/lexicons/src/content.ts index 4c8c7895..3f49d5dc 100644 --- a/lexicons/src/content.ts +++ b/lexicons/src/content.ts @@ -23,6 +23,23 @@ export const PubLeafletContent: LexiconDoc = { ], }, }, + blobPages: { + type: "blob", + accept: ["application/json"], + maxSize: 5000000, + description: + "JSON-encoded array of pages. When the inline pages array would be too large to store on the PDS, the pages are uploaded as a blob and referenced here. When set, consumers MUST ignore `pages` and use the decoded blob contents as the page array; the inline `pages` field will be empty or a stub.", + }, + blobs: { + type: "array", + description: + "Blobs referenced inside `blobPages`. Load-bearing when `blobPages` is set: the PDS only scans the top level of a record for blob references when deciding what to garbage-collect, so any image/etc. blob now living inside the opaque JSON blob must be mirrored here to remain referenced.", + items: { + type: "blob", + accept: ["*/*"], + maxSize: 10000000, + }, + }, }, }, }, -- 2.51.2 From 82190946d5d9be722f5003d1bed3d5a79aa69aa2 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 20 May 2026 20:53:58 -0700 Subject: [PATCH 05/10] 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 From 37bf481350c098a413864b21ae6e0e733f320365 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 21 May 2026 00:48:40 -0400 Subject: [PATCH 06/10] fix broken tailwind class --- app/lish/[did]/[publication]/PublicationPostItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lish/[did]/[publication]/PublicationPostItem.tsx b/app/lish/[did]/[publication]/PublicationPostItem.tsx index 78028549..38a1e26c 100644 --- a/app/lish/[did]/[publication]/PublicationPostItem.tsx +++ b/app/lish/[did]/[publication]/PublicationPostItem.tsx @@ -171,7 +171,7 @@ export function PublicationPostItemLarge(props: LargeProps) { {props.coverImageAlt
)} -- 2.51.2 From 29aad29d59a533193c869e1d801d09130f138f35 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 21 May 2026 12:19:21 -0400 Subject: [PATCH 07/10] fix padding and header things --- app/globals.css | 20 +++++++++++++++++++ .../[publication]/PublicationContent.tsx | 17 +++++++++------- .../[did]/[publication]/PublicationHeader.tsx | 12 +++-------- .../[publication]/PublicationHomeLayout.tsx | 2 +- .../[publication]/PublicationPostItem.tsx | 6 +++--- .../[publication]/PublicationStickyHeader.tsx | 6 +++++- .../[rkey]/PublicationPageRenderer.tsx | 18 ++++++++++------- 7 files changed, 53 insertions(+), 28 deletions(-) diff --git a/app/globals.css b/app/globals.css index e2d5a0f5..6c3c1968 100644 --- a/app/globals.css +++ b/app/globals.css @@ -701,3 +701,23 @@ pre.shiki { .toastError a { color: var(--color-white); } + +.pubHeaderIcon { + width: 40px; + height: 40px; + transition: + width 180ms ease-out, + height 180ms ease-out; +} +.pubHeaderTitle { + font-size: 22px; + line-height: 1.2; + transition: font-size 180ms ease-out; +} +.pubStickyHeader.header-shrunk .pubHeaderIcon { + width: 24px; + height: 24px; +} +.pubStickyHeader.header-shrunk .pubHeaderTitle { + font-size: 16px; +} diff --git a/app/lish/[did]/[publication]/PublicationContent.tsx b/app/lish/[did]/[publication]/PublicationContent.tsx index 4d9ac966..424963c1 100644 --- a/app/lish/[did]/[publication]/PublicationContent.tsx +++ b/app/lish/[did]/[publication]/PublicationContent.tsx @@ -60,6 +60,9 @@ export const PublicationContent = ({ fakePosts?: FakePost[]; }) => { const newsletterMode = !!publication.publication_newsletter_settings?.enabled; + const navPages = (publication.publication_pages ?? []).filter( + (p) => p.record_uri, + ); const posts: PublicationPostsListPost[] = fakePosts ? [] : publication.documents_in_publications @@ -90,13 +93,13 @@ export const PublicationContent = ({ stickyHeader={ p.record_uri, - )} - activePath="/" - /> + navPages.length > 0 ? ( + + ) : undefined } > {props.publicationName} @@ -42,10 +39,7 @@ export function PublicationHeader(props: { }} > {variant === "inline" ? ( -
+
{icon} {title}
diff --git a/app/lish/[did]/[publication]/PublicationHomeLayout.tsx b/app/lish/[did]/[publication]/PublicationHomeLayout.tsx index 96a61e7d..28151805 100644 --- a/app/lish/[did]/[publication]/PublicationHomeLayout.tsx +++ b/app/lish/[did]/[publication]/PublicationHomeLayout.tsx @@ -12,7 +12,7 @@ export function PublicationHomeLayout(props: { let inner = ( <> {props.stickyHeader} -
+
{props.children}
diff --git a/app/lish/[did]/[publication]/PublicationPostItem.tsx b/app/lish/[did]/[publication]/PublicationPostItem.tsx index 38a1e26c..99f3d846 100644 --- a/app/lish/[did]/[publication]/PublicationPostItem.tsx +++ b/app/lish/[did]/[publication]/PublicationPostItem.tsx @@ -99,7 +99,7 @@ export function PublicationPostItemMedium(props: MediumProps) { const hasCoverImage = !!props.coverImageSrc; return ( <> -
+
{props.title && ( @@ -117,11 +117,11 @@ export function PublicationPostItemMedium(props: MediumProps) { />
{hasCoverImage && ( -
+
{props.coverImageAlt
)} diff --git a/app/lish/[did]/[publication]/PublicationStickyHeader.tsx b/app/lish/[did]/[publication]/PublicationStickyHeader.tsx index d2bef4ad..f3608108 100644 --- a/app/lish/[did]/[publication]/PublicationStickyHeader.tsx +++ b/app/lish/[did]/[publication]/PublicationStickyHeader.tsx @@ -10,8 +10,10 @@ export function PublicationStickyHeader(props: { let ref = useRef(null); let pathname = usePathname(); let sticky = props.sticky ?? true; + let hasNav = !!props.nav; useEffect(() => { + if (!hasNav) return; let el = ref.current; let parent = el?.parentElement; if (!el || !parent) return; @@ -20,6 +22,7 @@ export function PublicationStickyHeader(props: { let writeProgress = (target: HTMLElement) => { let p = Math.min(Math.max(target.scrollTop / 100, 0), 1); el!.style.setProperty("--header-shrink", String(p)); + el!.classList.toggle("header-shrunk", target.scrollTop > 32); }; let schedule = (target: HTMLElement) => { if (rafId !== null) return; @@ -51,10 +54,11 @@ export function PublicationStickyHeader(props: { if (rafId !== null) cancelAnimationFrame(rafId); parent.removeEventListener("scroll", onScroll, true); }; - }, []); + }, [hasNav]); useEffect(() => { ref.current?.style.setProperty("--header-shrink", "0"); + ref.current?.classList.remove("header-shrunk"); }, [pathname]); return ( diff --git a/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx index 68e3a2ca..55bb5592 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx @@ -156,6 +156,10 @@ export async function PublicationPageRenderer({ recommendsCount: 0, }; + const navPages = (publication.publication_pages ?? []).filter( + (p) => p.record_uri, + ); + return ( @@ -177,13 +181,13 @@ export async function PublicationPageRenderer({ stickyHeader={ p.record_uri, - )} - activePath={page.path} - /> + navPages.length > 0 ? ( + + ) : undefined } > Date: Thu, 21 May 2026 12:46:40 -0400 Subject: [PATCH 08/10] publish highlight colors in post --- .../[rkey]/Blocks/TextBlockCore.tsx | 43 ++++++++++++-- emails/post.tsx | 29 ++++++++- lexicons/api/lexicons.ts | 10 +++- .../api/types/pub/leaflet/richtext/facet.ts | 5 ++ lexicons/pub/leaflet/richtext/facet.json | 10 +++- lexicons/src/facet.ts | 5 +- src/utils/factsToPagesRecord.ts | 59 ++++++++++++++++++- 7 files changed, 147 insertions(+), 14 deletions(-) diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx index d3125de2..72f3e0e0 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx @@ -1,7 +1,22 @@ import { UnicodeString } from "@atproto/api"; import { PubLeafletRichtextFacet } from "lexicons/api"; import { AtMentionLink } from "components/AtMentionLink"; -import { ReactNode } from "react"; +import { CSSProperties, ReactNode } from "react"; + +function highlightFacetToStyle( + highlight: PubLeafletRichtextFacet.Highlight | undefined, +): CSSProperties | undefined { + const color = highlight?.color; + if (!color || typeof color !== "object") return undefined; + const { r, g, b } = color as { r?: number; g?: number; b?: number }; + if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") { + return undefined; + } + const a = (color as { a?: number }).a; + const backgroundColor = + typeof a === "number" ? `rgba(${r}, ${g}, ${b}, ${a / 100})` : `rgb(${r}, ${g}, ${b})`; + return { backgroundColor }; +} type Facet = PubLeafletRichtextFacet.Main; @@ -48,6 +63,7 @@ export function TextBlockCore(props: TextBlockCoreProps) { let isHighlighted = segment.facet?.find( PubLeafletRichtextFacet.isHighlight, ); + let highlightStyle = highlightFacetToStyle(isHighlighted); if (isFootnote) { let fnIndex = props.footnoteIndexMap?.get(isFootnote.footnoteId) ?? 0; @@ -91,7 +107,7 @@ export function TextBlockCore(props: TextBlockCoreProps) { ${isItalic ? "italic" : ""} ${isUnderline ? "underline" : ""} ${isStrikethrough ? "line-through decoration-tertiary" : ""} - ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); + ${isHighlighted ? (highlightStyle ? "highlight" : "highlight bg-highlight-1") : ""}`.replaceAll("\n", " "); // Split text by newlines and insert
tags const textParts = segment.text.split("\n"); @@ -103,7 +119,12 @@ export function TextBlockCore(props: TextBlockCoreProps) { if (isCode) { children.push( - + {renderedText} , ); @@ -112,7 +133,9 @@ export function TextBlockCore(props: TextBlockCoreProps) { if (DidMentionRenderer) { children.push( - {renderedText} + + {renderedText} + , ); } else { @@ -124,7 +147,9 @@ export function TextBlockCore(props: TextBlockCoreProps) { target="_blank" className="no-underline" > - {renderedText} + + {renderedText} +
, ); } @@ -146,13 +171,19 @@ export function TextBlockCore(props: TextBlockCoreProps) { href={link.uri.trim()} className={`text-accent-contrast hover:underline ${className}`} target="_blank" + style={highlightStyle} > {renderedText} , ); } else { children.push( - + {renderedText} , ); diff --git a/emails/post.tsx b/emails/post.tsx index bf8b72ed..c7af696f 100644 --- a/emails/post.tsx +++ b/emails/post.tsx @@ -40,6 +40,7 @@ import { MailHead, makeEmailIconUrl, makeStaticUrl, + mixRgb, resolveColors, type EmailTheme, type ResolvedColors, @@ -1388,6 +1389,27 @@ const resolveAtMentionHref = ( } }; +function highlightFacetBackground( + highlight: PubLeafletRichtextFacet.Highlight | undefined, +): string | undefined { + const color = highlight?.color; + if (!color || typeof color !== "object") return undefined; + const { r, g, b } = color as { r?: number; g?: number; b?: number }; + if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") { + return undefined; + } + const a = (color as { a?: number }).a; + return typeof a === "number" + ? `rgba(${r}, ${g}, ${b}, ${a / 100})` + : `rgb(${r}, ${g}, ${b})`; +} + +// Mirrors the web's default --highlight-1: +// color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%) +// Gmail strips color-mix(), so we resolve it to a literal rgb() at render time. +const defaultHighlightBackground = (theme: EmailTheme): string => + mixRgb(theme.accentBackground, theme.pageBackground, 75); + export const RichTextSpans = ({ plaintext, facets, @@ -1414,7 +1436,10 @@ export const RichTextSpans = ({ PubLeafletRichtextFacet.isStrikethrough, ); const isCode = !!features?.find(PubLeafletRichtextFacet.isCode); - const isHighlight = !!features?.find(PubLeafletRichtextFacet.isHighlight); + const highlight = features?.find(PubLeafletRichtextFacet.isHighlight); + const isHighlight = !!highlight; + const highlightBackground = + highlightFacetBackground(highlight) ?? defaultHighlightBackground(theme); const decorations: string[] = []; if (isUnderline) decorations.push("underline"); @@ -1427,7 +1452,7 @@ export const RichTextSpans = ({ backgroundColor: isCode ? "rgba(0,0,0,0.06)" : isHighlight - ? "#fff3a3" + ? highlightBackground : undefined, fontFamily: isCode ? "monospace" : undefined, padding: isCode ? "1px 4px" : undefined, diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 9bd49790..3afca7b0 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -2489,7 +2489,15 @@ export const schemaDict = { type: 'object', description: 'Facet feature for highlighted text.', required: [], - properties: {}, + properties: { + color: { + type: 'union', + refs: [ + 'lex:pub.leaflet.theme.color#rgba', + 'lex:pub.leaflet.theme.color#rgb', + ], + }, + }, }, underline: { type: 'object', diff --git a/lexicons/api/types/pub/leaflet/richtext/facet.ts b/lexicons/api/types/pub/leaflet/richtext/facet.ts index 576c574d..8b6952eb 100644 --- a/lexicons/api/types/pub/leaflet/richtext/facet.ts +++ b/lexicons/api/types/pub/leaflet/richtext/facet.ts @@ -9,6 +9,7 @@ import { is$typed as _is$typed, type OmitKey, } from '../../../../util' +import type * as PubLeafletThemeColor from '../theme/color' const is$typed = _is$typed, validate = _validate @@ -128,6 +129,10 @@ export function validateCode(v: V) { /** Facet feature for highlighted text. */ export interface Highlight { $type?: 'pub.leaflet.richtext.facet#highlight' + color?: + | $Typed + | $Typed + | { $type: string } } const hashHighlight = 'highlight' diff --git a/lexicons/pub/leaflet/richtext/facet.json b/lexicons/pub/leaflet/richtext/facet.json index 9520868a..f120f859 100644 --- a/lexicons/pub/leaflet/richtext/facet.json +++ b/lexicons/pub/leaflet/richtext/facet.json @@ -105,7 +105,15 @@ "type": "object", "description": "Facet feature for highlighted text.", "required": [], - "properties": {} + "properties": { + "color": { + "type": "union", + "refs": [ + "pub.leaflet.theme.color#rgba", + "pub.leaflet.theme.color#rgb" + ] + } + } }, "underline": { "type": "object", diff --git a/lexicons/src/facet.ts b/lexicons/src/facet.ts index 00e19d57..7cc56bac 100644 --- a/lexicons/src/facet.ts +++ b/lexicons/src/facet.ts @@ -1,4 +1,5 @@ import { LexiconDoc } from "@atproto/lexicon"; +import { ColorUnion } from "./theme"; const FacetItems: LexiconDoc["defs"] = { link: { type: "object", @@ -34,7 +35,9 @@ const FacetItems: LexiconDoc["defs"] = { type: "object", description: "Facet feature for highlighted text.", required: [], - properties: {}, + properties: { + color: ColorUnion, + }, }, underline: { type: "object", diff --git a/src/utils/factsToPagesRecord.ts b/src/utils/factsToPagesRecord.ts index 8bc70422..a6b2af67 100644 --- a/src/utils/factsToPagesRecord.ts +++ b/src/utils/factsToPagesRecord.ts @@ -35,6 +35,9 @@ import { scanIndexLocal } from "src/replicache/utils"; import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; +import { ColorToRGB } from "components/ThemeManager/colorToLexicons"; +import { ThemeDefaults } from "components/ThemeManager/themeUtils"; +import { parseColor } from "@react-stately/color"; type ExcludeString = T extends string ? string extends T @@ -75,6 +78,35 @@ export type ProcessBlocksToPagesResult = { }[]; }; +function resolveHighlightColors( + scan: ReturnType, + root_entity: string, +): HighlightColors { + const result: HighlightColors = {}; + const slots: Array<{ + slot: "2" | "3"; + attribute: "theme/highlight-2" | "theme/highlight-3"; + }> = [ + { slot: "2", attribute: "theme/highlight-2" }, + { slot: "3", attribute: "theme/highlight-3" }, + ]; + for (const { slot, attribute } of slots) { + const stored = scan.eav(root_entity, attribute)?.[0]?.data.value; + // Mirror useColorAttribute's fallback: theme/highlight-2 and -3 facts are + // only written when the user opens the color picker, so an uncustomized + // leaflet has no fact and the editor shows the JS default. Without this + // fallback, slot-2 and slot-3 highlights would publish without a color + // and collapse to slot-1 styling on render. + const colorString = stored ? `hsba(${stored})` : ThemeDefaults[attribute]; + try { + result[slot] = ColorToRGB(parseColor(colorString)); + } catch { + // ignore unparseable color values + } + } + return result; +} + export async function processBlocksToPages(opts: { facts: Fact[]; root_entity: string; @@ -84,6 +116,8 @@ export async function processBlocksToPages(opts: { const scan = scanIndexLocal(facts); const pages: ProcessBlocksToPagesResult["pages"] = []; + const highlightColors = resolveHighlightColors(scan, root_entity); + const firstEntity = scan.eav(root_entity, "root/page")?.[0]; if (!firstEntity) throw new Error("No root page"); @@ -276,7 +310,12 @@ export async function processBlocksToPages(opts: { Y.applyUpdate(doc, update); const nodes = doc.getXmlElement("prosemirror").toArray(); const plaintext = YJSFragmentToString(nodes[0]); - const { facets } = YJSFragmentToFacets(nodes[0]); + const { facets } = YJSFragmentToFacets( + nodes[0], + 0, + undefined, + highlightColors, + ); return { plaintext, facets }; }; const getBlockContent = (b: string) => { @@ -292,6 +331,7 @@ export async function processBlocksToPages(opts: { nodes[0], 0, footnoteContentResolver, + highlightColors, ); return [stringValue, facets] as const; }; @@ -570,6 +610,10 @@ export async function processBlocksToPages(opts: { } } +export type HighlightColors = Partial< + Record<"1" | "2" | "3", PubLeafletRichtextFacet.Highlight["color"]> +>; + export function YJSFragmentToFacets( node: Y.XmlElement | Y.XmlText | Y.XmlHook, byteOffset: number = 0, @@ -577,6 +621,7 @@ export function YJSFragmentToFacets( plaintext: string; facets: PubLeafletRichtextFacet.Main[]; }, + highlightColors?: HighlightColors, ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { if (node.constructor === Y.XmlElement) { if (node.nodeName === "footnote") { @@ -654,6 +699,7 @@ export function YJSFragmentToFacets( child, currentOffset, footnoteContentResolver, + highlightColors, ); allFacets.push(...result.facets); currentOffset += result.byteLength; @@ -683,8 +729,15 @@ export function YJSFragmentToFacets( if (d.attributes?.code) facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); - if (d.attributes?.highlight) - facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" }); + if (d.attributes?.highlight) { + const slot = d.attributes.highlight.color as "1" | "2" | "3"; + const resolvedColor = + slot && slot !== "1" ? highlightColors?.[slot] : undefined; + facet.features.push({ + $type: "pub.leaflet.richtext.facet#highlight", + ...(resolvedColor ? { color: resolvedColor } : {}), + }); + } if (d.attributes?.underline) facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" }); if (d.attributes?.strong) -- 2.51.2 From ee6e0beb13a1da1f4d5eaafb201f0f2075c8cf53 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 21 May 2026 13:31:13 -0400 Subject: [PATCH 09/10] handle updating pub records in bskypost publish flow --- actions/publishToPublication.ts | 58 ++--------------- app/[leaflet_id]/publish/publishBskyPost.ts | 8 ++- src/utils/offloadPagesToBlob.ts | 69 +++++++++++++++++++++ 3 files changed, 82 insertions(+), 53 deletions(-) create mode 100644 src/utils/offloadPagesToBlob.ts diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 1bf07483..988f4175 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -24,6 +24,7 @@ import { Json } from "supabase/database.types"; import { Lock } from "src/utils/lock"; import type { PubLeafletPublication } from "lexicons/api"; import { processBlocksToPages } from "src/utils/factsToPagesRecord"; +import { maybeOffloadPagesToBlob } from "src/utils/offloadPagesToBlob"; import { normalizeDocumentRecord, type NormalizedDocument, @@ -281,7 +282,7 @@ export async function publishToPublication({ const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; - record = { + const siteRecord: SiteStandardDocument.Record = { $type: "site.standard.document", title: title || "", site: siteUri, @@ -307,35 +308,10 @@ export async function publishToPublication({ $type: "pub.leaflet.content" as const, pages: pagesArray, }, - } satisfies SiteStandardDocument.Record; - - // If the inline pages would push the record past the PDS's per-record size - // limits, offload them to a JSON blob and reference it via blobPages. We - // also lift every BlobRef found inside the pages onto a top-level `blobs` - // array — the PDS only scans the record itself for blob references when - // deciding what to garbage-collect, so any image/etc. blob that now lives - // inside the opaque JSON blob would otherwise look orphaned. - const CONTENT_BLOB_THRESHOLD = 100 * 1024; - const inlinePagesJson = JSON.stringify(pagesArray); - const inlinePagesBytes = Buffer.byteLength(inlinePagesJson, "utf8"); - if (inlinePagesBytes > CONTENT_BLOB_THRESHOLD) { - const pagesBlob = await agent.com.atproto.repo.uploadBlob( - new Blob([inlinePagesJson], { type: "application/json" }), - { headers: { "Content-Type": "application/json" } }, - ); - const referencedBlobs = collectBlobRefs(pagesArray); - recordForPDS = { - ...record, - content: { - $type: "pub.leaflet.content" as const, - pages: [], - blobPages: pagesBlob.data.blob, - ...(referencedBlobs.length > 0 && { blobs: referencedBlobs }), - }, - }; - } else { - recordForPDS = record; - } + }; + record = siteRecord; + + recordForPDS = await maybeOffloadPagesToBlob(siteRecord, agent); } else { // pub.leaflet.document format (legacy) record = { @@ -461,28 +437,6 @@ export async function publishToPublication({ return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; } -// Walks an arbitrary value and returns every BlobRef instance reachable from -// it. Used to hoist image/etc. blob refs out of pages content so they remain -// referenced by the record after pages are offloaded to a JSON blob. -function collectBlobRefs(value: unknown): BlobRef[] { - const out: BlobRef[] = []; - const visit = (v: unknown) => { - if (v instanceof BlobRef) { - out.push(v); - return; - } - if (Array.isArray(v)) { - for (const item of v) visit(item); - return; - } - if (v && typeof v === "object") { - for (const item of Object.values(v)) visit(item); - } - }; - visit(value); - return out; -} - async function extractThemeFromFacts( facts: Fact[], root_entity: string, diff --git a/app/[leaflet_id]/publish/publishBskyPost.ts b/app/[leaflet_id]/publish/publishBskyPost.ts index 7edce1f1..adad4ea5 100644 --- a/app/[leaflet_id]/publish/publishBskyPost.ts +++ b/app/[leaflet_id]/publish/publishBskyPost.ts @@ -17,6 +17,7 @@ import { getWebpageImage, } from "src/utils/getMicroLinkOgImage"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; +import { maybeOffloadPagesToBlob } from "src/utils/offloadPagesToBlob"; type PublishBskyResult = | { success: true } @@ -114,11 +115,16 @@ export async function publishPostToBsky(args: { let record = args.document_record; record.bskyPostRef = post; + // The caller hands us the fully inflated record. Large docs would 413 on + // putRecord without first offloading pages to a blob (the same offload + // publishToPublication did on the initial publish). + const recordForPDS = await maybeOffloadPagesToBlob(record, agent); + let { data: result } = await agent.com.atproto.repo.putRecord({ rkey: args.rkey, repo: credentialSession.did!, collection: args.document_record.$type, - record, + record: recordForPDS, validate: false, //TODO publish the lexicon so we can validate! }); await supabaseServerClient diff --git a/src/utils/offloadPagesToBlob.ts b/src/utils/offloadPagesToBlob.ts new file mode 100644 index 00000000..92a3d6da --- /dev/null +++ b/src/utils/offloadPagesToBlob.ts @@ -0,0 +1,69 @@ +import { BlobRef } from "@atproto/lexicon"; +import type { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; + +const CONTENT_BLOB_THRESHOLD = 100 * 1024; + +// Walks an arbitrary value and returns every BlobRef instance reachable from +// it. Used to hoist image/etc. blob refs out of pages content so they remain +// referenced by the record after pages are offloaded to a JSON blob — the PDS +// only scans the record itself for blob references when deciding what to +// garbage-collect. +export function collectBlobRefs(value: unknown): BlobRef[] { + const out: BlobRef[] = []; + const visit = (v: unknown) => { + if (v instanceof BlobRef) { + out.push(v); + return; + } + if (Array.isArray(v)) { + for (const item of v) visit(item); + return; + } + if (v && typeof v === "object") { + for (const item of Object.values(v)) visit(item); + } + }; + visit(value); + return out; +} + +// If the record's inline pages would push it past the PDS's per-record size +// limits, offload them to a JSON blob and reference it via blobPages. Returns +// the record unchanged if it's already offloaded (content.blobPages is set) or +// if inline pages fit under the threshold. +// +// Any caller that round-trips a SiteStandardDocument.Record through +// putRecord — including post-publish updates like attaching bskyPostRef — +// MUST run the record through this helper first, otherwise large docs will +// 413 against the PDS even though the original publish offloaded successfully. +export async function maybeOffloadPagesToBlob( + record: SiteStandardDocument.Record, + agent: AtpBaseClient, +): Promise { + const content = record.content as Record | undefined; + if (!content) return record; + + if (content.blobPages) return record; + + const pages = content.pages; + if (!Array.isArray(pages) || pages.length === 0) return record; + + const inlinePagesJson = JSON.stringify(pages); + const inlinePagesBytes = Buffer.byteLength(inlinePagesJson, "utf8"); + if (inlinePagesBytes <= CONTENT_BLOB_THRESHOLD) return record; + + const pagesBlob = await agent.com.atproto.repo.uploadBlob( + new Blob([inlinePagesJson], { type: "application/json" }), + { headers: { "Content-Type": "application/json" } }, + ); + const referencedBlobs = collectBlobRefs(pages); + return { + ...record, + content: { + $type: "pub.leaflet.content" as const, + pages: [], + blobPages: pagesBlob.data.blob, + ...(referencedBlobs.length > 0 && { blobs: referencedBlobs }), + }, + }; +} -- 2.51.2 From 5dd1c964fd3e8ec52214c8829d260c137a8c75d6 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 21 May 2026 14:10:40 -0400 Subject: [PATCH 10/10] fix headers --- .../[publication]/PublicationContent.tsx | 77 +++++++++++-------- .../[publication]/PublicationFullHeader.tsx | 11 +++ .../[publication]/PublicationStickyHeader.tsx | 6 +- .../[rkey]/PublicationPageRenderer.tsx | 43 +++++++---- .../edit/[[...route]]/layout.tsx | 14 ++-- 5 files changed, 94 insertions(+), 57 deletions(-) create mode 100644 app/lish/[did]/[publication]/PublicationFullHeader.tsx diff --git a/app/lish/[did]/[publication]/PublicationContent.tsx b/app/lish/[did]/[publication]/PublicationContent.tsx index 424963c1..ab814557 100644 --- a/app/lish/[did]/[publication]/PublicationContent.tsx +++ b/app/lish/[did]/[publication]/PublicationContent.tsx @@ -4,6 +4,7 @@ import { PublicationAuthor } from "./PublicationAuthor"; import { PublicationHeader } from "./PublicationHeader"; import { PublicationNav } from "./PublicationNav"; import { PublicationStickyHeader } from "./PublicationStickyHeader"; +import { PublicationFullHeader } from "./PublicationFullHeader"; import { normalizePublicationRecord, normalizeDocumentRecord, @@ -91,45 +92,55 @@ export const PublicationContent = ({ uri={publication.uri} showPageBackground={!!showPageBackground} stickyHeader={ - 0 ? ( + navPages.length > 0 ? ( + - ) : undefined - } - > - - ) : undefined - } - subscribeButton={ -
- -
} - /> -
+ > + +
+ ) : ( + + + ) : undefined + } + subscribeButton={ +
+ +
+ } + /> +
+ ) } > +
+ {props.children} +
+
+ ); +} diff --git a/app/lish/[did]/[publication]/PublicationStickyHeader.tsx b/app/lish/[did]/[publication]/PublicationStickyHeader.tsx index f3608108..dd653e86 100644 --- a/app/lish/[did]/[publication]/PublicationStickyHeader.tsx +++ b/app/lish/[did]/[publication]/PublicationStickyHeader.tsx @@ -4,16 +4,14 @@ import { usePathname } from "next/navigation"; export function PublicationStickyHeader(props: { sticky?: boolean; - nav?: React.ReactNode; + nav: React.ReactNode; children: React.ReactNode; }) { let ref = useRef(null); let pathname = usePathname(); let sticky = props.sticky ?? true; - let hasNav = !!props.nav; useEffect(() => { - if (!hasNav) return; let el = ref.current; let parent = el?.parentElement; if (!el || !parent) return; @@ -54,7 +52,7 @@ export function PublicationStickyHeader(props: { if (rafId !== null) cancelAnimationFrame(rafId); parent.removeEventListener("scroll", onScroll, true); }; - }, [hasNav]); + }, []); useEffect(() => { ref.current?.style.setProperty("--header-shrink", "0"); diff --git a/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx index 55bb5592..2c2bcfdd 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx @@ -26,6 +26,7 @@ import { PublicationNav } from "../PublicationNav"; import { PublicationHeader } from "../PublicationHeader"; import { PublicationHomeLayout } from "../PublicationHomeLayout"; import { PublicationStickyHeader } from "../PublicationStickyHeader"; +import { PublicationFullHeader } from "../PublicationFullHeader"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; @@ -179,27 +180,39 @@ export async function PublicationPageRenderer({ uri={publication.uri} showPageBackground={showPageBackground} stickyHeader={ - 0 ? ( + navPages.length > 0 ? ( + - ) : undefined - } - > - - + > + + + ) : ( + + + + ) } >
diff --git a/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx b/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx index 7e2b6e49..0aa90e49 100644 --- a/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx +++ b/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx @@ -97,7 +97,15 @@ export default async function PublicationEditLayout(props: { publicationName={params.publication} />
- + + } + > -
{props.children}