feat: allow custom excerpt

This commit is contained in:
EGOIST 2022-05-19 17:06:13 +08:00
parent 4bae50d28e
commit e7fa4d2f24
24 changed files with 262 additions and 346 deletions

View File

@ -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;

View File

@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "login_tokens" ADD COLUMN "subscribeForm" JSONB;
-- AlterTable
ALTER TABLE "pages" ADD COLUMN "subscribersNotifiedAt" TIMESTAMP(3);

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "sites" ADD COLUMN "navigation" JSONB;

View File

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "pages" ADD COLUMN "autoExcerpt" TEXT,
ALTER COLUMN "excerpt" DROP NOT NULL;

View File

@ -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"

View File

@ -112,6 +112,7 @@ model Page {
type PageType @default(POST)
title String
content String
contentHTML String
excerpt String?
autoExcerpt String?
format String @default("markdown")

View File

@ -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>
</>
)

View File

@ -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(() => {

View File

@ -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

View File

@ -1,8 +0,0 @@
import { trpc } from "~/lib/trpc"
export const useSignedJwt = () => {
const jwt = trpc.useQuery(["user.getSignedJwt"], {
refetchInterval: 60_000,
})
return jwt.data
}

View File

@ -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

View File

@ -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>

View File

@ -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)
}

View File

@ -17,6 +17,14 @@ export const wrapTrpc = ({ ssr }: { ssr?: boolean } = {}) => {
credentials: "same-origin",
})
},
queryClientConfig: {
defaultOptions: {
queries: {
// refetchOnWindowFocus: false,
},
},
},
}
},
ssr,

View File

@ -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, "")
}

25
src/markdown/index.ts Normal file
View File

@ -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 }
}

View File

@ -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>`
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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 })) {

View File

@ -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,

View File

@ -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

View File

@ -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![${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 (
<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>

View File

@ -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(),