a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* wip publish publication pages

* add standard-site-post block size setting: small/medium/large

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
Co-authored-by: celine <celine@hyperlink.academy>

authored by

Jared Pereira
Claude Opus 4.7 (1M context)
celine
and committed by
GitHub
(May 20, 2026, 11:53 PM EDT) 82190946 7183f0e1

+4033 -409
+63 -29
actions/createNewLeaflet.ts
··· 14 14 import { redirect } from "next/navigation"; 15 15 import { v7 } from "uuid"; 16 16 import { sql, eq, and } from "drizzle-orm"; 17 + import { generateKeyBetween } from "fractional-indexing"; 17 18 import { cookies } from "next/headers"; 18 19 import { pool } from "supabase/pool"; 19 20 21 + type DefaultBlockType = "h1" | "text" | "posts-list"; 22 + 20 23 export async function createNewLeaflet({ 21 24 pageType, 22 25 redirectUser, 23 26 firstBlockType, 27 + firstBlocks, 28 + addToHomepage = true, 24 29 }: { 25 30 pageType: "canvas" | "doc"; 26 31 redirectUser: boolean; 27 32 firstBlockType?: "h1" | "text"; 33 + firstBlocks?: DefaultBlockType[]; 34 + addToHomepage?: boolean; 28 35 }) { 29 36 let auth_token = (await cookies()).get("auth_token")?.value; 30 37 const client = await pool.connect(); ··· 105 112 }, 106 113 ]); 107 114 } else { 108 - await tx.insert(facts).values([ 109 - { 115 + let blockSpecs: DefaultBlockType[] = 116 + firstBlocks ?? [firstBlockType === "text" ? "text" : "h1"]; 117 + 118 + // Reuse the pre-created blockEntity for the first block so the rest of 119 + // the flow (e.g. focusFirstBlock) still has an entity to target. 120 + let blockEntities = blockSpecs.map((_, i) => 121 + i === 0 ? blockEntity.id : v7(), 122 + ); 123 + 124 + if (blockEntities.length > 1) { 125 + await tx 126 + .insert(entities) 127 + .values(blockEntities.slice(1).map((id) => ({ set: entity_set.id, id }))); 128 + } 129 + 130 + let blockFacts: Array<{ 131 + id: string; 132 + entity: string; 133 + attribute: string; 134 + data: ReturnType<typeof sql>; 135 + }> = []; 136 + let prevPosition: string | null = null; 137 + blockSpecs.forEach((spec, i) => { 138 + let entity = blockEntities[i]; 139 + let position = generateKeyBetween(prevPosition, null); 140 + prevPosition = position; 141 + blockFacts.push({ 110 142 id: v7(), 111 143 entity: first_page.id, 112 144 attribute: "card/block", 113 - data: sql`${{ type: "ordered-reference", value: blockEntity.id, position: "a0" }}::jsonb`, 114 - }, 115 - ...(firstBlockType === "text" 116 - ? [ 117 - { 118 - id: v7(), 119 - entity: blockEntity.id, 120 - attribute: "block/type", 121 - data: sql`${{ type: "block-type-union", value: "text" }}::jsonb`, 122 - }, 123 - ] 124 - : [ 125 - { 126 - id: v7(), 127 - entity: blockEntity.id, 128 - attribute: "block/type", 129 - data: sql`${{ type: "block-type-union", value: "heading" }}::jsonb`, 130 - }, 131 - { 132 - id: v7(), 133 - entity: blockEntity.id, 134 - attribute: "block/heading-level", 135 - data: sql`${{ type: "number", value: 1 }}::jsonb`, 136 - }, 137 - ]), 138 - ]); 145 + data: sql`${{ type: "ordered-reference", value: entity, position }}::jsonb`, 146 + }); 147 + if (spec === "h1") { 148 + blockFacts.push( 149 + { 150 + id: v7(), 151 + entity, 152 + attribute: "block/type", 153 + data: sql`${{ type: "block-type-union", value: "heading" }}::jsonb`, 154 + }, 155 + { 156 + id: v7(), 157 + entity, 158 + attribute: "block/heading-level", 159 + data: sql`${{ type: "number", value: 1 }}::jsonb`, 160 + }, 161 + ); 162 + } else { 163 + blockFacts.push({ 164 + id: v7(), 165 + entity, 166 + attribute: "block/type", 167 + data: sql`${{ type: "block-type-union", value: spec }}::jsonb`, 168 + }); 169 + } 170 + }); 171 + 172 + await tx.insert(facts).values(blockFacts); 139 173 } 140 - if (auth_token) { 174 + if (auth_token && addToHomepage) { 141 175 await tx.execute(sql` 142 176 WITH auth_token AS ( 143 177 SELECT identities.id as identity_id
+57
actions/createPublicationPage.ts
··· 1 + "use server"; 2 + import { generateKeyBetween } from "fractional-indexing"; 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { createNewLeaflet } from "./createNewLeaflet"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + 7 + export async function createPublicationPage(args: { 8 + publication_uri: string; 9 + path: string; 10 + title?: string; 11 + sort_order?: string; 12 + includePostsList?: boolean; 13 + }) { 14 + let identity = await getIdentityData(); 15 + if (!identity || !identity.atp_did) return null; 16 + 17 + let { data: publication } = await supabaseServerClient 18 + .from("publications") 19 + .select("uri, identity_did") 20 + .eq("uri", args.publication_uri) 21 + .single(); 22 + if (!publication || publication.identity_did !== identity.atp_did) 23 + return null; 24 + 25 + let sort_order = args.sort_order; 26 + if (!sort_order) { 27 + let { data: last } = await supabaseServerClient 28 + .from("publication_pages") 29 + .select("sort_order") 30 + .eq("publication", args.publication_uri) 31 + .order("sort_order", { ascending: false }) 32 + .limit(1) 33 + .maybeSingle(); 34 + sort_order = generateKeyBetween(last?.sort_order ?? null, null); 35 + } 36 + 37 + let leaflet_src = await createNewLeaflet({ 38 + pageType: "doc", 39 + redirectUser: false, 40 + firstBlocks: args.includePostsList ? ["text", "posts-list"] : ["text"], 41 + addToHomepage: false, 42 + }); 43 + 44 + let { data: page } = await supabaseServerClient 45 + .from("publication_pages") 46 + .insert({ 47 + publication: args.publication_uri, 48 + leaflet_src, 49 + path: args.path, 50 + title: args.title ?? "", 51 + sort_order, 52 + }) 53 + .select("*") 54 + .single(); 55 + 56 + return page; 57 + }
+146
actions/publishPublicationPages.ts
··· 1 + "use server"; 2 + 3 + import { AtpBaseClient } from "lexicons/api"; 4 + import { Json } from "supabase/database.types"; 5 + import type { Fact } from "src/replicache"; 6 + import type { Attribute } from "src/replicache/attributes"; 7 + import { Lock } from "src/utils/lock"; 8 + import { OAuthSessionError, restoreOAuthSession } from "src/atproto-oauth"; 9 + import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 10 + import { getIdentityData } from "actions/getIdentityData"; 11 + import { 12 + leafletToPublicationPageRecord, 13 + pathToRkey, 14 + } from "src/utils/leafletToPublicationPageRecord"; 15 + import { supabaseServerClient } from "supabase/serverClient"; 16 + 17 + type PublishPagesResult = 18 + | { success: true; published: { id: number; uri: string }[] } 19 + | { 20 + success: false; 21 + error: OAuthSessionError | { type: "other"; message: string }; 22 + }; 23 + 24 + export async function publishPublicationPages({ 25 + publication_uri, 26 + }: { 27 + publication_uri: string; 28 + }): Promise<PublishPagesResult> { 29 + const identity = await getIdentityData(); 30 + if (!identity || !identity.atp_did) { 31 + return { 32 + success: false, 33 + error: { 34 + type: "oauth_session_expired", 35 + message: "Not authenticated", 36 + did: "", 37 + }, 38 + }; 39 + } 40 + 41 + const { data: publication } = await supabaseServerClient 42 + .from("publications") 43 + .select("uri, identity_did") 44 + .eq("uri", publication_uri) 45 + .single(); 46 + if (!publication || publication.identity_did !== identity.atp_did) { 47 + return { 48 + success: false, 49 + error: { type: "other", message: "Not the publication owner" }, 50 + }; 51 + } 52 + 53 + const sessionResult = await restoreOAuthSession(identity.atp_did); 54 + if (!sessionResult.ok) { 55 + return { success: false, error: sessionResult.error }; 56 + } 57 + const credentialSession = sessionResult.value; 58 + const agent = new AtpBaseClient( 59 + credentialSession.fetchHandler.bind(credentialSession), 60 + ); 61 + 62 + const { data: pageRows } = await supabaseServerClient 63 + .from("publication_pages") 64 + .select("id, title, path, leaflet_src") 65 + .eq("publication", publication_uri); 66 + if (!pageRows || pageRows.length === 0) { 67 + return { success: true, published: [] }; 68 + } 69 + 70 + const uploadLock = new Lock(); 71 + 72 + const published: { id: number; uri: string }[] = []; 73 + for (const page of pageRows) { 74 + const path = page.path ?? "/"; 75 + 76 + const { result: leafletRes } = await get_leaflet_data.handler( 77 + { token_id: page.leaflet_src }, 78 + { supabase: supabaseServerClient }, 79 + ); 80 + const rootEntity = leafletRes.data?.root_entity; 81 + if (!rootEntity) continue; 82 + 83 + const { data: factData } = await supabaseServerClient.rpc("get_facts", { 84 + root: rootEntity, 85 + }); 86 + const facts = (factData as unknown as Fact<Attribute>[]) || []; 87 + 88 + const record = await leafletToPublicationPageRecord({ 89 + facts, 90 + root_entity: rootEntity, 91 + publication_uri, 92 + path, 93 + title: page.title ?? "", 94 + hooks: { 95 + uploadImage: async (src: string) => { 96 + const data = await fetch(src); 97 + if (data.status !== 200) return; 98 + const binary = await data.blob(); 99 + return uploadLock.withLock(async () => { 100 + const blob = await agent.com.atproto.repo.uploadBlob(binary, { 101 + headers: { "Content-Type": binary.type }, 102 + }); 103 + return blob.data.blob; 104 + }); 105 + }, 106 + uploadPoll: async (entityId, pollRecord) => { 107 + const { data: pollResult } = await agent.com.atproto.repo.putRecord({ 108 + rkey: entityId, 109 + repo: credentialSession.did!, 110 + collection: pollRecord.$type, 111 + record: pollRecord, 112 + validate: false, 113 + }); 114 + await supabaseServerClient.from("atp_poll_records").upsert({ 115 + uri: pollResult.uri, 116 + cid: pollResult.cid, 117 + record: pollRecord as Json, 118 + }); 119 + return { uri: pollResult.uri, cid: pollResult.cid }; 120 + }, 121 + }, 122 + }); 123 + 124 + const rkey = pathToRkey(path); 125 + const { data: putResult } = await agent.com.atproto.repo.putRecord({ 126 + rkey, 127 + repo: credentialSession.did!, 128 + collection: "pub.leaflet.publicationPage", 129 + record, 130 + validate: false, 131 + }); 132 + 133 + await supabaseServerClient 134 + .from("publication_pages") 135 + .update({ 136 + record: record as unknown as Json, 137 + record_uri: putResult.uri, 138 + }) 139 + .eq("id", page.id) 140 + .eq("publication", publication_uri); 141 + 142 + published.push({ id: page.id, uri: putResult.uri }); 143 + } 144 + 145 + return { success: true, published }; 146 + }
+29
actions/reorderPublicationPages.ts
··· 1 + "use server"; 2 + import { getIdentityData } from "actions/getIdentityData"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + 5 + export async function reorderPublicationPages(args: { 6 + publication_uri: string; 7 + page_id: number; 8 + sort_order: string; 9 + }): Promise<{ success: boolean }> { 10 + let identity = await getIdentityData(); 11 + if (!identity || !identity.atp_did) return { success: false }; 12 + 13 + let { data: publication } = await supabaseServerClient 14 + .from("publications") 15 + .select("uri, identity_did") 16 + .eq("uri", args.publication_uri) 17 + .single(); 18 + if (!publication || publication.identity_did !== identity.atp_did) 19 + return { success: false }; 20 + 21 + let { error } = await supabaseServerClient 22 + .from("publication_pages") 23 + .update({ sort_order: args.sort_order }) 24 + .eq("id", args.page_id) 25 + .eq("publication", args.publication_uri); 26 + if (error) return { success: false }; 27 + 28 + return { success: true }; 29 + }
+6 -1
app/[leaflet_id]/Sidebar.tsx
··· 7 7 HomeButton, 8 8 } from "app/[leaflet_id]/actions/HomeButton"; 9 9 import { Media } from "components/Media"; 10 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 10 + import { 11 + useLeafletPublicationData, 12 + useLeafletPublicationPage, 13 + } from "components/PageSWRDataProvider"; 11 14 import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 12 15 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 13 16 import { PublishButton } from "./actions/PublishButton"; ··· 24 27 let { rootEntity } = useReplicache(); 25 28 let { data: pub } = useLeafletPublicationData(); 26 29 let { identity } = useIdentityData(); 30 + let publicationPage = useLeafletPublicationPage(); 27 31 let { permission_token } = useReplicache(); 28 32 let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { 29 33 fallbackData: [], ··· 37 41 !!pub?.publications && 38 42 !!identity?.atp_did && 39 43 pub.publications.identity_did === identity.atp_did; 44 + if (publicationPage) return null; 40 45 41 46 return ( 42 47 <Media mobile={false} className="w-0 h-full relative">
+6 -13
app/api/oauth/[route]/route.ts
··· 86 86 .eq("atp_did", session.did) 87 87 .single(); 88 88 89 - const currentAuthToken = (await cookies()).get(AUTH_TOKEN_COOKIE)?.value; 89 + const currentAuthToken = (await cookies()).get( 90 + AUTH_TOKEN_COOKIE, 91 + )?.value; 90 92 let currentIdentity: { 91 93 id: string; 92 94 email: string | null; ··· 183 185 targetId: identity.id, 184 186 }); 185 187 if (!merged.ok) { 186 - console.error( 187 - "[oauth/callback] autoMerge failed:", 188 - merged.error, 189 - ); 188 + console.error("[oauth/callback] autoMerge failed:", merged.error); 190 189 await stagePendingMerge(identity.id, redirectPath); 191 190 } 192 191 } else { ··· 247 246 // success (via `redirect()`), so callers only reach the line after the call 248 247 // when the token insert failed — at which point they should fall through to 249 248 // the normal sign-in flow rather than blocking the user. 250 - const stagePendingMerge = async ( 251 - identityId: string, 252 - redirectPath: string, 253 - ) => { 249 + const stagePendingMerge = async (identityId: string, redirectPath: string) => { 254 250 const { data: targetToken, error } = await supabaseServerClient 255 251 .from("email_auth_tokens") 256 252 .insert({ ··· 261 257 .select("id") 262 258 .single(); 263 259 if (error) 264 - console.error( 265 - "[oauth/callback] pending merge token insert failed:", 266 - error, 267 - ); 260 + console.error("[oauth/callback] pending merge token insert failed:", error); 268 261 if (!targetToken) return; 269 262 await setPendingMergeToken(targetToken.id); 270 263 redirect(`/merge-accounts?redirect=${encodeURIComponent(redirectPath)}`);
+27 -3
app/api/rpc/[command]/get_publication_data.ts
··· 54 54 permission_token_rights(*), 55 55 custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*) 56 56 ) 57 + ), 58 + publication_pages(*, 59 + permission_tokens!publication_pages_leaflet_src_fkey(*, 60 + permission_token_rights(*) 61 + ) 57 62 )`, 58 63 ) 59 64 .or( ··· 66 71 67 72 let leaflet_data = await getFactsFromHomeLeaflets.handler( 68 73 { 69 - tokens: 70 - publication?.leaflets_in_publications.map( 74 + tokens: [ 75 + ...(publication?.leaflets_in_publications.map( 71 76 (l) => l.permission_tokens?.root_entity!, 72 - ) || [], 77 + ) || []), 78 + ...(publication?.publication_pages.map( 79 + (p) => p.permission_tokens?.root_entity!, 80 + ) || []), 81 + ].filter(Boolean), 73 82 }, 74 83 { supabase }, 75 84 ); ··· 111 120 _raw: l, 112 121 })); 113 122 123 + const pages = (publication?.publication_pages || []).map((p) => ({ 124 + id: p.id, 125 + title: p.title, 126 + path: p.path, 127 + document: p.document, 128 + metadata: p.metadata, 129 + record: p.record, 130 + record_uri: p.record_uri, 131 + leaflet_src: p.leaflet_src, 132 + created_at: p.created_at, 133 + sort_order: p.sort_order, 134 + permission_tokens: p.permission_tokens, 135 + })); 136 + 114 137 return { 115 138 result: { 116 139 publication, 117 140 documents, 118 141 drafts, 142 + pages, 119 143 leaflet_data: leaflet_data.result, 120 144 }, 121 145 };
+122
app/api/rpc/[command]/get_standard_site_posts.ts
··· 1 + import { z } from "zod"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { makeRoute } from "../lib"; 4 + import type { Env } from "./route"; 5 + import { 6 + normalizeDocumentRecord, 7 + normalizePublicationRecord, 8 + type NormalizedDocument, 9 + type NormalizedPublication, 10 + } from "src/utils/normalizeRecords"; 11 + 12 + export type StandardSitePostData = { 13 + uri: string; 14 + record: NormalizedDocument; 15 + publication: { 16 + uri: string; 17 + record: NormalizedPublication | null; 18 + } | null; 19 + author: { 20 + did: string; 21 + handle: string | null; 22 + displayName: string | null; 23 + } | null; 24 + commentsCount: number; 25 + mentionsCount: number; 26 + recommendsCount: number; 27 + }; 28 + 29 + export type GetStandardSitePostsReturnType = Awaited< 30 + ReturnType<(typeof get_standard_site_posts)["handler"]> 31 + >; 32 + 33 + export const get_standard_site_posts = makeRoute({ 34 + route: "get_standard_site_posts", 35 + input: z.object({ 36 + uris: z.array(z.string()).max(100), 37 + }), 38 + handler: async ({ uris }, { supabase }: Pick<Env, "supabase">) => { 39 + if (uris.length === 0) return { result: { posts: [] } }; 40 + 41 + const { data: documents } = await supabase 42 + .from("documents") 43 + .select( 44 + ` 45 + uri, 46 + data, 47 + comments_on_documents(count), 48 + document_mentions_in_bsky(count), 49 + recommends_on_documents(count), 50 + documents_in_publications(publications(uri, record)) 51 + `, 52 + ) 53 + .in("uri", uris); 54 + 55 + const dids = Array.from( 56 + new Set( 57 + (documents || []) 58 + .map((d) => { 59 + try { 60 + return new AtUri(d.uri).host; 61 + } catch { 62 + return null; 63 + } 64 + }) 65 + .filter((did): did is string => !!did), 66 + ), 67 + ); 68 + 69 + const { data: profiles } = dids.length 70 + ? await supabase 71 + .from("bsky_profiles") 72 + .select("did, handle, record") 73 + .in("did", dids) 74 + : { data: [] as { did: string; handle: string | null; record: unknown }[] }; 75 + 76 + const profileByDid = new Map( 77 + (profiles || []).map((p) => [p.did, p] as const), 78 + ); 79 + 80 + const posts: StandardSitePostData[] = (documents || []) 81 + .map((d): StandardSitePostData | null => { 82 + const normalized = normalizeDocumentRecord(d.data, d.uri); 83 + if (!normalized) return null; 84 + 85 + const pubRow = d.documents_in_publications?.[0]?.publications; 86 + const publication = pubRow 87 + ? { uri: pubRow.uri, record: normalizePublicationRecord(pubRow.record) } 88 + : null; 89 + 90 + let did: string | null; 91 + try { 92 + did = new AtUri(d.uri).host; 93 + } catch { 94 + did = null; 95 + } 96 + const profile = did ? profileByDid.get(did) : undefined; 97 + const profileRecord = (profile?.record ?? null) as 98 + | { displayName?: string } 99 + | null; 100 + const author = did 101 + ? { 102 + did, 103 + handle: profile?.handle ?? null, 104 + displayName: profileRecord?.displayName ?? null, 105 + } 106 + : null; 107 + 108 + return { 109 + uri: d.uri, 110 + record: normalized, 111 + publication, 112 + author, 113 + commentsCount: d.comments_on_documents?.[0]?.count || 0, 114 + mentionsCount: d.document_mentions_in_bsky?.[0]?.count || 0, 115 + recommendsCount: d.recommends_on_documents?.[0]?.count || 0, 116 + }; 117 + }) 118 + .filter((p): p is StandardSitePostData => p !== null); 119 + 120 + return { result: { posts } }; 121 + }, 122 + });
+2
app/api/rpc/[command]/route.ts
··· 11 11 } from "./domain_routes"; 12 12 import { get_leaflet_data } from "./get_leaflet_data"; 13 13 import { get_publication_data } from "./get_publication_data"; 14 + import { get_standard_site_posts } from "./get_standard_site_posts"; 14 15 import { search_publication_names } from "./search_publication_names"; 15 16 import { search_publication_documents } from "./search_publication_documents"; 16 17 import { get_profile_data } from "./get_profile_data"; ··· 45 46 get_leaflet_subdomain_status, 46 47 get_leaflet_data, 47 48 get_publication_data, 49 + get_standard_site_posts, 48 50 search_publication_names, 49 51 search_publication_documents, 50 52 get_profile_data,
+91 -157
app/lish/[did]/[publication]/PublicationContent.tsx
··· 1 1 import React from "react"; 2 - import { AtUri } from "@atproto/syntax"; 3 - import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 4 - import { InteractionPreview } from "components/InteractionsPreview"; 5 - import { LocalizedDate } from "./LocalizedDate"; 6 2 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 7 3 import { PublicationAuthor } from "./PublicationAuthor"; 4 + import { PublicationHeader } from "./PublicationHeader"; 5 + import { PublicationNav } from "./PublicationNav"; 6 + import { PublicationStickyHeader } from "./PublicationStickyHeader"; 8 7 import { 9 8 normalizePublicationRecord, 10 9 normalizeDocumentRecord, 11 10 } from "src/utils/normalizeRecords"; 12 - import { getFirstParagraph } from "src/utils/getFirstParagraph"; 13 11 import { FontLoader } from "components/FontLoader"; 14 12 import { SpeedyLink } from "components/SpeedyLink"; 13 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 14 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 15 15 import { SubscribeInput } from "components/Subscribe/SubscribeButton"; 16 + import { 17 + PublicationPostsList, 18 + type PublicationPostsListPost, 19 + type PublicationPostsListFakePost, 20 + } from "./PublicationPostsList"; 16 21 17 - type FakePost = { 18 - title: string; 19 - description: string; 20 - date: React.ReactNode; 21 - }; 22 + type FakePost = PublicationPostsListFakePost; 22 23 23 24 export const PublicationContent = ({ 24 25 record, ··· 36 37 record: unknown; 37 38 publication_subscriptions: { identity: string }[]; 38 39 publication_newsletter_settings: { enabled: boolean } | null; 40 + publication_pages?: { 41 + id: number; 42 + path: string | null; 43 + title: string | null; 44 + record_uri: string | null; 45 + sort_order: string; 46 + }[]; 39 47 documents_in_publications: { 40 48 documents: { 41 49 uri: string; ··· 52 60 fakePosts?: FakePost[]; 53 61 }) => { 54 62 const newsletterMode = !!publication.publication_newsletter_settings?.enabled; 63 + const posts: PublicationPostsListPost[] = fakePosts 64 + ? [] 65 + : publication.documents_in_publications 66 + .map((dip) => { 67 + if (!dip.documents) return null; 68 + const normalized = normalizeDocumentRecord(dip.documents.data); 69 + if (!normalized) return null; 70 + return { 71 + uri: dip.documents.uri, 72 + record: normalized, 73 + commentsCount: dip.documents.comments_on_documents[0]?.count || 0, 74 + mentionsCount: 75 + dip.documents.document_mentions_in_bsky[0]?.count || 0, 76 + recommendsCount: 77 + dip.documents.recommends_on_documents?.[0]?.count || 0, 78 + }; 79 + }) 80 + .filter((p): p is PublicationPostsListPost => p !== null); 55 81 return ( 56 82 <> 57 83 <FontLoader ··· 61 87 <PublicationHomeLayout 62 88 uri={publication.uri} 63 89 showPageBackground={!!showPageBackground} 64 - > 65 - <PublicationHeader 66 - iconUrl={ 67 - record?.icon 68 - ? `/api/atproto_images?did=${did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}` 69 - : undefined 70 - } 71 - publicationName={publication.name} 72 - description={record?.description} 73 - author={ 74 - profile ? ( 75 - <PublicationAuthor 76 - did={profile.did} 77 - displayName={profile.displayName} 78 - handle={profile.handle} 79 - /> 80 - ) : undefined 81 - } 82 - subscribeButton={ 83 - <div className="max-w-sm mx-auto"> 84 - <SubscribeInput 85 - publicationUri={publication.uri} 86 - publicationUrl={record?.url} 87 - publicationName={record?.name ?? publication.name} 88 - publicationDescription={record?.description} 89 - newsletterMode={newsletterMode} 90 + stickyHeader={ 91 + <PublicationStickyHeader 92 + nav={ 93 + <PublicationNav 94 + publicationUrl={getPublicationURL(publication)} 95 + pages={(publication.publication_pages ?? []).filter( 96 + (p) => p.record_uri, 97 + )} 98 + activePath="/" 90 99 /> 91 - </div> 92 - } 100 + } 101 + > 102 + <PublicationHeader 103 + iconUrl={ 104 + record?.icon ? blobRefToSrc(record.icon.ref, did) : undefined 105 + } 106 + publicationName={publication.name} 107 + description={record?.description} 108 + author={ 109 + profile ? ( 110 + <PublicationAuthor 111 + did={profile.did} 112 + displayName={profile.displayName} 113 + handle={profile.handle} 114 + /> 115 + ) : undefined 116 + } 117 + subscribeButton={ 118 + <div className="max-w-sm mx-auto"> 119 + <SubscribeInput 120 + publicationUri={publication.uri} 121 + publicationUrl={record?.url} 122 + publicationName={record?.name ?? publication.name} 123 + publicationDescription={record?.description} 124 + newsletterMode={newsletterMode} 125 + /> 126 + </div> 127 + } 128 + /> 129 + </PublicationStickyHeader> 130 + } 131 + > 132 + <PublicationPostsList 133 + publication={publication} 134 + publicationRecord={record} 135 + posts={posts} 136 + fakePosts={fakePosts} 93 137 /> 94 - <div className="publicationPostList w-full flex flex-col gap-4"> 95 - {fakePosts && 96 - fakePosts.map((post, i) => ( 97 - <PublicationPostItem 98 - key={i} 99 - title={post.title} 100 - description={post.description} 101 - date={post.date} 102 - /> 103 - ))} 104 - {!fakePosts && 105 - publication.documents_in_publications 106 - .filter((d) => !!d?.documents) 107 - .sort((a, b) => { 108 - const aRecord = normalizeDocumentRecord(a.documents?.data); 109 - const bRecord = normalizeDocumentRecord(b.documents?.data); 110 - const aDate = aRecord?.publishedAt 111 - ? new Date(aRecord.publishedAt) 112 - : new Date(0); 113 - const bDate = bRecord?.publishedAt 114 - ? new Date(bRecord.publishedAt) 115 - : new Date(0); 116 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 117 - }) 118 - .map((doc) => { 119 - if (!doc.documents) return null; 120 - const doc_record = normalizeDocumentRecord(doc.documents.data); 121 - if (!doc_record) return null; 122 - let uri = new AtUri(doc.documents.uri); 123 - let quotes = 124 - doc.documents.document_mentions_in_bsky[0].count || 0; 125 - let comments = 126 - record?.preferences?.showComments === false 127 - ? 0 128 - : doc.documents.comments_on_documents[0].count || 0; 129 - let recommends = 130 - doc.documents.recommends_on_documents?.[0]?.count || 0; 131 - let tags = doc_record.tags || []; 132 - 133 - const docUrl = getDocumentURL( 134 - doc_record, 135 - doc.documents.uri, 136 - publication, 137 - ); 138 - return ( 139 - <React.Fragment key={doc.documents?.uri}> 140 - <PublicationPostItem 141 - href={docUrl} 142 - title={doc_record.title} 143 - description={ 144 - doc_record.description || getFirstParagraph(doc_record) 145 - } 146 - date={ 147 - doc_record.publishedAt ? ( 148 - <LocalizedDate 149 - dateString={doc_record.publishedAt} 150 - options={{ 151 - year: "numeric", 152 - month: "long", 153 - day: "2-digit", 154 - }} 155 - /> 156 - ) : undefined 157 - } 158 - interactions={ 159 - <InteractionPreview 160 - quotesCount={quotes} 161 - commentsCount={comments} 162 - recommendsCount={recommends} 163 - documentUri={doc.documents.uri} 164 - tags={tags} 165 - postUrl={docUrl} 166 - showComments={ 167 - record?.preferences?.showComments !== false 168 - } 169 - showMentions={ 170 - record?.preferences?.showMentions !== false 171 - } 172 - showRecommends={ 173 - record?.preferences?.showRecommends !== false 174 - } 175 - /> 176 - } 177 - /> 178 - </React.Fragment> 179 - ); 180 - })} 181 - </div> 182 138 </PublicationHomeLayout> 183 139 </> 184 140 ); 185 141 }; 186 142 187 - export function PublicationHeader(props: { 188 - iconUrl?: string; 189 - publicationName: string; 190 - description?: string; 191 - author?: React.ReactNode; 192 - subscribeButton?: React.ReactNode; 193 - }) { 194 - return ( 195 - <div className="pubHeader flex flex-col pb-8 w-full text-center justify-center "> 196 - {props.iconUrl && ( 197 - <div 198 - className="shrink-0 w-10 h-10 rounded-full mx-auto" 199 - style={{ 200 - backgroundImage: `url(${props.iconUrl})`, 201 - backgroundRepeat: "no-repeat", 202 - backgroundPosition: "center", 203 - backgroundSize: "cover", 204 - }} 205 - /> 206 - )} 207 - <h2 className="text-accent-contrast sm:text-xl text-[22px] pt-1 "> 208 - {props.publicationName} 209 - </h2> 210 - <p className="sm:text-lg text-secondary">{props.description} </p> 211 - {props.author} 212 - <div className="sm:pt-4 pt-4">{props.subscribeButton}</div> 213 - </div> 214 - ); 215 - } 216 - 217 143 export function PublicationPostItem(props: { 218 144 href?: string; 219 145 title?: string; 220 146 description?: string; 147 + author?: React.ReactNode; 221 148 date?: React.ReactNode; 222 149 interactions?: React.ReactNode; 223 150 }) { ··· 228 155 </> 229 156 ); 230 157 158 + const hasAuthor = props.author !== undefined && props.author !== null; 159 + const hasDate = props.date !== undefined && props.date !== null; 160 + 231 161 return ( 232 162 <> 233 163 <div className="flex w-full grow flex-col "> ··· 245 175 )} 246 176 247 177 <div className="justify-between w-full text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 248 - <p className="text-sm text-tertiary ">{props.date} </p> 178 + <p className="text-sm text-tertiary flex gap-1 items-center"> 179 + {hasAuthor && <span>{props.author}</span>} 180 + {hasAuthor && hasDate && <span>|</span>} 181 + {hasDate && <span>{props.date}</span>} 182 + </p> 249 183 {props.interactions} 250 184 </div> 251 185 </div>
+75
app/lish/[did]/[publication]/PublicationHeader.tsx
··· 1 + import React from "react"; 2 + 3 + export function PublicationHeader(props: { 4 + iconUrl?: string; 5 + publicationName: string; 6 + description?: string; 7 + author?: React.ReactNode; 8 + subscribeButton?: React.ReactNode; 9 + variant?: "stacked" | "inline"; 10 + }) { 11 + let variant = props.variant ?? "stacked"; 12 + let icon = props.iconUrl ? ( 13 + <div 14 + className={`shrink-0 rounded-full${variant === "stacked" ? " mx-auto" : ""}`} 15 + style={{ 16 + width: "calc(40px - 16px * var(--header-shrink, 0))", 17 + height: "calc(40px - 16px * var(--header-shrink, 0))", 18 + backgroundImage: `url(${props.iconUrl})`, 19 + backgroundRepeat: "no-repeat", 20 + backgroundPosition: "center", 21 + backgroundSize: "cover", 22 + }} 23 + /> 24 + ) : null; 25 + let title = ( 26 + <h2 27 + className={`text-accent-contrast${variant === "stacked" ? " pt-1" : ""}`} 28 + style={{ fontSize: "calc(22px - 6px * var(--header-shrink, 0))" }} 29 + > 30 + {props.publicationName} 31 + </h2> 32 + ); 33 + 34 + return ( 35 + <div 36 + className="pubHeader flex flex-col w-full text-center justify-center" 37 + style={{ 38 + paddingBottom: "calc(32px - 32px * var(--header-shrink, 0))", 39 + ...(variant === "stacked" 40 + ? { paddingTop: "calc(4px - 4px * var(--header-shrink, 0))" } 41 + : null), 42 + }} 43 + > 44 + {variant === "inline" ? ( 45 + <div 46 + className="flex items-center justify-center" 47 + style={{ gap: "calc(12px - 6px * var(--header-shrink, 0))" }} 48 + > 49 + {icon} 50 + {title} 51 + </div> 52 + ) : ( 53 + <> 54 + {icon} 55 + {title} 56 + </> 57 + )} 58 + <div 59 + className="overflow-hidden" 60 + style={{ 61 + maxHeight: "calc((1 - var(--header-shrink, 0)) * 400px)", 62 + opacity: "calc(1 - var(--header-shrink, 0))", 63 + }} 64 + > 65 + {props.description && ( 66 + <p className="sm:text-lg text-secondary">{props.description}</p> 67 + )} 68 + {props.author} 69 + {props.subscribeButton && ( 70 + <div className="sm:pt-4 pt-4">{props.subscribeButton}</div> 71 + )} 72 + </div> 73 + </div> 74 + ); 75 + }
+24 -8
app/lish/[did]/[publication]/PublicationHomeLayout.tsx
··· 5 5 export function PublicationHomeLayout(props: { 6 6 uri: string; 7 7 showPageBackground: boolean; 8 + stickyHeader?: React.ReactNode; 8 9 children: React.ReactNode; 9 10 }) { 10 11 let { ref } = usePreserveScroll<HTMLDivElement>(props.uri); 12 + let inner = ( 13 + <> 14 + {props.stickyHeader} 15 + <div className="pubContent sm:max-w-(--page-width-units) sm:min-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 pb-5"> 16 + {props.children} 17 + </div> 18 + </> 19 + ); 20 + if (props.showPageBackground) { 21 + return ( 22 + <div className="pubWrapper flex flex-col sm:py-6 h-full max-w-(--page-width-units) mx-auto px-0 py-2"> 23 + <div 24 + ref={ref} 25 + className="pubContentScroll publicationScrollContainer overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg flex flex-col" 26 + > 27 + {inner} 28 + </div> 29 + </div> 30 + ); 31 + } 11 32 return ( 12 33 <div 13 - ref={props.showPageBackground ? null : ref} 14 - className={`pubWrapper flex flex-col sm:py-6 h-full ${props.showPageBackground ? "max-w-(--page-width-units) mx-auto px-0 py-2" : "w-full overflow-y-scroll"}`} 34 + ref={ref} 35 + className="pubWrapper publicationScrollContainer flex flex-col sm:pb-6 h-full w-full overflow-y-scroll" 15 36 > 16 - <div 17 - ref={!props.showPageBackground ? null : ref} 18 - className={`pubContent sm:max-w-(--page-width-units) sm:min-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 py-5 ${props.showPageBackground ? "overflow-auto h-full bg-[rgba(var(--bg-page),var(--bg-page-alpha))] border border-border rounded-lg" : "h-fit"}`} 19 - > 20 - {props.children} 21 - </div> 37 + {inner} 22 38 </div> 23 39 ); 24 40 }
+45
app/lish/[did]/[publication]/PublicationNav.tsx
··· 1 + import { SpeedyLink } from "components/SpeedyLink"; 2 + import { sortPublicationPages } from "./sortPublicationPages"; 3 + 4 + export type PublicationNavPage = { 5 + id: number; 6 + path: string | null; 7 + title: string | null; 8 + sort_order: string; 9 + }; 10 + 11 + export function PublicationNav(props: { 12 + publicationUrl: string; 13 + pages: PublicationNavPage[]; 14 + activePath: string | null; 15 + }) { 16 + if (props.pages.length === 0) return null; 17 + 18 + let sortedPages = sortPublicationPages(props.pages); 19 + 20 + return ( 21 + <nav className="publicationNav border-t border-b border-border-light w-full sm:max-w-[calc(var(--page-width-units)*1.25)] mx-auto"> 22 + <div className="flex items-center gap-1 px-2 sm:px-3 py-1 overflow-x-auto sm:max-w-(--page-width-units) mx-auto"> 23 + {sortedPages.map((page) => { 24 + if (!page.path) return null; 25 + let segment = page.path === "/" ? "" : page.path; 26 + let href = `${props.publicationUrl}${segment}`; 27 + let active = props.activePath === page.path; 28 + return ( 29 + <SpeedyLink 30 + key={page.id} 31 + href={href} 32 + className={`shrink-0 px-2 py-0.5 rounded-md text-sm hover:no-underline! ${ 33 + active 34 + ? "bg-accent-1 text-accent-2 font-semibold" 35 + : "text-secondary" 36 + }`} 37 + > 38 + {page.title || page.path} 39 + </SpeedyLink> 40 + ); 41 + })} 42 + </div> 43 + </nav> 44 + ); 45 + }
+183
app/lish/[did]/[publication]/PublicationPostItem.tsx
··· 1 + import React from "react"; 2 + import { SpeedyLink } from "components/SpeedyLink"; 3 + 4 + type CommonProps = { 5 + href?: string; 6 + title?: string; 7 + description?: string; 8 + author?: React.ReactNode; 9 + date?: React.ReactNode; 10 + interactions?: React.ReactNode; 11 + }; 12 + 13 + type LargeProps = CommonProps & { 14 + coverImageSrc?: string; 15 + coverImageAlt?: string; 16 + pageWidth?: number; 17 + }; 18 + 19 + type MediumProps = CommonProps & { 20 + coverImageSrc?: string; 21 + coverImageAlt?: string; 22 + }; 23 + 24 + function MetaRow({ 25 + author, 26 + date, 27 + interactions, 28 + textClassName, 29 + }: { 30 + author?: React.ReactNode; 31 + date?: React.ReactNode; 32 + interactions?: React.ReactNode; 33 + textClassName: string; 34 + }) { 35 + const hasAuthor = author !== undefined && author !== null; 36 + const hasDate = date !== undefined && date !== null; 37 + return ( 38 + <div 39 + className={`justify-between w-full ${textClassName} text-tertiary flex gap-1 flex-wrap items-center`} 40 + > 41 + <p 42 + className={`${textClassName} text-tertiary flex gap-1 items-center flex-wrap`} 43 + > 44 + {hasAuthor && ( 45 + <span className="whitespace-nowrap"> 46 + {author} 47 + {hasDate && <span className="ml-1">|</span>} 48 + </span> 49 + )} 50 + {hasDate && <span className="whitespace-nowrap">{date}</span>} 51 + </p> 52 + {interactions} 53 + </div> 54 + ); 55 + } 56 + 57 + function PostLink({ 58 + href, 59 + children, 60 + }: { 61 + href?: string; 62 + children: React.ReactNode; 63 + }) { 64 + if (href) { 65 + return ( 66 + <SpeedyLink 67 + href={href} 68 + className="publishedPost no-underline! flex flex-col" 69 + > 70 + {children} 71 + </SpeedyLink> 72 + ); 73 + } 74 + return ( 75 + <div className="publishedPost no-underline! flex flex-col">{children}</div> 76 + ); 77 + } 78 + 79 + export function PublicationPostItemSmall(props: CommonProps) { 80 + return ( 81 + <> 82 + <div className="flex w-full grow flex-col gap-1 px-3 py-2"> 83 + <PostLink href={props.href}> 84 + {props.title && <h3 className="text-primary">{props.title}</h3>} 85 + </PostLink> 86 + <MetaRow 87 + author={props.author} 88 + date={props.date} 89 + interactions={props.interactions} 90 + textClassName="text-sm" 91 + /> 92 + </div> 93 + <hr className="last:hidden border-border-light" /> 94 + </> 95 + ); 96 + } 97 + 98 + export function PublicationPostItemMedium(props: MediumProps) { 99 + const hasCoverImage = !!props.coverImageSrc; 100 + return ( 101 + <> 102 + <div className="flex w-full gap-3 items-stretch sm:h-36"> 103 + <div className="flex w-full gap-2 grow flex-col justify-between min-w-0 pl-3 py-2"> 104 + <PostLink href={props.href}> 105 + {props.title && ( 106 + <h3 className="text-primary line-clamp-2">{props.title}</h3> 107 + )} 108 + <p className="text-secondary line-clamp-3"> 109 + {props.description} {props.description} 110 + </p> 111 + </PostLink> 112 + <MetaRow 113 + author={props.author} 114 + date={props.date} 115 + interactions={props.interactions} 116 + textClassName="text-sm place-self-end" 117 + /> 118 + </div> 119 + {hasCoverImage && ( 120 + <div className="self-stretch shrink-0 aspect-square w-16 sm:w-36"> 121 + <img 122 + src={props.coverImageSrc} 123 + alt={props.coverImageAlt || props.title || ""} 124 + className="h-full aspect-square object-cover rounded" 125 + /> 126 + </div> 127 + )} 128 + </div> 129 + <hr className="last:hidden border-border-light" /> 130 + </> 131 + ); 132 + } 133 + 134 + export function PublicationPostItemLarge(props: LargeProps) { 135 + const hasCoverImage = !!props.coverImageSrc; 136 + const widePage = (props.pageWidth ?? 0) >= 768; 137 + const body = ( 138 + <div 139 + className={`flex w-full grow flex-col gap-2 justify-between ${widePage ? " px-3 py-2 sm:pb-3" : "px-3 py-2 "}`} 140 + > 141 + <PostLink href={props.href}> 142 + {props.title && ( 143 + <h3 144 + className={`text-primary text-lg clamp-2 ${widePage ? "sm:text-xl " : ""}`} 145 + > 146 + {props.title} 147 + </h3> 148 + )} 149 + <p 150 + className={`text-secondary line-clamp-3 text-base ${widePage ? "sm:text-lg " : ""}`} 151 + > 152 + {props.description} 153 + </p> 154 + </PostLink> 155 + <MetaRow 156 + author={props.author} 157 + date={props.date} 158 + interactions={props.interactions} 159 + textClassName={`${widePage ? "text-sm sm:text-base " : "text-sm "} `} 160 + /> 161 + </div> 162 + ); 163 + 164 + return ( 165 + <> 166 + <div 167 + className={`flex flex-col items-stretch ${widePage ? "sm:flex-row sm:gap-2 gap-0" : ""} w-full items-start`} 168 + > 169 + {hasCoverImage && ( 170 + <div className="w-fit"> 171 + <img 172 + src={props.coverImageSrc} 173 + alt={props.coverImageAlt || props.title || ""} 174 + className="h-full aspect-[1.91:1] object-cover rounded" 175 + /> 176 + </div> 177 + )} 178 + {body} 179 + </div> 180 + <hr className="last:hidden border-border-light" /> 181 + </> 182 + ); 183 + }
+177
app/lish/[did]/[publication]/PublicationPostsList.tsx
··· 1 + import React from "react"; 2 + import { AtUri } from "@atproto/api"; 3 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 4 + import { InteractionPreview } from "components/InteractionsPreview"; 5 + import { LocalizedDate } from "./LocalizedDate"; 6 + import { PublicationPostItem } from "./PublicationContent"; 7 + import { 8 + PublicationPostItemSmall, 9 + PublicationPostItemMedium, 10 + PublicationPostItemLarge, 11 + } from "./PublicationPostItem"; 12 + import { 13 + type NormalizedDocument, 14 + type NormalizedPublication, 15 + } from "src/utils/normalizeRecords"; 16 + import { getFirstParagraph } from "src/utils/getFirstParagraph"; 17 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 18 + 19 + export type PublicationPostsListPost = { 20 + uri: string; 21 + record: NormalizedDocument; 22 + commentsCount: number; 23 + mentionsCount: number; 24 + recommendsCount: number; 25 + }; 26 + 27 + export type PublicationPostsListFakePost = { 28 + title: string; 29 + description: string; 30 + date: React.ReactNode; 31 + }; 32 + 33 + type PublicationForURL = { 34 + uri: string; 35 + record: unknown; 36 + }; 37 + 38 + export type PublicationPostsListView = "compact" | "full"; 39 + 40 + export function PublicationPostsList({ 41 + publication, 42 + publicationRecord, 43 + posts, 44 + fakePosts, 45 + view = "full", 46 + highlightFirstPost = false, 47 + }: { 48 + publication: PublicationForURL; 49 + publicationRecord: NormalizedPublication | null; 50 + posts?: PublicationPostsListPost[]; 51 + fakePosts?: PublicationPostsListFakePost[]; 52 + view?: PublicationPostsListView; 53 + highlightFirstPost?: boolean; 54 + }) { 55 + return ( 56 + <div className="publicationPostList w-full flex flex-col gap-4"> 57 + {fakePosts 58 + ? fakePosts.map((post, i) => ( 59 + <PublicationPostItem 60 + key={i} 61 + title={post.title} 62 + description={post.description} 63 + date={post.date} 64 + /> 65 + )) 66 + : posts 67 + ?.slice() 68 + .sort((a, b) => { 69 + const aDate = a.record.publishedAt 70 + ? new Date(a.record.publishedAt) 71 + : new Date(0); 72 + const bDate = b.record.publishedAt 73 + ? new Date(b.record.publishedAt) 74 + : new Date(0); 75 + return bDate.getTime() - aDate.getTime(); 76 + }) 77 + .map((post, index) => { 78 + const doc_record = post.record; 79 + const quotes = post.mentionsCount; 80 + const comments = 81 + publicationRecord?.preferences?.showComments === false 82 + ? 0 83 + : post.commentsCount; 84 + const recommends = post.recommendsCount; 85 + const tags = doc_record.tags || []; 86 + 87 + const docUrl = getDocumentURL(doc_record, post.uri, publication); 88 + const date = doc_record.publishedAt ? ( 89 + <LocalizedDate 90 + dateString={doc_record.publishedAt} 91 + options={{ 92 + year: "numeric", 93 + month: "long", 94 + day: "2-digit", 95 + }} 96 + /> 97 + ) : undefined; 98 + const interactions = ( 99 + <InteractionPreview 100 + quotesCount={quotes} 101 + commentsCount={comments} 102 + recommendsCount={recommends} 103 + documentUri={post.uri} 104 + tags={tags} 105 + postUrl={docUrl} 106 + showComments={ 107 + publicationRecord?.preferences?.showComments !== false 108 + } 109 + showMentions={ 110 + publicationRecord?.preferences?.showMentions !== false 111 + } 112 + showRecommends={ 113 + publicationRecord?.preferences?.showRecommends !== false 114 + } 115 + /> 116 + ); 117 + 118 + const isHighlightedFirst = highlightFirstPost && index === 0; 119 + const Variant = isHighlightedFirst 120 + ? "large" 121 + : view === "compact" 122 + ? "small" 123 + : "medium"; 124 + 125 + const postDid = new AtUri(post.uri).host; 126 + const coverImageSrc = doc_record.coverImage 127 + ? blobRefToSrc(doc_record.coverImage.ref, postDid) 128 + : undefined; 129 + 130 + if (Variant === "large") { 131 + return ( 132 + <PublicationPostItemLarge 133 + key={post.uri} 134 + href={docUrl} 135 + title={doc_record.title} 136 + description={ 137 + doc_record.description || getFirstParagraph(doc_record) 138 + } 139 + date={date} 140 + interactions={interactions} 141 + coverImageSrc={coverImageSrc} 142 + coverImageAlt={doc_record.title} 143 + pageWidth={publicationRecord?.theme?.pageWidth} 144 + /> 145 + ); 146 + } 147 + 148 + if (Variant === "small") { 149 + return ( 150 + <PublicationPostItemSmall 151 + key={post.uri} 152 + href={docUrl} 153 + title={doc_record.title} 154 + date={date} 155 + interactions={interactions} 156 + /> 157 + ); 158 + } 159 + 160 + return ( 161 + <PublicationPostItemMedium 162 + key={post.uri} 163 + href={docUrl} 164 + title={doc_record.title} 165 + description={ 166 + doc_record.description || getFirstParagraph(doc_record) 167 + } 168 + date={date} 169 + interactions={interactions} 170 + coverImageSrc={coverImageSrc} 171 + coverImageAlt={doc_record.title} 172 + /> 173 + ); 174 + })} 175 + </div> 176 + ); 177 + }
+80
app/lish/[did]/[publication]/PublicationStickyHeader.tsx
··· 1 + "use client"; 2 + import { useEffect, useRef } from "react"; 3 + import { usePathname } from "next/navigation"; 4 + 5 + export function PublicationStickyHeader(props: { 6 + sticky?: boolean; 7 + nav?: React.ReactNode; 8 + children: React.ReactNode; 9 + }) { 10 + let ref = useRef<HTMLDivElement>(null); 11 + let pathname = usePathname(); 12 + let sticky = props.sticky ?? true; 13 + 14 + useEffect(() => { 15 + let el = ref.current; 16 + let parent = el?.parentElement; 17 + if (!el || !parent) return; 18 + 19 + let rafId: number | null = null; 20 + let writeProgress = (target: HTMLElement) => { 21 + let p = Math.min(Math.max(target.scrollTop / 100, 0), 1); 22 + el!.style.setProperty("--header-shrink", String(p)); 23 + }; 24 + let schedule = (target: HTMLElement) => { 25 + if (rafId !== null) return; 26 + rafId = requestAnimationFrame(() => { 27 + rafId = null; 28 + writeProgress(target); 29 + }); 30 + }; 31 + 32 + let onScroll = (e: Event) => { 33 + let t = e.target; 34 + if (!(t instanceof HTMLElement)) return; 35 + if (!t.matches(".publicationScrollContainer")) return; 36 + schedule(t); 37 + }; 38 + parent.addEventListener("scroll", onScroll, { 39 + passive: true, 40 + capture: true, 41 + }); 42 + 43 + let initial = ( 44 + parent.matches(".publicationScrollContainer") 45 + ? parent 46 + : parent.querySelector(".publicationScrollContainer") 47 + ) as HTMLElement | null; 48 + if (initial) writeProgress(initial); 49 + 50 + return () => { 51 + if (rafId !== null) cancelAnimationFrame(rafId); 52 + parent.removeEventListener("scroll", onScroll, true); 53 + }; 54 + }, []); 55 + 56 + useEffect(() => { 57 + ref.current?.style.setProperty("--header-shrink", "0"); 58 + }, [pathname]); 59 + 60 + return ( 61 + <div 62 + ref={ref} 63 + className={ 64 + sticky 65 + ? "pubStickyHeader sticky top-0 z-10 bg-bg-page shrink-0" 66 + : "pubStickyHeader shrink-0" 67 + } 68 + > 69 + <div 70 + className="sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4" 71 + style={{ 72 + paddingTop: "calc(20px - 20px * var(--header-shrink, 0))", 73 + }} 74 + > 75 + {props.children} 76 + </div> 77 + {props.nav} 78 + </div> 79 + ); 80 + }
+6
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
··· 14 14 PubLeafletPublication, 15 15 } from "lexicons/api"; 16 16 import { AppBskyFeedDefs } from "@atproto/api"; 17 + import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; 17 18 import { TextBlock } from "./TextBlock"; 18 19 import { useDocument } from "contexts/DocumentContext"; 19 20 import { openPage, useOpenPages } from "../postPageState"; ··· 35 36 className?: string; 36 37 prerenderedCodeBlocks?: Map<string, string>; 37 38 bskyPostData: AppBskyFeedDefs.PostView[]; 39 + standardSitePostData: StandardSitePostData[]; 38 40 isCanvas?: boolean; 39 41 pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 40 42 }) { ··· 70 72 did={props.did} 71 73 pageId={props.pageId} 72 74 bskyPostData={props.bskyPostData} 75 + standardSitePostData={props.standardSitePostData} 73 76 pages={props.pages || []} 74 77 /> 75 78 ) : ( ··· 197 200 blocks={props.blocks} 198 201 preview 199 202 bskyPostData={[]} 203 + standardSitePostData={[]} 200 204 /> 201 205 </div> 202 206 </div> ··· 269 273 did: string; 270 274 pageId: string; 271 275 bskyPostData: AppBskyFeedDefs.PostView[]; 276 + standardSitePostData: StandardSitePostData[]; 272 277 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 273 278 }) => { 274 279 let pageWidth = `var(--page-width-unitless)`; ··· 332 337 pageId={props.pageId} 333 338 pages={props.pages} 334 339 bskyPostData={props.bskyPostData} 340 + standardSitePostData={props.standardSitePostData} 335 341 block={linearBlock} 336 342 did={props.did} 337 343 index={[index]}
+9
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 22 22 import { useDrawerOpen } from "./Interactions/InteractionDrawer"; 23 23 import { PollData } from "./fetchPollData"; 24 24 import { SharedPageProps } from "./PostPages"; 25 + import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; 25 26 import { useIsMobile } from "src/hooks/isMobile"; 26 27 27 28 export function CanvasPage({ ··· 41 42 theme, 42 43 prerenderedCodeBlocks, 43 44 bskyPostData, 45 + standardSitePostData, 44 46 pollData, 45 47 document_uri, 46 48 pageId, ··· 78 80 did={did} 79 81 prerenderedCodeBlocks={prerenderedCodeBlocks} 80 82 bskyPostData={bskyPostData} 83 + standardSitePostData={standardSitePostData} 81 84 pollData={pollData} 82 85 pageId={pageId} 83 86 pages={pages} ··· 91 94 did, 92 95 prerenderedCodeBlocks, 93 96 bskyPostData, 97 + standardSitePostData, 94 98 pageId, 95 99 pollData, 96 100 pages, ··· 100 104 prerenderedCodeBlocks?: Map<string, string>; 101 105 pollData: PollData[]; 102 106 bskyPostData: AppBskyFeedDefs.PostView[]; 107 + standardSitePostData: StandardSitePostData[]; 103 108 pageId?: string; 104 109 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 105 110 }) { ··· 132 137 pollData={pollData} 133 138 prerenderedCodeBlocks={prerenderedCodeBlocks} 134 139 bskyPostData={bskyPostData} 140 + standardSitePostData={standardSitePostData} 135 141 pageId={pageId} 136 142 pages={pages} 137 143 index={index} ··· 148 154 did, 149 155 prerenderedCodeBlocks, 150 156 bskyPostData, 157 + standardSitePostData, 151 158 pollData, 152 159 pageId, 153 160 pages, ··· 157 164 did: string; 158 165 prerenderedCodeBlocks?: Map<string, string>; 159 166 bskyPostData: AppBskyFeedDefs.PostView[]; 167 + standardSitePostData: StandardSitePostData[]; 160 168 pollData: PollData[]; 161 169 pageId?: string; 162 170 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; ··· 187 195 pageId={pageId} 188 196 pages={pages} 189 197 bskyPostData={bskyPostData} 198 + standardSitePostData={standardSitePostData} 190 199 block={linearBlock} 191 200 did={did} 192 201 index={[index]}
+28 -116
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 1 1 import { AtpAgent } from "@atproto/api"; 2 - import { ids } from "lexicons/api/lexicons"; 3 2 import { 4 - PubLeafletBlocksBskyPost, 5 - PubLeafletBlocksOrderedList, 6 - PubLeafletBlocksUnorderedList, 7 3 PubLeafletPagesLinearDocument, 8 4 PubLeafletPagesCanvas, 9 - PubLeafletBlocksPoll, 10 5 } from "lexicons/api"; 11 - import { type $Typed } from "lexicons/api/util"; 12 6 import { QuoteHandler } from "./QuoteHandler"; 13 7 import { 14 8 PublicationBackgroundProvider, ··· 16 10 } from "components/ThemeManager/PublicationThemeProvider"; 17 11 import { getPostPageData } from "./getPostPageData"; 18 12 import { PostPages } from "./PostPages"; 19 - import { extractCodeBlocks } from "./extractCodeBlocks"; 13 + import { collectAndFetchBlockResources } from "./collectAndFetchBlockResources"; 20 14 import { LeafletLayout } from "components/LeafletLayout"; 21 - import { fetchPollData } from "./fetchPollData"; 22 - import { 23 - getDocumentPages, 24 - hasLeafletContent, 25 - } from "src/utils/normalizeRecords"; 15 + import { getDocumentPages } from "src/utils/normalizeRecords"; 26 16 import { DocumentProvider } from "contexts/DocumentContext"; 27 17 import { LeafletContentProvider } from "contexts/LeafletContentContext"; 28 18 import { FontLoader } from "components/FontLoader"; 29 19 import { mergePreferences } from "src/utils/mergePreferences"; 20 + import { PublicationNav } from "../PublicationNav"; 21 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 30 22 31 23 export async function DocumentPageRenderer({ 32 24 did, ··· 69 61 </div> 70 62 </div> 71 63 ); 72 - let bskyPosts = 73 - pages.flatMap((p) => { 74 - let page = p as PubLeafletPagesLinearDocument.Main; 75 - return extractBlocksByType<$Typed<PubLeafletBlocksBskyPost.Main>>( 76 - page.blocks || [], 77 - ids.PubLeafletBlocksBskyPost, 78 - ); 79 - }) || []; 80 64 81 - // Batch bsky posts into groups of 25 and fetch in parallel 82 - let bskyPostBatches = []; 83 - for (let i = 0; i < bskyPosts.length; i += 25) { 84 - bskyPostBatches.push(bskyPosts.slice(i, i + 25)); 85 - } 86 - 87 - let bskyPostResponses = await Promise.all( 88 - bskyPostBatches.map((batch) => 89 - agent.getPosts( 90 - { 91 - uris: batch.map((p) => { 92 - let block = p?.block as unknown as PubLeafletBlocksBskyPost.Main; 93 - return block.postRef.uri; 94 - }), 95 - }, 96 - { headers: {} }, 97 - ), 98 - ), 99 - ); 100 - 101 - let bskyPostData = 102 - bskyPostResponses.length > 0 103 - ? bskyPostResponses.flatMap((response) => response.data.posts) 104 - : []; 105 - 106 - // Extract poll blocks and fetch vote data 107 - let pollBlocks = pages.flatMap((p) => { 108 - let page = p as PubLeafletPagesLinearDocument.Main; 109 - return extractBlocksByType<$Typed<PubLeafletBlocksPoll.Main>>( 110 - page.blocks || [], 111 - ids.PubLeafletBlocksPoll, 112 - ); 65 + const { 66 + bskyPostData, 67 + standardSitePostData: standardSitePosts, 68 + pollData, 69 + prerenderedCodeBlocks, 70 + } = await collectAndFetchBlockResources({ 71 + agent, 72 + pages: pages as ( 73 + | PubLeafletPagesLinearDocument.Main 74 + | PubLeafletPagesCanvas.Main 75 + )[], 113 76 }); 114 - let pollData = await fetchPollData( 115 - pollBlocks.map((b) => b.block.pollRef.uri), 116 - ); 117 77 118 78 const pubRecord = document.normalizedPublication; 119 79 let pub_creator = document.publication?.identity_did || did; 120 80 let isStandalone = !pubRecord; 121 81 122 - let firstPage = pages[0]; 123 - let firstPageBlocks = 124 - ( 125 - firstPage as 126 - | PubLeafletPagesLinearDocument.Main 127 - | PubLeafletPagesCanvas.Main 128 - ).blocks || []; 129 - let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); 130 - 131 82 return ( 132 83 <DocumentProvider value={document}> 133 84 <LeafletContentProvider value={{ pages }}> ··· 145 96 pub_creator={pub_creator} 146 97 > 147 98 <LeafletLayout> 99 + {document.publication?.pages?.length ? ( 100 + <PublicationNav 101 + publicationUrl={getPublicationURL(document.publication)} 102 + pages={document.publication.pages} 103 + activePath={null} 104 + /> 105 + ) : null} 148 106 <PostPages 149 107 document_uri={document.uri} 150 108 preferences={mergePreferences( ··· 152 110 pubRecord?.preferences, 153 111 )} 154 112 pubRecord={pubRecord} 155 - profile={profile ? JSON.parse(JSON.stringify(profile)) : undefined} 113 + profile={ 114 + profile ? JSON.parse(JSON.stringify(profile)) : undefined 115 + } 156 116 document={document} 157 117 bskyPostData={JSON.parse(JSON.stringify(bskyPostData))} 118 + standardSitePostData={JSON.parse( 119 + JSON.stringify(standardSitePosts), 120 + )} 158 121 did={did} 159 122 prerenderedCodeBlocks={prerenderedCodeBlocks} 160 123 pollData={pollData} ··· 168 131 </DocumentProvider> 169 132 ); 170 133 } 171 - 172 - function extractBlocksByType<T extends { $type: string }>( 173 - blocks: PubLeafletPagesLinearDocument.Block[], 174 - type: string, 175 - ): { block: T }[] { 176 - let results: { block: T }[] = []; 177 - for (let b of blocks) { 178 - if (b.block.$type === type) { 179 - results.push(b as unknown as { block: T }); 180 - } 181 - if ( 182 - b.block.$type === ids.PubLeafletBlocksOrderedList || 183 - b.block.$type === ids.PubLeafletBlocksUnorderedList 184 - ) { 185 - let list = b.block as 186 - | PubLeafletBlocksOrderedList.Main 187 - | PubLeafletBlocksUnorderedList.Main; 188 - extractFromListItems(list.children, type, results); 189 - } 190 - } 191 - return results; 192 - } 193 - 194 - function extractFromListItems<T extends { $type: string }>( 195 - items: 196 - | PubLeafletBlocksOrderedList.ListItem[] 197 - | PubLeafletBlocksUnorderedList.ListItem[], 198 - type: string, 199 - results: { block: T }[], 200 - ) { 201 - for (let item of items) { 202 - if ((item.content as { $type?: string })?.$type === type) { 203 - results.push({ 204 - block: item.content as unknown as T, 205 - }); 206 - } 207 - if (item.children) { 208 - extractFromListItems(item.children, type, results); 209 - } 210 - let orderedChildren = (item as PubLeafletBlocksUnorderedList.ListItem) 211 - .orderedListChildren; 212 - if (orderedChildren) { 213 - extractFromListItems(orderedChildren.children, type, results); 214 - } 215 - let unorderedChildren = (item as PubLeafletBlocksOrderedList.ListItem) 216 - .unorderedListChildren; 217 - if (unorderedChildren) { 218 - extractFromListItems(unorderedChildren.children, type, results); 219 - } 220 - } 221 - }
+1
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 281 281 pollData={[]} 282 282 pages={[]} 283 283 bskyPostData={[]} 284 + standardSitePostData={[]} 284 285 blocks={content} 285 286 did={props.did} 286 287 preview
+2
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 38 38 preferences, 39 39 prerenderedCodeBlocks, 40 40 bskyPostData, 41 + standardSitePostData, 41 42 pollData, 42 43 document_uri, 43 44 pageId, ··· 85 86 pages={pages as PubLeafletPagesLinearDocument.Main[]} 86 87 pageId={pageId} 87 88 bskyPostData={bskyPostData} 89 + standardSitePostData={standardSitePostData} 88 90 blocks={blocks} 89 91 did={did} 90 92 prerenderedCodeBlocks={prerenderedCodeBlocks}
+80
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 14 14 PubLeafletBlocksHorizontalRule, 15 15 PubLeafletBlocksBlockquote, 16 16 PubLeafletBlocksBskyPost, 17 + PubLeafletBlocksStandardSitePost, 17 18 PubLeafletBlocksIframe, 18 19 PubLeafletBlocksPage, 19 20 PubLeafletBlocksPoll, 21 + PubLeafletBlocksPostsList, 20 22 PubLeafletBlocksButton, 21 23 } from "lexicons/api"; 24 + import { 25 + PublicationPostsList, 26 + type PublicationPostsListPost, 27 + } from "../PublicationPostsList"; 28 + import type { NormalizedPublication } from "src/utils/normalizeRecords"; 22 29 23 30 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 24 31 import { TextBlock } from "./Blocks/TextBlock"; ··· 29 36 import { PubCodeBlock } from "./Blocks/PubCodeBlock"; 30 37 import { AppBskyFeedDefs } from "@atproto/api"; 31 38 import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock"; 39 + import { StandardSitePostItemView } from "components/Blocks/StandardSitePostBlock/StandardSitePostItem"; 40 + import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; 32 41 import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock"; 33 42 import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; 34 43 import { PollData } from "./fetchPollData"; ··· 43 52 import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 44 53 import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 45 54 55 + export type PostsListData = { 56 + publication: { uri: string; record: unknown }; 57 + publicationRecord: NormalizedPublication | null; 58 + posts: PublicationPostsListPost[]; 59 + }; 60 + 46 61 export function PostContent({ 47 62 blocks, 48 63 did, ··· 50 65 className, 51 66 prerenderedCodeBlocks, 52 67 bskyPostData, 68 + standardSitePostData, 53 69 pageId, 54 70 pages, 55 71 pollData, 56 72 footnoteIndexMap, 73 + postsListData, 57 74 }: { 58 75 blocks: PubLeafletPagesLinearDocument.Block[]; 59 76 pageId?: string; ··· 62 79 className?: string; 63 80 prerenderedCodeBlocks?: Map<string, string>; 64 81 bskyPostData: AppBskyFeedDefs.PostView[]; 82 + standardSitePostData: StandardSitePostData[]; 65 83 pollData: PollData[]; 66 84 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 67 85 footnoteIndexMap?: Map<string, number>; 86 + postsListData?: PostsListData; 68 87 }) { 69 88 return ( 70 89 <div ··· 77 96 pageId={pageId} 78 97 pages={pages} 79 98 bskyPostData={bskyPostData} 99 + standardSitePostData={standardSitePostData} 80 100 block={b} 81 101 did={did} 82 102 key={index} ··· 87 107 prerenderedCodeBlocks={prerenderedCodeBlocks} 88 108 pollData={pollData} 89 109 footnoteIndexMap={footnoteIndexMap} 110 + postsListData={postsListData} 90 111 isFirst={index === 0} 91 112 isLast={index === blocks.length - 1} 92 113 /> ··· 106 127 nextBlock, 107 128 prerenderedCodeBlocks, 108 129 bskyPostData, 130 + standardSitePostData, 109 131 pageId, 110 132 pages, 111 133 pollData, 112 134 footnoteIndexMap, 135 + postsListData, 113 136 isFirst, 114 137 isLast, 115 138 }: { ··· 124 147 nextBlock?: PubLeafletPagesLinearDocument.Block; 125 148 prerenderedCodeBlocks?: Map<string, string>; 126 149 bskyPostData: AppBskyFeedDefs.PostView[]; 150 + standardSitePostData: StandardSitePostData[]; 127 151 pollData: PollData[]; 128 152 footnoteIndexMap?: Map<string, number>; 153 + postsListData?: PostsListData; 129 154 isFirst?: boolean; 130 155 isLast?: boolean; 131 156 }) => { ··· 187 212 parentPageId={pageId} 188 213 did={did} 189 214 bskyPostData={bskyPostData} 215 + standardSitePostData={standardSitePostData} 190 216 isCanvas={isCanvas} 191 217 pages={pages} 192 218 className={className} ··· 206 232 /> 207 233 ); 208 234 } 235 + case PubLeafletBlocksStandardSitePost.isMain(b.block): { 236 + let uri = b.block.uri; 237 + let post = standardSitePostData.find((p) => p.uri === uri); 238 + if (!post) { 239 + return ( 240 + <div className={className} {...blockProps}> 241 + <p className="text-sm italic text-tertiary">Post not found.</p> 242 + </div> 243 + ); 244 + } 245 + let size: "large" | "medium" | "small" = 246 + b.block.size === "large" 247 + ? "large" 248 + : b.block.size === "medium" 249 + ? "medium" 250 + : "small"; 251 + return ( 252 + <div className={className} {...blockProps}> 253 + <StandardSitePostItemView post={post} size={size} /> 254 + </div> 255 + ); 256 + } 209 257 case PubLeafletBlocksIframe.isMain(b.block): { 210 258 return ( 211 259 <PublishedIframeBlock ··· 219 267 case PubLeafletBlocksHorizontalRule.isMain(b.block): { 220 268 return <hr className="my-2 w-full border-border-light" />; 221 269 } 270 + case PubLeafletBlocksPostsList.isMain(b.block): { 271 + if (!postsListData) return null; 272 + const view: "compact" | "full" = 273 + b.block.view === "compact" ? "compact" : "full"; 274 + const filterByTag = b.block.filterByTag; 275 + const posts = filterByTag 276 + ? postsListData.posts.filter((p) => 277 + p.record.tags?.includes(filterByTag), 278 + ) 279 + : postsListData.posts; 280 + return ( 281 + <div className={className} {...blockProps}> 282 + <PublicationPostsList 283 + publication={postsListData.publication} 284 + publicationRecord={postsListData.publicationRecord} 285 + posts={posts} 286 + view={view} 287 + highlightFirstPost={!!b.block.highlightFirstPost} 288 + /> 289 + </div> 290 + ); 291 + } 222 292 case PubLeafletBlocksPoll.isMain(b.block): { 223 293 let { cid, uri } = b.block.pollRef; 224 294 const pollVoteData = pollData.find((p) => p.uri === uri && p.cid === cid); ··· 250 320 pollData={pollData} 251 321 pages={pages} 252 322 bskyPostData={bskyPostData} 323 + standardSitePostData={standardSitePostData} 253 324 index={[...index, i]} 254 325 item={child} 255 326 did={did} ··· 271 342 pollData={pollData} 272 343 pages={pages} 273 344 bskyPostData={bskyPostData} 345 + standardSitePostData={standardSitePostData} 274 346 index={[...index, i]} 275 347 item={child} 276 348 did={did} ··· 547 619 did: string; 548 620 className?: string; 549 621 bskyPostData: AppBskyFeedDefs.PostView[]; 622 + standardSitePostData: StandardSitePostData[]; 550 623 pollData: PollData[]; 551 624 pageId?: string; 552 625 footnoteIndexMap?: Map<string, number>; ··· 558 631 pages={props.pages} 559 632 pollData={props.pollData} 560 633 bskyPostData={props.bskyPostData} 634 + standardSitePostData={props.standardSitePostData} 561 635 index={[...props.index, index]} 562 636 item={child} 563 637 did={props.did} ··· 576 650 pages={props.pages} 577 651 pollData={props.pollData} 578 652 bskyPostData={props.bskyPostData} 653 + standardSitePostData={props.standardSitePostData} 579 654 index={[...props.index, index]} 580 655 item={child} 581 656 did={props.did} ··· 606 681 pollData={props.pollData} 607 682 pages={props.pages} 608 683 bskyPostData={props.bskyPostData} 684 + standardSitePostData={props.standardSitePostData} 609 685 block={{ block: props.item.content }} 610 686 did={props.did} 611 687 isList ··· 627 703 did: string; 628 704 className?: string; 629 705 bskyPostData: AppBskyFeedDefs.PostView[]; 706 + standardSitePostData: StandardSitePostData[]; 630 707 pollData: PollData[]; 631 708 pageId?: string; 632 709 startIndex?: number; ··· 641 718 pages={props.pages} 642 719 pollData={props.pollData} 643 720 bskyPostData={props.bskyPostData} 721 + standardSitePostData={props.standardSitePostData} 644 722 index={[...props.index, index]} 645 723 item={child} 646 724 did={props.did} ··· 660 738 pages={props.pages} 661 739 pollData={props.pollData} 662 740 bskyPostData={props.bskyPostData} 741 + standardSitePostData={props.standardSitePostData} 663 742 index={[...props.index, index]} 664 743 item={child} 665 744 did={props.did} ··· 689 768 pollData={props.pollData} 690 769 pages={props.pages} 691 770 bskyPostData={props.bskyPostData} 771 + standardSitePostData={props.standardSitePostData} 692 772 block={{ block: props.item.content }} 693 773 did={props.did} 694 774 isList
+5
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 19 19 import { CloseTiny } from "components/Icons/CloseTiny"; 20 20 import { Fragment } from "react"; 21 21 import { PollData } from "./fetchPollData"; 22 + import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; 22 23 import { LinearDocumentPage } from "./LinearDocumentPage"; 23 24 import { CanvasPage } from "./CanvasPage"; 24 25 import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; ··· 62 63 theme?: PubLeafletPublication.Theme | null; 63 64 prerenderedCodeBlocks?: Map<string, string>; 64 65 bskyPostData: AppBskyFeedDefs.PostView[]; 66 + standardSitePostData: StandardSitePostData[]; 65 67 pollData: PollData[]; 66 68 document_uri: string; 67 69 fullPageScroll: boolean; ··· 107 109 pubRecord, 108 110 prerenderedCodeBlocks, 109 111 bskyPostData, 112 + standardSitePostData, 110 113 document_uri, 111 114 pollData, 112 115 }: { ··· 117 120 did: string; 118 121 prerenderedCodeBlocks?: Map<string, string>; 119 122 bskyPostData: AppBskyFeedDefs.PostView[]; 123 + standardSitePostData: StandardSitePostData[]; 120 124 preferences: { 121 125 showComments?: boolean; 122 126 showMentions?: boolean; ··· 153 157 theme, 154 158 prerenderedCodeBlocks, 155 159 bskyPostData, 160 + standardSitePostData, 156 161 pollData, 157 162 document_uri, 158 163 hasPageBackground,
+221
app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx
··· 1 + import { 2 + PubLeafletBlocksPostsList, 3 + PubLeafletPagesLinearDocument, 4 + PubLeafletPublicationPage, 5 + } from "lexicons/api"; 6 + import { AtpAgent } from "@atproto/api"; 7 + 8 + import { 9 + normalizeDocumentRecord, 10 + normalizePublicationRecord, 11 + type NormalizedPublication, 12 + } from "src/utils/normalizeRecords"; 13 + 14 + import { 15 + PublicationBackgroundProvider, 16 + PublicationThemeProvider, 17 + } from "components/ThemeManager/PublicationThemeProvider"; 18 + import { FontLoader } from "components/FontLoader"; 19 + import { LeafletContentProvider } from "contexts/LeafletContentContext"; 20 + import { DocumentProvider } from "contexts/DocumentContext"; 21 + import type { DocumentContextValue } from "contexts/DocumentContext"; 22 + import { 23 + type PublicationPostsListPost, 24 + } from "../PublicationPostsList"; 25 + import { PublicationNav } from "../PublicationNav"; 26 + import { PublicationHeader } from "../PublicationHeader"; 27 + import { PublicationHomeLayout } from "../PublicationHomeLayout"; 28 + import { PublicationStickyHeader } from "../PublicationStickyHeader"; 29 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 30 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 31 + 32 + import { collectAndFetchBlockResources } from "./collectAndFetchBlockResources"; 33 + import { PostContent } from "./PostContent"; 34 + 35 + export type PublicationPageRecord = PubLeafletPublicationPage.Record; 36 + 37 + type PublicationRow = { 38 + uri: string; 39 + name: string; 40 + identity_did: string; 41 + record: unknown; 42 + publication_pages?: { 43 + id: number; 44 + path: string | null; 45 + title: string; 46 + record_uri: string | null; 47 + sort_order: string; 48 + }[]; 49 + documents_in_publications?: { 50 + documents: { 51 + uri: string; 52 + data: unknown; 53 + comments_on_documents?: { count: number }[]; 54 + document_mentions_in_bsky?: { count: number }[]; 55 + recommends_on_documents?: { count: number }[]; 56 + } | null; 57 + }[]; 58 + }; 59 + 60 + export async function PublicationPageRenderer({ 61 + did, 62 + page, 63 + publication, 64 + }: { 65 + did: string; 66 + page: { id: number; path: string; title: string | null; record: PublicationPageRecord }; 67 + publication: PublicationRow; 68 + }) { 69 + const normalizedPublication = normalizePublicationRecord(publication.record); 70 + const pages = page.record.content.pages || []; 71 + const firstPage = pages[0]; 72 + 73 + const allBlocks: PubLeafletPagesLinearDocument.Block[] = 74 + firstPage && firstPage.$type === "pub.leaflet.pages.linearDocument" 75 + ? ((firstPage as PubLeafletPagesLinearDocument.Main).blocks ?? []) 76 + : []; 77 + 78 + const agent = new AtpAgent({ 79 + service: "https://public.api.bsky.app", 80 + fetch: (...args) => 81 + fetch(args[0], { 82 + ...args[1], 83 + next: { revalidate: 3600 }, 84 + }), 85 + }); 86 + 87 + const resourcePages = 88 + firstPage && firstPage.$type === "pub.leaflet.pages.linearDocument" 89 + ? [firstPage as PubLeafletPagesLinearDocument.Main] 90 + : []; 91 + 92 + const { 93 + bskyPostData, 94 + standardSitePostData: standardSitePosts, 95 + pollData, 96 + prerenderedCodeBlocks, 97 + } = await collectAndFetchBlockResources({ agent, pages: resourcePages }); 98 + 99 + const hasPostsList = allBlocks.some((b) => 100 + PubLeafletBlocksPostsList.isMain(b.block), 101 + ); 102 + const postsListPosts: PublicationPostsListPost[] = hasPostsList 103 + ? (publication.documents_in_publications ?? []) 104 + .map((dip) => { 105 + if (!dip.documents) return null; 106 + const normalized = normalizeDocumentRecord( 107 + dip.documents.data, 108 + dip.documents.uri, 109 + ); 110 + if (!normalized) return null; 111 + return { 112 + uri: dip.documents.uri, 113 + record: normalized, 114 + commentsCount: dip.documents.comments_on_documents?.[0]?.count || 0, 115 + mentionsCount: 116 + dip.documents.document_mentions_in_bsky?.[0]?.count || 0, 117 + recommendsCount: 118 + dip.documents.recommends_on_documents?.[0]?.count || 0, 119 + }; 120 + }) 121 + .filter((p): p is PublicationPostsListPost => p !== null) 122 + : []; 123 + 124 + const postsListData = hasPostsList 125 + ? { 126 + publication: { uri: publication.uri, record: publication.record }, 127 + publicationRecord: normalizedPublication as NormalizedPublication | null, 128 + posts: postsListPosts, 129 + } 130 + : undefined; 131 + 132 + const theme = normalizedPublication?.theme; 133 + const showPageBackground = !!theme?.showPageBackground; 134 + 135 + const documentContextValue: DocumentContextValue = { 136 + uri: page.record.publication, 137 + normalizedDocument: null as unknown as DocumentContextValue["normalizedDocument"], 138 + normalizedPublication, 139 + theme, 140 + prevNext: undefined, 141 + quotesAndMentions: [], 142 + publication: { 143 + uri: publication.uri, 144 + name: publication.name, 145 + identity_did: publication.identity_did, 146 + record: publication.record as NonNullable< 147 + DocumentContextValue["publication"] 148 + >["record"], 149 + publication_subscriptions: [], 150 + newsletterMode: false, 151 + pages: (publication.publication_pages ?? []).filter((p) => p.record_uri), 152 + }, 153 + comments: [], 154 + mentions: [], 155 + leafletId: null, 156 + recommendsCount: 0, 157 + }; 158 + 159 + return ( 160 + <DocumentProvider value={documentContextValue}> 161 + <LeafletContentProvider value={{ pages }}> 162 + <FontLoader 163 + headingFontId={theme?.headingFont} 164 + bodyFontId={theme?.bodyFont} 165 + /> 166 + <PublicationThemeProvider 167 + theme={theme} 168 + pub_creator={publication.identity_did} 169 + > 170 + <PublicationBackgroundProvider 171 + theme={theme} 172 + pub_creator={publication.identity_did} 173 + > 174 + <PublicationHomeLayout 175 + uri={publication.uri} 176 + showPageBackground={showPageBackground} 177 + stickyHeader={ 178 + <PublicationStickyHeader 179 + nav={ 180 + <PublicationNav 181 + publicationUrl={getPublicationURL(publication)} 182 + pages={(publication.publication_pages ?? []).filter( 183 + (p) => p.record_uri, 184 + )} 185 + activePath={page.path} 186 + /> 187 + } 188 + > 189 + <PublicationHeader 190 + iconUrl={ 191 + normalizedPublication?.icon 192 + ? blobRefToSrc(normalizedPublication.icon.ref, did) 193 + : undefined 194 + } 195 + publicationName={publication.name} 196 + description={normalizedPublication?.description} 197 + /> 198 + </PublicationStickyHeader> 199 + } 200 + > 201 + <div className="pubPageContent pt-6"> 202 + <PostContent 203 + blocks={allBlocks} 204 + did={did} 205 + pages={pages as PubLeafletPagesLinearDocument.Main[]} 206 + bskyPostData={JSON.parse(JSON.stringify(bskyPostData))} 207 + standardSitePostData={JSON.parse( 208 + JSON.stringify(standardSitePosts), 209 + )} 210 + pollData={pollData} 211 + prerenderedCodeBlocks={prerenderedCodeBlocks} 212 + postsListData={postsListData} 213 + /> 214 + </div> 215 + </PublicationHomeLayout> 216 + </PublicationBackgroundProvider> 217 + </PublicationThemeProvider> 218 + </LeafletContentProvider> 219 + </DocumentProvider> 220 + ); 221 + }
+91
app/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts
··· 1 + import { ids } from "lexicons/api/lexicons"; 2 + import { 3 + PubLeafletBlocksBskyPost, 4 + PubLeafletBlocksPoll, 5 + PubLeafletBlocksStandardSitePost, 6 + PubLeafletPagesLinearDocument, 7 + PubLeafletPagesCanvas, 8 + } from "lexicons/api"; 9 + import { type $Typed } from "lexicons/api/util"; 10 + import { AtpAgent, AppBskyFeedDefs } from "@atproto/api"; 11 + import { supabaseServerClient } from "supabase/serverClient"; 12 + import { 13 + get_standard_site_posts, 14 + type StandardSitePostData, 15 + } from "app/api/rpc/[command]/get_standard_site_posts"; 16 + import { extractBlocksByType } from "./extractBlocksByType"; 17 + import { extractCodeBlocks } from "./extractCodeBlocks"; 18 + import { fetchPollData, type PollData } from "./fetchPollData"; 19 + 20 + type Page = 21 + | PubLeafletPagesLinearDocument.Main 22 + | PubLeafletPagesCanvas.Main; 23 + 24 + export async function collectAndFetchBlockResources({ 25 + agent, 26 + pages, 27 + }: { 28 + agent: AtpAgent; 29 + pages: Page[]; 30 + }): Promise<{ 31 + bskyPostData: AppBskyFeedDefs.PostView[]; 32 + standardSitePostData: StandardSitePostData[]; 33 + pollData: PollData[]; 34 + prerenderedCodeBlocks: Map<string, string>; 35 + }> { 36 + const allBlocks: PubLeafletPagesLinearDocument.Block[] = pages.flatMap( 37 + (p) => (p as PubLeafletPagesLinearDocument.Main).blocks ?? [], 38 + ); 39 + 40 + const bskyPostBlocks = extractBlocksByType< 41 + $Typed<PubLeafletBlocksBskyPost.Main> 42 + >(allBlocks, ids.PubLeafletBlocksBskyPost); 43 + const bskyPostBatches: typeof bskyPostBlocks[] = []; 44 + for (let i = 0; i < bskyPostBlocks.length; i += 25) { 45 + bskyPostBatches.push(bskyPostBlocks.slice(i, i + 25)); 46 + } 47 + const bskyPostResponses = await Promise.all( 48 + bskyPostBatches.map((batch) => 49 + agent.getPosts( 50 + { uris: batch.map((p) => p.block.postRef.uri) }, 51 + { headers: {} }, 52 + ), 53 + ), 54 + ); 55 + const bskyPostData = bskyPostResponses.flatMap((r) => r.data.posts); 56 + 57 + const standardSitePostUris = Array.from( 58 + new Set( 59 + extractBlocksByType<$Typed<PubLeafletBlocksStandardSitePost.Main>>( 60 + allBlocks, 61 + ids.PubLeafletBlocksStandardSitePost, 62 + ).map((b) => b.block.uri), 63 + ), 64 + ); 65 + const standardSitePostsResult = 66 + standardSitePostUris.length > 0 67 + ? await get_standard_site_posts.handler( 68 + { uris: standardSitePostUris }, 69 + { supabase: supabaseServerClient }, 70 + ) 71 + : { result: { posts: [] } }; 72 + const standardSitePostData = standardSitePostsResult.result.posts; 73 + 74 + const pollBlocks = extractBlocksByType<$Typed<PubLeafletBlocksPoll.Main>>( 75 + allBlocks, 76 + ids.PubLeafletBlocksPoll, 77 + ); 78 + const pollData = await fetchPollData( 79 + pollBlocks.map((b) => b.block.pollRef.uri), 80 + ); 81 + 82 + const firstPageBlocks = (pages[0] as Page | undefined)?.blocks ?? []; 83 + const prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); 84 + 85 + return { 86 + bskyPostData, 87 + standardSitePostData, 88 + pollData, 89 + prerenderedCodeBlocks, 90 + }; 91 + }
+55
app/lish/[did]/[publication]/[rkey]/extractBlocksByType.ts
··· 1 + import { ids } from "lexicons/api/lexicons"; 2 + import { 3 + PubLeafletBlocksOrderedList, 4 + PubLeafletBlocksUnorderedList, 5 + PubLeafletPagesLinearDocument, 6 + } from "lexicons/api"; 7 + 8 + export function extractBlocksByType<T extends { $type: string }>( 9 + blocks: PubLeafletPagesLinearDocument.Block[], 10 + type: string, 11 + ): { block: T }[] { 12 + const results: { block: T }[] = []; 13 + for (const b of blocks) { 14 + if (b.block.$type === type) { 15 + results.push(b as unknown as { block: T }); 16 + } 17 + if ( 18 + b.block.$type === ids.PubLeafletBlocksOrderedList || 19 + b.block.$type === ids.PubLeafletBlocksUnorderedList 20 + ) { 21 + const list = b.block as 22 + | PubLeafletBlocksOrderedList.Main 23 + | PubLeafletBlocksUnorderedList.Main; 24 + extractFromListItems(list.children, type, results); 25 + } 26 + } 27 + return results; 28 + } 29 + 30 + function extractFromListItems<T extends { $type: string }>( 31 + items: 32 + | PubLeafletBlocksOrderedList.ListItem[] 33 + | PubLeafletBlocksUnorderedList.ListItem[], 34 + type: string, 35 + results: { block: T }[], 36 + ) { 37 + for (const item of items) { 38 + if ((item.content as { $type?: string })?.$type === type) { 39 + results.push({ block: item.content as unknown as T }); 40 + } 41 + if (item.children) { 42 + extractFromListItems(item.children, type, results); 43 + } 44 + const orderedChildren = (item as PubLeafletBlocksUnorderedList.ListItem) 45 + .orderedListChildren; 46 + if (orderedChildren) { 47 + extractFromListItems(orderedChildren.children, type, results); 48 + } 49 + const unorderedChildren = (item as PubLeafletBlocksOrderedList.ListItem) 50 + .unorderedListChildren; 51 + if (unorderedChildren) { 52 + extractFromListItems(unorderedChildren.children, type, results); 53 + } 54 + } 55 + }
+3 -1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 19 19 documents_in_publications(publications(*, 20 20 documents_in_publications(documents(uri, data)), 21 21 publication_subscriptions(*), 22 - publication_newsletter_settings(enabled)) 22 + publication_newsletter_settings(enabled), 23 + publication_pages(id, path, title, record_uri, sort_order)) 23 24 ), 24 25 document_mentions_in_bsky(*), 25 26 leaflets_in_publications(*), ··· 143 144 | null, 144 145 publication_subscriptions: rawPub.publication_subscriptions || [], 145 146 newsletterMode: !!rawPub.publication_newsletter_settings?.enabled, 147 + pages: (rawPub.publication_pages || []).filter((p) => p.record_uri), 146 148 } 147 149 : null; 148 150 const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0;
+51 -2
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { Metadata } from "next"; 3 3 import { DocumentPageRenderer } from "./DocumentPageRenderer"; 4 + import { tryRenderPublicationPage } from "../tryRenderPublicationPage"; 4 5 import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 5 - import { documentUriFilter } from "src/utils/uriHelpers"; 6 + import { 7 + documentUriFilter, 8 + publicationNameOrUriFilter, 9 + } from "src/utils/uriHelpers"; 6 10 7 11 export async function generateMetadata(props: { 8 12 params: Promise<{ publication: string; did: string; rkey: string }>; 9 13 }): Promise<Metadata> { 10 14 let params = await props.params; 11 15 let did = decodeURIComponent(params.did); 16 + let publication_name = decodeURIComponent(params.publication); 17 + let rkey = decodeURIComponent(params.rkey); 12 18 if (!did) return { title: "Publication 404" }; 13 19 20 + let { data: pubs } = await supabaseServerClient 21 + .from("publications") 22 + .select("name, publication_pages(path, title, record_uri)") 23 + .eq("identity_did", did) 24 + .or(publicationNameOrUriFilter(did, publication_name)) 25 + .order("uri", { ascending: false }) 26 + .limit(1); 27 + let matchingPage = pubs?.[0]?.publication_pages?.find( 28 + (p) => p.path === "/" + rkey && p.record_uri, 29 + ); 30 + if (matchingPage) { 31 + return { 32 + title: `${matchingPage.title || matchingPage.path} - ${pubs?.[0]?.name}`, 33 + }; 34 + } 35 + 14 36 let [{ data: documents }] = await Promise.all([ 15 37 supabaseServerClient 16 38 .from("documents") ··· 55 77 }) { 56 78 let params = await props.params; 57 79 let did = decodeURIComponent(params.did); 80 + let publication_name = decodeURIComponent(params.publication); 81 + let rkey = decodeURIComponent(params.rkey); 58 82 59 83 if (!did) 60 84 return ( ··· 67 91 </div> 68 92 ); 69 93 70 - return <DocumentPageRenderer did={did} rkey={params.rkey} />; 94 + let { data: publications } = await supabaseServerClient 95 + .from("publications") 96 + .select( 97 + `uri, name, identity_did, record, 98 + publication_pages(id, path, title, record, record_uri, sort_order), 99 + documents_in_publications(documents(uri, data, 100 + comments_on_documents(count), 101 + document_mentions_in_bsky(count), 102 + recommends_on_documents(count)))`, 103 + ) 104 + .eq("identity_did", did) 105 + .or(publicationNameOrUriFilter(did, publication_name)) 106 + .order("uri", { ascending: false }) 107 + .limit(1); 108 + let publication = publications?.[0]; 109 + 110 + if (publication) { 111 + const pageRender = tryRenderPublicationPage({ 112 + did, 113 + publication, 114 + path: "/" + rkey, 115 + }); 116 + if (pageRender) return pageRender; 117 + } 118 + 119 + return <DocumentPageRenderer did={did} rkey={rkey} />; 71 120 }
+58
app/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx
··· 1 + "use client"; 2 + import { ActionButton } from "components/ActionBar/ActionButton"; 3 + import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 4 + import { useRouter } from "next/navigation"; 5 + import { useState } from "react"; 6 + import { createPublicationPage } from "actions/createPublicationPage"; 7 + import { useHasEntitlement } from "src/hooks/useEntitlement"; 8 + import { usePublicationData } from "./PublicationSWRProvider"; 9 + 10 + export function EditPagesNavLink(props: { 11 + publication: string; 12 + did: string; 13 + publicationName: string; 14 + }) { 15 + let router = useRouter(); 16 + let { data, mutate } = usePublicationData(); 17 + let [loading, setLoading] = useState(false); 18 + let editPubPagesEnabled = useHasEntitlement("edit-pub-pages"); 19 + 20 + let pages = data?.publication?.publication_pages || []; 21 + 22 + async function handleClick() { 23 + if (loading) return; 24 + let targetPath: string | null; 25 + let existing = pages[0]; 26 + if (existing) { 27 + targetPath = existing.path; 28 + } else { 29 + setLoading(true); 30 + let created = await createPublicationPage({ 31 + publication_uri: props.publication, 32 + path: "/", 33 + title: "home", 34 + includePostsList: true, 35 + }); 36 + setLoading(false); 37 + if (!created) return; 38 + targetPath = created.path; 39 + mutate(); 40 + } 41 + let routeSegment = targetPath && targetPath !== "/" ? targetPath : ""; 42 + router.push( 43 + `/lish/${props.did}/${props.publicationName}/edit${routeSegment}`, 44 + ); 45 + } 46 + 47 + if (!editPubPagesEnabled) return null; 48 + 49 + return ( 50 + <ActionButton 51 + id="edit-pages-button" 52 + icon={<BlockDocPageSmall />} 53 + label={loading ? "Creating..." : "Edit Pages"} 54 + onClick={handleClick} 55 + className="w-full" 56 + /> 57 + ); 58 + }
+11 -1
app/lish/[did]/[publication]/dashboard/layout.tsx
··· 11 11 import { DashboardShell } from "components/PageLayouts/DashboardShell"; 12 12 import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; 13 13 import { Actions } from "./Actions"; 14 + import { EditPagesNavLink } from "./EditPagesNavLink"; 14 15 import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; 15 16 import { AnalyticsSmall } from "components/Icons/AnalyticsSmall"; 16 17 import { SubscribersSmall } from "components/Icons/SubscribersSmall"; ··· 98 99 id={publication.uri} 99 100 publication={publication.uri} 100 101 pageTitle={<PageTitle pageTitle={record.name} showBackButton />} 101 - actions={<Actions publication={publication.uri} />} 102 + actions={ 103 + <> 104 + <Actions publication={publication.uri} /> 105 + <EditPagesNavLink 106 + publication={publication.uri} 107 + did={uri.host} 108 + publicationName={uri.rkey} 109 + /> 110 + </> 111 + } 102 112 tabs={{ 103 113 Drafts: { href: baseHref, icon: <ArchiveSmall /> }, 104 114 Posts: { href: `${baseHref}/posts`, icon: <PublishSmall /> },
+78
app/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx
··· 1 + "use client"; 2 + import { useState } from "react"; 3 + import { SpeedyLink } from "components/SpeedyLink"; 4 + import { GoToArrowLined } from "components/Icons/GoToArrowLined"; 5 + import { publishPublicationPages } from "actions/publishPublicationPages"; 6 + import { useToaster } from "components/Toast"; 7 + import { usePublicationData } from "../../dashboard/PublicationSWRProvider"; 8 + 9 + type Status = "idle" | "publishing" | "success"; 10 + 11 + export function PublicationEditHeader(props: { 12 + did: string; 13 + publicationName: string; 14 + }) { 15 + let { data } = usePublicationData(); 16 + let publicationUri = data?.publication?.uri; 17 + let [status, setStatus] = useState<Status>("idle"); 18 + let toaster = useToaster(); 19 + 20 + let dashboardHref = `/lish/${props.did}/${props.publicationName}/dashboard`; 21 + 22 + async function handlePublish() { 23 + if (!publicationUri || status === "publishing") return; 24 + setStatus("publishing"); 25 + try { 26 + const result = await publishPublicationPages({ 27 + publication_uri: publicationUri, 28 + }); 29 + if (result.success) { 30 + setStatus("success"); 31 + setTimeout(() => setStatus("idle"), 2000); 32 + } else { 33 + setStatus("idle"); 34 + toaster({ 35 + type: "error", 36 + content: 37 + result.error.type === "oauth_session_expired" 38 + ? "Sign in again to publish" 39 + : result.error.message, 40 + }); 41 + } 42 + } catch (e) { 43 + setStatus("idle"); 44 + toaster({ 45 + type: "error", 46 + content: e instanceof Error ? e.message : "Failed to publish", 47 + }); 48 + } 49 + } 50 + 51 + let label = 52 + status === "publishing" 53 + ? "Publishing..." 54 + : status === "success" 55 + ? "Published!" 56 + : "Update Publication"; 57 + 58 + return ( 59 + <div className="publicationEditHeader bg-accent-1 text-accent-2 px-4 py-2 flex items-center justify-between gap-2 shrink-0"> 60 + <SpeedyLink 61 + href={dashboardHref} 62 + className="flex items-center gap-1 text-accent-2 hover:no-underline! font-bold text-sm" 63 + aria-label="Back to dashboard" 64 + > 65 + <GoToArrowLined className="rotate-180" /> 66 + Back to Dashboard 67 + </SpeedyLink> 68 + <button 69 + type="button" 70 + onClick={handlePublish} 71 + disabled={status === "publishing" || !publicationUri} 72 + className="bg-accent-2 text-accent-1 font-bold px-3 py-1 rounded-md text-sm shrink-0 disabled:opacity-60" 73 + > 74 + {label} 75 + </button> 76 + </div> 77 + ); 78 + }
+52
app/lish/[did]/[publication]/edit/[[...route]]/PublicationPageLeaflet.tsx
··· 1 + "use client"; 2 + import { Fact, PermissionToken, ReplicacheProvider } from "src/replicache"; 3 + import type { Attribute } from "src/replicache/attributes"; 4 + import { SelectionManager } from "components/SelectionManager"; 5 + import { Pages } from "components/Pages"; 6 + import { 7 + PublicationThemeProvider, 8 + PublicationBackgroundProvider, 9 + } from "components/ThemeManager/PublicationThemeProvider"; 10 + import { EntitySetProvider } from "components/EntitySetProvider"; 11 + import { UpdateLeafletTitle } from "components/utils/UpdateLeafletTitle"; 12 + import { LeafletLayout } from "components/LeafletLayout"; 13 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 14 + import { Json } from "supabase/database.types"; 15 + 16 + export function PublicationPageLeaflet(props: { 17 + token: PermissionToken; 18 + initialFacts: Fact<Attribute>[]; 19 + leaflet_id: string; 20 + publicationRecord: Json | null; 21 + publicationCreator: string; 22 + }) { 23 + let normalizedPub = normalizePublicationRecord(props.publicationRecord); 24 + return ( 25 + <ReplicacheProvider 26 + rootEntity={props.leaflet_id} 27 + token={props.token} 28 + name={props.leaflet_id} 29 + initialFacts={props.initialFacts} 30 + > 31 + <EntitySetProvider 32 + set={props.token.permission_token_rights[0].entity_set} 33 + > 34 + <PublicationThemeProvider 35 + theme={normalizedPub?.theme} 36 + pub_creator={props.publicationCreator} 37 + > 38 + <PublicationBackgroundProvider 39 + theme={normalizedPub?.theme} 40 + pub_creator={props.publicationCreator} 41 + > 42 + <UpdateLeafletTitle entityID={props.leaflet_id} /> 43 + <SelectionManager /> 44 + <LeafletLayout className="!pb-6"> 45 + <Pages rootPage={props.leaflet_id} /> 46 + </LeafletLayout> 47 + </PublicationBackgroundProvider> 48 + </PublicationThemeProvider> 49 + </EntitySetProvider> 50 + </ReplicacheProvider> 51 + ); 52 + }
+251
app/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesNav.tsx
··· 1 + "use client"; 2 + import { useRouter, usePathname } from "next/navigation"; 3 + import { useMemo, useState } from "react"; 4 + import { 5 + DndContext, 6 + DragEndEvent, 7 + MeasuringStrategy, 8 + PointerSensor, 9 + KeyboardSensor, 10 + closestCenter, 11 + useSensor, 12 + useSensors, 13 + } from "@dnd-kit/core"; 14 + import { 15 + SortableContext, 16 + arrayMove, 17 + horizontalListSortingStrategy, 18 + sortableKeyboardCoordinates, 19 + useSortable, 20 + } from "@dnd-kit/sortable"; 21 + import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; 22 + import { CSS } from "@dnd-kit/utilities"; 23 + import { generateKeyBetween } from "fractional-indexing"; 24 + import { SpeedyLink } from "components/SpeedyLink"; 25 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 26 + import { InputWithLabel } from "components/Input"; 27 + import { Popover } from "components/Popover"; 28 + import { AddTiny } from "components/Icons/AddTiny"; 29 + import { createPublicationPage } from "actions/createPublicationPage"; 30 + import { reorderPublicationPages } from "actions/reorderPublicationPages"; 31 + import { 32 + usePublicationData, 33 + mutatePublicationData, 34 + } from "../../dashboard/PublicationSWRProvider"; 35 + import { sortPublicationPages } from "../../sortPublicationPages"; 36 + 37 + type SortablePage = { 38 + id: number; 39 + path: string | null; 40 + title: string; 41 + sort_order: string; 42 + }; 43 + 44 + export function PublicationPagesNav(props: { 45 + did: string; 46 + publicationName: string; 47 + }) { 48 + let router = useRouter(); 49 + let pathname = usePathname() ?? ""; 50 + let { data, mutate } = usePublicationData(); 51 + let [creating, setCreating] = useState(false); 52 + let [open, setOpen] = useState(false); 53 + let [name, setName] = useState(""); 54 + let [path, setPath] = useState("/"); 55 + 56 + let pages = data?.publication?.publication_pages ?? []; 57 + let publicationUri = data?.publication?.uri; 58 + 59 + let baseHref = `/lish/${props.did}/${props.publicationName}`; 60 + 61 + function hrefForPath(path: string | null) { 62 + let segment = path && path !== "/" ? path : ""; 63 + return `${baseHref}/edit${segment}`; 64 + } 65 + 66 + async function handleSubmit(e: React.FormEvent) { 67 + e.preventDefault(); 68 + if (creating || !publicationUri) return; 69 + let trimmedPath = path.trim(); 70 + if (!trimmedPath) return; 71 + if (!trimmedPath.startsWith("/")) trimmedPath = "/" + trimmedPath; 72 + setCreating(true); 73 + let created = await createPublicationPage({ 74 + publication_uri: publicationUri, 75 + path: trimmedPath, 76 + title: name.trim() || undefined, 77 + }); 78 + setCreating(false); 79 + if (!created) return; 80 + setOpen(false); 81 + setName(""); 82 + setPath("/"); 83 + await mutate(); 84 + router.push(hrefForPath(created.path)); 85 + } 86 + 87 + let sortedPages = useMemo(() => sortPublicationPages(pages), [pages]); 88 + let sortableIds = useMemo(() => sortedPages.map((p) => p.id), [sortedPages]); 89 + 90 + let sensors = useSensors( 91 + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), 92 + useSensor(KeyboardSensor, { 93 + coordinateGetter: sortableKeyboardCoordinates, 94 + }), 95 + ); 96 + 97 + function handleDragEnd(event: DragEndEvent) { 98 + let { active, over } = event; 99 + if (!over || active.id === over.id || !publicationUri) return; 100 + 101 + let activeId = Number(active.id); 102 + let overId = Number(over.id); 103 + let oldIndex = sortedPages.findIndex((p) => p.id === activeId); 104 + let newIndex = sortedPages.findIndex((p) => p.id === overId); 105 + if (oldIndex === -1 || newIndex === -1) return; 106 + 107 + let reordered = arrayMove(sortedPages, oldIndex, newIndex); 108 + let before = reordered[newIndex - 1]?.sort_order ?? null; 109 + let after = reordered[newIndex + 1]?.sort_order ?? null; 110 + let newSortOrder = generateKeyBetween(before, after); 111 + 112 + mutatePublicationData(mutate, (draft) => { 113 + let page = draft.publication?.publication_pages.find( 114 + (p) => p.id === activeId, 115 + ); 116 + if (page) page.sort_order = newSortOrder; 117 + }); 118 + 119 + reorderPublicationPages({ 120 + publication_uri: publicationUri, 121 + page_id: activeId, 122 + sort_order: newSortOrder, 123 + }).then((result) => { 124 + if (!result.success) mutate(); 125 + }); 126 + } 127 + 128 + return ( 129 + <nav className="publicationPagesNav border-t border-b border-border-light shrink-0 w-full sm:max-w-[calc(var(--page-width-units)*1.25)] mx-auto"> 130 + <div className="flex items-center gap-2 px-3 sm:px-4 py-2 overflow-x-auto w-full sm:max-w-(--page-width-units) mx-auto"> 131 + <div className="flex items-center gap-1 grow min-w-0 overflow-x-auto"> 132 + <DndContext 133 + sensors={sensors} 134 + collisionDetection={closestCenter} 135 + measuring={{ 136 + droppable: { strategy: MeasuringStrategy.Always }, 137 + }} 138 + modifiers={[restrictToHorizontalAxis]} 139 + onDragEnd={handleDragEnd} 140 + > 141 + <SortableContext 142 + items={sortableIds} 143 + strategy={horizontalListSortingStrategy} 144 + > 145 + {sortedPages.map((page) => ( 146 + <SortableTab 147 + key={page.id} 148 + page={page} 149 + href={hrefForPath(page.path)} 150 + active={ 151 + decodeURIComponent(pathname) === 152 + decodeURIComponent(hrefForPath(page.path)) 153 + } 154 + /> 155 + ))} 156 + </SortableContext> 157 + </DndContext> 158 + </div> 159 + <Popover 160 + asChild 161 + align="end" 162 + open={open} 163 + onOpenChange={setOpen} 164 + className="w-64" 165 + trigger={ 166 + <ButtonSecondary compact> 167 + <AddTiny className="scale-90" /> 168 + Page 169 + </ButtonSecondary> 170 + } 171 + > 172 + <form onSubmit={handleSubmit} className="flex flex-col gap-2"> 173 + <InputWithLabel 174 + label="Name" 175 + type="text" 176 + value={name} 177 + onChange={(e) => setName(e.currentTarget.value)} 178 + autoFocus 179 + /> 180 + <InputWithLabel 181 + label="Path" 182 + type="text" 183 + value={path} 184 + onChange={(e) => setPath(e.currentTarget.value)} 185 + placeholder="/about" 186 + /> 187 + <ButtonPrimary type="submit" disabled={creating}> 188 + {creating ? "Creating..." : "Create Page"} 189 + </ButtonPrimary> 190 + </form> 191 + </Popover> 192 + </div> 193 + </nav> 194 + ); 195 + } 196 + 197 + function SortableTab(props: { 198 + page: SortablePage; 199 + href: string; 200 + active: boolean; 201 + }) { 202 + let { 203 + attributes, 204 + listeners, 205 + setNodeRef, 206 + setActivatorNodeRef, 207 + transform, 208 + transition, 209 + isDragging, 210 + } = useSortable({ id: props.page.id }); 211 + 212 + let style = { 213 + transform: CSS.Translate.toString(transform), 214 + transition, 215 + zIndex: isDragging ? 1 : undefined, 216 + }; 217 + 218 + return ( 219 + <div 220 + ref={setNodeRef} 221 + style={style} 222 + className={`group/sortable-tab flex items-center shrink-0 rounded-md ${ 223 + isDragging ? "opacity-50" : "" 224 + } ${ 225 + props.active 226 + ? "bg-accent-1 text-accent-2" 227 + : "text-secondary hover:bg-border-light" 228 + }`} 229 + > 230 + <button 231 + ref={setActivatorNodeRef} 232 + type="button" 233 + aria-label="Drag to reorder" 234 + className="shrink-0 w-[9px] h-4 ml-1 cursor-grab touch-none opacity-0 group-hover/sortable-tab:opacity-100 focus:opacity-100" 235 + style={{ 236 + maskImage: "var(--gripperSVG)", 237 + maskRepeat: "repeat", 238 + backgroundColor: "currentColor", 239 + }} 240 + {...attributes} 241 + {...listeners} 242 + /> 243 + <SpeedyLink 244 + href={props.href} 245 + className="block pr-2 pl-1 py-1 rounded-md text-sm text-inherit hover:no-underline! select-none" 246 + > 247 + {props.page.title || props.page.path || "/"} 248 + </SpeedyLink> 249 + </div> 250 + ); 251 + }
+126
app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { Metadata } from "next"; 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 5 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 6 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 7 + import { LoginModal } from "components/LoginButton"; 8 + import { AtUri } from "@atproto/syntax"; 9 + import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; 10 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + import { PublicationSWRDataProvider } from "../../dashboard/PublicationSWRProvider"; 12 + import { PublicationPagesNav } from "./PublicationPagesNav"; 13 + import { PublicationEditHeader } from "./PublicationEditHeader"; 14 + import { PublicationHeader } from "../../PublicationHeader"; 15 + import { PublicationStickyHeader } from "../../PublicationStickyHeader"; 16 + 17 + export async function generateMetadata(props: { 18 + params: Promise<{ publication: string; did: string }>; 19 + }): Promise<Metadata> { 20 + let did = decodeURIComponent((await props.params).did); 21 + if (!did) return { title: "Publication 404" }; 22 + 23 + let { result: publication_data } = await get_publication_data.handler( 24 + { 25 + did, 26 + publication_name: decodeURIComponent((await props.params).publication), 27 + }, 28 + { supabase: supabaseServerClient }, 29 + ); 30 + let { publication } = publication_data; 31 + const record = normalizePublicationRecord(publication?.record); 32 + if (!publication) return { title: "404 Publication" }; 33 + return { title: `Edit Pages — ${record?.name || "Untitled Publication"}` }; 34 + } 35 + 36 + export default async function PublicationEditLayout(props: { 37 + children: React.ReactNode; 38 + params: Promise<{ publication: string; did: string }>; 39 + }) { 40 + let params = await props.params; 41 + let identity = await getIdentityData(); 42 + if (!identity || !identity.atp_did) { 43 + return ( 44 + <NotFoundLayout> 45 + <p> 46 + Looks like you&apos;re not logged in.{" "} 47 + <LoginModal 48 + redirectRoute={`/lish/${params.did}/${params.publication}/dashboard`} 49 + trigger={ 50 + <div className="text-accent-contrast font-bold">Log in here</div> 51 + } 52 + /> 53 + ! 54 + </p> 55 + </NotFoundLayout> 56 + ); 57 + } 58 + 59 + let did = decodeURIComponent(params.did); 60 + if (!did) return <PubNotFound />; 61 + 62 + if (!identity.entitlements?.["edit-pub-pages"]) { 63 + return <PubNotFound />; 64 + } 65 + 66 + let { result: publication_data } = await get_publication_data.handler( 67 + { 68 + did, 69 + publication_name: decodeURIComponent(params.publication), 70 + }, 71 + { supabase: supabaseServerClient }, 72 + ); 73 + let { publication } = publication_data; 74 + const record = normalizePublicationRecord(publication?.record); 75 + 76 + if ( 77 + !publication || 78 + identity.atp_did !== publication.identity_did || 79 + !record 80 + ) { 81 + return <PubNotFound />; 82 + } 83 + 84 + let uri = new AtUri(publication.uri); 85 + const iconUrl = record?.icon ? blobRefToSrc(record.icon.ref, did) : undefined; 86 + 87 + return ( 88 + <PublicationSWRDataProvider 89 + publication_did={uri.host} 90 + publication_rkey={uri.rkey} 91 + publication_data={publication_data} 92 + > 93 + <PublicationThemeProviderDashboard> 94 + <div className="flex flex-col h-full w-full bg-accent-1"> 95 + <PublicationEditHeader 96 + did={params.did} 97 + publicationName={params.publication} 98 + /> 99 + <div className="pubWrapper flex flex-col grow min-h-0 bg-bg-page rounded-t-lg overflow-hidden"> 100 + <PublicationStickyHeader sticky={false}> 101 + <PublicationHeader 102 + variant="inline" 103 + iconUrl={iconUrl} 104 + publicationName={publication.name} 105 + description={record?.description} 106 + /> 107 + </PublicationStickyHeader> 108 + <PublicationPagesNav 109 + did={params.did} 110 + publicationName={params.publication} 111 + /> 112 + <div className="grow min-h-0 flex flex-col">{props.children}</div> 113 + </div> 114 + </div> 115 + </PublicationThemeProviderDashboard> 116 + </PublicationSWRDataProvider> 117 + ); 118 + } 119 + 120 + const PubNotFound = () => { 121 + return ( 122 + <NotFoundLayout> 123 + <p className="font-bold">Sorry, we can&apos;t find this publication!</p> 124 + </NotFoundLayout> 125 + ); 126 + };
+85
app/lish/[did]/[publication]/edit/[[...route]]/page.tsx
··· 1 + import React from "react"; 2 + import type { Fact } from "src/replicache"; 3 + import type { Attribute } from "src/replicache/attributes"; 4 + import { PublicationPageLeaflet } from "./PublicationPageLeaflet"; 5 + import { PageSWRDataProvider } from "components/PageSWRDataProvider"; 6 + import { getPollData } from "actions/pollActions"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 9 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 10 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 11 + import { FontLoader, extractFontsFromFacts } from "components/FontLoader"; 12 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 13 + 14 + export const preferredRegion = ["sfo1"]; 15 + export const dynamic = "force-dynamic"; 16 + export const fetchCache = "force-no-store"; 17 + 18 + type Props = { 19 + params: Promise<{ did: string; publication: string; route?: string[] }>; 20 + }; 21 + 22 + export default async function PublicationEditPage(props: Props) { 23 + let params = await props.params; 24 + let did = decodeURIComponent(params.did); 25 + let publicationName = decodeURIComponent(params.publication); 26 + let path = "/" + (params.route?.map(decodeURIComponent).join("/") ?? ""); 27 + 28 + let { result: publication_data } = await get_publication_data.handler( 29 + { did, publication_name: publicationName }, 30 + { supabase: supabaseServerClient }, 31 + ); 32 + let publication = publication_data.publication; 33 + if (!publication) return <PageNotFound path={path} />; 34 + 35 + let page = publication.publication_pages?.find((p) => p.path === path); 36 + if (!page) return <PageNotFound path={path} />; 37 + 38 + let { result: res } = await get_leaflet_data.handler( 39 + { token_id: page.leaflet_src }, 40 + { supabase: supabaseServerClient }, 41 + ); 42 + let rootEntity = res.data?.root_entity; 43 + if (!rootEntity || !res.data || res.data.blocked_by_admin) 44 + return <PageNotFound path={path} />; 45 + 46 + let [{ data }, poll_data] = await Promise.all([ 47 + supabaseServerClient.rpc("get_facts", { root: rootEntity }), 48 + getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 49 + ]); 50 + let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 51 + 52 + let pubRecord = normalizePublicationRecord(publication.record); 53 + const headingFontId = pubRecord?.theme?.headingFont; 54 + const bodyFontId = pubRecord?.theme?.bodyFont; 55 + const fallbackFonts = extractFontsFromFacts(initialFacts as any, rootEntity); 56 + 57 + return ( 58 + <React.Fragment key={page.leaflet_src}> 59 + <FontLoader 60 + headingFontId={headingFontId ?? fallbackFonts.headingFontId} 61 + bodyFontId={bodyFontId ?? fallbackFonts.bodyFontId} 62 + /> 63 + <PageSWRDataProvider 64 + poll_data={poll_data} 65 + leaflet_id={res.data.id} 66 + leaflet_data={res} 67 + > 68 + <PublicationPageLeaflet 69 + initialFacts={initialFacts} 70 + leaflet_id={rootEntity} 71 + token={res.data} 72 + publicationRecord={publication.record} 73 + publicationCreator={publication.identity_did} 74 + /> 75 + </PageSWRDataProvider> 76 + </React.Fragment> 77 + ); 78 + } 79 + 80 + const PageNotFound = (props: { path: string }) => ( 81 + <NotFoundLayout> 82 + <p className="font-bold">No publication page at {props.path}</p> 83 + <p>Use the Editing Pages button to create one.</p> 84 + </NotFoundLayout> 85 + );
+9
app/lish/[did]/[publication]/page.tsx
··· 9 9 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 10 10 import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 11 11 import { PublicationContent } from "./PublicationContent"; 12 + import { tryRenderPublicationPage } from "./tryRenderPublicationPage"; 12 13 import { 13 14 PublicationThemeProvider, 14 15 PublicationBackgroundProvider, ··· 29 30 `*, 30 31 publication_subscriptions(*), 31 32 publication_newsletter_settings(enabled), 33 + publication_pages(id, path, title, record, record_uri, sort_order), 32 34 documents_in_publications(documents( 33 35 *, 34 36 comments_on_documents(count), ··· 50 52 const showPageBackground = record?.theme?.showPageBackground; 51 53 52 54 if (!publication) return <PubNotFound />; 55 + 53 56 try { 57 + const homePageRender = tryRenderPublicationPage({ 58 + did, 59 + publication, 60 + path: "/", 61 + }); 62 + if (homePageRender) return homePageRender; 54 63 return ( 55 64 <PublicationThemeProvider 56 65 theme={record?.theme}
+8
app/lish/[did]/[publication]/sortPublicationPages.ts
··· 1 + export function sortPublicationPages< 2 + T extends { id: number; sort_order: string }, 3 + >(pages: T[]): T[] { 4 + return [...pages].sort((a, b) => { 5 + if (a.sort_order === b.sort_order) return a.id - b.id; 6 + return a.sort_order.localeCompare(b.sort_order); 7 + }); 8 + }
+2
app/lish/[did]/[publication]/theme-settings/PostPreview.tsx
··· 96 96 })), 97 97 newsletterMode: 98 98 !!publication.publication_newsletter_settings?.enabled, 99 + pages: [], 99 100 } 100 101 : null; 101 102 ··· 139 140 }} 140 141 prerenderedCodeBlocks={new Map()} 141 142 bskyPostData={[]} 143 + standardSitePostData={[]} 142 144 pollData={[]} 143 145 document_uri={FAKE_DOC_URI} 144 146 fullPageScroll={!props.showPageBackground}
+49
app/lish/[did]/[publication]/tryRenderPublicationPage.tsx
··· 1 + import { 2 + PublicationPageRenderer, 3 + type PublicationPageRecord, 4 + } from "./[rkey]/PublicationPageRenderer"; 5 + 6 + type RendererProps = Parameters<typeof PublicationPageRenderer>[0]; 7 + 8 + type PublicationForRenderer = Omit< 9 + RendererProps["publication"], 10 + "publication_pages" 11 + > & { 12 + publication_pages?: { 13 + id: number; 14 + path: string | null; 15 + title: string; 16 + record: unknown; 17 + record_uri: string | null; 18 + sort_order: string; 19 + }[]; 20 + }; 21 + 22 + export function tryRenderPublicationPage({ 23 + did, 24 + publication, 25 + path, 26 + }: { 27 + did: string; 28 + publication: PublicationForRenderer; 29 + path: string; 30 + }) { 31 + const matchingPage = publication.publication_pages?.find( 32 + (p) => p.path === path && p.record_uri && p.record, 33 + ); 34 + if (!matchingPage || !matchingPage.record) return null; 35 + const pageRecord = 36 + matchingPage.record as unknown as PublicationPageRecord; 37 + return ( 38 + <PublicationPageRenderer 39 + did={did} 40 + page={{ 41 + id: matchingPage.id, 42 + path: matchingPage.path ?? "/", 43 + title: matchingPage.title, 44 + record: pageRecord, 45 + }} 46 + publication={publication} 47 + /> 48 + ); 49 + }
+17 -6
components/Blocks/Block.tsx
··· 25 25 import { ButtonBlock } from "./ButtonBlock"; 26 26 import { PollBlock } from "./PollBlock"; 27 27 import { BlueskyPostBlock } from "./BlueskyPostBlock"; 28 + import { StandardSitePostBlock } from "./StandardSitePostBlock"; 28 29 import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 29 30 import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 30 31 import { MathBlock } from "./MathBlock"; 31 32 import { CodeBlock } from "./CodeBlock"; 32 33 import { HorizontalRule } from "./HorizontalRule"; 34 + import { PostsListBlock } from "./PostsListBlock"; 33 35 import { deepEquals } from "src/utils/deepEquals"; 34 36 import { isTextBlock } from "src/utils/isTextBlock"; 35 37 import { DeleteTiny } from "components/Icons/DeleteTiny"; ··· 124 126 const bindSwipe = useDrag( 125 127 ({ last, movement: [mx], event }) => { 126 128 if (!last) return; 127 - if ( 128 - event && 129 - "pointerType" in event && 130 - event.pointerType !== "touch" 131 - ) 129 + if (event && "pointerType" in event && event.pointerType !== "touch") 132 130 return; 133 131 if (!rep || !props.listData || !entity_set.permissions.write) return; 134 132 if (Math.abs(mx) < SWIPE_THRESHOLD) return; ··· 362 360 button: ButtonBlock, 363 361 poll: PollBlock, 364 362 "bluesky-post": BlueskyPostBlock, 363 + "standard-site-post": StandardSitePostBlock, 365 364 "horizontal-rule": HorizontalRule, 365 + "posts-list": PostsListBlock, 366 366 }; 367 367 368 368 export const BlockMultiselectIndicator = (props: BlockProps) => { ··· 412 412 hasAlignment?: boolean; 413 413 areYouSure?: boolean; 414 414 setAreYouSure?: (value: boolean) => void; 415 + extraOptions?: React.ReactNode; 415 416 }) => { 416 417 // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete 417 418 return ( ··· 439 440 optionsClassName={props.optionsClassName} 440 441 areYouSure={props.areYouSure} 441 442 setAreYouSure={props.setAreYouSure} 443 + extraOptions={props.extraOptions} 442 444 /> 443 445 )} 444 446 </div> ··· 451 453 areYouSure?: boolean; 452 454 setAreYouSure?: (value: boolean) => void; 453 455 optionsClassName?: string; 456 + extraOptions?: React.ReactNode; 454 457 }) => { 455 458 let { rep } = useReplicache(); 456 459 let entity_set = useEntitySetContext(); ··· 494 497 > 495 498 <ArrowDownTiny className="rotate-180" /> 496 499 </button> 500 + <Separator classname="border-bg-page! h-4! mx-0.5" /> 501 + </> 502 + )} 503 + {props.extraOptions && ( 504 + <> 505 + {props.extraOptions}{" "} 497 506 <Separator classname="border-bg-page! h-4! mx-0.5" /> 498 507 </> 499 508 )} ··· 640 649 onClick={(e) => { 641 650 e.stopPropagation(); 642 651 if (permissions.write && listStyle?.data.value === "ordered") { 643 - setNumberInputValue(String(props.listData?.displayNumber || 1)); 652 + setNumberInputValue( 653 + String(props.listData?.displayNumber || 1), 654 + ); 644 655 setEditingNumber(true); 645 656 } 646 657 }}
+8 -2
components/Blocks/BlockCommandBar.tsx
··· 2 2 import { blockCommands } from "./BlockCommands"; 3 3 import { useReplicache } from "src/replicache"; 4 4 import { useEntitySetContext } from "components/EntitySetProvider"; 5 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 5 + import { 6 + useLeafletPublicationData, 7 + useLeafletPublicationPage, 8 + } from "components/PageSWRDataProvider"; 6 9 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 7 10 import { Combobox, ComboboxResult } from "components/Combobox"; 8 11 ··· 28 31 let { rep, undoManager } = useReplicache(); 29 32 let entity_set = useEntitySetContext(); 30 33 let { data: pub } = useLeafletPublicationData(); 34 + let publicationPage = useLeafletPublicationPage(); 35 + let inPublicationEdit = !!publicationPage; 31 36 32 37 // This clears '/' AND anything typed after it 33 38 const clearCommandSearchText = () => { ··· 53 58 ) ?? false; 54 59 const matchesSearch = matchesName || matchesAlternate; 55 60 const isVisible = !pub || !command.hiddenInPublication; 56 - return matchesSearch && isVisible; 61 + const allowedInContext = !command.publicationOnly || inPublicationEdit; 62 + return matchesSearch && isVisible && allowedInContext; 57 63 }); 58 64 59 65 return (
+25
components/Blocks/BlockCommands.tsx
··· 108 108 type: string; 109 109 alternateNames?: string[]; 110 110 hiddenInPublication?: boolean; 111 + publicationOnly?: boolean; 111 112 onSelect: ( 112 113 rep: Replicache<ReplicacheMutators>, 113 114 props: Props & { entity_set: string }, ··· 438 439 focusPage(newPage, rep, "focusFirstBlock"); 439 440 }, 440 441 }); 442 + }, 443 + }, 444 + 445 + // PUBLICATION BLOCKS — only shown when editing a publication page 446 + { 447 + name: "Posts List", 448 + icon: <BlockDocPageSmall />, 449 + type: "publication", 450 + alternateNames: ["posts", "archive", "feed", "listing"], 451 + publicationOnly: true, 452 + onSelect: async (rep, props) => { 453 + props.entityID && clearCommandSearchText(props.entityID); 454 + await createBlockWithType(rep, props, "posts-list"); 455 + }, 456 + }, 457 + { 458 + name: "Subscribe Form", 459 + icon: <BlockMailboxSmall />, 460 + type: "publication", 461 + alternateNames: ["subscribe", "newsletter", "email"], 462 + publicationOnly: true, 463 + onSelect: async (rep, props) => { 464 + props.entityID && clearCommandSearchText(props.entityID); 465 + await createBlockWithType(rep, props, "text"); 441 466 }, 442 467 }, 443 468 ];
+6
components/Blocks/EmbedBlock.tsx
··· 24 24 import { scrollIntoView } from "src/utils/scrollIntoView"; 25 25 import { EmbedBlockData } from "src/partsPageChannel"; 26 26 import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 27 + import { assertStandardSitePostFacts } from "src/utils/addLinkBlock"; 27 28 28 29 export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => { 29 30 let entity_set = useEntitySetContext(); ··· 268 269 269 270 if (res.status === 200) { 270 271 let data = await (res.json() as LinkPreviewMetadataResult); 272 + if (data.leafletPost) { 273 + await assertStandardSitePostFacts(rep, entity, data.leafletPost.uri); 274 + setLoading(false); 275 + return; 276 + } 271 277 if (data.success && data.data.links?.player?.[0]) { 272 278 let embed = data.data.links.player[0]; 273 279 embedUrl = embed.href;
+205
components/Blocks/PostsListBlock.tsx
··· 1 + import { useMemo } from "react"; 2 + import { useUIState } from "src/useUIState"; 3 + import { useEntity, useReplicache } from "src/replicache"; 4 + import { BlockProps, BlockLayout } from "./Block"; 5 + import { 6 + usePublicationData, 7 + useNormalizedPublicationRecord, 8 + } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 9 + import { PublicationPostsList } from "app/lish/[did]/[publication]/PublicationPostsList"; 10 + import { Popover } from "components/Popover"; 11 + import { Toggle } from "components/Toggle"; 12 + import { ToggleGroup } from "components/ToggleGroup"; 13 + import { SettingsTriggerButton } from "./SettingsTriggerButton"; 14 + 15 + type PostsListView = "compact" | "full"; 16 + 17 + export const PostsListBlock = (props: BlockProps & { preview?: boolean }) => { 18 + let isSelected = useUIState((s) => 19 + !!s.selectedBlocks.find((b) => b.value === props.value), 20 + ); 21 + 22 + if (props.preview) { 23 + return ( 24 + <BlockLayout isSelected={isSelected} className="border-none!"> 25 + <PostsListPlaceholder /> 26 + </BlockLayout> 27 + ); 28 + } 29 + 30 + return ( 31 + <BlockLayout 32 + isSelected={isSelected} 33 + className="border-none!" 34 + extraOptions={<PostsListSettingsButton entityID={props.entityID} />} 35 + > 36 + <PostsListBlockContent entityID={props.entityID} /> 37 + </BlockLayout> 38 + ); 39 + }; 40 + 41 + function PostsListBlockContent({ entityID }: { entityID: string }) { 42 + let { data } = usePublicationData(); 43 + let publicationRecord = useNormalizedPublicationRecord(); 44 + 45 + let viewFact = useEntity(entityID, "posts-list/view"); 46 + let view: PostsListView = viewFact?.data.value ?? "full"; 47 + 48 + let highlightFirstFact = useEntity( 49 + entityID, 50 + "posts-list/highlight-first-post", 51 + ); 52 + let highlightFirst = highlightFirstFact?.data.value ?? false; 53 + 54 + let filterTagFact = useEntity(entityID, "posts-list/filter-tag"); 55 + let filterTag = filterTagFact?.data.value; 56 + 57 + let filteredPosts = useMemo(() => { 58 + if (!data?.documents) return data?.documents; 59 + if (!filterTag) return data.documents; 60 + return data.documents.filter((d) => d.record.tags?.includes(filterTag)); 61 + }, [data?.documents, filterTag]); 62 + 63 + if (data === undefined) return <PostsListPlaceholder />; 64 + if (!data?.publication) return <PostsListPlaceholder />; 65 + 66 + return ( 67 + <PublicationPostsList 68 + publication={data.publication} 69 + publicationRecord={publicationRecord} 70 + posts={filteredPosts} 71 + view={view} 72 + highlightFirstPost={highlightFirst} 73 + /> 74 + ); 75 + } 76 + 77 + function PostsListPlaceholder() { 78 + return ( 79 + <div className="publicationPostList w-full flex flex-col gap-4"> 80 + {[0, 1, 2].map((i) => ( 81 + <div key={i} className="flex w-full grow flex-col"> 82 + <div className="flex flex-col gap-2"> 83 + <div className="h-5 w-2/3 bg-border-light rounded animate-pulse" /> 84 + <div className="h-4 w-full bg-border-light rounded animate-pulse" /> 85 + </div> 86 + <div className="pt-2"> 87 + <div className="h-3 w-24 bg-border-light rounded animate-pulse" /> 88 + </div> 89 + <hr className="last:hidden border-border-light mt-4" /> 90 + </div> 91 + ))} 92 + </div> 93 + ); 94 + } 95 + 96 + function PostsListSettingsButton(props: { entityID: string }) { 97 + let { rep } = useReplicache(); 98 + let { data } = usePublicationData(); 99 + 100 + let viewFact = useEntity(props.entityID, "posts-list/view"); 101 + let view: PostsListView = viewFact?.data.value ?? "full"; 102 + 103 + let highlightFirstFact = useEntity( 104 + props.entityID, 105 + "posts-list/highlight-first-post", 106 + ); 107 + let highlightFirst = highlightFirstFact?.data.value ?? false; 108 + 109 + let filterTagFact = useEntity(props.entityID, "posts-list/filter-tag"); 110 + let filterTag = filterTagFact?.data.value; 111 + 112 + let allTags = useMemo(() => { 113 + let tagSet = new Set<string>(); 114 + for (let doc of data?.documents ?? []) { 115 + for (let tag of doc.record.tags ?? []) tagSet.add(tag); 116 + } 117 + return Array.from(tagSet).sort((a, b) => 118 + a.localeCompare(b, undefined, { sensitivity: "base" }), 119 + ); 120 + }, [data?.documents]); 121 + 122 + return ( 123 + <Popover 124 + asChild 125 + side="top" 126 + align="end" 127 + sideOffset={6} 128 + trigger={<SettingsTriggerButton aria-label="Posts List Settings" />} 129 + > 130 + <div className="flex flex-col gap-3 text-primary py-1 min-w-[220px]"> 131 + <div className="flex flex-col gap-1"> 132 + <div className="font-bold text-sm">Post View</div> 133 + <ToggleGroup<PostsListView> 134 + fullWidth 135 + value={view} 136 + onChange={(value) => { 137 + if (!rep) return; 138 + rep.mutate.assertFact({ 139 + entity: props.entityID, 140 + attribute: "posts-list/view", 141 + data: { type: "posts-list-view-union", value }, 142 + }); 143 + }} 144 + options={[ 145 + { value: "compact", label: "Compact" }, 146 + { value: "full", label: "Full" }, 147 + ]} 148 + /> 149 + </div> 150 + <Toggle 151 + toggle={highlightFirst} 152 + onToggle={() => { 153 + if (!rep) return; 154 + rep.mutate.assertFact({ 155 + entity: props.entityID, 156 + attribute: "posts-list/highlight-first-post", 157 + data: { type: "boolean", value: !highlightFirst }, 158 + }); 159 + }} 160 + > 161 + <div className="font-bold">Highlight First Post</div> 162 + </Toggle> 163 + <div className="flex flex-col gap-1"> 164 + <div className="font-bold text-sm">Filter by Tag</div> 165 + {allTags.length === 0 ? ( 166 + <div className="text-tertiary italic text-sm">no tags yet</div> 167 + ) : ( 168 + <div className="flex flex-col max-h-40 overflow-y-auto border border-border-light rounded-md"> 169 + {allTags.map((tag) => { 170 + let isSelected = filterTag === tag; 171 + return ( 172 + <button 173 + key={tag} 174 + type="button" 175 + onMouseDown={(e) => e.preventDefault()} 176 + onClick={() => { 177 + if (!rep) return; 178 + if (isSelected) { 179 + if (filterTagFact) 180 + rep.mutate.retractFact({ 181 + factID: filterTagFact.id, 182 + }); 183 + } else { 184 + rep.mutate.assertFact({ 185 + entity: props.entityID, 186 + attribute: "posts-list/filter-tag", 187 + data: { type: "string", value: tag }, 188 + }); 189 + } 190 + }} 191 + className={`text-left px-2 py-1 text-sm hover:bg-border-light ${ 192 + isSelected ? "bg-accent-1 text-accent-2 font-bold" : "" 193 + }`} 194 + > 195 + {tag} 196 + </button> 197 + ); 198 + })} 199 + </div> 200 + )} 201 + </div> 202 + </div> 203 + </Popover> 204 + ); 205 + }
+18
components/Blocks/SettingsTriggerButton.tsx
··· 1 + import { forwardRef } from "react"; 2 + import { SettingsTiny } from "components/Icons/SettingsTiny"; 3 + 4 + export const SettingsTriggerButton = forwardRef< 5 + HTMLButtonElement, 6 + React.ButtonHTMLAttributes<HTMLButtonElement> 7 + >(({ "aria-label": ariaLabel = "Settings", ...props }, ref) => ( 8 + <button 9 + {...props} 10 + ref={ref} 11 + onMouseDown={(e) => e.preventDefault()} 12 + aria-label={ariaLabel} 13 + className="flex items-center" 14 + > 15 + <SettingsTiny /> 16 + </button> 17 + )); 18 + SettingsTriggerButton.displayName = "SettingsTriggerButton";
+181
components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx
··· 1 + "use client"; 2 + import { AtUri } from "@atproto/api"; 3 + import { 4 + PublicationPostItemSmall, 5 + PublicationPostItemMedium, 6 + PublicationPostItemLarge, 7 + } from "app/lish/[did]/[publication]/PublicationPostItem"; 8 + import { LocalizedDate } from "app/lish/[did]/[publication]/LocalizedDate"; 9 + import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 10 + import { getFirstParagraph } from "src/utils/getFirstParagraph"; 11 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 12 + import { useStandardSitePost } from "components/StandardSitePostDataProvider"; 13 + import { useEntity, useReplicache } from "src/replicache"; 14 + import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; 15 + 16 + export type StandardSitePostSize = "large" | "medium" | "small"; 17 + 18 + export function StandardSitePostItem({ 19 + uri, 20 + size = "medium", 21 + }: { 22 + uri: string; 23 + size?: StandardSitePostSize; 24 + }) { 25 + const { data, isLoading } = useStandardSitePost(uri); 26 + const { rootEntity } = useReplicache(); 27 + const pageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; 28 + 29 + if (isLoading) { 30 + return <StandardSitePostItemPlaceholder size={size} pageWidth={pageWidth} />; 31 + } 32 + 33 + if (!data) { 34 + return ( 35 + <p className="text-sm italic text-tertiary"> 36 + Post not found. 37 + </p> 38 + ); 39 + } 40 + 41 + return <StandardSitePostItemView post={data} size={size} />; 42 + } 43 + 44 + function StandardSitePostItemPlaceholder({ 45 + size, 46 + pageWidth, 47 + }: { 48 + size: StandardSitePostSize; 49 + pageWidth?: number; 50 + }) { 51 + if (size === "small") { 52 + return ( 53 + <> 54 + <div className="flex w-full grow flex-col gap-1 px-3 py-2"> 55 + <div className="h-7 w-2/3 bg-border-light rounded animate-pulse" /> 56 + <div className="h-4 w-32 bg-border-light rounded animate-pulse" /> 57 + </div> 58 + <hr className="last:hidden border-border-light" /> 59 + </> 60 + ); 61 + } 62 + 63 + if (size === "medium") { 64 + return ( 65 + <> 66 + <div className="flex w-full gap-3 items-stretch sm:h-36"> 67 + <div className="flex w-full gap-2 grow flex-col justify-between min-w-0 pl-3 py-2"> 68 + <div className="flex flex-col gap-1.5"> 69 + <div className="h-7 w-2/3 bg-border-light rounded animate-pulse" /> 70 + <div className="h-4 w-full bg-border-light rounded animate-pulse" /> 71 + <div className="h-4 w-5/6 bg-border-light rounded animate-pulse" /> 72 + </div> 73 + <div className="h-4 w-32 bg-border-light rounded animate-pulse" /> 74 + </div> 75 + <div className="self-stretch shrink-0 aspect-square w-16 sm:w-36 bg-border-light rounded animate-pulse" /> 76 + </div> 77 + <hr className="last:hidden border-border-light" /> 78 + </> 79 + ); 80 + } 81 + 82 + const widePage = (pageWidth ?? 0) >= 768; 83 + return ( 84 + <> 85 + <div 86 + className={`flex flex-col items-stretch ${widePage ? "sm:flex-row sm:gap-2 gap-0" : ""} w-full items-start`} 87 + > 88 + <div 89 + className={`bg-border-light rounded animate-pulse aspect-[1.91/1] ${widePage ? "w-full sm:w-2/5 shrink-0" : "w-full"}`} 90 + /> 91 + <div 92 + className={`flex w-full grow flex-col gap-2 justify-between ${widePage ? "px-3 py-2 sm:pb-3" : "px-3 py-2"}`} 93 + > 94 + <div className="flex flex-col gap-1.5"> 95 + <div 96 + className={`h-7 w-2/3 bg-border-light rounded animate-pulse ${widePage ? "sm:h-8" : ""}`} 97 + /> 98 + <div 99 + className={`h-5 w-full bg-border-light rounded animate-pulse ${widePage ? "sm:h-6" : ""}`} 100 + /> 101 + <div 102 + className={`h-5 w-5/6 bg-border-light rounded animate-pulse ${widePage ? "sm:h-6" : ""}`} 103 + /> 104 + </div> 105 + <div 106 + className={`h-4 w-32 bg-border-light rounded animate-pulse ${widePage ? "sm:h-5" : ""}`} 107 + /> 108 + </div> 109 + </div> 110 + <hr className="last:hidden border-border-light" /> 111 + </> 112 + ); 113 + } 114 + 115 + export function StandardSitePostItemView({ 116 + post, 117 + size = "medium", 118 + }: { 119 + post: StandardSitePostData; 120 + size?: StandardSitePostSize; 121 + }) { 122 + const docUrl = getDocumentURL( 123 + post.record, 124 + post.uri, 125 + post.publication ?? undefined, 126 + ); 127 + const authorLabel = 128 + post.author?.displayName || 129 + (post.author?.handle ? `@${post.author.handle}` : undefined); 130 + const date = post.record.publishedAt ? ( 131 + <LocalizedDate 132 + dateString={post.record.publishedAt} 133 + options={{ year: "numeric", month: "long", day: "2-digit" }} 134 + /> 135 + ) : undefined; 136 + const description = post.record.description || getFirstParagraph(post.record); 137 + 138 + let postDid: string | undefined; 139 + try { 140 + postDid = new AtUri(post.uri).host; 141 + } catch { 142 + postDid = undefined; 143 + } 144 + const coverImageSrc = 145 + post.record.coverImage && postDid 146 + ? blobRefToSrc(post.record.coverImage.ref, postDid) 147 + : undefined; 148 + 149 + const { rootEntity } = useReplicache(); 150 + const pageWidth = useEntity(rootEntity, "theme/page-width")?.data.value; 151 + 152 + const commonProps = { 153 + href: docUrl, 154 + title: post.record.title, 155 + author: authorLabel, 156 + date, 157 + }; 158 + 159 + if (size === "large") { 160 + return ( 161 + <PublicationPostItemLarge 162 + {...commonProps} 163 + description={description} 164 + coverImageSrc={coverImageSrc} 165 + coverImageAlt={post.record.title} 166 + pageWidth={pageWidth} 167 + /> 168 + ); 169 + } 170 + if (size === "medium") { 171 + return ( 172 + <PublicationPostItemMedium 173 + {...commonProps} 174 + description={description} 175 + coverImageSrc={coverImageSrc} 176 + coverImageAlt={post.record.title} 177 + /> 178 + ); 179 + } 180 + return <PublicationPostItemSmall {...commonProps} />; 181 + }
+149
components/Blocks/StandardSitePostBlock/index.tsx
··· 1 + import { BlockProps, BlockLayout } from "../Block"; 2 + import { useEntity, useReplicache } from "src/replicache"; 3 + import { useUIState } from "src/useUIState"; 4 + import { Popover } from "components/Popover"; 5 + import { SettingsTriggerButton } from "../SettingsTriggerButton"; 6 + import { 7 + StandardSitePostItem, 8 + type StandardSitePostSize, 9 + } from "./StandardSitePostItem"; 10 + 11 + export const StandardSitePostBlock = ( 12 + props: BlockProps & { preview?: boolean }, 13 + ) => { 14 + let isSelected = useUIState((s) => 15 + s.selectedBlocks.find((b) => b.value === props.entityID), 16 + ); 17 + let uri = useEntity(props.entityID, "block/standard-site-post")?.data.value; 18 + let sizeFact = useEntity(props.entityID, "standard-site-post/size"); 19 + let size: StandardSitePostSize = sizeFact?.data.value ?? "medium"; 20 + 21 + if (!uri) return null; 22 + 23 + return ( 24 + <BlockLayout 25 + isSelected={!!isSelected} 26 + hasBackground="page" 27 + borderOnHover 28 + className="standardSitePostBlock p-0!" 29 + extraOptions={ 30 + <StandardSitePostSettingsButton entityID={props.entityID} /> 31 + } 32 + > 33 + <StandardSitePostItem uri={uri} size={size} /> 34 + </BlockLayout> 35 + ); 36 + }; 37 + 38 + function StandardSitePostSettingsButton(props: { entityID: string }) { 39 + let { rep } = useReplicache(); 40 + let sizeFact = useEntity(props.entityID, "standard-site-post/size"); 41 + let size: StandardSitePostSize = sizeFact?.data.value ?? "medium"; 42 + 43 + return ( 44 + <Popover 45 + asChild 46 + side="top" 47 + align="end" 48 + className="flex flex-col gap-2 w-xs pb-3!" 49 + onOpenAutoFocus={(e) => e.preventDefault()} 50 + trigger={ 51 + <SettingsTriggerButton aria-label="Standard Site Post Settings" /> 52 + } 53 + > 54 + <h4>Post Size</h4> 55 + <div className="flex flex-col gap-3 w-full"> 56 + {( 57 + [ 58 + { value: "small", Icon: SmallIcon }, 59 + { value: "medium", Icon: MedIcon }, 60 + { value: "large", Icon: LargeIcon }, 61 + ] as { 62 + value: StandardSitePostSize; 63 + Icon: (props: { selected: boolean }) => React.ReactNode; 64 + }[] 65 + ).map((option) => { 66 + let selected = 67 + size === option.value || 68 + (option.value === "medium" && size !== "small" && size !== "large"); 69 + return ( 70 + <button 71 + className="text-left" 72 + key={option.value} 73 + type="button" 74 + aria-pressed={selected} 75 + onClick={() => { 76 + if (!rep) return; 77 + rep.mutate.assertFact({ 78 + entity: props.entityID, 79 + attribute: "standard-site-post/size", 80 + data: { 81 + type: "standard-site-post-size-union", 82 + value: option.value, 83 + }, 84 + }); 85 + }} 86 + > 87 + <option.Icon selected={selected} /> 88 + </button> 89 + ); 90 + })} 91 + </div> 92 + </Popover> 93 + ); 94 + } 95 + 96 + const SmallIcon = ({ selected }: { selected: boolean }) => { 97 + return ( 98 + <div 99 + className={`flex gap-2 p-2 w-full light-container outline-2 outline-offset-1 ${selected ? "outline-accent-contrast border-accent-contrast!" : "outline-transparent"}`} 100 + > 101 + <div className="flex flex-col gap-1 grow min-w-0"> 102 + <div className="w-full h-4 bg-tertiary rounded-[2px]" /> 103 + 104 + <div className="flex justify-between mt-1 w-full"> 105 + <div className="w-[60%] h-2 bg-border rounded-[2px]" /> 106 + <div className="w-6 h-2 bg-border rounded-[2px]" /> 107 + </div> 108 + </div> 109 + </div> 110 + ); 111 + }; 112 + 113 + const MedIcon = ({ selected }: { selected: boolean }) => { 114 + return ( 115 + <div 116 + className={`flex gap-2 w-full light-container outline-2 outline-offset-1 ${selected ? "outline-accent-contrast border-accent-contrast!" : "outline-transparent "}`} 117 + > 118 + <div className="flex flex-col gap-1 p-2 grow min-w-0"> 119 + <div className="w-full h-4 bg-tertiary rounded-[2px]" /> 120 + <div className="w-full h-2 bg-tertiary mt-1 rounded-[2px]" /> 121 + <div className="w-full h-2 bg-tertiary rounded-[2px]" /> 122 + <div className="flex justify-between mt-2 w-full"> 123 + <div className="w-[60%] h-2 bg-border rounded-[2px]" /> 124 + <div className="w-6 h-2 bg-border rounded-[2px]" /> 125 + </div> 126 + </div> 127 + <div className="aspect-square h-[82px] bg-test shrink-0" /> 128 + </div> 129 + ); 130 + }; 131 + 132 + const LargeIcon = ({ selected }: { selected: boolean }) => { 133 + return ( 134 + <div 135 + className={`flex flex-col gap-2 w-full outline-2 outline-offset-1 light-container ${selected ? "outline-accent-contrast border-accent-contrast!" : "outline-transparent"}`} 136 + > 137 + <div className="w-full aspect-video bg-test rounded-t-[2px]" /> 138 + 139 + <div className="flex flex-col gap-1 p-2 pt-0.5!"> 140 + <div className="w-full h-4 bg-tertiary rounded-[2px]" /> 141 + <div className="w-full h-2 bg-tertiary mt-1 rounded-[2px]" /> 142 + <div className="flex justify-between mt-2 w-full"> 143 + <div className="w-[60%] h-2 bg-border rounded-[2px]" /> 144 + <div className="w-6 h-2 bg-border rounded-[2px]" /> 145 + </div> 146 + </div> 147 + </div> 148 + ); 149 + };
+23
components/Blocks/StandardSitePostBlock/parseStandardSitePostInput.ts
··· 1 + import { ids } from "lexicons/api/lexicons"; 2 + 3 + export function parseStandardSitePostInput(input: string): string | null { 4 + const trimmed = input.trim(); 5 + if (!trimmed) return null; 6 + 7 + let url: URL; 8 + try { 9 + url = new URL(trimmed); 10 + } catch { 11 + return null; 12 + } 13 + 14 + // https://leaflet.pub/p/{did}/{rkey} 15 + let m = url.pathname.match(/^\/p\/([^/]+)\/([^/]+)\/?$/); 16 + if (m) return `at://${m[1]}/${ids.SiteStandardDocument}/${m[2]}`; 17 + 18 + // https://leaflet.pub/lish/{did}/{publication}/{rkey} 19 + m = url.pathname.match(/^\/lish\/([^/]+)\/[^/]+\/([^/]+)\/?$/); 20 + if (m) return `at://${m[1]}/${ids.SiteStandardDocument}/${m[2]}`; 21 + 22 + return null; 23 + }
+3 -3
components/Icons/GoToArrowLined.tsx
··· 13 13 <path 14 14 d="M8.59574 3L13.8041 8M13.8041 8L8.59574 13M13.8041 8H2.19589" 15 15 stroke="currentColor" 16 - stroke-width="2" 17 - stroke-linecap="round" 18 - stroke-linejoin="round" 16 + strokeWidth="2" 17 + strokeLinecap="round" 18 + strokeLinejoin="round" 19 19 /> 20 20 </svg> 21 21 );
+10
components/PageSWRDataProvider.tsx
··· 94 94 return { data: data?.custom_domain_routes, mutate: mutate }; 95 95 } 96 96 97 + /** 98 + * Returns the publication_pages row this leaflet is the source of, if any. 99 + * Mirrors `draftInPublication` from useLeafletPublicationStatus, but for 100 + * leaflets that back a publication page rather than a draft post. 101 + */ 102 + export function useLeafletPublicationPage() { 103 + let { data } = useLeafletData(); 104 + return data?.publication_pages?.[0] ?? null; 105 + } 106 + 97 107 export function useLeafletPublicationStatus() { 98 108 const data = useContext(StaticLeafletDataContext); 99 109 if (!data) return null;
+5 -4
components/Pages/Page.tsx
··· 11 11 import { Canvas } from "../Canvas"; 12 12 import { Blocks } from "components/Blocks"; 13 13 import { PublicationMetadata } from "./PublicationMetadata"; 14 + import { useLeafletPublicationPage } from "components/PageSWRDataProvider"; 14 15 import { useCardBorderHidden } from "./useCardBorderHidden"; 15 16 import { focusPage } from "src/utils/focusPage"; 16 17 import { PageOptions } from "./PageOptions"; ··· 30 31 fullPageScroll: boolean; 31 32 }) { 32 33 let { rep } = useReplicache(); 34 + let publicationPage = useLeafletPublicationPage(); 33 35 34 36 let isFocused = useUIState((s) => { 35 37 let focusedElement = s.focusedEntity; ··· 81 83 /> 82 84 } 83 85 > 84 - {props.first && pageType === "doc" && ( 85 - <> 86 - <PublicationMetadata /> 87 - </> 86 + {props.first && pageType === "doc" && !publicationPage && ( 87 + <PublicationMetadata /> 88 88 )} 89 89 <PageContent entityID={props.entityID} first={props.first} /> 90 90 </PageWrapper> ··· 129 129 id={props.id} 130 130 className={` 131 131 pageScrollWrapper 132 + publicationScrollContainer 132 133 grow 133 134 shrink-0 snap-center 134 135 ${props.overflow === "hidden" ? "overflow-hidden" : "overflow-y-scroll"}
+6 -1
components/Pages/PageOptions.tsx
··· 16 16 import { PaintSmall } from "components/Icons/PaintSmall"; 17 17 import { ShareSmall } from "components/Icons/ShareSmall"; 18 18 import { useCardBorderHidden } from "./useCardBorderHidden"; 19 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 19 + import { 20 + useLeafletPublicationData, 21 + useLeafletPublicationPage, 22 + } from "components/PageSWRDataProvider"; 20 23 21 24 export const PageOptionButton = ({ 22 25 children, ··· 57 60 first: boolean | undefined; 58 61 isFocused: boolean; 59 62 }) => { 63 + let publicationPage = useLeafletPublicationPage(); 64 + if (publicationPage) return null; 60 65 return ( 61 66 <div 62 67 className={`pageOptions w-fit z-10
+81
components/StandardSitePostDataProvider.tsx
··· 1 + "use client"; 2 + import { createContext, useContext } from "react"; 3 + import useSWR from "swr"; 4 + import { callRPC } from "app/api/rpc/client"; 5 + import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; 6 + 7 + type Resolver = (data: StandardSitePostData | null) => void; 8 + let pendingURIs = new Set<string>(); 9 + let pendingResolvers = new Map<string, Resolver[]>(); 10 + let flushScheduled = false; 11 + 12 + function scheduleFlush() { 13 + if (flushScheduled) return; 14 + flushScheduled = true; 15 + queueMicrotask(async () => { 16 + const uris = Array.from(pendingURIs); 17 + pendingURIs = new Set(); 18 + const resolvers = pendingResolvers; 19 + pendingResolvers = new Map(); 20 + flushScheduled = false; 21 + if (uris.length === 0) return; 22 + try { 23 + const res = await callRPC("get_standard_site_posts", { uris }); 24 + const byUri = new Map( 25 + (res?.result.posts || []).map((p) => [p.uri, p] as const), 26 + ); 27 + for (const uri of uris) { 28 + const post = byUri.get(uri) ?? null; 29 + for (const r of resolvers.get(uri) || []) r(post); 30 + } 31 + } catch { 32 + for (const uri of uris) { 33 + for (const r of resolvers.get(uri) || []) r(null); 34 + } 35 + } 36 + }); 37 + } 38 + 39 + function batchFetchStandardSitePost( 40 + uri: string, 41 + ): Promise<StandardSitePostData | null> { 42 + return new Promise((resolve) => { 43 + pendingURIs.add(uri); 44 + const existing = pendingResolvers.get(uri) || []; 45 + existing.push(resolve); 46 + pendingResolvers.set(uri, existing); 47 + scheduleFlush(); 48 + }); 49 + } 50 + 51 + export const StandardSitePostDataContext = createContext<Record< 52 + string, 53 + StandardSitePostData 54 + > | null>(null); 55 + 56 + export function StandardSitePostDataProvider(props: { 57 + posts: StandardSitePostData[]; 58 + children: React.ReactNode; 59 + }) { 60 + const map: Record<string, StandardSitePostData> = {}; 61 + for (const p of props.posts) map[p.uri] = p; 62 + return ( 63 + <StandardSitePostDataContext.Provider value={map}> 64 + {props.children} 65 + </StandardSitePostDataContext.Provider> 66 + ); 67 + } 68 + 69 + export function useStandardSitePost(uri: string | null | undefined) { 70 + const ctx = useContext(StandardSitePostDataContext); 71 + const fromContext = uri ? ctx?.[uri] : undefined; 72 + const swr = useSWR<StandardSitePostData | null>( 73 + !uri || fromContext ? null : `standard-site-post:${uri}`, 74 + () => batchFetchStandardSitePost(uri!), 75 + { revalidateOnFocus: false, revalidateIfStale: false }, 76 + ); 77 + return { 78 + data: fromContext ?? swr.data, 79 + isLoading: !fromContext && swr.isLoading, 80 + }; 81 + }
+19 -19
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - 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"; 2 + 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"; 3 3 4 4 export const notificationsRelations = relations(notifications, ({one}) => ({ 5 5 identity: one(identities, { ··· 53 53 publication_domains: many(publication_domains), 54 54 publication_subscriptions: many(publication_subscriptions), 55 55 publication_post_sends: many(publication_post_sends), 56 - publication_pages: many(publication_pages), 57 56 leaflets_in_publications: many(leaflets_in_publications), 57 + publication_pages: many(publication_pages), 58 58 })); 59 59 60 60 export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({ ··· 74 74 documents_in_publications: many(documents_in_publications), 75 75 document_mentions_in_bskies: many(document_mentions_in_bsky), 76 76 publication_post_sends: many(publication_post_sends), 77 - publication_pages: many(publication_pages), 78 77 leaflets_to_documents: many(leaflets_to_documents), 79 78 leaflets_in_publications: many(leaflets_in_publications), 79 + publication_pages: many(publication_pages), 80 80 })); 81 81 82 82 export const bsky_profilesRelations = relations(bsky_profiles, ({one, many}) => ({ ··· 162 162 email_subscriptions_to_entities: many(email_subscriptions_to_entity), 163 163 permission_token_on_homepages: many(permission_token_on_homepage), 164 164 permission_token_rights: many(permission_token_rights), 165 - publication_pages: many(publication_pages), 166 165 leaflets_to_documents: many(leaflets_to_documents), 167 166 leaflets_in_publications: many(leaflets_in_publications), 167 + publication_pages: many(publication_pages), 168 168 })); 169 169 170 170 export const user_subscriptionsRelations = relations(user_subscriptions, ({one}) => ({ ··· 409 409 }), 410 410 })); 411 411 412 - export const publication_pagesRelations = relations(publication_pages, ({one}) => ({ 413 - document: one(documents, { 414 - fields: [publication_pages.document], 415 - references: [documents.uri] 416 - }), 417 - permission_token: one(permission_tokens, { 418 - fields: [publication_pages.leaflet_src], 419 - references: [permission_tokens.id] 420 - }), 421 - publication: one(publications, { 422 - fields: [publication_pages.publication], 423 - references: [publications.uri] 424 - }), 425 - })); 426 - 427 412 export const leaflets_to_documentsRelations = relations(leaflets_to_documents, ({one}) => ({ 428 413 document: one(documents, { 429 414 fields: [leaflets_to_documents.document], ··· 446 431 }), 447 432 publication: one(publications, { 448 433 fields: [leaflets_in_publications.publication], 434 + references: [publications.uri] 435 + }), 436 + })); 437 + 438 + export const publication_pagesRelations = relations(publication_pages, ({one}) => ({ 439 + document: one(documents, { 440 + fields: [publication_pages.document], 441 + references: [documents.uri] 442 + }), 443 + permission_token: one(permission_tokens, { 444 + fields: [publication_pages.leaflet_src], 445 + references: [permission_tokens.id] 446 + }), 447 + publication: one(publications, { 448 + fields: [publication_pages.publication], 449 449 references: [publications.uri] 450 450 }), 451 451 }));
+21 -17
drizzle/schema.ts
··· 353 353 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 354 354 confirmed_at: timestamp("confirmed_at", { withTimezone: true, mode: 'string' }), 355 355 unsubscribed_at: timestamp("unsubscribed_at", { withTimezone: true, mode: 'string' }), 356 + metadata: jsonb("metadata"), 356 357 }, 357 358 (table) => { 358 359 return { ··· 527 528 } 528 529 }); 529 530 530 - export const publication_pages = pgTable("publication_pages", { 531 - // You can use { mode: "bigint" } if numbers are exceeding js number limitations 532 - id: bigint("id", { mode: "number" }).notNull(), 533 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 534 - leaflet_src: uuid("leaflet_src").notNull().references(() => permission_tokens.id), 535 - document: text("document").references(() => documents.uri), 536 - path: text("path"), 537 - publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 538 - title: text("title").default('').notNull(), 539 - metadata: jsonb("metadata").default({}).notNull(), 540 - }, 541 - (table) => { 542 - return { 543 - publication_pages_pkey: primaryKey({ columns: [table.id, table.publication], name: "publication_pages_pkey"}), 544 - } 545 - }); 546 - 547 531 export const leaflets_to_documents = pgTable("leaflets_to_documents", { 548 532 leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 549 533 document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), ··· 581 565 publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), 582 566 doc_idx: index("leaflets_in_publications_doc_idx").on(table.doc), 583 567 leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), 568 + } 569 + }); 570 + 571 + export const publication_pages = pgTable("publication_pages", { 572 + // You can use { mode: "bigint" } if numbers are exceeding js number limitations 573 + id: bigint("id", { mode: "number" }).notNull(), 574 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 575 + leaflet_src: uuid("leaflet_src").notNull().references(() => permission_tokens.id), 576 + document: text("document").references(() => documents.uri), 577 + path: text("path"), 578 + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 579 + title: text("title").default('').notNull(), 580 + metadata: jsonb("metadata").default({}).notNull(), 581 + record: jsonb("record"), 582 + record_uri: text("record_uri"), 583 + sort_order: text("sort_order").notNull(), 584 + }, 585 + (table) => { 586 + return { 587 + publication_pages_pkey: primaryKey({ columns: [table.id, table.publication], name: "publication_pages_pkey"}), 584 588 } 585 589 });
+91
lexicons/api/index.ts
··· 39 39 import * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' 40 40 import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 41 41 import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 42 + import * as PubLeafletBlocksPostsList from './types/pub/leaflet/blocks/postsList' 43 + import * as PubLeafletBlocksStandardSitePost from './types/pub/leaflet/blocks/standardSitePost' 42 44 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 43 45 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 44 46 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' ··· 52 54 import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 53 55 import * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' 54 56 import * as PubLeafletPublication from './types/pub/leaflet/publication' 57 + import * as PubLeafletPublicationPage from './types/pub/leaflet/publicationPage' 55 58 import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 56 59 import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 57 60 import * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' ··· 92 95 export * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' 93 96 export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 94 97 export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 98 + export * as PubLeafletBlocksPostsList from './types/pub/leaflet/blocks/postsList' 99 + export * as PubLeafletBlocksStandardSitePost from './types/pub/leaflet/blocks/standardSitePost' 95 100 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 96 101 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 97 102 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' ··· 105 110 export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' 106 111 export * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' 107 112 export * as PubLeafletPublication from './types/pub/leaflet/publication' 113 + export * as PubLeafletPublicationPage from './types/pub/leaflet/publicationPage' 108 114 export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 109 115 export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 110 116 export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' ··· 637 643 comment: PubLeafletCommentRecord 638 644 document: PubLeafletDocumentRecord 639 645 publication: PubLeafletPublicationRecord 646 + publicationPage: PubLeafletPublicationPageRecord 640 647 blocks: PubLeafletBlocksNS 641 648 graph: PubLeafletGraphNS 642 649 interactions: PubLeafletInteractionsNS ··· 657 664 this.comment = new PubLeafletCommentRecord(client) 658 665 this.document = new PubLeafletDocumentRecord(client) 659 666 this.publication = new PubLeafletPublicationRecord(client) 667 + this.publicationPage = new PubLeafletPublicationPageRecord(client) 660 668 } 661 669 } 662 670 ··· 1288 1296 'com.atproto.repo.deleteRecord', 1289 1297 undefined, 1290 1298 { collection: 'pub.leaflet.publication', ...params }, 1299 + { headers }, 1300 + ) 1301 + } 1302 + } 1303 + 1304 + export class PubLeafletPublicationPageRecord { 1305 + _client: XrpcClient 1306 + 1307 + constructor(client: XrpcClient) { 1308 + this._client = client 1309 + } 1310 + 1311 + async list( 1312 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 1313 + ): Promise<{ 1314 + cursor?: string 1315 + records: { uri: string; value: PubLeafletPublicationPage.Record }[] 1316 + }> { 1317 + const res = await this._client.call('com.atproto.repo.listRecords', { 1318 + collection: 'pub.leaflet.publicationPage', 1319 + ...params, 1320 + }) 1321 + return res.data 1322 + } 1323 + 1324 + async get( 1325 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 1326 + ): Promise<{ 1327 + uri: string 1328 + cid: string 1329 + value: PubLeafletPublicationPage.Record 1330 + }> { 1331 + const res = await this._client.call('com.atproto.repo.getRecord', { 1332 + collection: 'pub.leaflet.publicationPage', 1333 + ...params, 1334 + }) 1335 + return res.data 1336 + } 1337 + 1338 + async create( 1339 + params: OmitKey< 1340 + ComAtprotoRepoCreateRecord.InputSchema, 1341 + 'collection' | 'record' 1342 + >, 1343 + record: Un$Typed<PubLeafletPublicationPage.Record>, 1344 + headers?: Record<string, string>, 1345 + ): Promise<{ uri: string; cid: string }> { 1346 + const collection = 'pub.leaflet.publicationPage' 1347 + const res = await this._client.call( 1348 + 'com.atproto.repo.createRecord', 1349 + undefined, 1350 + { collection, ...params, record: { ...record, $type: collection } }, 1351 + { encoding: 'application/json', headers }, 1352 + ) 1353 + return res.data 1354 + } 1355 + 1356 + async put( 1357 + params: OmitKey< 1358 + ComAtprotoRepoPutRecord.InputSchema, 1359 + 'collection' | 'record' 1360 + >, 1361 + record: Un$Typed<PubLeafletPublicationPage.Record>, 1362 + headers?: Record<string, string>, 1363 + ): Promise<{ uri: string; cid: string }> { 1364 + const collection = 'pub.leaflet.publicationPage' 1365 + const res = await this._client.call( 1366 + 'com.atproto.repo.putRecord', 1367 + undefined, 1368 + { collection, ...params, record: { ...record, $type: collection } }, 1369 + { encoding: 'application/json', headers }, 1370 + ) 1371 + return res.data 1372 + } 1373 + 1374 + async delete( 1375 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 1376 + headers?: Record<string, string>, 1377 + ): Promise<void> { 1378 + await this._client.call( 1379 + 'com.atproto.repo.deleteRecord', 1380 + undefined, 1381 + { collection: 'pub.leaflet.publicationPage', ...params }, 1291 1382 { headers }, 1292 1383 ) 1293 1384 }
+89
lexicons/api/lexicons.ts
··· 1568 1568 }, 1569 1569 }, 1570 1570 }, 1571 + PubLeafletBlocksPostsList: { 1572 + lexicon: 1, 1573 + id: 'pub.leaflet.blocks.postsList', 1574 + defs: { 1575 + main: { 1576 + type: 'object', 1577 + required: [], 1578 + properties: { 1579 + view: { 1580 + type: 'string', 1581 + knownValues: ['compact', 'full'], 1582 + }, 1583 + highlightFirstPost: { 1584 + type: 'boolean', 1585 + }, 1586 + filterByTag: { 1587 + type: 'string', 1588 + }, 1589 + }, 1590 + }, 1591 + }, 1592 + }, 1593 + PubLeafletBlocksStandardSitePost: { 1594 + lexicon: 1, 1595 + id: 'pub.leaflet.blocks.standardSitePost', 1596 + defs: { 1597 + main: { 1598 + type: 'object', 1599 + required: ['uri'], 1600 + properties: { 1601 + uri: { 1602 + type: 'string', 1603 + format: 'at-uri', 1604 + }, 1605 + cid: { 1606 + type: 'string', 1607 + }, 1608 + size: { 1609 + type: 'string', 1610 + knownValues: ['large', 'medium', 'small'], 1611 + }, 1612 + }, 1613 + }, 1614 + }, 1615 + }, 1571 1616 PubLeafletBlocksText: { 1572 1617 lexicon: 1, 1573 1618 id: 'pub.leaflet.blocks.text', ··· 1948 1993 'lex:pub.leaflet.blocks.code', 1949 1994 'lex:pub.leaflet.blocks.horizontalRule', 1950 1995 'lex:pub.leaflet.blocks.bskyPost', 1996 + 'lex:pub.leaflet.blocks.standardSitePost', 1951 1997 'lex:pub.leaflet.blocks.page', 1952 1998 'lex:pub.leaflet.blocks.poll', 1953 1999 'lex:pub.leaflet.blocks.button', 2000 + 'lex:pub.leaflet.blocks.postsList', 1954 2001 ], 1955 2002 }, 1956 2003 x: { ··· 2050 2097 'lex:pub.leaflet.blocks.code', 2051 2098 'lex:pub.leaflet.blocks.horizontalRule', 2052 2099 'lex:pub.leaflet.blocks.bskyPost', 2100 + 'lex:pub.leaflet.blocks.standardSitePost', 2053 2101 'lex:pub.leaflet.blocks.page', 2054 2102 'lex:pub.leaflet.blocks.poll', 2055 2103 'lex:pub.leaflet.blocks.button', 2104 + 'lex:pub.leaflet.blocks.postsList', 2056 2105 ], 2057 2106 }, 2058 2107 alignment: { ··· 2301 2350 bodyFont: { 2302 2351 type: 'string', 2303 2352 maxLength: 100, 2353 + }, 2354 + }, 2355 + }, 2356 + }, 2357 + }, 2358 + PubLeafletPublicationPage: { 2359 + lexicon: 1, 2360 + id: 'pub.leaflet.publicationPage', 2361 + defs: { 2362 + main: { 2363 + type: 'record', 2364 + key: 'any', 2365 + description: 2366 + 'A static page belonging to a publication (e.g. about / contact). The rkey is derived from the page path slug.', 2367 + record: { 2368 + type: 'object', 2369 + required: ['publication', 'path', 'content'], 2370 + properties: { 2371 + publication: { 2372 + type: 'string', 2373 + format: 'at-uri', 2374 + }, 2375 + path: { 2376 + type: 'string', 2377 + }, 2378 + title: { 2379 + type: 'string', 2380 + maxLength: 2000, 2381 + }, 2382 + publishedAt: { 2383 + type: 'string', 2384 + format: 'datetime', 2385 + }, 2386 + content: { 2387 + type: 'ref', 2388 + ref: 'lex:pub.leaflet.content', 2389 + }, 2304 2390 }, 2305 2391 }, 2306 2392 }, ··· 2883 2969 PubLeafletBlocksOrderedList: 'pub.leaflet.blocks.orderedList', 2884 2970 PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 2885 2971 PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', 2972 + PubLeafletBlocksPostsList: 'pub.leaflet.blocks.postsList', 2973 + PubLeafletBlocksStandardSitePost: 'pub.leaflet.blocks.standardSitePost', 2886 2974 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 2887 2975 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 2888 2976 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', ··· 2896 2984 PubLeafletPollDefinition: 'pub.leaflet.poll.definition', 2897 2985 PubLeafletPollVote: 'pub.leaflet.poll.vote', 2898 2986 PubLeafletPublication: 'pub.leaflet.publication', 2987 + PubLeafletPublicationPage: 'pub.leaflet.publicationPage', 2899 2988 PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 2900 2989 PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage', 2901 2990 PubLeafletThemeColor: 'pub.leaflet.theme.color',
+32
lexicons/api/types/pub/leaflet/blocks/postsList.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'pub.leaflet.blocks.postsList' 16 + 17 + export interface Main { 18 + $type?: 'pub.leaflet.blocks.postsList' 19 + view?: 'compact' | 'full' | (string & {}) 20 + highlightFirstPost?: boolean 21 + filterByTag?: string 22 + } 23 + 24 + const hashMain = 'main' 25 + 26 + export function isMain<V>(v: V) { 27 + return is$typed(v, id, hashMain) 28 + } 29 + 30 + export function validateMain<V>(v: V) { 31 + return validate<Main & V>(v, id, hashMain) 32 + }
+32
lexicons/api/types/pub/leaflet/blocks/standardSitePost.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'pub.leaflet.blocks.standardSitePost' 16 + 17 + export interface Main { 18 + $type?: 'pub.leaflet.blocks.standardSitePost' 19 + uri: string 20 + cid?: string 21 + size?: 'large' | 'medium' | 'small' | (string & {}) 22 + } 23 + 24 + const hashMain = 'main' 25 + 26 + export function isMain<V>(v: V) { 27 + return is$typed(v, id, hashMain) 28 + } 29 + 30 + export function validateMain<V>(v: V) { 31 + return validate<Main & V>(v, id, hashMain) 32 + }
+4
lexicons/api/types/pub/leaflet/pages/canvas.ts
··· 21 21 import type * as PubLeafletBlocksCode from '../blocks/code' 22 22 import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' 23 23 import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' 24 + import type * as PubLeafletBlocksStandardSitePost from '../blocks/standardSitePost' 24 25 import type * as PubLeafletBlocksPage from '../blocks/page' 25 26 import type * as PubLeafletBlocksPoll from '../blocks/poll' 26 27 import type * as PubLeafletBlocksButton from '../blocks/button' 28 + import type * as PubLeafletBlocksPostsList from '../blocks/postsList' 27 29 28 30 const is$typed = _is$typed, 29 31 validate = _validate ··· 60 62 | $Typed<PubLeafletBlocksCode.Main> 61 63 | $Typed<PubLeafletBlocksHorizontalRule.Main> 62 64 | $Typed<PubLeafletBlocksBskyPost.Main> 65 + | $Typed<PubLeafletBlocksStandardSitePost.Main> 63 66 | $Typed<PubLeafletBlocksPage.Main> 64 67 | $Typed<PubLeafletBlocksPoll.Main> 65 68 | $Typed<PubLeafletBlocksButton.Main> 69 + | $Typed<PubLeafletBlocksPostsList.Main> 66 70 | { $type: string } 67 71 x: number 68 72 y: number
+4
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 21 21 import type * as PubLeafletBlocksCode from '../blocks/code' 22 22 import type * as PubLeafletBlocksHorizontalRule from '../blocks/horizontalRule' 23 23 import type * as PubLeafletBlocksBskyPost from '../blocks/bskyPost' 24 + import type * as PubLeafletBlocksStandardSitePost from '../blocks/standardSitePost' 24 25 import type * as PubLeafletBlocksPage from '../blocks/page' 25 26 import type * as PubLeafletBlocksPoll from '../blocks/poll' 26 27 import type * as PubLeafletBlocksButton from '../blocks/button' 28 + import type * as PubLeafletBlocksPostsList from '../blocks/postsList' 27 29 28 30 const is$typed = _is$typed, 29 31 validate = _validate ··· 60 62 | $Typed<PubLeafletBlocksCode.Main> 61 63 | $Typed<PubLeafletBlocksHorizontalRule.Main> 62 64 | $Typed<PubLeafletBlocksBskyPost.Main> 65 + | $Typed<PubLeafletBlocksStandardSitePost.Main> 63 66 | $Typed<PubLeafletBlocksPage.Main> 64 67 | $Typed<PubLeafletBlocksPoll.Main> 65 68 | $Typed<PubLeafletBlocksButton.Main> 69 + | $Typed<PubLeafletBlocksPostsList.Main> 66 70 | { $type: string } 67 71 alignment?: 68 72 | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft'
+32
lexicons/api/types/pub/leaflet/publicationPage.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + import type * as PubLeafletContent from './content' 9 + 10 + const is$typed = _is$typed, 11 + validate = _validate 12 + const id = 'pub.leaflet.publicationPage' 13 + 14 + export interface Record { 15 + $type: 'pub.leaflet.publicationPage' 16 + publication: string 17 + path: string 18 + title?: string 19 + publishedAt?: string 20 + content: PubLeafletContent.Main 21 + [k: string]: unknown 22 + } 23 + 24 + const hashRecord = 'main' 25 + 26 + export function isRecord<V>(v: V) { 27 + return is$typed(v, id, hashRecord) 28 + } 29 + 30 + export function validateRecord<V>(v: V) { 31 + return validate<Record & V>(v, id, hashRecord, true) 32 + }
+2
lexicons/build.ts
··· 12 12 import { PubLeafletComment } from "./src/comment"; 13 13 import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions"; 14 14 import { PubLeafletContent } from "./src/content"; 15 + import { PubLeafletPublicationPage } from "./src/publicationPage"; 15 16 import * as MentionServiceLexicons from "./src/mentionService"; 16 17 17 18 const outdir = path.join("lexicons", "pub", "leaflet"); ··· 24 25 const lexicons = [ 25 26 PubLeafletDocument, 26 27 PubLeafletContent, 28 + PubLeafletPublicationPage, 27 29 PubLeafletComment, 28 30 PubLeafletRichTextFacet, 29 31 PubLeafletAuthFullPermissions,
+2 -1
lexicons/pub/leaflet/authFullPermissions.json
··· 22 22 "pub.leaflet.poll.definition", 23 23 "pub.leaflet.poll.vote", 24 24 "pub.leaflet.graph.subscription", 25 - "pub.leaflet.interactions.recommend" 25 + "pub.leaflet.interactions.recommend", 26 + "pub.leaflet.publicationPage" 26 27 ] 27 28 } 28 29 ]
+25
lexicons/pub/leaflet/blocks/postsList.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.postsList", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [], 8 + "properties": { 9 + "view": { 10 + "type": "string", 11 + "knownValues": [ 12 + "compact", 13 + "full" 14 + ] 15 + }, 16 + "highlightFirstPost": { 17 + "type": "boolean" 18 + }, 19 + "filterByTag": { 20 + "type": "string" 21 + } 22 + } 23 + } 24 + } 25 + }
+29
lexicons/pub/leaflet/blocks/standardSitePost.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.standardSitePost", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "uri" 9 + ], 10 + "properties": { 11 + "uri": { 12 + "type": "string", 13 + "format": "at-uri" 14 + }, 15 + "cid": { 16 + "type": "string" 17 + }, 18 + "size": { 19 + "type": "string", 20 + "knownValues": [ 21 + "large", 22 + "medium", 23 + "small" 24 + ] 25 + } 26 + } 27 + } 28 + } 29 + }
+3 -1
lexicons/pub/leaflet/pages/canvas.json
··· 44 44 "pub.leaflet.blocks.code", 45 45 "pub.leaflet.blocks.horizontalRule", 46 46 "pub.leaflet.blocks.bskyPost", 47 + "pub.leaflet.blocks.standardSitePost", 47 48 "pub.leaflet.blocks.page", 48 49 "pub.leaflet.blocks.poll", 49 - "pub.leaflet.blocks.button" 50 + "pub.leaflet.blocks.button", 51 + "pub.leaflet.blocks.postsList" 50 52 ] 51 53 }, 52 54 "x": {
+3 -1
lexicons/pub/leaflet/pages/linearDocument.json
··· 41 41 "pub.leaflet.blocks.code", 42 42 "pub.leaflet.blocks.horizontalRule", 43 43 "pub.leaflet.blocks.bskyPost", 44 + "pub.leaflet.blocks.standardSitePost", 44 45 "pub.leaflet.blocks.page", 45 46 "pub.leaflet.blocks.poll", 46 - "pub.leaflet.blocks.button" 47 + "pub.leaflet.blocks.button", 48 + "pub.leaflet.blocks.postsList" 47 49 ] 48 50 }, 49 51 "alignment": {
+40
lexicons/pub/leaflet/publicationPage.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.publicationPage", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "description": "A static page belonging to a publication (e.g. about / contact). The rkey is derived from the page path slug.", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "publication", 13 + "path", 14 + "content" 15 + ], 16 + "properties": { 17 + "publication": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "path": { 22 + "type": "string" 23 + }, 24 + "title": { 25 + "type": "string", 26 + "maxLength": 2000 27 + }, 28 + "publishedAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + }, 32 + "content": { 33 + "type": "ref", 34 + "ref": "pub.leaflet.content" 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+2
lexicons/src/authFullPermissions.ts
··· 7 7 import { PubLeafletComment } from "./comment"; 8 8 import { PubLeafletPollDefinition, PubLeafletPollVote } from "./polls"; 9 9 import { PubLeafletInteractionsRecommend } from "./interactions"; 10 + import { PubLeafletPublicationPage } from "./publicationPage"; 10 11 11 12 export const PubLeafletAuthFullPermissions: LexiconDoc = { 12 13 lexicon: 1, ··· 30 31 PubLeafletPollVote.id, 31 32 PubLeafletPublicationSubscription.id, 32 33 PubLeafletInteractionsRecommend.id, 34 + PubLeafletPublicationPage.id, 33 35 ], 34 36 }, 35 37 ],
+34
lexicons/src/blocks.ts
··· 49 49 }, 50 50 }; 51 51 52 + export const PubLeafletBlocksStandardSitePost: LexiconDoc = { 53 + lexicon: 1, 54 + id: "pub.leaflet.blocks.standardSitePost", 55 + defs: { 56 + main: { 57 + type: "object", 58 + required: ["uri"], 59 + properties: { 60 + uri: { type: "string", format: "at-uri" }, 61 + cid: { type: "string" }, 62 + size: { type: "string", knownValues: ["large", "medium", "small"] }, 63 + }, 64 + }, 65 + }, 66 + }; 67 + 52 68 export const PubLeafletBlocksBlockQuote: LexiconDoc = { 53 69 lexicon: 1, 54 70 id: "pub.leaflet.blocks.blockquote", ··· 349 365 }, 350 366 }; 351 367 368 + export const PubLeafletBlocksPostsList: LexiconDoc = { 369 + lexicon: 1, 370 + id: "pub.leaflet.blocks.postsList", 371 + defs: { 372 + main: { 373 + type: "object", 374 + required: [], 375 + properties: { 376 + view: { type: "string", knownValues: ["compact", "full"] }, 377 + highlightFirstPost: { type: "boolean" }, 378 + filterByTag: { type: "string" }, 379 + }, 380 + }, 381 + }, 382 + }; 383 + 352 384 export const BlockLexicons = [ 353 385 PubLeafletBlocksIFrame, 354 386 PubLeafletBlocksText, ··· 362 394 PubLeafletBlocksCode, 363 395 PubLeafletBlocksHorizontalRule, 364 396 PubLeafletBlocksBskyPost, 397 + PubLeafletBlocksStandardSitePost, 365 398 PubLeafletBlocksPage, 366 399 PubLeafletBlocksPoll, 367 400 PubLeafletBlocksButton, 401 + PubLeafletBlocksPostsList, 368 402 ]; 369 403 export const BlockUnion: LexRefUnion = { 370 404 type: "union",
+26
lexicons/src/publicationPage.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + import { PubLeafletContent } from "./content"; 3 + 4 + export const PubLeafletPublicationPage: LexiconDoc = { 5 + lexicon: 1, 6 + id: "pub.leaflet.publicationPage", 7 + defs: { 8 + main: { 9 + type: "record", 10 + key: "any", 11 + description: 12 + "A static page belonging to a publication (e.g. about / contact). The rkey is derived from the page path slug.", 13 + record: { 14 + type: "object", 15 + required: ["publication", "path", "content"], 16 + properties: { 17 + publication: { type: "string", format: "at-uri" }, 18 + path: { type: "string" }, 19 + title: { type: "string", maxLength: 2000 }, 20 + publishedAt: { type: "string", format: "datetime" }, 21 + content: { type: "ref", ref: PubLeafletContent.id }, 22 + }, 23 + }, 24 + }, 25 + }, 26 + };
+70
package-lock.json
··· 18 18 "@atproto/syntax": "^0.3.3", 19 19 "@atproto/xrpc": "^0.7.5", 20 20 "@atproto/xrpc-server": "^0.9.5", 21 + "@dnd-kit/core": "^6.3.1", 22 + "@dnd-kit/modifiers": "^9.0.0", 23 + "@dnd-kit/sortable": "^10.0.0", 21 24 "@hono/node-server": "^1.14.3", 22 25 "@mdx-js/loader": "^3.1.0", 23 26 "@mdx-js/react": "^3.1.0", ··· 1174 1177 "license": "MIT", 1175 1178 "engines": { 1176 1179 "node": ">=10.0.0" 1180 + } 1181 + }, 1182 + "node_modules/@dnd-kit/accessibility": { 1183 + "version": "3.1.1", 1184 + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", 1185 + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", 1186 + "license": "MIT", 1187 + "dependencies": { 1188 + "tslib": "^2.0.0" 1189 + }, 1190 + "peerDependencies": { 1191 + "react": ">=16.8.0" 1192 + } 1193 + }, 1194 + "node_modules/@dnd-kit/core": { 1195 + "version": "6.3.1", 1196 + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", 1197 + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", 1198 + "license": "MIT", 1199 + "dependencies": { 1200 + "@dnd-kit/accessibility": "^3.1.1", 1201 + "@dnd-kit/utilities": "^3.2.2", 1202 + "tslib": "^2.0.0" 1203 + }, 1204 + "peerDependencies": { 1205 + "react": ">=16.8.0", 1206 + "react-dom": ">=16.8.0" 1207 + } 1208 + }, 1209 + "node_modules/@dnd-kit/modifiers": { 1210 + "version": "9.0.0", 1211 + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", 1212 + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", 1213 + "license": "MIT", 1214 + "dependencies": { 1215 + "@dnd-kit/utilities": "^3.2.2", 1216 + "tslib": "^2.0.0" 1217 + }, 1218 + "peerDependencies": { 1219 + "@dnd-kit/core": "^6.3.0", 1220 + "react": ">=16.8.0" 1221 + } 1222 + }, 1223 + "node_modules/@dnd-kit/sortable": { 1224 + "version": "10.0.0", 1225 + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", 1226 + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", 1227 + "license": "MIT", 1228 + "dependencies": { 1229 + "@dnd-kit/utilities": "^3.2.2", 1230 + "tslib": "^2.0.0" 1231 + }, 1232 + "peerDependencies": { 1233 + "@dnd-kit/core": "^6.3.0", 1234 + "react": ">=16.8.0" 1235 + } 1236 + }, 1237 + "node_modules/@dnd-kit/utilities": { 1238 + "version": "3.2.2", 1239 + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", 1240 + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", 1241 + "license": "MIT", 1242 + "dependencies": { 1243 + "tslib": "^2.0.0" 1244 + }, 1245 + "peerDependencies": { 1246 + "react": ">=16.8.0" 1177 1247 } 1178 1248 }, 1179 1249 "node_modules/@emnapi/runtime": {
+4 -1
package.json
··· 8 8 "dev": "TZ=UTC next dev --turbo", 9 9 "publish-lexicons": "tsx lexicons/publish.ts", 10 10 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 11 - "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", 11 + "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", 12 12 "wrangler-dev": "wrangler dev", 13 13 "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", 14 14 "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", ··· 30 30 "@atproto/syntax": "^0.3.3", 31 31 "@atproto/xrpc": "^0.7.5", 32 32 "@atproto/xrpc-server": "^0.9.5", 33 + "@dnd-kit/core": "^6.3.1", 34 + "@dnd-kit/modifiers": "^9.0.0", 35 + "@dnd-kit/sortable": "^10.0.0", 33 36 "@hono/node-server": "^1.14.3", 34 37 "@mdx-js/loader": "^3.1.0", 35 38 "@mdx-js/react": "^3.1.0",
+35 -1
src/replicache/attributes.ts
··· 87 87 type: "bluesky-post", 88 88 cardinality: "one", 89 89 }, 90 + "block/standard-site-post": { 91 + type: "string", 92 + cardinality: "one", 93 + }, 94 + "standard-site-post/size": { 95 + type: "standard-site-post-size-union", 96 + cardinality: "one", 97 + }, 90 98 "block/math": { 91 99 type: "string", 92 100 cardinality: "one", ··· 206 214 }, 207 215 } as const; 208 216 217 + const PostsListBlockAttributes = { 218 + "posts-list/view": { 219 + type: "posts-list-view-union", 220 + cardinality: "one", 221 + }, 222 + "posts-list/highlight-first-post": { 223 + type: "boolean", 224 + cardinality: "one", 225 + }, 226 + "posts-list/filter-tag": { 227 + type: "string", 228 + cardinality: "one", 229 + }, 230 + } as const; 231 + 209 232 export const ThemeAttributes = { 210 233 "theme/heading-font": { 211 234 type: "string", ··· 297 320 ...ButtonBlockAttributes, 298 321 ...ImageBlockAttributes, 299 322 ...PollBlockAttributes, 323 + ...PostsListBlockAttributes, 300 324 }; 301 325 export type Attributes = typeof Attributes; 302 326 export type Attribute = keyof Attributes; ··· 370 394 | "button" 371 395 | "poll" 372 396 | "bluesky-post" 397 + | "standard-site-post" 373 398 | "math" 374 399 | "code" 375 400 | "blockquote" 376 - | "horizontal-rule"; 401 + | "horizontal-rule" 402 + | "posts-list"; 377 403 }; 378 404 "canvas-pattern-union": { 379 405 type: "canvas-pattern-union"; ··· 382 408 "list-style-union": { 383 409 type: "list-style-union"; 384 410 value: "ordered" | "unordered"; 411 + }; 412 + "posts-list-view-union": { 413 + type: "posts-list-view-union"; 414 + value: "compact" | "full"; 415 + }; 416 + "standard-site-post-size-union": { 417 + type: "standard-site-post-size-union"; 418 + value: "large" | "medium" | "small"; 385 419 }; 386 420 color: { type: "color"; value: string }; 387 421 }[(typeof Attributes)[A]["type"]];
+25
src/utils/addLinkBlock.ts
··· 9 9 import { v7 } from "uuid"; 10 10 import { getAspectRatio } from "src/utils/aspectRatio"; 11 11 12 + export async function assertStandardSitePostFacts( 13 + rep: Replicache<ReplicacheMutators>, 14 + entityID: string, 15 + uri: string, 16 + ) { 17 + await rep.mutate.assertFact([ 18 + { 19 + entity: entityID, 20 + attribute: "block/type", 21 + data: { type: "block-type-union", value: "standard-site-post" }, 22 + }, 23 + { 24 + entity: entityID, 25 + attribute: "block/standard-site-post", 26 + data: { type: "string", value: uri }, 27 + }, 28 + ]); 29 + } 30 + 12 31 export async function addLinkBlock( 13 32 url: string, 14 33 entityID: string, ··· 40 59 return; 41 60 } 42 61 let data = await (res.json() as LinkPreviewMetadataResult); 62 + 63 + if (data.leafletPost) { 64 + await assertStandardSitePostFacts(rep, entityID, data.leafletPost.uri); 65 + return; 66 + } 67 + 43 68 if (!data.success) { 44 69 await rep?.mutate.assertFact([ 45 70 {
+28
src/utils/factsToPagesRecord.ts
··· 6 6 import { 7 7 PubLeafletBlocksBlockquote, 8 8 PubLeafletBlocksBskyPost, 9 + PubLeafletBlocksStandardSitePost, 9 10 PubLeafletBlocksButton, 10 11 PubLeafletBlocksCode, 11 12 PubLeafletBlocksHeader, ··· 16 17 PubLeafletBlocksOrderedList, 17 18 PubLeafletBlocksPage, 18 19 PubLeafletBlocksPoll, 20 + PubLeafletBlocksPostsList, 19 21 PubLeafletBlocksText, 20 22 PubLeafletBlocksUnorderedList, 21 23 PubLeafletBlocksWebsite, ··· 335 337 }; 336 338 return block; 337 339 } 340 + if (b.type === "standard-site-post") { 341 + const [uri] = scan.eav(b.value, "block/standard-site-post"); 342 + if (!uri) return; 343 + const [sizeFact] = scan.eav(b.value, "standard-site-post/size"); 344 + const block: $Typed<PubLeafletBlocksStandardSitePost.Main> = { 345 + $type: ids.PubLeafletBlocksStandardSitePost, 346 + uri: uri.data.value, 347 + ...(sizeFact && { size: sizeFact.data.value }), 348 + }; 349 + return block; 350 + } 338 351 if (b.type === "horizontal-rule") { 339 352 const block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { 340 353 $type: ids.PubLeafletBlocksHorizontalRule, ··· 491 504 $type: "pub.leaflet.blocks.button", 492 505 text: text.data.value, 493 506 url: url.data.value, 507 + }; 508 + return block; 509 + } 510 + if (b.type === "posts-list") { 511 + const [viewFact] = scan.eav(b.value, "posts-list/view"); 512 + const [highlightFact] = scan.eav( 513 + b.value, 514 + "posts-list/highlight-first-post", 515 + ); 516 + const [filterTagFact] = scan.eav(b.value, "posts-list/filter-tag"); 517 + const block: $Typed<PubLeafletBlocksPostsList.Main> = { 518 + $type: "pub.leaflet.blocks.postsList", 519 + ...(viewFact && { view: viewFact.data.value }), 520 + ...(highlightFact && { highlightFirstPost: highlightFact.data.value }), 521 + ...(filterTagFact && { filterByTag: filterTagFact.data.value }), 494 522 }; 495 523 return block; 496 524 }
+6
src/utils/getBlocksAsHTML.tsx
··· 98 98 /> 99 99 ); 100 100 }, 101 + "standard-site-post": async (b, tx) => { 102 + let [uri] = await scanIndex(tx).eav(b.value, "block/standard-site-post"); 103 + if (!uri) return null; 104 + return <div data-type="standard-site-post" data-uri={uri.data.value} />; 105 + }, 101 106 math: async (b, tx, a) => { 102 107 let [math] = await scanIndex(tx).eav(b.value, "block/math"); 103 108 const html = Katex.renderToString(math?.data.value || "", { ··· 202 207 /> 203 208 ); 204 209 }, 210 + "posts-list": async () => null, 205 211 }; 206 212 207 213 async function renderBlock(b: Block, tx: ReadTransaction) {
+60 -18
src/utils/getPublicationMetadataFromLeafletData.ts
··· 7 7 * Consumers should use `normalizePublicationRecord()` and `normalizeDocumentRecord()` 8 8 * from `src/utils/normalizeRecords` to get properly typed data. 9 9 */ 10 + export type PublicationPageMetadataEntry = { 11 + id: number; 12 + title: string; 13 + path: string | null; 14 + document: string | null; 15 + metadata: Json; 16 + }; 17 + 10 18 export type PublicationMetadata = { 11 19 description: string; 12 20 title: string; ··· 26 34 indexed_at: string; 27 35 uri: string; 28 36 } | null; 37 + /** Set when this leaflet is the source of a publication_pages row. */ 38 + page?: PublicationPageMetadataEntry; 29 39 } | null; 30 40 31 41 export function getPublicationMetadataFromLeafletData( ··· 33 43 ): PublicationMetadata { 34 44 if (!data) return null; 35 45 36 - let pubData: 37 - | NonNullable<PublicationMetadata> 38 - | undefined 39 - | null = 40 - data?.leaflets_in_publications?.[0] || 41 - data?.permission_token_rights[0].entity_sets?.permission_tokens?.find( 42 - (p) => p.leaflets_in_publications?.length, 43 - )?.leaflets_in_publications?.[0]; 46 + // Look across the leaflet's own associations and any sibling tokens that share 47 + // its entity set. Each association is checked in a single pass. 48 + let leafletInPub = data.leaflets_in_publications?.[0]; 49 + let standaloneDoc = data.leaflets_to_documents?.[0]; 50 + let pageRow = data.publication_pages?.[0]; 44 51 45 - // If not found, check for standalone documents 46 - let standaloneDoc = 47 - data?.leaflets_to_documents?.[0] || 48 - data?.permission_token_rights[0].entity_sets?.permission_tokens.find( 49 - (p) => p.leaflets_to_documents?.length, 50 - )?.leaflets_to_documents?.[0]; 51 - if (!pubData && standaloneDoc) { 52 - // Transform standalone document data to match the expected format 52 + if (!leafletInPub || !standaloneDoc || !pageRow) { 53 + let siblingTokens = 54 + data.permission_token_rights[0].entity_sets?.permission_tokens || []; 55 + for (let token of siblingTokens) { 56 + if (!leafletInPub && token.leaflets_in_publications?.length) 57 + leafletInPub = token.leaflets_in_publications[0]; 58 + if (!standaloneDoc && token.leaflets_to_documents?.length) 59 + standaloneDoc = token.leaflets_to_documents[0]; 60 + if (!pageRow && token.publication_pages?.length) 61 + pageRow = token.publication_pages[0]; 62 + if (leafletInPub && standaloneDoc && pageRow) break; 63 + } 64 + } 65 + 66 + let pubData: NonNullable<PublicationMetadata> | null = null; 67 + 68 + if (leafletInPub) { 69 + pubData = leafletInPub; 70 + } else if (standaloneDoc) { 53 71 pubData = { 54 72 ...standaloneDoc, 55 - publications: null, // No publication for standalone docs 73 + publications: null, 56 74 doc: standaloneDoc.document, 57 75 }; 76 + } else if (pageRow && pageRow.publications) { 77 + pubData = { 78 + title: pageRow.title || "", 79 + description: "", 80 + leaflet: data.id, 81 + doc: pageRow.document, 82 + publications: pageRow.publications, 83 + documents: null, 84 + }; 58 85 } 59 - return pubData || null; 86 + 87 + if (pageRow) { 88 + if (!pubData) return null; 89 + pubData = { 90 + ...pubData, 91 + page: { 92 + id: pageRow.id, 93 + title: pageRow.title, 94 + path: pageRow.path, 95 + document: pageRow.document, 96 + metadata: pageRow.metadata, 97 + }, 98 + }; 99 + } 100 + 101 + return pubData; 60 102 }
+66
src/utils/leafletToPublicationPageRecord.ts
··· 1 + import { 2 + PubLeafletContent, 3 + PubLeafletPagesCanvas, 4 + PubLeafletPagesLinearDocument, 5 + PubLeafletPublicationPage, 6 + } from "lexicons/api"; 7 + 8 + import type { Fact } from "src/replicache"; 9 + import type { Attribute } from "src/replicache/attributes"; 10 + import { 11 + processBlocksToPages, 12 + type ProcessBlocksToPagesHooks, 13 + } from "src/utils/factsToPagesRecord"; 14 + 15 + export async function leafletToPublicationPageRecord(opts: { 16 + facts: Fact<Attribute>[]; 17 + root_entity: string; 18 + publication_uri: string; 19 + path: string; 20 + title?: string; 21 + publishedAt?: string; 22 + hooks: ProcessBlocksToPagesHooks; 23 + }): Promise<PubLeafletPublicationPage.Record> { 24 + const { pages } = await processBlocksToPages({ 25 + facts: opts.facts, 26 + root_entity: opts.root_entity, 27 + hooks: opts.hooks, 28 + }); 29 + 30 + const pagesArray: PubLeafletContent.Main["pages"] = pages.map((p) => { 31 + if (p.type === "canvas") { 32 + return { 33 + $type: "pub.leaflet.pages.canvas" as const, 34 + id: p.id, 35 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 36 + }; 37 + } 38 + return { 39 + $type: "pub.leaflet.pages.linearDocument" as const, 40 + id: p.id, 41 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 42 + }; 43 + }); 44 + 45 + return { 46 + $type: "pub.leaflet.publicationPage", 47 + publication: opts.publication_uri, 48 + path: opts.path, 49 + ...(opts.title !== undefined && { title: opts.title }), 50 + publishedAt: opts.publishedAt ?? new Date().toISOString(), 51 + content: { 52 + $type: "pub.leaflet.content", 53 + pages: pagesArray, 54 + }, 55 + }; 56 + } 57 + 58 + export function pathToRkey(path: string): string { 59 + let trimmed = path.replace(/^\/+/, "").replace(/\/+$/, ""); 60 + if (trimmed === "") return "home"; 61 + let slug = trimmed 62 + .toLowerCase() 63 + .replace(/[^a-z0-9]+/g, "-") 64 + .replace(/^-+|-+$/g, ""); 65 + return slug || "home"; 66 + }
+72
src/utils/resolveStandardSitePostUrl.ts
··· 1 + import type { SupabaseClient } from "@supabase/supabase-js"; 2 + import type { Database, Json } from "supabase/database.types"; 3 + import { parseStandardSitePostInput } from "components/Blocks/StandardSitePostBlock/parseStandardSitePostInput"; 4 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 5 + 6 + /** 7 + * Resolve a URL or AT URI to a standard-site-post AT URI by checking our DB. 8 + * Handles at:// URIs, leaflet.pub patterns synchronously, and custom-domain 9 + * URLs via DB lookup against the publications + documents tables. 10 + */ 11 + export async function resolveStandardSitePostUrl( 12 + input: string, 13 + supabase: SupabaseClient<Database>, 14 + ): Promise<string | null> { 15 + const direct = parseStandardSitePostInput(input); 16 + if (direct) { 17 + const { data } = await supabase 18 + .from("documents") 19 + .select("uri") 20 + .eq("uri", direct) 21 + .maybeSingle(); 22 + return data?.uri ?? null; 23 + } 24 + 25 + let url: URL; 26 + try { 27 + url = new URL(input); 28 + } catch { 29 + return null; 30 + } 31 + const origin = `${url.protocol}//${url.host}`; 32 + const pathOnly = url.pathname.replace(/\/$/, "") || "/"; 33 + 34 + // Find a publication whose URL matches this origin. Supports both 35 + // pub.leaflet.publication (base_path) and site.standard.publication (url). 36 + // Descending uri order prefers site.standard.publication over pub.leaflet.publication. 37 + const { data: publication } = await supabase 38 + .from("publications") 39 + .select("uri") 40 + .or( 41 + `record->>base_path.eq.${url.host},record->>url.eq.${origin},record->>url.eq.${origin}/`, 42 + ) 43 + .order("uri", { ascending: false }) 44 + .limit(1) 45 + .maybeSingle(); 46 + console.log(publication); 47 + if (!publication) return null; 48 + 49 + // Find documents in that publication where data.path matches pathOnly 50 + const { data: docsInPub } = await supabase 51 + .from("documents_in_publications") 52 + .select("documents(uri, data)") 53 + .eq("publication", publication.uri); 54 + 55 + if (!docsInPub) return null; 56 + 57 + for (const row of docsInPub) { 58 + const doc = row.documents; 59 + if (!doc) continue; 60 + const normalized = normalizeDocumentRecord(doc.data as Json, doc.uri); 61 + if (!normalized) continue; 62 + const rawPath = normalized.path 63 + ? normalized.path.startsWith("/") 64 + ? normalized.path 65 + : `/${normalized.path}` 66 + : null; 67 + const docPath = rawPath ? rawPath.replace(/\/$/, "") || "/" : null; 68 + console.log(docPath, pathOnly); 69 + if (docPath === pathOnly) return doc.uri; 70 + } 71 + return null; 72 + }
+12
supabase/database.types.ts
··· 1116 1116 email: string 1117 1117 id: string 1118 1118 identity_id: string | null 1119 + metadata: Json | null 1119 1120 publication: string 1120 1121 state: string 1121 1122 unsubscribe_token: string ··· 1128 1129 email: string 1129 1130 id?: string 1130 1131 identity_id?: string | null 1132 + metadata?: Json | null 1131 1133 publication: string 1132 1134 state?: string 1133 1135 unsubscribe_token?: string ··· 1140 1142 email?: string 1141 1143 id?: string 1142 1144 identity_id?: string | null 1145 + metadata?: Json | null 1143 1146 publication?: string 1144 1147 state?: string 1145 1148 unsubscribe_token?: string ··· 1209 1212 metadata: Json 1210 1213 path: string | null 1211 1214 publication: string 1215 + record: Json | null 1216 + record_uri: string | null 1217 + sort_order: string 1212 1218 title: string 1213 1219 } 1214 1220 Insert: { ··· 1219 1225 metadata?: Json 1220 1226 path?: string | null 1221 1227 publication: string 1228 + record?: Json | null 1229 + record_uri?: string | null 1230 + sort_order: string 1222 1231 title?: string 1223 1232 } 1224 1233 Update: { ··· 1229 1238 metadata?: Json 1230 1239 path?: string | null 1231 1240 publication?: string 1241 + record?: Json | null 1242 + record_uri?: string | null 1243 + sort_order?: string 1232 1244 title?: string 1233 1245 } 1234 1246 Relationships: [