diff --git a/.env.example b/.env.example index 2d53ff6492..148b144e5d 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ NEXT_PUBLIC_TWITTER_LINK="https://x.com/_xLog" NEXT_PUBLIC_GITHUB_LINK="https://github.com/Crossbell-Box/xlog" NEXT_PUBLIC_CSB_IO="https://crossbell.io" NEXT_PUBLIC_CSB_SCAN="https://scan.crossbell.io" -NEXT_PUBLIC_IPFS_GATEWAY="https://ipfs.4everland.xyz/ipfs/" +NEXT_PUBLIC_IPFS_GATEWAY="https://ipfs.4everland.io/ipfs/" NEXT_PUBLIC_MIRA_LINK="https://mira.crossbell.io" NEXT_PUBLIC_WALLET_CONNECT_V2_PROJECT_ID= diff --git a/package.json b/package.json index 031775a454..c08a49be8f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "prisma:migrate:resolve": "prisma migrate resolve --preview-feature", "prisma:generate": "prisma generate", "prisma:studio": "prisma studio", - "docker:db": "docker-compose -f docker-compose.db.yml up -d", + "docker:db": "docker compose -f docker-compose.db.yml up -d", "spell-check": "cspell lint . --no-progress", "analyze": "NODE_ENV=production ANALYZE=true BUNDLE_ANALYZE=browser next build", "postinstall": "prisma generate", diff --git a/src/app/[locale]/site/[site]/[slug]/page.tsx b/src/app/[locale]/site/[site]/[slug]/page.tsx index 59b3d32fd7..0e3c584695 100644 --- a/src/app/[locale]/site/[site]/[slug]/page.tsx +++ b/src/app/[locale]/site/[site]/[slug]/page.tsx @@ -176,7 +176,13 @@ export default async function SitePagePage({ )}
- {page?.metadata?.content?.content} +
") || + "", + }} + />
) : ( diff --git a/src/app/[locale]/site/[site]/feed/route.ts b/src/app/[locale]/site/[site]/feed/route.ts index b511bad193..2ec8b012de 100644 --- a/src/app/[locale]/site/[site]/feed/route.ts +++ b/src/app/[locale]/site/[site]/feed/route.ts @@ -2,6 +2,7 @@ import RSS from "rss" import { SITE_URL } from "~/lib/env" import { getSiteLink } from "~/lib/helpers" +import { toGateway } from "~/lib/ipfs-parser" import { NextServerResponse } from "~/lib/server-helper" import { PageVisibilityEnum } from "~/lib/types" import { getPagesBySite } from "~/models/page.model" @@ -18,7 +19,11 @@ export async function GET( }, ) { const site = await getSite(params.site) - const pages = await getPagesBySite({ + + const url = new URL(request.url) + const includeShorts = url.searchParams.get("shorts") !== "false" + + const posts = await getPagesBySite({ characterId: site?.characterId, type: "post", visibility: PageVisibilityEnum.Published, @@ -26,7 +31,25 @@ export async function GET( useHTML: true, }) - const hasAudio = pages.list?.find((page) => page.metadata?.content?.audio) + let allPages = posts.list || [] + + if (includeShorts) { + const shorts = await getPagesBySite({ + characterId: site?.characterId, + type: "short", + visibility: PageVisibilityEnum.Published, + keepBody: true, + useHTML: true, + }) + + allPages = [...(posts.list || []), ...(shorts.list || [])].sort((a, b) => { + const dateA = new Date(a.metadata?.content?.date_published || a.createdAt) + const dateB = new Date(b.metadata?.content?.date_published || b.createdAt) + return dateB.getTime() - dateA.getTime() + }) + } + + const hasAudio = allPages.find((page) => page.metadata?.content?.audio) const email = site?.metadata?.content?.connected_accounts ?.map((account) => { @@ -71,31 +94,78 @@ export async function GET( }, } : {}), - items: pages.list?.map((page) => ({ - id: page.characterId + "-" + page.noteId, - title: page.metadata?.content?.title || "Untitled", - summary: page.metadata?.content?.summary, - content_html: page.metadata?.content?.contentHTML, - url: `${SITE_URL}/api/redirection?characterId=${page.characterId}¬eId=${page.noteId}`, - image: page.metadata?.content?.cover, - date_published: page.metadata?.content?.date_published, - date_modified: page.updatedAt, - tags: page.metadata?.content?.tags, - author: site?.metadata?.content?.name, - ...(page.metadata?.content?.audio && { - _itunes: { - image: page.metadata?.content?.cover, - summary: page.metadata?.content?.summary, - }, - attachments: [ - { - url: page.metadata?.content?.audio, - mime_type: "audio/mpeg", - title: page.metadata?.content?.title, + items: allPages?.map((page) => { + const isShort = page.metadata?.content?.tags?.includes("short") + const title = + page.metadata?.content?.title || + (isShort + ? `${new Date(page.metadata?.content?.date_published || page.createdAt).toLocaleDateString()}` + : "Untitled") + + const allImages = isShort + ? (page.metadata?.content?.images || []).map((img: string) => + toGateway(img), + ) + : page.metadata?.content?.cover + ? [toGateway(page.metadata?.content?.cover)] + : [] + + const originalContent = + page.metadata?.content?.contentHTML || + page.metadata?.content?.content || + "" + const imagesHtml = + isShort && allImages.length > 0 + ? allImages + .map( + (img: string) => + ``, + ) + .join("") + : "" + + const contentWithImages = + isShort && imagesHtml + ? `${imagesHtml}
${originalContent}` + : originalContent + + return { + id: page.characterId + "-" + page.noteId, + title, + summary: + page.metadata?.content?.summary || + (page.metadata?.content?.content + ? page.metadata.content.content.slice(0, 200) + "..." + : ""), + content_html: contentWithImages, + url: `${SITE_URL}/api/redirection?characterId=${page.characterId}¬eId=${page.noteId}`, + image: allImages[0], + ...(isShort && + allImages.length > 0 && { + attachments: allImages.map((img: string) => ({ + url: img, + })), + }), + date_published: + page.metadata?.content?.date_published || page.createdAt, + date_modified: page.updatedAt, + tags: page.metadata?.content?.tags, + author: site?.metadata?.content?.name, + ...(page.metadata?.content?.audio && { + _itunes: { + image: page.metadata?.content?.cover, + summary: page.metadata?.content?.summary, }, - ], - }), - })), + attachments: [ + { + url: page.metadata?.content?.audio, + mime_type: "audio/mpeg", + title: page.metadata?.content?.title, + }, + ], + }), + } + }), } const feed = new RSS({ @@ -109,6 +179,8 @@ export async function GET( feed_url: `${link}/feed`, custom_namespaces: { itunes: "http://www.itunes.com/dtds/podcast-1.0.dtd", + media: "http://search.yahoo.com/mrss/", + content: "http://purl.org/rss/1.0/modules/content/", }, custom_elements: [ ...(hasAudio @@ -146,18 +218,60 @@ export async function GET( ], }) - pages.list.forEach((page) => { + allPages.forEach((page) => { + const isShort = page.metadata?.content?.tags?.includes("short") + const title = + page.metadata?.content?.title || + (isShort + ? `${new Date(page.metadata?.content?.date_published || page.createdAt).toLocaleDateString()}` + : "Untitled") + + const allImages = isShort + ? (page.metadata?.content?.images || []).map((img: string) => + toGateway(img), + ) + : page.metadata?.content?.cover + ? [toGateway(page.metadata?.content?.cover)] + : [] + + const itemContent = + page.metadata?.content?.contentHTML || + page.metadata?.content?.content || + "" + + const imagesHtml = + isShort && allImages.length > 0 + ? allImages + .map( + (img: string) => + ``, + ) + .join("") + : "" + + const contentWithImages = + isShort && imagesHtml ? `${imagesHtml}
${itemContent}` : itemContent + + const mediaContentElements = allImages.map((img: string) => ({ + "media:content": { _attr: { url: img, type: "image", medium: "image" } }, + })) + feed.item({ guid: page.characterId + "-" + page.noteId, - title: page.metadata?.content?.title || "Untitled", - description: page.metadata?.content?.summary, + title, + description: + page.metadata?.content?.summary || + (page.metadata?.content?.content + ? page.metadata.content.content.slice(0, 200) + "..." + : ""), custom_elements: [ { - "content:encoded": page.metadata?.content?.contentHTML, + "content:encoded": contentWithImages, }, + ...mediaContentElements, ], url: `${SITE_URL}/api/redirection?characterId=${page.characterId}¬eId=${page.noteId}`, - date: new Date(page.metadata?.content?.date_published), + date: new Date(page.metadata?.content?.date_published || page.createdAt), categories: page.metadata?.content?.tags, author: site?.metadata?.content?.name, enclosure: hasAudio @@ -165,7 +279,12 @@ export async function GET( url: page.metadata?.content?.audio, type: "audio/mpeg", } - : undefined, + : allImages[0] + ? { + url: allImages[0], + type: "image/jpeg", + } + : undefined, }) }) diff --git a/src/app/[locale]/site/[site]/feed/shorts/route.ts b/src/app/[locale]/site/[site]/feed/shorts/route.ts new file mode 100644 index 0000000000..89d75fae1a --- /dev/null +++ b/src/app/[locale]/site/[site]/feed/shorts/route.ts @@ -0,0 +1,184 @@ +import RSS from "rss" + +import { SITE_URL } from "~/lib/env" +import { getSiteLink } from "~/lib/helpers" +import { toGateway } from "~/lib/ipfs-parser" +import { NextServerResponse } from "~/lib/server-helper" +import { PageVisibilityEnum } from "~/lib/types" +import { getPagesBySite } from "~/models/page.model" +import { getSite } from "~/models/site.model" + +export async function GET( + request: Request, + { + params, + }: { + params: { + site: string + } + }, +) { + const site = await getSite(params.site) + + const shorts = await getPagesBySite({ + characterId: site?.characterId, + type: "short", + visibility: PageVisibilityEnum.Published, + keepBody: true, + useHTML: true, + }) + + const email = site?.metadata?.content?.connected_accounts + ?.map((account) => { + const match = account.match(/:\/\/account:(.*)@email/) + if (match) { + return match[1] + } + }) + .find((email) => email) + const link = getSiteLink({ + subdomain: site?.handle || "", + domain: site?.metadata?.content?.custom_domain, + }) + + const data = { + version: "https://jsonfeed.org/version/1", + title: `${site?.metadata?.content?.site_name || site?.metadata?.content?.name}`, + description: `${site?.metadata?.content?.name || site?.handle}`, + icon: site?.metadata?.content?.avatars?.[0], + home_page_url: `${link}/shorts`, + feed_url: `${link}/shorts/feed`, + items: shorts.list?.map((page) => { + const title = + page.metadata?.content?.title || + `${new Date(page.metadata?.content?.date_published || page.createdAt).toLocaleDateString()}` + + const allImages = (page.metadata?.content?.images || []).map( + (img: string) => toGateway(img), + ) + + const originalContent = + page.metadata?.content?.contentHTML || + page.metadata?.content?.content || + "" + const imagesHtml = + allImages.length > 0 + ? allImages + .map( + (img: string) => + ``, + ) + .join("") + : "" + + const contentWithImages = imagesHtml + ? `${imagesHtml}
${originalContent}` + : originalContent + + return { + id: page.characterId + "-" + page.noteId, + title, + summary: + page.metadata?.content?.summary || + (page.metadata?.content?.content + ? page.metadata.content.content.slice(0, 200) + "..." + : ""), + content_html: contentWithImages, + url: `${SITE_URL}/api/redirection?characterId=${page.characterId}¬eId=${page.noteId}`, + image: allImages[0], + ...(allImages.length > 0 && { + attachments: allImages.map((img: string) => ({ + url: img, + })), + }), + date_published: + page.metadata?.content?.date_published || page.createdAt, + date_modified: page.updatedAt, + tags: page.metadata?.content?.tags, + author: site?.metadata?.content?.name, + } + }), + } + + const feed = new RSS({ + title: `${site?.metadata?.content?.site_name || site?.metadata?.content?.name || "Untitled"}`, + description: `${site?.metadata?.content?.name || site?.handle}`, + image_url: site?.metadata?.content?.avatars?.[0], + site_url: `${link}/shorts`, + feed_url: `${link}/shorts/feed`, + custom_namespaces: { + media: "http://search.yahoo.com/mrss/", + content: "http://purl.org/rss/1.0/modules/content/", + }, + }) + + shorts.list?.forEach((page) => { + const title = + page.metadata?.content?.title || + `${new Date(page.metadata?.content?.date_published || page.createdAt).toLocaleDateString()}` + + const allImages = (page.metadata?.content?.images || []).map( + (img: string) => toGateway(img), + ) + + const itemContent = + page.metadata?.content?.contentHTML || + page.metadata?.content?.content || + "" + + const imagesHtml = + allImages.length > 0 + ? allImages + .map( + (img: string) => + ``, + ) + .join("") + : "" + + const contentWithImages = imagesHtml + ? `${imagesHtml}
${itemContent}` + : itemContent + + const mediaContentElements = allImages.map((img: string) => ({ + "media:content": { _attr: { url: img, type: "image", medium: "image" } }, + })) + + feed.item({ + guid: page.characterId + "-" + page.noteId, + title, + description: + page.metadata?.content?.summary || + (page.metadata?.content?.content + ? page.metadata.content.content.slice(0, 200) + "..." + : ""), + custom_elements: [ + { + "content:encoded": contentWithImages, + }, + ...mediaContentElements, + ], + url: `${SITE_URL}/api/redirection?characterId=${page.characterId}¬eId=${page.noteId}`, + date: new Date(page.metadata?.content?.date_published || page.createdAt), + categories: page.metadata?.content?.tags, + author: site?.metadata?.content?.name, + enclosure: allImages[0] + ? { + url: allImages[0], + type: "image/jpeg", + } + : undefined, + }) + }) + + const format = + new URLSearchParams(request.url.split("?")[1]).get("format") === "json" + ? "json" + : "xml" + + const res = new NextServerResponse() + return res.status(200).rss(format === "json" ? data : feed.xml(), format) +} + +export const dynamic = "force-dynamic" +export const revalidate = 0 diff --git a/src/lib/env.ts b/src/lib/env.ts index 48f4fedd98..e1c096b2ac 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -24,7 +24,7 @@ export const CSB_SCAN = export const CSB_XCHAR = process.env.NEXT_PUBLIC_CSB_XCHAR || "https://xchar.app" export const IPFS_GATEWAY = - process.env.NEXT_PUBLIC_IPFS_GATEWAY || "https://ipfs.4everland.xyz/ipfs/" + process.env.NEXT_PUBLIC_IPFS_GATEWAY || "https://ipfs.4everland.io/ipfs/" export const IPFS_GATEWAY_FALLBACK = process.env.NEXT_PUBLIC_IPFS_GATEWAY_FALLBACK || "https://gateway.ipfs.io/ipfs/" diff --git a/src/lib/ipfs-parser.ts b/src/lib/ipfs-parser.ts index 1a7e755fa8..eba0d5cf7b 100644 --- a/src/lib/ipfs-parser.ts +++ b/src/lib/ipfs-parser.ts @@ -24,7 +24,7 @@ export const toIPFS = (url: string) => { .replaceAll("https://gateway.ipfs.io/ipfs/", IPFS_PREFIX) .replaceAll("https://ipfs.io/ipfs/", IPFS_PREFIX) .replaceAll("https://cf-ipfs.com/ipfs/", IPFS_PREFIX) - .replaceAll("https://ipfs.4everland.xyz/ipfs/", IPFS_PREFIX) + .replaceAll("https://ipfs.4everland.io/ipfs/", IPFS_PREFIX) .replaceAll("https://rss3.mypinata.cloud/ipfs/", IPFS_PREFIX) .replaceAll("https://cloudflare-ipfs.com/ipfs/", IPFS_PREFIX) .replaceAll("https://ipfs.xlog.app/ipfs/", IPFS_PREFIX) @@ -36,7 +36,7 @@ export const toCid = (url: string) => { .replaceAll("https://gateway.ipfs.io/ipfs/", "") .replaceAll("https://ipfs.io/ipfs/", "") .replaceAll("https://cf-ipfs.com/ipfs/", "") - .replaceAll("https://ipfs.4everland.xyz/ipfs/", "") + .replaceAll("https://ipfs.4everland.io/ipfs/", "") .replaceAll("https://rss3.mypinata.cloud/ipfs/", "") .replaceAll("https://cloudflare-ipfs.com/ipfs/", "") .replaceAll("https://ipfs.xlog.app/ipfs/", "")