feat: manually send email for new posts
This commit is contained in:
parent
f2a6492ceb
commit
26a93cf45b
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `subscribersNotifiedAt` on the `pages` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PageEmailStatus" AS ENUM ('PENDING', 'RUNNING', 'SUCCESS', 'FAILED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "pages" DROP COLUMN "subscribersNotifiedAt",
|
||||
ADD COLUMN "emailStatus" "PageEmailStatus",
|
||||
ADD COLUMN "emailSubject" TEXT;
|
||||
|
|
@ -101,26 +101,34 @@ enum PageType {
|
|||
PAGE
|
||||
}
|
||||
|
||||
enum PageEmailStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
|
||||
model Page {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contentUpdatedAt DateTime @default(now())
|
||||
publishedAt DateTime @default(now())
|
||||
published Boolean @default(false)
|
||||
type PageType @default(POST)
|
||||
title String
|
||||
content String
|
||||
excerpt String?
|
||||
format String @default("markdown")
|
||||
slug String
|
||||
site Site @relation(name: "SitePages", fields: [siteId], references: [id], onDelete: Cascade)
|
||||
siteId String
|
||||
deletedAt DateTime?
|
||||
subscribersNotifiedAt DateTime?
|
||||
authors User[]
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contentUpdatedAt DateTime @default(now())
|
||||
publishedAt DateTime @default(now())
|
||||
published Boolean @default(false)
|
||||
type PageType @default(POST)
|
||||
title String
|
||||
content String
|
||||
excerpt String?
|
||||
format String @default("markdown")
|
||||
slug String
|
||||
site Site @relation(name: "SitePages", fields: [siteId], references: [id], onDelete: Cascade)
|
||||
siteId String
|
||||
deletedAt DateTime?
|
||||
authors User[]
|
||||
// prerendered markdown to html, including extra data like excerpt
|
||||
rendered Json?
|
||||
rendered Json?
|
||||
emailSubject String?
|
||||
emailStatus PageEmailStatus?
|
||||
|
||||
@@unique([siteId, slug])
|
||||
@@map("pages")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { PageEmailStatus } from "@prisma/client"
|
||||
import { useForm } from "react-hook-form"
|
||||
import toast from "react-hot-toast"
|
||||
import { useStore } from "~/lib/store"
|
||||
import { trpc } from "~/lib/trpc"
|
||||
import { Button } from "../ui/Button"
|
||||
import { Input } from "../ui/Input"
|
||||
import { Modal } from "../ui/Modal"
|
||||
|
||||
export const EmailPostModal: React.FC<{
|
||||
pageId: string
|
||||
}> = ({ pageId }) => {
|
||||
const [open, setOpen] = useStore((store) => [
|
||||
store.emailPostModalOpened,
|
||||
store.setEmailPostModalOpened,
|
||||
])
|
||||
|
||||
const { handleSubmit, register } = useForm({
|
||||
defaultValues: {
|
||||
subject: "",
|
||||
},
|
||||
})
|
||||
const scheduleEmailForPost = trpc.useMutation("site.scheduleEmailForPost")
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
await scheduleEmailForPost.mutateAsync({
|
||||
pageId,
|
||||
emailSubject: values.subject,
|
||||
})
|
||||
toast.success("Scheduled!")
|
||||
setOpen(false)
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal title="Email Post" open={open} setOpen={setOpen}>
|
||||
{
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="p-5">
|
||||
<Input
|
||||
label="Email subject"
|
||||
id="subject"
|
||||
help="Defaults to post title"
|
||||
isBlock
|
||||
{...register("subject")}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-5 border-t">
|
||||
<Button type="submit" isLoading={scheduleEmailForPost.isLoading}>
|
||||
<span className="i-mdi:email-send text-xl mr-1"></span>
|
||||
<span>Send Email</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { Button } from "~/components/ui/Button"
|
||||
import { useStore } from "~/lib/store"
|
||||
import { Dialog } from "@headlessui/react"
|
||||
import { trpc } from "~/lib/trpc"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Modal } from "../ui/Modal"
|
||||
|
|
|
|||
|
|
@ -126,8 +126,10 @@ export const useEditor = ({
|
|||
|
||||
return {
|
||||
view,
|
||||
editorRef(node: HTMLDivElement) {
|
||||
setNode(node)
|
||||
editorRef(newNode: HTMLDivElement) {
|
||||
if (!newNode) return
|
||||
if (node && newNode.isEqualNode(node)) return
|
||||
setNode(newNode)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({
|
|||
const active = isPreviewModeCommand && previewVisible
|
||||
const disabled = !isPreviewModeCommand && previewVisible
|
||||
return (
|
||||
<Tooltip label={label} placement="bottom">
|
||||
<Tooltip key={name} label={label} placement="bottom">
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
export const FieldLabel: React.FC<{ id?: string; label: string }> = ({
|
||||
id,
|
||||
label,
|
||||
}) => {
|
||||
return (
|
||||
<label className="form-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import clsx from "clsx"
|
||||
import { forwardRef } from "react"
|
||||
import { FieldLabel } from "./FieldLabel"
|
||||
|
||||
type InputProps<TMultiline extends boolean> = {
|
||||
label?: string
|
||||
|
|
@ -11,7 +12,7 @@ type InputProps<TMultiline extends boolean> = {
|
|||
} & React.ComponentPropsWithRef<TMultiline extends true ? "textarea" : "input">
|
||||
|
||||
export const Input = forwardRef(function Input<
|
||||
TMutliline extends boolean = false
|
||||
TMutliline extends boolean = false,
|
||||
>(
|
||||
{
|
||||
label,
|
||||
|
|
@ -25,18 +26,14 @@ export const Input = forwardRef(function Input<
|
|||
}: InputProps<TMutliline>,
|
||||
ref: TMutliline extends true
|
||||
? React.ForwardedRef<HTMLTextAreaElement>
|
||||
: React.ForwardedRef<HTMLInputElement>
|
||||
: React.ForwardedRef<HTMLInputElement>,
|
||||
) {
|
||||
const hasAddon = !!addon
|
||||
const Component = (multiline ? "textarea" : "input") as any
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label className="form-label" htmlFor={inputProps.id}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{label && <FieldLabel label={label} id={inputProps.id} />}
|
||||
<div className="flex items-center">
|
||||
<Component
|
||||
{...inputProps}
|
||||
|
|
@ -45,7 +42,7 @@ export const Input = forwardRef(function Input<
|
|||
"input",
|
||||
hasAddon && `has-addon`,
|
||||
isBlock && `is-block`,
|
||||
className
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{addon && (
|
||||
|
|
@ -61,5 +58,5 @@ export const Input = forwardRef(function Input<
|
|||
</div>
|
||||
)
|
||||
}) as <TMultiline extends boolean = false>(
|
||||
props: InputProps<TMultiline>
|
||||
props: InputProps<TMultiline>,
|
||||
) => JSX.Element
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ type Action =
|
|||
type: "can-create-page"
|
||||
siteId: string
|
||||
}
|
||||
| { type: "can-update-page"; siteId: string }
|
||||
| {
|
||||
type: "can-read-page"
|
||||
page: Page
|
||||
|
|
@ -41,7 +42,7 @@ export const createGate = <TRequiredAuth extends boolean | undefined>({
|
|||
const isSiteMember = (siteId: string, roles: MembershipRole[]) => {
|
||||
if (!user) return false
|
||||
return user.memberships.some(
|
||||
(m) => m.siteId === siteId && roles.includes(m.role)
|
||||
(m) => m.siteId === siteId && roles.includes(m.role),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ export const createGate = <TRequiredAuth extends boolean | undefined>({
|
|||
|
||||
return {
|
||||
getUser<TRequiredAuth extends boolean | undefined>(
|
||||
requireAuth?: TRequiredAuth
|
||||
requireAuth?: TRequiredAuth,
|
||||
) {
|
||||
if (requireAuth && !user) {
|
||||
throw new Error("unauthorized")
|
||||
|
|
@ -95,6 +96,13 @@ export const createGate = <TRequiredAuth extends boolean | undefined>({
|
|||
])
|
||||
}
|
||||
|
||||
if (action.type === "can-update-page") {
|
||||
return isSiteMember(action.siteId, [
|
||||
MembershipRole.ADMIN,
|
||||
MembershipRole.OWNER,
|
||||
])
|
||||
}
|
||||
|
||||
if (action.type === "can-read-page") {
|
||||
const isPublished =
|
||||
action.page.published &&
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const sendLoginEmail = async (payload: {
|
|||
|
||||
<a href="${loginLink}">confirm</a>
|
||||
|
||||
<p>This link will expire in 20 minutes.</p>`
|
||||
<p>This link will expire in 7 days. If you did not request this link, you can safely ignore this email.</p>`
|
||||
}
|
||||
|
||||
const message: MailgunMessageData = {
|
||||
|
|
@ -128,13 +128,21 @@ export const sendLoginEmail = async (payload: {
|
|||
}
|
||||
|
||||
export const sendEmailForNewPost = async (payload: {
|
||||
post: { slug: string; title: string; rendered: { contentHTML: string } }
|
||||
post: {
|
||||
emailSubject?: string | null
|
||||
slug: string
|
||||
title: string
|
||||
rendered: { contentHTML: string }
|
||||
}
|
||||
site: Site
|
||||
subscribers: { id: string; email: string }[]
|
||||
}) => {
|
||||
if (payload.subscribers.length === 0) return
|
||||
try {
|
||||
const from = `${payload.site.name} <updates@${payload.site.subdomain}.proselog.com>`
|
||||
const subject = `${payload.post.title} - ${payload.site.name}`
|
||||
const subject =
|
||||
payload.post.emailSubject ||
|
||||
`${payload.post.title} - ${payload.site.name}`
|
||||
|
||||
const siteLink = getSiteLink({ subdomain: payload.site.subdomain })
|
||||
const html = `
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ export type Store = {
|
|||
|
||||
subscribeModalOpened: boolean
|
||||
setSubscribeModalOpened: (open: boolean) => void
|
||||
|
||||
emailPostModalOpened: boolean
|
||||
setEmailPostModalOpened: (open: boolean) => void
|
||||
}
|
||||
|
||||
const { Provider, useStore } = createContext<StoreApi<Store>>()
|
||||
|
|
@ -26,4 +29,9 @@ export const createStore = () =>
|
|||
setSubscribeModalOpened(open) {
|
||||
set({ subscribeModalOpened: open })
|
||||
},
|
||||
|
||||
emailPostModalOpened: false,
|
||||
setEmailPostModalOpened(open) {
|
||||
set({ emailPostModalOpened: open })
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ const key = singletonAsync("encryption_key", () =>
|
|||
getDerivedKey(ENCRYPT_SECRET),
|
||||
)
|
||||
|
||||
export const generateEncryptedToken = async (payload: any) => {
|
||||
export const generateEncryptedToken = async (
|
||||
payload: any,
|
||||
expiresIn = "20m",
|
||||
) => {
|
||||
await key.wait
|
||||
const token = await encrypt(payload, key.value, {
|
||||
expiresIn: "20m",
|
||||
expiresIn,
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
|
@ -30,7 +33,10 @@ type LoginTokenPayload =
|
|||
}
|
||||
|
||||
export const generateLoginToken = async (payload: LoginTokenPayload) => {
|
||||
const token = await generateEncryptedToken(payload)
|
||||
const token = await generateEncryptedToken(
|
||||
payload,
|
||||
payload.type === "subscribe" ? "7d" : "20m",
|
||||
)
|
||||
return token
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,17 @@ import {
|
|||
prismaPrimary,
|
||||
Prisma,
|
||||
prismaRead,
|
||||
PageType,
|
||||
MembershipRole,
|
||||
PageEmailStatus,
|
||||
} from "~/lib/db.server"
|
||||
import { type Gate } from "~/lib/gate.server"
|
||||
import { sendEmailForNewPost } from "~/lib/mailgun.server"
|
||||
import { Rendered, 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"
|
||||
import { sendEmailForNewPost } from "~/lib/mailgun.server"
|
||||
|
||||
const checkPageSlug = async ({
|
||||
slug,
|
||||
|
|
@ -62,6 +62,7 @@ export async function createOrUpdatePage(
|
|||
published?: boolean
|
||||
publishedAt?: string
|
||||
excerpt?: string
|
||||
/** Only needed when creating page */
|
||||
isPost?: boolean
|
||||
},
|
||||
) {
|
||||
|
|
@ -101,8 +102,14 @@ export async function createOrUpdatePage(
|
|||
throw new Error(`Page not found`)
|
||||
}
|
||||
|
||||
if (!gate.allows({ type: "can-create-page", siteId: page.siteId })) {
|
||||
throw gate.permissionError()
|
||||
if (input.pageId) {
|
||||
if (!gate.allows({ type: "can-update-page", siteId: page.siteId })) {
|
||||
throw gate.permissionError()
|
||||
}
|
||||
} else {
|
||||
if (!gate.allows({ type: "can-create-page", siteId: page.siteId })) {
|
||||
throw gate.permissionError()
|
||||
}
|
||||
}
|
||||
|
||||
const slug = input.slug || page.slug
|
||||
|
|
@ -129,18 +136,36 @@ export async function createOrUpdatePage(
|
|||
},
|
||||
})
|
||||
|
||||
if (
|
||||
updated.type === PageType.POST &&
|
||||
!updated.subscribersNotifiedAt &&
|
||||
updated.published &&
|
||||
updated.publishedAt <= new Date()
|
||||
) {
|
||||
await notifySubscribersForNewPost(gate, {
|
||||
pageId: updated.id,
|
||||
})
|
||||
return { page: updated }
|
||||
}
|
||||
|
||||
export async function scheduleEmailForPost(
|
||||
gate: Gate,
|
||||
input: { pageId: string; emailSubject?: string },
|
||||
) {
|
||||
const page = await getPage(gate, { page: input.pageId })
|
||||
|
||||
if (!gate.allows({ type: "can-update-page", siteId: page.siteId })) {
|
||||
throw gate.permissionError()
|
||||
}
|
||||
|
||||
return { page: updated }
|
||||
if (page.emailStatus) {
|
||||
throw new Error("Email already scheduled or sent")
|
||||
}
|
||||
|
||||
await prismaPrimary.page.update({
|
||||
where: {
|
||||
id: page.id,
|
||||
},
|
||||
data: {
|
||||
emailStatus: "PENDING",
|
||||
emailSubject: input.emailSubject,
|
||||
},
|
||||
})
|
||||
|
||||
await notifySubscribersForNewPost(gate, {
|
||||
pageId: page.id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPagesBySite(
|
||||
|
|
@ -332,13 +357,30 @@ export const notifySubscribersForNewPost = async (
|
|||
const page = await getPage(gate, { page: input.pageId, render: true })
|
||||
const site = await getSite(page.siteId)
|
||||
|
||||
if (page.emailStatus !== PageEmailStatus.PENDING) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!page.published || page.publishedAt > new Date()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (page.type === "PAGE") {
|
||||
throw new Error("You can only notify subscribers for post updates")
|
||||
}
|
||||
|
||||
if (!gate.allows({ type: "can-notify-site-subscribers", site })) {
|
||||
throw gate.permissionError()
|
||||
}
|
||||
|
||||
if (page.subscribersNotifiedAt) {
|
||||
throw new Error("You have already notified subscribers for this post")
|
||||
}
|
||||
await prismaPrimary.page.update({
|
||||
where: {
|
||||
id: page.id,
|
||||
},
|
||||
data: {
|
||||
emailStatus: PageEmailStatus.RUNNING,
|
||||
},
|
||||
})
|
||||
|
||||
const memberships = await prismaPrimary.membership.findMany({
|
||||
where: {
|
||||
|
|
@ -350,28 +392,33 @@ export const notifySubscribersForNewPost = async (
|
|||
},
|
||||
})
|
||||
|
||||
if (memberships.length === 0) return
|
||||
|
||||
await prismaPrimary.page.update({
|
||||
where: {
|
||||
id: page.id,
|
||||
},
|
||||
data: {
|
||||
subscribersNotifiedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
const emailSubscribers = memberships
|
||||
.filter((member) => (member.config as Prisma.JsonObject).email)
|
||||
.filter((member) => (member.config as any).email)
|
||||
.map((member) => member.user)
|
||||
|
||||
// TODO: maybe run this in a job queue
|
||||
// We will need to use BullMQ to send email for scheduled posts anyway
|
||||
sendEmailForNewPost({
|
||||
post: page,
|
||||
site,
|
||||
subscribers: emailSubscribers,
|
||||
}).catch((error) => {
|
||||
try {
|
||||
await sendEmailForNewPost({
|
||||
post: page,
|
||||
site,
|
||||
subscribers: emailSubscribers,
|
||||
})
|
||||
await prismaPrimary.page.update({
|
||||
where: {
|
||||
id: page.id,
|
||||
},
|
||||
data: {
|
||||
emailStatus: PageEmailStatus.SUCCESS,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
await prismaPrimary.page.update({
|
||||
where: {
|
||||
id: page.id,
|
||||
},
|
||||
data: {
|
||||
emailStatus: PageEmailStatus.FAILED,
|
||||
},
|
||||
})
|
||||
console.error("failed to send email", error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,11 @@ import { prismaPrimary, prismaRead } from "~/lib/db.server"
|
|||
import { isUUID } from "~/lib/uuid"
|
||||
import { MembershipRole, PageType, type Site } from "~/lib/db.server"
|
||||
import { Gate } from "~/lib/gate.server"
|
||||
import dayjs from "dayjs"
|
||||
import { sendLoginEmail } from "~/lib/mailgun.server"
|
||||
import { SiteNavigationItem, SubscribeFormData } from "~/lib/types"
|
||||
import { SiteNavigationItem } from "~/lib/types"
|
||||
import { nanoid } from "nanoid"
|
||||
import { getMembership } from "./membership"
|
||||
import { checkReservedWords } from "~/lib/reserved-words"
|
||||
import { generateLoginToken } from "~/lib/token.server"
|
||||
|
||||
export const checkSubdomain = async ({
|
||||
subdomain,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { type EditorView } from "@codemirror/view"
|
|||
import clsx from "clsx"
|
||||
import dayjs from "dayjs"
|
||||
import { useRouter } from "next/router"
|
||||
import { ChangeEvent, useCallback, useEffect, useState } from "react"
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import toast from "react-hot-toast"
|
||||
import { modeToolbars, toolbars } from "~/editor"
|
||||
import { DashboardLayout } from "~/components/dashboard/DashboardLayout"
|
||||
|
|
@ -19,6 +19,10 @@ import { getSiteLink } from "~/lib/helpers"
|
|||
import { getPageVisibility } from "~/lib/page-helpers"
|
||||
import { trpc } from "~/lib/trpc"
|
||||
import { PageVisibilityEnum } from "~/lib/types"
|
||||
import { FieldLabel } from "~/components/ui/FieldLabel"
|
||||
import { Button } from "~/components/ui/Button"
|
||||
import { EmailPostModal } from "~/components/common/EmailPostModal"
|
||||
import { useStore } from "~/lib/store"
|
||||
|
||||
const getInputDatetimeValue = (date: Date | string) => {
|
||||
const str = dayjs(date).format()
|
||||
|
|
@ -35,13 +39,14 @@ export default function SubdomainEditor() {
|
|||
const siteResult = trpc.useQuery(["site", { site: subdomain }], {
|
||||
enabled: !!subdomain,
|
||||
})
|
||||
const { data: page } = trpc.useQuery(
|
||||
const { data: page, refetch: refetchPage } = trpc.useQuery(
|
||||
["site.page", { page: pageId!, site: subdomain, render: false }],
|
||||
{
|
||||
enabled: !!pageId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
)
|
||||
const pageContent = useMemo(() => page?.content, [page?.content])
|
||||
|
||||
const published = page?.published ?? false
|
||||
const visibility = getPageVisibility({
|
||||
published,
|
||||
|
|
@ -55,6 +60,10 @@ export default function SubdomainEditor() {
|
|||
reset: resetCreateOrUpdatePage,
|
||||
} = trpc.useMutation("page.createOrUpdate")
|
||||
const uploadFile = useUploadFile()
|
||||
const [emailPostModalOpened, setEmailPostModalOpened] = useStore((store) => [
|
||||
store.emailPostModalOpened,
|
||||
store.setEmailPostModalOpened,
|
||||
])
|
||||
|
||||
const [values, setValues] = useState({
|
||||
title: "",
|
||||
|
|
@ -170,132 +179,177 @@ export default function SubdomainEditor() {
|
|||
slug: page.slug,
|
||||
excerpt: page.excerpt || "",
|
||||
})
|
||||
setContent(page.content)
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof pageContent === "string") {
|
||||
setContent(pageContent)
|
||||
}
|
||||
}, [pageContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!emailPostModalOpened) {
|
||||
refetchPage()
|
||||
}
|
||||
}, [refetchPage, emailPostModalOpened])
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Editor">
|
||||
<DashboardMain fullWidth>
|
||||
<header className="flex justify-between absolute top-0 left-0 right-0 z-10 px-5 h-14 border-b items-center text-sm">
|
||||
<EditorToolbar
|
||||
view={view}
|
||||
toolbars={toolbars}
|
||||
modeToolbars={modeToolbars}
|
||||
previewVisible={previewVisible}
|
||||
setPreviewVisible={setPreviewVisible}
|
||||
></EditorToolbar>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span
|
||||
className={clsx(
|
||||
`text-sm`,
|
||||
published ? `text-accent` : `text-zinc-300`,
|
||||
)}
|
||||
>
|
||||
{visibility === PageVisibilityEnum.Published
|
||||
? "Published"
|
||||
: visibility === PageVisibilityEnum.Scheduled
|
||||
? "Scheduled"
|
||||
: "Draft"}
|
||||
</span>
|
||||
<PublishButton
|
||||
save={savePage}
|
||||
published={published}
|
||||
isSaving={createOrUpdatePageStatus === "loading"}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div className="h-screen pt-14 flex w-full">
|
||||
<div className="h-full overflow-auto w-full">
|
||||
<div className="max-w-screen-md mx-auto py-5 px-5">
|
||||
<>
|
||||
<DashboardLayout title="Editor">
|
||||
<DashboardMain fullWidth>
|
||||
<header className="flex justify-between absolute top-0 left-0 right-0 z-10 px-5 h-14 border-b items-center text-sm">
|
||||
<EditorToolbar
|
||||
view={view}
|
||||
toolbars={toolbars}
|
||||
modeToolbars={modeToolbars}
|
||||
previewVisible={previewVisible}
|
||||
setPreviewVisible={setPreviewVisible}
|
||||
></EditorToolbar>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span
|
||||
className={clsx(
|
||||
`text-sm`,
|
||||
published ? `text-accent` : `text-zinc-300`,
|
||||
)}
|
||||
>
|
||||
{visibility === PageVisibilityEnum.Published
|
||||
? "Published"
|
||||
: visibility === PageVisibilityEnum.Scheduled
|
||||
? "Scheduled"
|
||||
: "Draft"}
|
||||
</span>
|
||||
<PublishButton
|
||||
save={savePage}
|
||||
published={published}
|
||||
isSaving={createOrUpdatePageStatus === "loading"}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div className="h-screen pt-14 flex w-full">
|
||||
<div className="h-full overflow-auto w-full">
|
||||
<div className="max-w-screen-md mx-auto py-5 px-5">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={values.title}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
view?.focus()
|
||||
}
|
||||
}}
|
||||
onChange={(e) => updateValue("title", e.target.value)}
|
||||
className="h-12 ml-1 inline-flex items-center border-none text-3xl font-bold w-full focus:outline-none"
|
||||
placeholder="Title goes here.."
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
{previewVisible ? (
|
||||
<EditorPreview content={content}></EditorPreview>
|
||||
) : (
|
||||
<div ref={editorRef}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full overflow-auto flex-shrink-0 w-[280px] border-l bg-zinc-50 p-5 space-y-5">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={values.title}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
view?.focus()
|
||||
}
|
||||
<Input
|
||||
type="datetime-local"
|
||||
label="Publish at"
|
||||
isBlock
|
||||
name="publishAt"
|
||||
id="publishAt"
|
||||
value={getInputDatetimeValue(values.publishedAt)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = inLocalTimezone(e.target.value).toISOString()
|
||||
updateValue("publishedAt", value)
|
||||
}}
|
||||
onChange={(e) => updateValue("title", e.target.value)}
|
||||
className="h-12 ml-1 inline-flex items-center border-none text-3xl font-bold w-full focus:outline-none"
|
||||
placeholder="Title goes here.."
|
||||
help={`This ${
|
||||
isPost ? "post" : "page"
|
||||
} will be accesisble from this time`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
{previewVisible ? (
|
||||
<EditorPreview content={content}></EditorPreview>
|
||||
) : (
|
||||
<div ref={editorRef}></div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
name="slug"
|
||||
value={values.slug}
|
||||
label="Page slug"
|
||||
id="slug"
|
||||
isBlock
|
||||
placeholder="some-slug"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
updateValue("slug", e.target.value)
|
||||
}
|
||||
help={
|
||||
<>
|
||||
{values.slug && (
|
||||
<>
|
||||
This {isPost ? "post" : "page"} will be accessible at{" "}
|
||||
<UniLink
|
||||
href={`${getSiteLink({ subdomain })}/${
|
||||
values.slug
|
||||
}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{getSiteLink({ subdomain, noProtocol: true })}/
|
||||
{values.slug}
|
||||
</UniLink>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
{isPost && page && (
|
||||
<div>
|
||||
<FieldLabel label="Email Post" id="email-post" />
|
||||
{page?.emailStatus ? (
|
||||
<div className="capitalize text-zinc-500 text-sm flex items-center space-x-1">
|
||||
<span
|
||||
className={clsx(
|
||||
page.emailStatus === "SUCCESS"
|
||||
? `i-bi:check-circle text-green-500`
|
||||
: page.emailStatus === "FAILED"
|
||||
? `i-bi:x-circle text-red-500`
|
||||
: ``,
|
||||
)}
|
||||
></span>
|
||||
<span>{page.emailStatus.toLowerCase()}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
variantColor="gray"
|
||||
size="sm"
|
||||
isBlock
|
||||
onClick={() => setEmailPostModalOpened(true)}
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full overflow-auto flex-shrink-0 w-[280px] border-l bg-zinc-50 p-5 space-y-5">
|
||||
<div>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
label="Publish at"
|
||||
isBlock
|
||||
name="publishAt"
|
||||
id="publishAt"
|
||||
value={getInputDatetimeValue(values.publishedAt)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = inLocalTimezone(e.target.value).toISOString()
|
||||
updateValue("publishedAt", value)
|
||||
}}
|
||||
help={`This ${
|
||||
isPost ? "post" : "page"
|
||||
} will be accesisble from this time`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
name="slug"
|
||||
value={values.slug}
|
||||
label="Page slug"
|
||||
id="slug"
|
||||
isBlock
|
||||
placeholder="some-slug"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
updateValue("slug", e.target.value)
|
||||
}
|
||||
help={
|
||||
<>
|
||||
{values.slug && (
|
||||
<>
|
||||
This {isPost ? "post" : "page"} will be accessible at{" "}
|
||||
<UniLink
|
||||
href={`${getSiteLink({ subdomain })}/${values.slug}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{getSiteLink({ subdomain, noProtocol: true })}/
|
||||
{values.slug}
|
||||
</UniLink>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
</DashboardLayout>
|
||||
</DashboardMain>
|
||||
</DashboardLayout>
|
||||
{pageId && <EmailPostModal pageId={pageId} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ export const pageRouter = createRouter()
|
|||
published: z.boolean().optional(),
|
||||
publishedAt: z.string().optional(),
|
||||
excerpt: z.string().optional(),
|
||||
isPost: z.boolean(),
|
||||
isPost: z.boolean().optional(),
|
||||
slug: z.string().optional(),
|
||||
emailSubject: z.string().optional(),
|
||||
}),
|
||||
output: z.object({
|
||||
id: z.string(),
|
||||
|
|
|
|||
|
|
@ -10,30 +10,12 @@ import {
|
|||
unsubscribeFromSite,
|
||||
} from "~/models/site.model"
|
||||
import {
|
||||
createOrUpdatePage,
|
||||
getPage,
|
||||
deletePage,
|
||||
getPagesBySite,
|
||||
notifySubscribersForNewPost,
|
||||
scheduleEmailForPost,
|
||||
} from "~/models/page.model"
|
||||
|
||||
export const siteRouter = createRouter()
|
||||
.query("subscribersNotifiedAt", {
|
||||
input: z.object({
|
||||
pageId: z.string(),
|
||||
}),
|
||||
output: z
|
||||
.date()
|
||||
.transform((v) => v.toISOString())
|
||||
.nullable(),
|
||||
async resolve({ input, ctx }) {
|
||||
const page = await getPage(ctx.gate, { page: input.pageId })
|
||||
if (!ctx.gate.isOwnerOrAdmin(page.siteId)) {
|
||||
throw ctx.gate.permissionError()
|
||||
}
|
||||
return page.subscribersNotifiedAt
|
||||
},
|
||||
})
|
||||
.query("subscription", {
|
||||
input: z.object({
|
||||
site: z.string(),
|
||||
|
|
@ -123,6 +105,9 @@ export const siteRouter = createRouter()
|
|||
}),
|
||||
)
|
||||
.optional(),
|
||||
emailSubject: z.string().nullish(),
|
||||
emailStatus: z.string().nullish(),
|
||||
siteId: z.string(),
|
||||
}),
|
||||
async resolve({ input, ctx }) {
|
||||
const page = await getPage(ctx.gate, input)
|
||||
|
|
@ -205,13 +190,15 @@ export const siteRouter = createRouter()
|
|||
await unsubscribeFromSite(ctx.gate, input)
|
||||
},
|
||||
})
|
||||
.mutation("notifySubscribersForNewPost", {
|
||||
.mutation("scheduleEmailForPost", {
|
||||
input: z.object({
|
||||
pageId: z.string(),
|
||||
emailSubject: z.string().optional(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
await notifySubscribersForNewPost(ctx.gate, {
|
||||
await scheduleEmailForPost(ctx.gate, {
|
||||
pageId: input.pageId,
|
||||
emailSubject: input.emailSubject,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue