From 3e9583ca44883a77a09f220ff526c12f12a0f342 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 19 May 2026 15:27:15 -0700 Subject: [PATCH 01/10] don't trigger newsletter sends on firehose update events (#302) The firehose UPDATE path was also running the newsletter claim/send logic. Combined with the fact that the idempotency row in publication_post_sends is only inserted when newsletters are enabled at upsert time, this meant that editing an old post after newsletters were turned on for the publication would mail the post out to subscribers retroactively. Gate the newsletter block on event_type === "create" so only firehose creates can fire sends. Metadata sync (indexed flag, bsky like counts, bridgy deletion) still runs on updates as before. Co-authored-by: Claude --- app/api/inngest/client.ts | 6 +- .../functions/sync_document_metadata.ts | 101 +++++++++--------- appview/index.ts | 2 + 3 files changed, 59 insertions(+), 50 deletions(-) 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}
-- 2.51.2