diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 0a4f281b..2f8c9e05 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -11,6 +11,8 @@ import { PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletDocument, + SiteStandardDocument, + PubLeafletContent, PubLeafletPagesLinearDocument, PubLeafletPagesCanvas, PubLeafletRichtextFacet, @@ -56,6 +58,11 @@ import { pingIdentityToUpdateNotification, } from "src/notifications"; import { v7 } from "uuid"; +import { + isDocumentCollection, + isPublicationCollection, + getDocumentType, +} from "src/utils/collectionHelpers"; type PublishResult = | { success: true; rkey: string; record: PubLeafletDocument.Record } @@ -189,33 +196,64 @@ export async function publishToPublication({ } } - let record: PubLeafletDocument.Record = { - publishedAt: new Date().toISOString(), - ...existingRecord, - $type: "pub.leaflet.document", - author: credentialSession.did!, - ...(publication_uri && { publication: publication_uri }), - ...(theme && { theme }), - title: title || "Untitled", - description: description || "", - ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) - ...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded - pages: pages.map((p) => { - if (p.type === "canvas") { - return { - $type: "pub.leaflet.pages.canvas" as const, - id: p.id, - blocks: p.blocks as PubLeafletPagesCanvas.Block[], - }; - } else { - return { - $type: "pub.leaflet.pages.linearDocument" as const, - id: p.id, - blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], - }; - } - }), - }; + // Determine the collection to use - preserve existing schema if updating + const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined; + const documentType = getDocumentType(existingCollection); + + // Build the pages array (used by both formats) + const pagesArray = pages.map((p) => { + if (p.type === "canvas") { + return { + $type: "pub.leaflet.pages.canvas" as const, + id: p.id, + blocks: p.blocks as PubLeafletPagesCanvas.Block[], + }; + } else { + return { + $type: "pub.leaflet.pages.linearDocument" as const, + id: p.id, + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], + }; + } + }); + + // Create record based on the document type + let record: PubLeafletDocument.Record | SiteStandardDocument.Record; + + if (documentType === "site.standard.document") { + // site.standard.document format + // For standalone docs, use a constructed site URI; for publication docs, use the publication URI + const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; + + record = { + $type: "site.standard.document", + title: title || "Untitled", + site: siteUri, + publishedAt: existingRecord.publishedAt || new Date().toISOString(), + ...(description && { description }), + ...(tags !== undefined && { tags }), + ...(coverImageBlob && { coverImage: coverImageBlob }), + content: { + $type: "pub.leaflet.content" as const, + pages: pagesArray, + }, + } satisfies SiteStandardDocument.Record; + } else { + // pub.leaflet.document format (legacy) + record = { + $type: "pub.leaflet.document", + publishedAt: new Date().toISOString(), + ...existingRecord, + author: credentialSession.did!, + ...(publication_uri && { publication: publication_uri }), + ...(theme && { theme }), + title: title || "Untitled", + description: description || "", + ...(tags !== undefined && { tags }), + ...(coverImageBlob && { coverImage: coverImageBlob }), + pages: pagesArray, + } satisfies PubLeafletDocument.Record; + } // Keep the same rkey if updating an existing document let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); @@ -230,7 +268,7 @@ export async function publishToPublication({ // Optimistically create database entries await supabaseServerClient.from("documents").upsert({ uri: result.uri, - data: record as Json, + data: record as unknown as Json, }); if (publication_uri) { @@ -852,15 +890,28 @@ async function extractThemeFromFacts( */ async function createMentionNotifications( documentUri: string, - record: PubLeafletDocument.Record, + record: PubLeafletDocument.Record | SiteStandardDocument.Record, authorDid: string, ) { const mentionedDids = new Set(); const mentionedPublications = new Map(); // Map of DID -> publication URI const mentionedDocuments = new Map(); // Map of DID -> document URI + // Extract pages from either format + let pages: PubLeafletContent.Main["pages"] | undefined; + if (record.$type === "site.standard.document") { + const content = record.content; + if (content && PubLeafletContent.isMain(content)) { + pages = content.pages; + } + } else { + pages = record.pages; + } + + if (!pages) return; + // Extract mentions from all text blocks in all pages - for (const page of record.pages) { + for (const page of pages) { if (page.$type === "pub.leaflet.pages.linearDocument") { const linearPage = page as PubLeafletPagesLinearDocument.Main; for (const blockWrapper of linearPage.blocks) { @@ -880,7 +931,7 @@ async function createMentionNotifications( if (PubLeafletRichtextFacet.isAtMention(feature)) { const uri = new AtUri(feature.atURI); - if (uri.collection === "pub.leaflet.publication") { + if (isPublicationCollection(uri.collection)) { // Get the publication owner's DID const { data: publication } = await supabaseServerClient .from("publications") @@ -894,7 +945,7 @@ async function createMentionNotifications( feature.atURI, ); } - } else if (uri.collection === "pub.leaflet.document") { + } else if (isDocumentCollection(uri.collection)) { // Get the document owner's DID const { data: document } = await supabaseServerClient .from("documents") diff --git a/app/api/inngest/functions/index_post_mention.ts b/app/api/inngest/functions/index_post_mention.ts index 726c4c3a..9f28784e 100644 --- a/app/api/inngest/functions/index_post_mention.ts +++ b/app/api/inngest/functions/index_post_mention.ts @@ -6,6 +6,7 @@ import { ids } from "lexicons/api/lexicons"; import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; import { v7 } from "uuid"; import { idResolver } from "app/(home-pages)/reader/idResolver"; +import { documentUriFilter } from "src/utils/uriHelpers"; export const index_post_mention = inngest.createFunction( { id: "index_post_mention" }, @@ -37,7 +38,20 @@ export const index_post_mention = inngest.createFunction( did = resolved; } - documentUri = AtUri.make(did, ids.PubLeafletDocument, rkey).toString(); + // Query the database to find the actual document URI (could be either namespace) + const { data: docDataArr } = await supabaseServerClient + .from("documents") + .select("uri") + .or(documentUriFilter(did, rkey)) + .order("uri", { ascending: false }) + .limit(1); + const docData = docDataArr?.[0]; + + if (!docData) { + return { message: `No document found for did:${did} rkey:${rkey}` }; + } + + documentUri = docData.uri; authorDid = did; } else { // Publication post: look up by custom domain @@ -54,11 +68,20 @@ export const index_post_mention = inngest.createFunction( }; } - documentUri = AtUri.make( - pub.identity_did, - ids.PubLeafletDocument, - path[0], - ).toString(); + // Query the database to find the actual document URI (could be either namespace) + const { data: docDataArr } = await supabaseServerClient + .from("documents") + .select("uri") + .or(documentUriFilter(pub.identity_did, path[0])) + .order("uri", { ascending: false }) + .limit(1); + const docData = docDataArr?.[0]; + + if (!docData) { + return { message: `No document found for publication ${url.host}/${path[0]}` }; + } + + documentUri = docData.uri; authorDid = pub.identity_did; } diff --git a/app/api/pub_icon/route.ts b/app/api/pub_icon/route.ts index 26c698a9..0d58d57b 100644 --- a/app/api/pub_icon/route.ts +++ b/app/api/pub_icon/route.ts @@ -6,6 +6,11 @@ import { normalizePublicationRecord, type NormalizedPublication, } from "src/utils/normalizeRecords"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; +import { publicationUriFilter } from "src/utils/uriHelpers"; import sharp from "sharp"; const idResolver = new IdResolver(); @@ -36,7 +41,7 @@ export async function GET(req: NextRequest) { let publicationUri: string; // Check if it's a document or publication - if (uri.collection === "pub.leaflet.document") { + if (isDocumentCollection(uri.collection)) { // Query the documents_in_publications table to get the publication const { data: docInPub } = await supabaseServerClient .from("documents_in_publications") @@ -50,13 +55,15 @@ export async function GET(req: NextRequest) { publicationUri = docInPub.publication; normalizedPub = normalizePublicationRecord(docInPub.publications.record); - } else if (uri.collection === "pub.leaflet.publication") { + } else if (isPublicationCollection(uri.collection)) { // Query the publications table directly - const { data: publication } = await supabaseServerClient + const { data: publications } = await supabaseServerClient .from("publications") .select("record, uri") - .eq("uri", at_uri) - .single(); + .or(publicationUriFilter(uri.host, uri.rkey)) + .order("uri", { ascending: false }) + .limit(1); + const publication = publications?.[0]; if (!publication || !publication.record) { return new NextResponse(null, { status: 404 }); diff --git a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx index 5251fbd5..a2cdf6ce 100644 --- a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx @@ -1,5 +1,4 @@ import { AtpAgent } from "@atproto/api"; -import { AtUri } from "@atproto/syntax"; import { ids } from "lexicons/api/lexicons"; import { PubLeafletBlocksBskyPost, @@ -37,7 +36,7 @@ export async function DocumentPageRenderer({ }); let [document, profile] = await Promise.all([ - getPostPageData(AtUri.make(did, ids.PubLeafletDocument, rkey).toString()), + getPostPageData(did, rkey), agent.getProfile({ actor: did }), ]); diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts index d0509b6c..14c083a4 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -17,6 +17,10 @@ import { pingIdentityToUpdateNotification, } from "src/notifications"; import { v7 } from "uuid"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; type PublishCommentResult = | { success: true; record: Json; profile: any; uri: string } @@ -180,7 +184,7 @@ function createCommentMentionNotifications( if (notifiedRecipients.has(dedupeKey)) continue; notifiedRecipients.add(dedupeKey); - if (mentionedUri.collection === "pub.leaflet.publication") { + if (isPublicationCollection(mentionedUri.collection)) { notifications.push({ id: v7(), recipient: recipientDid, @@ -191,7 +195,7 @@ function createCommentMentionNotifications( mentioned_uri: feature.atURI, }, }); - } else if (mentionedUri.collection === "pub.leaflet.document") { + } else if (isDocumentCollection(mentionedUri.collection)) { notifications.push({ id: v7(), recipient: recipientDid, diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts index d40622cd..a72c537f 100644 --- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -7,9 +7,10 @@ import { type NormalizedPublication, } from "src/utils/normalizeRecords"; import { PubLeafletPublication } from "lexicons/api"; +import { documentUriFilter } from "src/utils/uriHelpers"; -export async function getPostPageData(uri: string) { - let { data: document } = await supabaseServerClient +export async function getPostPageData(did: string, rkey: string) { + let { data: documents } = await supabaseServerClient .from("documents") .select( ` @@ -24,8 +25,10 @@ export async function getPostPageData(uri: string) { leaflets_in_publications(*) `, ) - .eq("uri", uri) - .single(); + .or(documentUriFilter(did, rkey)) + .order("uri", { ascending: false }) + .limit(1); + let document = documents?.[0]; if (!document) return null; @@ -39,7 +42,7 @@ export async function getPostPageData(uri: string) { ); // Fetch constellation backlinks for mentions - let aturi = new AtUri(uri); + let aturi = new AtUri(document.uri); const postUrl = normalizedPublication ? `${normalizedPublication.url}/${aturi.rkey}` : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; @@ -95,7 +98,7 @@ export async function getPostPageData(uri: string) { ); // Find current document index - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri); + const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri); if (currentIndex !== -1) { prevNext = { diff --git a/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts b/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts index 01d53045..2ba3e011 100644 --- a/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts +++ b/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts @@ -1,10 +1,9 @@ import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; import { supabaseServerClient } from "supabase/serverClient"; -import { AtUri } from "@atproto/syntax"; -import { ids } from "lexicons/api/lexicons"; import { jsonToLex } from "@atproto/lexicon"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { documentUriFilter } from "src/utils/uriHelpers"; export const revalidate = 60; @@ -15,11 +14,13 @@ export default async function OpenGraphImage(props: { let did = decodeURIComponent(params.did); // Try to get the document's cover image - let { data: document } = await supabaseServerClient + let { data: documents } = await supabaseServerClient .from("documents") .select("data") - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) - .single(); + .or(documentUriFilter(did, params.rkey)) + .order("uri", { ascending: false }) + .limit(1); + let document = documents?.[0]; if (document) { const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index 197006cb..6bab19df 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -1,9 +1,8 @@ import { supabaseServerClient } from "supabase/serverClient"; -import { AtUri } from "@atproto/syntax"; -import { ids } from "lexicons/api/lexicons"; import { Metadata } from "next"; import { DocumentPageRenderer } from "./DocumentPageRenderer"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { documentUriFilter } from "src/utils/uriHelpers"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; @@ -12,13 +11,15 @@ export async function generateMetadata(props: { let did = decodeURIComponent(params.did); if (!did) return { title: "Publication 404" }; - let [{ data: document }] = await Promise.all([ + let [{ data: documents }] = await Promise.all([ supabaseServerClient .from("documents") .select("*, documents_in_publications(publications(*))") - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) - .single(), + .or(documentUriFilter(did, params.rkey)) + .order("uri", { ascending: false }) + .limit(1), ]); + let document = documents?.[0]; if (!document) return { title: "404" }; const docRecord = normalizeDocumentRecord(document.data); diff --git a/app/lish/[did]/[publication]/generateFeed.ts b/app/lish/[did]/[publication]/generateFeed.ts index 337f50d6..ef5887fc 100644 --- a/app/lish/[did]/[publication]/generateFeed.ts +++ b/app/lish/[did]/[publication]/generateFeed.ts @@ -10,6 +10,7 @@ import { normalizeDocumentRecord, hasLeafletContent, } from "src/utils/normalizeRecords"; +import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; export async function generateFeed( did: string, @@ -18,15 +19,7 @@ export async function generateFeed( let renderToReadableStream = await import("react-dom/server").then( (module) => module.renderToReadableStream, ); - let uri; - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { - uri = AtUri.make( - did, - "pub.leaflet.publication", - publication_name, - ).toString(); - } - let { data: publication } = await supabaseServerClient + let { data: publications } = await supabaseServerClient .from("publications") .select( `*, @@ -35,8 +28,10 @@ export async function generateFeed( `, ) .eq("identity_did", did) - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) - .single(); + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1); + let publication = publications?.[0]; const pubRecord = normalizePublicationRecord(publication?.record); if (!publication || !pubRecord) diff --git a/app/lish/[did]/[publication]/icon/route.ts b/app/lish/[did]/[publication]/icon/route.ts index 47c2cccc..ebfdbe82 100644 --- a/app/lish/[did]/[publication]/icon/route.ts +++ b/app/lish/[did]/[publication]/icon/route.ts @@ -1,10 +1,10 @@ import { NextRequest } from "next/server"; import { IdResolver } from "@atproto/identity"; -import { AtUri } from "@atproto/syntax"; import { supabaseServerClient } from "supabase/serverClient"; import sharp from "sharp"; import { redirect } from "next/navigation"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; let idResolver = new IdResolver(); @@ -18,15 +18,8 @@ export async function GET( const params = await props.params; try { let did = decodeURIComponent(params.did); - let uri; - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) { - uri = AtUri.make( - did, - "pub.leaflet.publication", - params.publication, - ).toString(); - } - let { data: publication } = await supabaseServerClient + let publication_name = decodeURIComponent(params.publication); + let { data: publications } = await supabaseServerClient .from("publications") .select( `*, @@ -35,8 +28,10 @@ export async function GET( `, ) .eq("identity_did", did) - .or(`name.eq."${params.publication}", uri.eq."${uri}"`) - .single(); + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1); + let publication = publications?.[0]; const record = normalizePublicationRecord(publication?.record); if (!record?.icon) return redirect("/icon.png"); diff --git a/app/lish/[did]/[publication]/layout.tsx b/app/lish/[did]/[publication]/layout.tsx index 2e4aaa2e..70e94991 100644 --- a/app/lish/[did]/[publication]/layout.tsx +++ b/app/lish/[did]/[publication]/layout.tsx @@ -1,7 +1,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Metadata } from "next"; -import { AtUri } from "@atproto/syntax"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; export default async function PublicationLayout(props: { children: React.ReactNode; @@ -19,16 +19,8 @@ export async function generateMetadata(props: { let did = decodeURIComponent(params.did); if (!params.did || !params.publication) return { title: "Publication 404" }; - let uri; let publication_name = decodeURIComponent(params.publication); - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { - uri = AtUri.make( - did, - "pub.leaflet.publication", - publication_name, - ).toString(); - } - let { data: publication } = await supabaseServerClient + let { data: publications } = await supabaseServerClient .from("publications") .select( `*, @@ -37,8 +29,10 @@ export async function generateMetadata(props: { `, ) .eq("identity_did", did) - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) - .single(); + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1); + let publication = publications?.[0]; if (!publication) return { title: "Publication 404" }; const pubRecord = normalizePublicationRecord(publication?.record); diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx index b61eb4c6..10279ee6 100644 --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -2,6 +2,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { BskyAgent } from "@atproto/api"; +import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; import { SubscribeWithBluesky } from "app/lish/Subscribe"; import React from "react"; import { @@ -27,16 +28,8 @@ export default async function Publication(props: { let did = decodeURIComponent(params.did); if (!did) return ; let agent = new BskyAgent({ service: "https://public.api.bsky.app" }); - let uri; let publication_name = decodeURIComponent(params.publication); - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { - uri = AtUri.make( - did, - "pub.leaflet.publication", - publication_name, - ).toString(); - } - let [{ data: publication }, { data: profile }] = await Promise.all([ + let [{ data: publications }, { data: profile }] = await Promise.all([ supabaseServerClient .from("publications") .select( @@ -50,10 +43,12 @@ export default async function Publication(props: { `, ) .eq("identity_did", did) - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) - .single(), + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1), agent.getProfile({ actor: did }), ]); + let publication = publications?.[0]; const record = normalizePublicationRecord(publication?.record); diff --git a/app/lish/createPub/createPublication.ts b/app/lish/createPub/createPublication.ts index d41dd7f0..18ed3a9a 100644 --- a/app/lish/createPub/createPublication.ts +++ b/app/lish/createPub/createPublication.ts @@ -1,17 +1,21 @@ "use server"; import { TID } from "@atproto/common"; -import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; +import { + AtpBaseClient, + PubLeafletPublication, + SiteStandardPublication, +} from "lexicons/api"; import { restoreOAuthSession, OAuthSessionError, } from "src/atproto-oauth"; import { getIdentityData } from "actions/getIdentityData"; import { supabaseServerClient } from "supabase/serverClient"; -import { Un$Typed } from "@atproto/api"; import { Json } from "supabase/database.types"; import { Vercel } from "@vercel/sdk"; import { isProductionDomain } from "src/utils/isProductionDeployment"; import { string } from "zod"; +import { getPublicationType } from "src/utils/collectionHelpers"; const VERCEL_TOKEN = process.env.VERCEL_TOKEN; const vercel = new Vercel({ @@ -64,15 +68,14 @@ export async function createPublication({ let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); - let record: Un$Typed = { - name, - base_path: domain, - preferences, - }; - if (description) { - record.description = description; - } + // Use site.standard.publication for new publications + const publicationType = getPublicationType(); + const url = `https://${domain}`; + + // Build record based on publication type + let record: SiteStandardPublication.Record | PubLeafletPublication.Record; + let iconBlob: Awaited>["data"]["blob"] | undefined; // Upload the icon if provided if (iconFile && iconFile.size > 0) { @@ -81,16 +84,41 @@ export async function createPublication({ new Uint8Array(buffer), { encoding: iconFile.type }, ); + iconBlob = uploadResult.data.blob; + } - if (uploadResult.data.blob) { - record.icon = uploadResult.data.blob; - } + if (publicationType === "site.standard.publication") { + record = { + $type: "site.standard.publication", + name, + url, + ...(description && { description }), + ...(iconBlob && { icon: iconBlob }), + preferences: { + showInDiscover: preferences.showInDiscover, + showComments: preferences.showComments, + showMentions: preferences.showMentions, + showPrevNext: preferences.showPrevNext, + }, + } satisfies SiteStandardPublication.Record; + } else { + record = { + $type: "pub.leaflet.publication", + name, + base_path: domain, + ...(description && { description }), + ...(iconBlob && { icon: iconBlob }), + preferences, + } satisfies PubLeafletPublication.Record; } - let result = await agent.pub.leaflet.publication.create( - { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false }, + let { data: result } = await agent.com.atproto.repo.putRecord({ + repo: credentialSession.did!, + rkey: TID.nextStr(), + collection: publicationType, record, - ); + validate: false, + }); //optimistically write to our db! let { data: publication } = await supabaseServerClient @@ -98,11 +126,8 @@ export async function createPublication({ .upsert({ uri: result.uri, identity_did: credentialSession.did!, - name: record.name, - record: { - ...record, - $type: "pub.leaflet.publication", - } as unknown as Json, + name, + record: record as unknown as Json, }) .select() .single(); diff --git a/app/lish/createPub/updatePublication.ts b/app/lish/createPub/updatePublication.ts index 11752c54..20fbab54 100644 --- a/app/lish/createPub/updatePublication.ts +++ b/app/lish/createPub/updatePublication.ts @@ -15,6 +15,7 @@ import { normalizePublicationRecord, type NormalizedPublication, } from "src/utils/normalizeRecords"; +import { getPublicationType } from "src/utils/collectionHelpers"; type UpdatePublicationResult = | { success: true; publication: any } @@ -62,12 +63,14 @@ export async function updatePublication({ return { success: false }; } let aturi = new AtUri(existingPub.uri); + // Preserve existing schema when updating + const publicationType = getPublicationType(aturi.collection); - let record: PubLeafletPublication.Record = { - $type: "pub.leaflet.publication", + let record = { + $type: publicationType, ...(existingPub.record as object), name, - }; + } as PubLeafletPublication.Record; if (preferences) { record.preferences = preferences; } @@ -93,7 +96,7 @@ export async function updatePublication({ repo: credentialSession.did!, rkey: aturi.rkey, record, - collection: record.$type, + collection: publicationType, validate: false, }); @@ -146,16 +149,18 @@ export async function updatePublicationBasePath({ return { success: false }; } let aturi = new AtUri(existingPub.uri); + // Preserve existing schema when updating + const publicationType = getPublicationType(aturi.collection); - // Normalize the existing record to read its properties, then build a new pub.leaflet record + // Normalize the existing record to read its properties const normalizedPub = normalizePublicationRecord(existingPub.record); // Extract base_path from url if it exists (url format is https://domain, base_path is just domain) const existingBasePath = normalizedPub?.url ? normalizedPub.url.replace(/^https?:\/\//, "") : undefined; - let record: PubLeafletPublication.Record = { - $type: "pub.leaflet.publication", + let record = { + $type: publicationType, name: normalizedPub?.name || "", description: normalizedPub?.description, icon: normalizedPub?.icon, @@ -170,13 +175,13 @@ export async function updatePublicationBasePath({ } : undefined, base_path, - }; + } as PubLeafletPublication.Record; let result = await agent.com.atproto.repo.putRecord({ repo: credentialSession.did!, rkey: aturi.rkey, record, - collection: record.$type, + collection: publicationType, validate: false, }); @@ -242,6 +247,8 @@ export async function updatePublicationTheme({ return { success: false }; } let aturi = new AtUri(existingPub.uri); + // Preserve existing schema when updating + const publicationType = getPublicationType(aturi.collection); // Normalize the existing record to read its properties const normalizedPub = normalizePublicationRecord(existingPub.record); @@ -250,8 +257,8 @@ export async function updatePublicationTheme({ ? normalizedPub.url.replace(/^https?:\/\//, "") : undefined; - let record: PubLeafletPublication.Record = { - $type: "pub.leaflet.publication", + let record = { + $type: publicationType, name: normalizedPub?.name || "", description: normalizedPub?.description, icon: normalizedPub?.icon, @@ -301,13 +308,13 @@ export async function updatePublicationTheme({ ...theme.accentText, }, }, - }; + } as PubLeafletPublication.Record; let result = await agent.com.atproto.repo.putRecord({ repo: credentialSession.did!, rkey: aturi.rkey, record, - collection: record.$type, + collection: publicationType, validate: false, }); diff --git a/app/lish/uri/[uri]/route.ts b/app/lish/uri/[uri]/route.ts index d15d4f0c..c3c907d6 100644 --- a/app/lish/uri/[uri]/route.ts +++ b/app/lish/uri/[uri]/route.ts @@ -5,6 +5,10 @@ import { normalizePublicationRecord, type NormalizedPublication, } from "src/utils/normalizeRecords"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; /** * Redirect route for AT URIs (publications and documents) @@ -19,7 +23,7 @@ export async function GET( const atUriString = decodeURIComponent(uriParam); const uri = new AtUri(atUriString); - if (uri.collection === "pub.leaflet.publication") { + if (isPublicationCollection(uri.collection)) { // Get the publication record to retrieve base_path const { data: publication } = await supabaseServerClient .from("publications") @@ -40,7 +44,7 @@ export async function GET( // Redirect to the publication's hosted domain (temporary redirect since url can change) return NextResponse.redirect(normalizedPub.url, 307); - } else if (uri.collection === "pub.leaflet.document") { + } else if (isDocumentCollection(uri.collection)) { // Document link - need to find the publication it belongs to const { data: docInPub } = await supabaseServerClient .from("documents_in_publications") diff --git a/app/p/[didOrHandle]/[rkey]/opengraph-image.ts b/app/p/[didOrHandle]/[rkey]/opengraph-image.ts index ba24a0ae..2cd0a78e 100644 --- a/app/p/[didOrHandle]/[rkey]/opengraph-image.ts +++ b/app/p/[didOrHandle]/[rkey]/opengraph-image.ts @@ -1,11 +1,10 @@ import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; import { supabaseServerClient } from "supabase/serverClient"; -import { AtUri } from "@atproto/syntax"; -import { ids } from "lexicons/api/lexicons"; import { jsonToLex } from "@atproto/lexicon"; import { idResolver } from "app/(home-pages)/reader/idResolver"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { documentUriFilter } from "src/utils/uriHelpers"; export const revalidate = 60; @@ -28,11 +27,13 @@ export default async function OpenGraphImage(props: { if (did) { // Try to get the document's cover image - let { data: document } = await supabaseServerClient + let { data: documents } = await supabaseServerClient .from("documents") .select("data") - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) - .single(); + .or(documentUriFilter(did, params.rkey)) + .order("uri", { ascending: false }) + .limit(1); + let document = documents?.[0]; if (document) { const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); diff --git a/app/p/[didOrHandle]/[rkey]/page.tsx b/app/p/[didOrHandle]/[rkey]/page.tsx index 2ba3143e..ea7cf8fc 100644 --- a/app/p/[didOrHandle]/[rkey]/page.tsx +++ b/app/p/[didOrHandle]/[rkey]/page.tsx @@ -1,11 +1,10 @@ import { supabaseServerClient } from "supabase/serverClient"; -import { AtUri } from "@atproto/syntax"; -import { ids } from "lexicons/api/lexicons"; import { Metadata } from "next"; import { idResolver } from "app/(home-pages)/reader/idResolver"; import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { documentUriFilter } from "src/utils/uriHelpers"; export async function generateMetadata(props: { params: Promise<{ didOrHandle: string; rkey: string }>; @@ -24,21 +23,19 @@ export async function generateMetadata(props: { } } - let { data: document } = await supabaseServerClient + let { data: documents } = await supabaseServerClient .from("documents") - .select("*, documents_in_publications(publications(*))") - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) - .single(); + .select("*") + .or(documentUriFilter(did, params.rkey)) + .order("uri", { ascending: false }) + .limit(1); + let document = documents?.[0]; if (!document) return { title: "404" }; const docRecord = normalizeDocumentRecord(document.data); if (!docRecord) return { title: "404" }; - // For documents in publications, include publication name - let publicationName = - document.documents_in_publications[0]?.publications?.name; - return { icons: { other: { @@ -46,9 +43,7 @@ export async function generateMetadata(props: { url: document.uri, }, }, - title: publicationName - ? `${docRecord.title} - ${publicationName}` - : docRecord.title, + title: docRecord.title, description: docRecord?.description || "", }; } diff --git a/components/AtMentionLink.tsx b/components/AtMentionLink.tsx index ba1cc629..29580785 100644 --- a/components/AtMentionLink.tsx +++ b/components/AtMentionLink.tsx @@ -1,5 +1,9 @@ import { AtUri } from "@atproto/api"; import { atUriToUrl } from "src/utils/mentionUtils"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; /** * Component for rendering at-uri mentions (publications and documents) as clickable links. @@ -16,8 +20,8 @@ export function AtMentionLink({ className?: string; }) { const aturi = new AtUri(atURI); - const isPublication = aturi.collection === "pub.leaflet.publication"; - const isDocument = aturi.collection === "pub.leaflet.document"; + const isPublication = isPublicationCollection(aturi.collection); + const isDocument = isDocumentCollection(aturi.collection); // Show publication icon if available const icon = diff --git a/components/Blocks/TextBlock/schema.ts b/components/Blocks/TextBlock/schema.ts index 2e6a444c..ab9ff2a6 100644 --- a/components/Blocks/TextBlock/schema.ts +++ b/components/Blocks/TextBlock/schema.ts @@ -2,6 +2,10 @@ import { AtUri } from "@atproto/api"; import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; import { marks } from "prosemirror-schema-basic"; import { theme } from "tailwind.config"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; let baseSchema = { marks: { @@ -149,14 +153,14 @@ let baseSchema = { // components/AtMentionLink.tsx. If you update one, update the other. let className = "atMention mention"; let aturi = new AtUri(node.attrs.atURI); - if (aturi.collection === "pub.leaflet.publication") + if (isPublicationCollection(aturi.collection)) className += " font-bold"; - if (aturi.collection === "pub.leaflet.document") className += " italic"; + if (isDocumentCollection(aturi.collection)) className += " italic"; // For publications and documents, show icon if ( - aturi.collection === "pub.leaflet.publication" || - aturi.collection === "pub.leaflet.document" + isPublicationCollection(aturi.collection) || + isDocumentCollection(aturi.collection) ) { return [ "span", diff --git a/src/utils/collectionHelpers.ts b/src/utils/collectionHelpers.ts new file mode 100644 index 00000000..881b1366 --- /dev/null +++ b/src/utils/collectionHelpers.ts @@ -0,0 +1,57 @@ +import { ids } from "lexicons/api/lexicons"; + +/** + * Check if a collection is a document collection (either namespace). + */ +export function isDocumentCollection(collection: string): boolean { + return ( + collection === ids.PubLeafletDocument || + collection === ids.SiteStandardDocument + ); +} + +/** + * Check if a collection is a publication collection (either namespace). + */ +export function isPublicationCollection(collection: string): boolean { + return ( + collection === ids.PubLeafletPublication || + collection === ids.SiteStandardPublication + ); +} + +/** + * Check if a collection belongs to the site.standard namespace. + */ +export function isSiteStandardCollection(collection: string): boolean { + return collection.startsWith("site.standard."); +} + +/** + * Check if a collection belongs to the pub.leaflet namespace. + */ +export function isPubLeafletCollection(collection: string): boolean { + return collection.startsWith("pub.leaflet."); +} + +/** + * Get the document $type to use based on an existing URI's collection. + * If no existing URI or collection isn't a document, defaults to site.standard.document. + */ +export function getDocumentType(existingCollection?: string): "pub.leaflet.document" | "site.standard.document" { + if (existingCollection === ids.PubLeafletDocument) { + return ids.PubLeafletDocument as "pub.leaflet.document"; + } + return ids.SiteStandardDocument as "site.standard.document"; +} + +/** + * Get the publication $type to use based on an existing URI's collection. + * If no existing URI or collection isn't a publication, defaults to site.standard.publication. + */ +export function getPublicationType(existingCollection?: string): "pub.leaflet.publication" | "site.standard.publication" { + if (existingCollection === ids.PubLeafletPublication) { + return ids.PubLeafletPublication as "pub.leaflet.publication"; + } + return ids.SiteStandardPublication as "site.standard.publication"; +} diff --git a/src/utils/mentionUtils.ts b/src/utils/mentionUtils.ts index 0202ecc6..706928e0 100644 --- a/src/utils/mentionUtils.ts +++ b/src/utils/mentionUtils.ts @@ -1,4 +1,8 @@ import { AtUri } from "@atproto/api"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; /** * Converts a DID to a Bluesky profile URL @@ -14,10 +18,10 @@ export function atUriToUrl(atUri: string): string { try { const uri = new AtUri(atUri); - if (uri.collection === "pub.leaflet.publication") { + if (isPublicationCollection(uri.collection)) { // Publication URL: /lish/{did}/{rkey} return `/lish/${uri.host}/${uri.rkey}`; - } else if (uri.collection === "pub.leaflet.document") { + } else if (isDocumentCollection(uri.collection)) { // Document URL - we need to resolve this via the API // For now, create a redirect route that will handle it return `/lish/uri/${encodeURIComponent(atUri)}`; diff --git a/src/utils/uriHelpers.ts b/src/utils/uriHelpers.ts new file mode 100644 index 00000000..2c909133 --- /dev/null +++ b/src/utils/uriHelpers.ts @@ -0,0 +1,34 @@ +import { AtUri } from "@atproto/syntax"; +import { ids } from "lexicons/api/lexicons"; + +/** + * Returns an OR filter string for Supabase queries to match either namespace URI. + * Used for querying documents that may be stored under either pub.leaflet.document + * or site.standard.document namespaces. + */ +export function documentUriFilter(did: string, rkey: string): string { + const standard = AtUri.make(did, ids.SiteStandardDocument, rkey).toString(); + const legacy = AtUri.make(did, ids.PubLeafletDocument, rkey).toString(); + return `uri.eq.${standard},uri.eq.${legacy}`; +} + +/** + * Returns an OR filter string for Supabase queries to match either namespace URI. + * Used for querying publications that may be stored under either pub.leaflet.publication + * or site.standard.publication namespaces. + */ +export function publicationUriFilter(did: string, rkey: string): string { + const standard = AtUri.make(did, ids.SiteStandardPublication, rkey).toString(); + const legacy = AtUri.make(did, ids.PubLeafletPublication, rkey).toString(); + return `uri.eq.${standard},uri.eq.${legacy}`; +} + +/** + * Returns an OR filter string for Supabase queries to match a publication by name + * or by either namespace URI. Used when the rkey might be the publication name. + */ +export function publicationNameOrUriFilter(did: string, nameOrRkey: string): string { + const standard = AtUri.make(did, ids.SiteStandardPublication, nameOrRkey).toString(); + const legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString(); + return `name.eq."${nameOrRkey}",uri.eq.${standard},uri.eq.${legacy}`; +}