feat: allow custom excerpt
This commit is contained in:
parent
4bae50d28e
commit
e7fa4d2f24
|
|
@ -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;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "login_tokens" ADD COLUMN "subscribeForm" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "pages" ADD COLUMN "subscribersNotifiedAt" TIMESTAMP(3);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "sites" ADD COLUMN "navigation" JSONB;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "pages" ADD COLUMN "autoExcerpt" TEXT,
|
||||
ALTER COLUMN "excerpt" DROP NOT 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"
|
||||
|
|
@ -112,6 +112,7 @@ model Page {
|
|||
type PageType @default(POST)
|
||||
title String
|
||||
content String
|
||||
contentHTML String
|
||||
excerpt String?
|
||||
autoExcerpt String?
|
||||
format String @default("markdown")
|
||||
|
|
|
|||
|
|
@ -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<{
|
|||
</div>
|
||||
<div
|
||||
className="my-8 prose"
|
||||
dangerouslySetInnerHTML={{ __html: page.content }}
|
||||
dangerouslySetInnerHTML={{ __html: page.contentHTML }}
|
||||
></div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,35 @@
|
|||
import clsx from "clsx"
|
||||
import { DetailedHTMLProps, forwardRef, InputHTMLAttributes } from "react"
|
||||
import { forwardRef } from "react"
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> & {
|
||||
type InputProps<TMultiline extends boolean> = {
|
||||
label?: string
|
||||
addon?: string
|
||||
isBlock?: boolean
|
||||
error?: string
|
||||
help?: React.ReactNode
|
||||
}
|
||||
multiline?: TMultiline
|
||||
} & React.ComponentPropsWithRef<TMultiline extends true ? "textarea" : "input">
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, Props>(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<TMutliline>,
|
||||
ref: TMutliline extends true
|
||||
? React.ForwardedRef<HTMLTextAreaElement>
|
||||
: React.ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
const hasAddon = !!addon
|
||||
const Component = (multiline ? "textarea" : "input") as any
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
|
|
@ -25,9 +38,9 @@ export const Input = forwardRef<HTMLInputElement, Props>(function Input(
|
|||
</label>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
<Component
|
||||
{...inputProps}
|
||||
ref={ref}
|
||||
ref={ref as any}
|
||||
className={clsx(
|
||||
"input",
|
||||
hasAddon && `has-addon`,
|
||||
|
|
@ -47,4 +60,6 @@ export const Input = forwardRef<HTMLInputElement, Props>(function Input(
|
|||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}) as <TMultiline extends boolean = false>(
|
||||
props: InputProps<TMultiline>
|
||||
) => JSX.Element
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
import { trpc } from "~/lib/trpc"
|
||||
|
||||
export const useSignedJwt = () => {
|
||||
const jwt = trpc.useQuery(["user.getSignedJwt"], {
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
return jwt.data
|
||||
}
|
||||
|
|
@ -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<UploadFile>(
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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: {
|
|||
|
||||
<h2>${payload.post.title}</h2>
|
||||
<div>
|
||||
${payload.post.content}
|
||||
${payload.post.contentHTML}
|
||||
</div>
|
||||
<p>
|
||||
<a href="${SITE_URL}/api/unsubscribe?token=%recipient.unsubscribeToken%">Unsubscribe (no login required)</a>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? `<pre><code>${Prism.highlight(code, grammer, lang)}</code></pre>`
|
||||
: `<pre><code>${md.utils.escapeHtml(code)}</code></pre>`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx]
|
||||
const code = highlight(token.content, token.info)
|
||||
return `<div class="code" data-lang="${token.info}">${code}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
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("<!--more-->")
|
||||
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)
|
||||
}
|
||||
|
|
@ -17,6 +17,14 @@ export const wrapTrpc = ({ ssr }: { ssr?: boolean } = {}) => {
|
|||
credentials: "same-origin",
|
||||
})
|
||||
},
|
||||
|
||||
queryClientConfig: {
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
ssr,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { IS_PROD } from "./constants"
|
||||
import { OUR_DOMAIN } from "./env"
|
||||
|
||||
type Truthy<T> = T extends false | "" | 0 | null | undefined ? never : T // from lodash
|
||||
|
||||
export function truthy<T>(value: T): value is Truthy<T> {
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
export function stripHTML(html: string) {
|
||||
return html.replace(/<(?:.|\n)*?>/gm, "")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, any>
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
? `<pre><code>${Prism.highlight(code, grammer, lang)}</code></pre>`
|
||||
: `<pre><code>${md.utils.escapeHtml(code)}</code></pre>`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx]
|
||||
const code = highlight(token.content, token.info)
|
||||
return `<div class="code" data-lang="${token.info}">${code}</div>`
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<K extends keyof Values>(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\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\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 (
|
||||
<DashboardLayout>
|
||||
<DashboardMain fullWidth>
|
||||
|
|
@ -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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) =>
|
||||
updateValue("slug", e.target.value)
|
||||
}
|
||||
help={
|
||||
<>
|
||||
{values.slug && (
|
||||
|
|
@ -246,6 +247,21 @@ export default function SubdomainEditor() {
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Excerpt"
|
||||
isBlock
|
||||
name="excerpt"
|
||||
id="excerpt"
|
||||
value={values.excerpt}
|
||||
multiline
|
||||
rows={5}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
updateValue("excerpt", e.target.value)
|
||||
}}
|
||||
help="Leave it blank to use auto-generated excerpt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardMain>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue