From e7fa4d2f24b2891a68ed137dd942cd15e8d54fff Mon Sep 17 00:00:00 2001 From: EGOIST Date: Thu, 19 May 2022 17:06:13 +0800 Subject: [PATCH] feat: allow custom excerpt --- .../20220508145033_init/migration.sql | 146 ------------------ .../migration.sql | 5 - .../migration.sql | 2 - .../20220513133748_auto_excerpt/migration.sql | 3 - prisma/migrations/migration_lock.toml | 3 - prisma/schema.prisma | 1 + src/components/site/SitePage.tsx | 4 +- src/components/ui/Editor.tsx | 3 +- src/components/ui/Input.tsx | 39 +++-- src/hooks/useSignedJwt.ts | 8 - src/hooks/useUploadFile.ts | 12 +- src/lib/mailgun.server.ts | 4 +- src/lib/markdown.server.ts | 73 --------- src/lib/trpc.ts | 8 + src/lib/utils.ts | 7 +- src/markdown/index.ts | 25 +++ src/markdown/plugin-code-block.ts | 24 +++ src/markdown/plugin-excerpt.ts | 29 ++++ src/markdown/plugin-image.ts | 35 +++++ src/models/page.model.ts | 21 ++- src/models/site.model.ts | 9 +- src/pages/_site/[site]/[page].tsx | 3 +- src/pages/dashboard/[subdomain]/editor.tsx | 142 +++++++++-------- src/router/site.ts | 2 +- 24 files changed, 262 insertions(+), 346 deletions(-) delete mode 100644 prisma/migrations/20220508145033_init/migration.sql delete mode 100644 prisma/migrations/20220511093602_subscribe_feature/migration.sql delete mode 100644 prisma/migrations/20220512161018_add_site_navigation/migration.sql delete mode 100644 prisma/migrations/20220513133748_auto_excerpt/migration.sql delete mode 100644 prisma/migrations/migration_lock.toml delete mode 100644 src/hooks/useSignedJwt.ts delete mode 100644 src/lib/markdown.server.ts create mode 100644 src/markdown/index.ts create mode 100644 src/markdown/plugin-code-block.ts create mode 100644 src/markdown/plugin-excerpt.ts create mode 100644 src/markdown/plugin-image.ts diff --git a/prisma/migrations/20220508145033_init/migration.sql b/prisma/migrations/20220508145033_init/migration.sql deleted file mode 100644 index 2725857e..00000000 --- a/prisma/migrations/20220508145033_init/migration.sql +++ /dev/null @@ -1,146 +0,0 @@ --- CreateEnum -CREATE TYPE "MembershipRole" AS ENUM ('OWNER', 'ADMIN', 'SUBSCRIBER'); - --- CreateEnum -CREATE TYPE "PageType" AS ENUM ('POST', 'PAGE'); - --- CreateTable -CREATE TABLE "users" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "username" TEXT NOT NULL, - "email" TEXT NOT NULL, - "emailVerified" TIMESTAMP(3), - "avatar" TEXT, - "bio" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "deletedAt" TIMESTAMP(3), - - CONSTRAINT "users_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "login_tokens" ( - "id" TEXT NOT NULL, - "email" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "login_tokens_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "access_tokens" ( - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "name" TEXT NOT NULL, - "token" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "publicId" TEXT, - "publicIdExpiresAt" TIMESTAMP(3), - - CONSTRAINT "access_tokens_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "memberships" ( - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "userId" TEXT NOT NULL, - "siteId" TEXT NOT NULL, - "role" "MembershipRole" NOT NULL, - "acceptedAt" TIMESTAMP(3), - "lastSwitchedTo" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "config" JSONB NOT NULL DEFAULT '{}', - - CONSTRAINT "memberships_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "sites" ( - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "name" TEXT NOT NULL, - "icon" TEXT, - "subdomain" TEXT NOT NULL, - "primaryDomainId" TEXT, - "description" TEXT, - "twitter" TEXT, - "deletedAt" TIMESTAMP(3), - - CONSTRAINT "sites_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "pages" ( - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "contentUpdatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "published" BOOLEAN NOT NULL DEFAULT false, - "type" "PageType" NOT NULL DEFAULT E'POST', - "title" TEXT NOT NULL, - "content" TEXT NOT NULL, - "excerpt" TEXT NOT NULL, - "format" TEXT NOT NULL DEFAULT E'markdown', - "slug" TEXT NOT NULL, - "siteId" TEXT NOT NULL, - "deletedAt" TIMESTAMP(3), - - CONSTRAINT "pages_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "domains" ( - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "siteId" TEXT NOT NULL, - "domain" TEXT NOT NULL, - - CONSTRAINT "domains_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); - --- CreateIndex -CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "access_tokens_token_key" ON "access_tokens"("token"); - --- CreateIndex -CREATE UNIQUE INDEX "access_tokens_publicId_key" ON "access_tokens"("publicId"); - --- CreateIndex -CREATE UNIQUE INDEX "sites_subdomain_key" ON "sites"("subdomain"); - --- CreateIndex -CREATE UNIQUE INDEX "sites_primaryDomainId_key" ON "sites"("primaryDomainId"); - --- CreateIndex -CREATE UNIQUE INDEX "pages_siteId_slug_key" ON "pages"("siteId", "slug"); - --- CreateIndex -CREATE UNIQUE INDEX "domains_domain_key" ON "domains"("domain"); - --- AddForeignKey -ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "memberships" ADD CONSTRAINT "memberships_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "memberships" ADD CONSTRAINT "memberships_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "sites" ADD CONSTRAINT "sites_primaryDomainId_fkey" FOREIGN KEY ("primaryDomainId") REFERENCES "domains"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; - --- AddForeignKey -ALTER TABLE "pages" ADD CONSTRAINT "pages_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "domains" ADD CONSTRAINT "domains_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20220511093602_subscribe_feature/migration.sql b/prisma/migrations/20220511093602_subscribe_feature/migration.sql deleted file mode 100644 index c71318ef..00000000 --- a/prisma/migrations/20220511093602_subscribe_feature/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "login_tokens" ADD COLUMN "subscribeForm" JSONB; - --- AlterTable -ALTER TABLE "pages" ADD COLUMN "subscribersNotifiedAt" TIMESTAMP(3); diff --git a/prisma/migrations/20220512161018_add_site_navigation/migration.sql b/prisma/migrations/20220512161018_add_site_navigation/migration.sql deleted file mode 100644 index 0eeef722..00000000 --- a/prisma/migrations/20220512161018_add_site_navigation/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "sites" ADD COLUMN "navigation" JSONB; diff --git a/prisma/migrations/20220513133748_auto_excerpt/migration.sql b/prisma/migrations/20220513133748_auto_excerpt/migration.sql deleted file mode 100644 index 087c4607..00000000 --- a/prisma/migrations/20220513133748_auto_excerpt/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "pages" ADD COLUMN "autoExcerpt" TEXT, -ALTER COLUMN "excerpt" DROP NOT NULL; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml deleted file mode 100644 index fbffa92c..00000000 --- a/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8ea9c1cb..b9308044 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -112,6 +112,7 @@ model Page { type PageType @default(POST) title String content String + contentHTML String excerpt String? autoExcerpt String? format String @default("markdown") diff --git a/src/components/site/SitePage.tsx b/src/components/site/SitePage.tsx index 5cde4494..da925644 100644 --- a/src/components/site/SitePage.tsx +++ b/src/components/site/SitePage.tsx @@ -6,7 +6,7 @@ export const SitePage: React.FC<{ type: PageType publishedAt: string title: string - content: string + contentHTML: string } }> = ({ page }) => { return ( @@ -25,7 +25,7 @@ export const SitePage: React.FC<{
) diff --git a/src/components/ui/Editor.tsx b/src/components/ui/Editor.tsx index 9fb16d70..8210f528 100644 --- a/src/components/ui/Editor.tsx +++ b/src/components/ui/Editor.tsx @@ -96,7 +96,8 @@ export const useEditor = ({ view.destroy() setView(null) } - }, [onChange, placeholder, onDropFile]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [placeholder, onDropFile]) // Update view state when `value` changed useEffect(() => { diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index 0cbdb031..12fbee71 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -1,22 +1,35 @@ import clsx from "clsx" -import { DetailedHTMLProps, forwardRef, InputHTMLAttributes } from "react" +import { forwardRef } from "react" -type Props = DetailedHTMLProps< - InputHTMLAttributes, - HTMLInputElement -> & { +type InputProps = { label?: string addon?: string isBlock?: boolean error?: string help?: React.ReactNode -} + multiline?: TMultiline +} & React.ComponentPropsWithRef -export const Input = forwardRef(function Input( - { label, addon, className, isBlock, error, help, ...inputProps }, - ref +export const Input = forwardRef(function Input< + TMutliline extends boolean = false +>( + { + label, + addon, + className, + isBlock, + error, + help, + multiline, + ...inputProps + }: InputProps, + ref: TMutliline extends true + ? React.ForwardedRef + : React.ForwardedRef ) { const hasAddon = !!addon + const Component = (multiline ? "textarea" : "input") as any + return (
{label && ( @@ -25,9 +38,9 @@ export const Input = forwardRef(function Input( )}
- (function Input( )}
) -}) +}) as ( + props: InputProps +) => JSX.Element diff --git a/src/hooks/useSignedJwt.ts b/src/hooks/useSignedJwt.ts deleted file mode 100644 index a8467016..00000000 --- a/src/hooks/useSignedJwt.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { trpc } from "~/lib/trpc" - -export const useSignedJwt = () => { - const jwt = trpc.useQuery(["user.getSignedJwt"], { - refetchInterval: 60_000, - }) - return jwt.data -} diff --git a/src/hooks/useUploadFile.ts b/src/hooks/useUploadFile.ts index 18a43859..9fb499df 100644 --- a/src/hooks/useUploadFile.ts +++ b/src/hooks/useUploadFile.ts @@ -1,15 +1,13 @@ import { useCallback } from "react" import { R2_URL } from "~/lib/env" -import { useSignedJwt } from "./useSignedJwt" +import { trpc } from "~/lib/trpc" export const useUploadFile = () => { - const signedJwt = useSignedJwt() + const { fetchQuery } = trpc.useContext() const uploadFile = useCallback( async (blob, filename) => { - if (!signedJwt) { - throw new Error("failed to retrieve signed jwt, please try again") - } + const jwt = await fetchQuery(["user.getSignedJwt"], {}) const form = new FormData() form.append("file", blob, filename) @@ -17,7 +15,7 @@ export const useUploadFile = () => { body: form, method: "post", headers: { - authorization: `Bearer ${signedJwt}`, + authorization: `Bearer ${jwt}`, }, }) if (!res.ok) { @@ -26,7 +24,7 @@ export const useUploadFile = () => { const data = await res.json() return data }, - [signedJwt] + [fetchQuery] ) return uploadFile diff --git a/src/lib/mailgun.server.ts b/src/lib/mailgun.server.ts index bdfce9dd..8801ba1a 100644 --- a/src/lib/mailgun.server.ts +++ b/src/lib/mailgun.server.ts @@ -98,7 +98,7 @@ export const sendLoginEmail = async (payload: { } export const sendEmailForNewPost = async (payload: { - post: { slug: string; title: string; content: string } + post: { slug: string; title: string; contentHTML: string } site: Site subscribers: { id: string; email: string }[] }) => { @@ -109,7 +109,7 @@ export const sendEmailForNewPost = async (payload: {

${payload.post.title}

- ${payload.post.content} + ${payload.post.contentHTML}

Unsubscribe (no login required) diff --git a/src/lib/markdown.server.ts b/src/lib/markdown.server.ts deleted file mode 100644 index d4670af2..00000000 --- a/src/lib/markdown.server.ts +++ /dev/null @@ -1,73 +0,0 @@ -import Markdown from "markdown-it" -import Prism from "prismjs" -import loadLanguages from "prismjs/components/index" -import { getUserContentsUrl } from "./user-contents" - -const isExternLink = (url: string) => /^https?:\/\//.test(url) - -const handleImages = (md: Markdown) => { - const imageRule = md.renderer.rules.image! - md.renderer.rules.image = (tokens, idx, options, env, self) => { - const token = tokens[idx] - const url = token.attrGet("src") - - // Don't allow images from other domains. - if (!url || isExternLink(url)) { - return "" - } - - token.attrSet("src", getUserContentsUrl(url)) - return imageRule(tokens, idx, options, env, self) - } -} - -const codeBlock = (md: Markdown) => { - const highlight = (code: string, lang: string) => { - if (lang === "vue" || lang === "svelte") { - lang = "html" - } - loadLanguages(lang) - const grammer = Prism.languages[lang] - const html = grammer - ? `

${Prism.highlight(code, grammer, lang)}
` - : `
${md.utils.escapeHtml(code)}
` - - return html - } - - md.renderer.rules.fence = (tokens, idx, options, env, self) => { - const token = tokens[idx] - const code = highlight(token.content, token.info) - return `
${code}
` - } -} - -export const renderPageContent = async (content: string) => { - const md = new Markdown({ - html: false, - linkify: true, - }) - - md.use(handleImages) - md.use(codeBlock) - - const html = md.render(content) - return { html } -} - -const stripHTML = (html: string) => { - return html.replace(/<(?:.|\n)*?>/gm, "") -} - -export const getAutoExcerpt = (content: string) => { - const indexOfMore = content.indexOf("") - const md = new Markdown({ - html: false, - linkify: true, - }) - if (indexOfMore > -1) { - content = content.substring(0, indexOfMore) - return stripHTML(md.render(content)) - } - return stripHTML(md.render(content)).slice(0, 400) -} diff --git a/src/lib/trpc.ts b/src/lib/trpc.ts index 27b1bb0c..096014ab 100644 --- a/src/lib/trpc.ts +++ b/src/lib/trpc.ts @@ -17,6 +17,14 @@ export const wrapTrpc = ({ ssr }: { ssr?: boolean } = {}) => { credentials: "same-origin", }) }, + + queryClientConfig: { + defaultOptions: { + queries: { + // refetchOnWindowFocus: false, + }, + }, + }, } }, ssr, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 51d97633..4e75d9cd 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,8 +1,9 @@ -import { IS_PROD } from "./constants" -import { OUR_DOMAIN } from "./env" - type Truthy = T extends false | "" | 0 | null | undefined ? never : T // from lodash export function truthy(value: T): value is Truthy { return Boolean(value) } + +export function stripHTML(html: string) { + return html.replace(/<(?:.|\n)*?>/gm, "") +} diff --git a/src/markdown/index.ts b/src/markdown/index.ts new file mode 100644 index 00000000..fd81dc8c --- /dev/null +++ b/src/markdown/index.ts @@ -0,0 +1,25 @@ +import Markdown from "markdown-it" +import { pluginCodeBlock } from "./plugin-code-block" +import { pluginExcerpt } from "./plugin-excerpt" +import { pluginImage } from "./plugin-image" + +export type MarkdownEnv = { + excerpt: string + __internal: Record +} + +export const renderPageContent = async (content: string) => { + const md = new Markdown({ + html: false, + linkify: true, + }) + + md.use(pluginImage) + md.use(pluginCodeBlock) + md.use(pluginExcerpt) + + const env: MarkdownEnv = { excerpt: "", __internal: {} } + const contentHTML = md.render(content, env) + + return { content, contentHTML, env } +} diff --git a/src/markdown/plugin-code-block.ts b/src/markdown/plugin-code-block.ts new file mode 100644 index 00000000..7554ba1a --- /dev/null +++ b/src/markdown/plugin-code-block.ts @@ -0,0 +1,24 @@ +import Markdown from "markdown-it" +import Prism from "prismjs" +import loadLanguages from "prismjs/components/index" + +export const pluginCodeBlock = (md: Markdown) => { + const highlight = (code: string, lang: string) => { + if (lang === "vue" || lang === "svelte") { + lang = "html" + } + loadLanguages(lang) + const grammer = Prism.languages[lang] + const html = grammer + ? `
${Prism.highlight(code, grammer, lang)}
` + : `
${md.utils.escapeHtml(code)}
` + + return html + } + + md.renderer.rules.fence = (tokens, idx, options, env, self) => { + const token = tokens[idx] + const code = highlight(token.content, token.info) + return `
${code}
` + } +} diff --git a/src/markdown/plugin-excerpt.ts b/src/markdown/plugin-excerpt.ts new file mode 100644 index 00000000..89f68453 --- /dev/null +++ b/src/markdown/plugin-excerpt.ts @@ -0,0 +1,29 @@ +import MarkdownIt from "markdown-it" +import { stripHTML } from "~/lib/utils" +import { MarkdownEnv } from "." + +export const pluginExcerpt = (md: MarkdownIt) => { + md.renderer.rules.paragraph_close = ( + tokens, + idx, + options, + env: MarkdownEnv, + self + ) => { + if (!env.__internal.excerpted) { + env.__internal.excerpted = true + let startIndex = 0 + for (const [index, token] of tokens.entries()) { + if (token.type === "paragraph_open") { + startIndex = index + break + } + } + env.excerpt = stripHTML( + self.render(tokens.slice(startIndex, idx + 1), options, env) + ) + } + + return self.renderToken(tokens, idx, options) + } +} diff --git a/src/markdown/plugin-image.ts b/src/markdown/plugin-image.ts new file mode 100644 index 00000000..515da6b5 --- /dev/null +++ b/src/markdown/plugin-image.ts @@ -0,0 +1,35 @@ +import Markdown from "markdown-it" +import { R2_URL } from "~/lib/env" +import { getUserContentsUrl } from "~/lib/user-contents" + +const isExternLink = (url: string) => /^https?:\/\//.test(url) + +const ALLOW_IMAGE_ORIGINS = [ + "user-images.githubusercontent.com", + "cdn.jsdelivr.net", + "images.unsplash.com", + R2_URL.replace(/^https?\:\/\//, ""), +] + +export const pluginImage = (md: Markdown) => { + const imageRule = md.renderer.rules.image! + md.renderer.rules.image = (tokens, idx, options, env, self) => { + const token = tokens[idx] + const url = token.attrGet("src") + + if (!url) { + return "" + } + + if (isExternLink(url)) { + const { hostname } = new URL(url) + if (!ALLOW_IMAGE_ORIGINS.includes(hostname)) { + throw new Error(`Image from ${hostname} is not allowed`) + } + return imageRule(tokens, idx, options, env, self) + } + + token.attrSet("src", getUserContentsUrl(url)) + return imageRule(tokens, idx, options, env, self) + } +} diff --git a/src/models/page.model.ts b/src/models/page.model.ts index 94c27371..afa36a14 100644 --- a/src/models/page.model.ts +++ b/src/models/page.model.ts @@ -9,11 +9,12 @@ import { } from "~/lib/db.server" import { type Gate } from "~/lib/gate.server" import { sendEmailForNewPost } from "~/lib/mailgun.server" -import { getAutoExcerpt, renderPageContent } from "~/lib/markdown.server" +import { renderPageContent } from "~/markdown" import { notFound } from "~/lib/server-side-props" import { PageVisibilityEnum } from "~/lib/types" import { isUUID } from "~/lib/uuid" import { getSite } from "./site.model" +import { stripHTML } from "~/lib/utils" const checkPageSlug = async ({ slug, @@ -84,6 +85,7 @@ export async function createOrUpdatePage( }, }, content: "", + contentHTML: "", excerpt: "", autoExcerpt: "", }, @@ -103,7 +105,9 @@ export async function createOrUpdatePage( const slug = input.slug || page.slug await checkPageSlug({ slug, excludePage: page.id, siteId: page.siteId }) - const autoExcerpt = input.content ? getAutoExcerpt(input.content) : undefined + const rendered = input.content + ? await renderPageContent(input.content) + : undefined const updated = await prismaPrimary.page.update({ where: { @@ -114,10 +118,11 @@ export async function createOrUpdatePage( content: input.content, published: input.published, publishedAt: input.publishedAt && new Date(input.publishedAt), - excerpt: input.excerpt, + excerpt: input.excerpt && stripHTML(input.excerpt), slug, type: input.isPost ? "POST" : "PAGE", - autoExcerpt, + autoExcerpt: rendered?.env.excerpt, + contentHTML: rendered?.contentHTML, }, }) @@ -236,7 +241,6 @@ export async function getPage( /** page slug or id, `site` is needed when `page` is a slug */ page: string site?: string - renderContent?: boolean } ) { const site = input.site ? await getSite(input.site) : null @@ -274,11 +278,6 @@ export async function getPage( } } - if (input.renderContent) { - const rendered = await renderPageContent(page.content) - page.content = rendered.html - } - return page } @@ -288,7 +287,7 @@ export const notifySubscribersForNewPost = async ( pageId: string } ) => { - const page = await getPage(gate, { page: input.pageId, renderContent: true }) + const page = await getPage(gate, { page: input.pageId }) const site = await getSite(page.siteId) if (!gate.allows({ type: "can-notify-site-subscribers", site })) { diff --git a/src/models/site.model.ts b/src/models/site.model.ts index 7c325d84..f57438fb 100644 --- a/src/models/site.model.ts +++ b/src/models/site.model.ts @@ -8,6 +8,7 @@ import { SiteNavigationItem, SubscribeFormData } from "~/lib/types" import { nanoid } from "nanoid" import { getMembership } from "./membership" import { checkReservedWords } from "~/lib/reserved-words" +import { renderPageContent } from "~/markdown" export const checkSubdomain = async ({ subdomain, @@ -165,6 +166,9 @@ export async function createSite( url: "/archives", }, ] + const aboutPage = await renderPageContent( + `My name is ${payload.name} and I'm a new site.` + ) const site = await prismaPrimary.site.create({ data: { name: payload.name, @@ -185,8 +189,9 @@ export async function createSite( title: "About", slug: "about", excerpt: "", - autoExcerpt: "", - content: `My name is ${payload.name} and I'm a new site.`, + autoExcerpt: aboutPage.env.excerpt, + content: aboutPage.content, + contentHTML: aboutPage.contentHTML, published: true, publishedAt: new Date(), type: PageType.PAGE, diff --git a/src/pages/_site/[site]/[page].tsx b/src/pages/_site/[site]/[page].tsx index 09e8015c..985f686c 100644 --- a/src/pages/_site/[site]/[page].tsx +++ b/src/pages/_site/[site]/[page].tsx @@ -23,7 +23,6 @@ export const getServerSideProps: GetServerSideProps = serverSidePropsHandler( ssg.fetchQuery("site.page", { site: domainOrSubdomain, page: pageSlug, - renderContent: true, }), ssg.fetchQuery("site.subscription", { site: domainOrSubdomain }), ]) @@ -57,7 +56,7 @@ function SitePagePage({ ]) const pageResult = trpc.useQuery([ "site.page", - { site: domainOrSubdomain, page: pageSlug, renderContent: true }, + { site: domainOrSubdomain, page: pageSlug }, ]) const site = siteResult.data diff --git a/src/pages/dashboard/[subdomain]/editor.tsx b/src/pages/dashboard/[subdomain]/editor.tsx index 376a164a..e6cd3bcf 100644 --- a/src/pages/dashboard/[subdomain]/editor.tsx +++ b/src/pages/dashboard/[subdomain]/editor.tsx @@ -1,6 +1,6 @@ import clsx from "clsx" import dayjs from "dayjs" -import { useCallback, useEffect, useState } from "react" +import { ChangeEvent, useCallback, useEffect, useState } from "react" import { DashboardMain } from "~/components/dashboard/DashboardMain" import { getPageVisibility } from "~/lib/page-helpers" import { PageVisibilityEnum } from "~/lib/types" @@ -35,6 +35,7 @@ export default function SubdomainEditor() { ["site.page", { page: pageId!, site: subdomain }], { enabled: !!pageId, + refetchOnWindowFocus: false, } ) const page = pageResult.data @@ -48,19 +49,24 @@ export default function SubdomainEditor() { const [values, setValues] = useState({ title: "", - content: "", publishedAt: new Date().toISOString(), published: false, slug: "", + excerpt: "", }) - const updateValue = (field: string, value: any) => { - setValues((values) => { - return { + const [content, setContent] = useState("") + + type Values = typeof values + + const updateValue = useCallback( + (key: K, value: Values[K]) => { + setValues({ ...values, - [field]: value, - } - }) - } + [key]: value, + }) + }, + [setValues, values] + ) const savePage = (published: boolean) => { createOrUpdatePage.mutate({ @@ -69,9 +75,46 @@ export default function SubdomainEditor() { pageId: page?.id, isPost: isPost, published, + content, }) } + const handleDropFile = useCallback( + async (file: File, view: EditorView) => { + const toastId = toast.loading("Uploading...") + try { + if (!file.type.startsWith("image/")) { + throw new Error("You can only upload images") + } + + const { key } = await uploadFile(file, file.name) + toast.success("Uploaded!", { + id: toastId, + }) + view.dispatch( + view.state.replaceSelection( + `\n\n![${file.name.replace(/\.\w+$/, "")}](${key})\n\n` + ) + ) + } catch (error) { + if (error instanceof Error) { + toast.error(error.message, { id: toastId }) + } + } + }, + [uploadFile] + ) + + const handleEditorChange = (newValue: string) => { + setContent(newValue) + } + + const { editorRef, view } = useEditor({ + value: content, + onChange: handleEditorChange, + onDropFile: handleDropFile, + }) + useEffect(() => { if (createOrUpdatePage.isSuccess) { createOrUpdatePage.reset() @@ -102,60 +145,16 @@ export default function SubdomainEditor() { useEffect(() => { if (!page) return - setValues((values) => { - return { - ...values, - title: page.title, - content: page.content, - publishedAt: page.publishedAt, - published: page.published, - slug: page.slug, - } + setValues({ + title: page.title, + publishedAt: page.publishedAt, + published: page.published, + slug: page.slug, + excerpt: page.excerpt || "", }) + setContent(page.content) }, [page]) - const handleEditorContentChange = useCallback( - (value: string) => updateValue("content", value), - [] - ) - - const handleDropFile = useCallback( - async (file: File, view: EditorView) => { - const toastId = toast.loading("Uploading...") - try { - if (!file.type.startsWith("image/")) { - throw new Error("You can only upload images") - } - - const { key } = await uploadFile(file, file.name) - toast.success("Uploaded!", { - id: toastId, - }) - view.dispatch( - view.state.replaceSelection( - `\n\n![${file.name.replace(/\.\w+$/, "")}](${key})\n\n` - ) - ) - } catch (error) { - if (error instanceof Error) { - toast.error(error.message, { id: toastId }) - } - } - }, - [uploadFile] - ) - - const { editorRef, view } = useEditor({ - value: values.content, - onChange: handleEditorContentChange, - onDropFile: handleDropFile, - placeholder: "Start writing here..", - }) - - const focusEditor = () => { - view?.focus() - } - return ( @@ -187,7 +186,7 @@ export default function SubdomainEditor() { value={values.title} onKeyDown={(e) => { if (e.key === "Enter") { - focusEditor() + view?.focus() } }} onChange={(e) => updateValue("title", e.target.value)} @@ -211,7 +210,7 @@ export default function SubdomainEditor() { name="publishAt" id="publishAt" value={getInputDatetimeValue(values.publishedAt)} - onChange={(e) => { + onChange={(e: ChangeEvent) => { updateValue("publishedAt", e.target.value) }} help={`This ${ @@ -227,7 +226,9 @@ export default function SubdomainEditor() { id="slug" isBlock placeholder="some-slug" - onChange={(e) => updateValue("slug", e.target.value)} + onChange={(e: ChangeEvent) => + updateValue("slug", e.target.value) + } help={ <> {values.slug && ( @@ -246,6 +247,21 @@ export default function SubdomainEditor() { } />
+
+ ) => { + updateValue("excerpt", e.target.value) + }} + help="Leave it blank to use auto-generated excerpt" + /> +
diff --git a/src/router/site.ts b/src/router/site.ts index 06a15e35..6f7ab532 100644 --- a/src/router/site.ts +++ b/src/router/site.ts @@ -95,12 +95,12 @@ export const siteRouter = createRouter() input: z.object({ site: z.string(), page: z.string(), - renderContent: z.boolean().optional(), }), output: z.object({ id: z.string(), title: z.string(), content: z.string(), + contentHTML: z.string(), excerpt: z.string().nullable(), autoExcerpt: z.string().nullable(), published: z.boolean(),