blog editor

This commit is contained in:
2026-04-24 11:58:19 +02:00
parent daab745c13
commit be6df0c8ad
16 changed files with 1448 additions and 559 deletions

680
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import type { UseTRPCMutationResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs";
import { createContext, useContext, type ReactNode } from "react";
interface ToString {
@@ -8,7 +7,7 @@ interface ToString {
export interface MutationInterface {
mutate: (params:{id:string}) => void
mutate: (params: any) => void
error: ToString | null
status: "error" | "idle" | "pending" | "success"
}

View File

@@ -0,0 +1,224 @@
'use client'
import { forwardRef, useMemo, useRef, useState, type KeyboardEvent, type TextareaHTMLAttributes } from 'react'
export type InternalLinkSuggestion = {
label: string
href: string
group: string
}
export type MdeAutocompleteSuggestion = {
label: string
value: string
detail: string
group: string
trigger: string
}
export const AUTOCOMPLETE_CURSOR_MARKER = '{{cursor}}'
export type AutocompleteTriggerConfig = {
trigger: string
label: string
isQueryValid?: (query: string) => boolean
}
type ActiveToken = {
start: number
end: number
query: string
trigger: MdeAutocompleteSuggestion['trigger']
}
const defaultTriggerConfigs: AutocompleteTriggerConfig[] = [
{
trigger: '[[',
label: 'Internal links',
isQueryValid: (query) => !query.includes(']'),
},
{
trigger: '<',
label: 'MDX components',
isQueryValid: (query) => !/[\s>]/.test(query),
},
]
function findActiveToken(
value: string,
cursor: number,
triggerConfigs: AutocompleteTriggerConfig[],
): ActiveToken | null {
const beforeCursor = value.slice(0, cursor)
const activeTrigger = triggerConfigs
.map((config) => ({ config, start: beforeCursor.lastIndexOf(config.trigger) }))
.filter((candidate) => candidate.start !== -1)
.sort((a, b) => b.start - a.start)[0]
if (!activeTrigger) return null
const query = beforeCursor.slice(activeTrigger.start + activeTrigger.config.trigger.length)
if (query.includes('\n')) return null
if (activeTrigger.config.isQueryValid && !activeTrigger.config.isQueryValid(query)) return null
return {
start: activeTrigger.start,
end: cursor,
query,
trigger: activeTrigger.config.trigger,
}
}
export function linkSuggestionsToAutocomplete(suggestions: InternalLinkSuggestion[]): MdeAutocompleteSuggestion[] {
return suggestions.map((suggestion) => ({
label: suggestion.label,
value: `[${suggestion.label}](${suggestion.href})`,
detail: suggestion.href,
group: suggestion.group,
trigger: '[[',
}))
}
export const InternalLinkTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & {
suggestions: MdeAutocompleteSuggestion[]
triggerConfigs?: AutocompleteTriggerConfig[]
}>(({ suggestions, triggerConfigs, value, onChange, onKeyDown, ...props }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const [token, setToken] = useState<ActiveToken | null>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
function setRefs(element: HTMLTextAreaElement | null) {
textareaRef.current = element
if (typeof ref === 'function') ref(element)
else if (ref) ref.current = element
}
const resolvedTriggerConfigs = useMemo(() => {
const configured = triggerConfigs?.length ? triggerConfigs : defaultTriggerConfigs
const merged = new Map(configured.map((config) => [config.trigger, config]))
for (const suggestion of suggestions) {
if (!merged.has(suggestion.trigger)) {
merged.set(suggestion.trigger, {
trigger: suggestion.trigger,
label: suggestion.trigger,
})
}
}
return Array.from(merged.values()).sort((a, b) => b.trigger.length - a.trigger.length)
}, [suggestions, triggerConfigs])
const triggerLabels = useMemo(
() => new Map(resolvedTriggerConfigs.map((config) => [config.trigger, config.label])),
[resolvedTriggerConfigs],
)
const matches = useMemo(() => {
if (!token) return []
const query = token.query.toLowerCase()
return suggestions
.filter((suggestion) => suggestion.trigger === token.trigger)
.filter((suggestion) => {
const haystack = `${suggestion.group} ${suggestion.label} ${suggestion.detail}`.toLowerCase()
return haystack.includes(query)
})
.slice(0, 8)
}, [suggestions, token])
function updateToken(textarea: HTMLTextAreaElement) {
const nextToken = findActiveToken(textarea.value, textarea.selectionStart, resolvedTriggerConfigs)
setToken(nextToken)
setSelectedIndex(0)
}
function insertSuggestion(textarea: HTMLTextAreaElement, suggestion: MdeAutocompleteSuggestion) {
if (!token) return
const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER)
const insertedValue = markerIndex === -1
? suggestion.value
: suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, '')
const cursor = token.start + (markerIndex === -1 ? insertedValue.length : markerIndex)
const nextValue = `${textarea.value.slice(0, token.start)}${insertedValue}${textarea.value.slice(token.end)}`
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
nativeInputValueSetter?.call(textarea, nextValue)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
textarea.setSelectionRange(cursor, cursor)
setToken(null)
setSelectedIndex(0)
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (token && matches.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex((index) => (index + 1) % matches.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex((index) => (index - 1 + matches.length) % matches.length)
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
const suggestion = matches[selectedIndex]
if (suggestion) insertSuggestion(event.currentTarget, suggestion)
return
}
if (event.key === 'Escape') {
event.preventDefault()
setToken(null)
return
}
}
onKeyDown?.(event)
}
return (
<>
<textarea
{...props}
ref={setRefs}
value={value}
onChange={(event) => {
onChange?.(event)
updateToken(event.currentTarget)
}}
onClick={(event) => updateToken(event.currentTarget)}
onKeyUp={(event) => {
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) return
updateToken(event.currentTarget)
}}
onKeyDown={handleKeyDown}
/>
{token && matches.length > 0 && (
<div className='bg-popover text-popover-foreground absolute left-3 top-12 z-50 w-80 overflow-hidden rounded-md border shadow-md'>
<div className='border-b px-3 py-2 text-xs text-muted-foreground'>
{triggerLabels.get(token.trigger) ?? token.trigger} for {token.trigger}{token.query}
</div>
<div className='max-h-64 overflow-y-auto py-1'>
{matches.map((suggestion, index) => (
<button
key={`${suggestion.trigger}:${suggestion.group}:${suggestion.label}`}
type='button'
className={`flex w-full flex-col px-3 py-2 text-left text-sm ${index === selectedIndex ? 'bg-muted' : ''}`}
onMouseDown={(event) => {
event.preventDefault()
if (textareaRef.current) insertSuggestion(textareaRef.current, suggestion)
}}
>
<span className='font-medium'>{suggestion.label}</span>
<span className='text-xs text-muted-foreground'>{suggestion.group} - {suggestion.detail}</span>
</button>
))}
</div>
</div>
)}
</>
)
})
InternalLinkTextarea.displayName = 'InternalLinkTextarea'

View File

@@ -1,25 +1,99 @@
"use client";
import MDEditor from "@uiw/react-md-editor";
import { Maximize2, Minimize2 } from "lucide-react";
import { useEffect, useState, type ReactElement, type TextareaHTMLAttributes } from "react";
import { createPortal } from "react-dom";
import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import {
InternalLinkTextarea,
type AutocompleteTriggerConfig,
type MdeAutocompleteSuggestion,
} from "./InternalLinkTextarea";
export default function MdeFormField<T extends FieldValues>(params: {
control: Control<T>,
name: Path<T>,
label: string,
dataColorMode: "dark"|"light",
autocompleteSuggestions?: MdeAutocompleteSuggestion[],
triggerConfigs?: AutocompleteTriggerConfig[],
renderPreview?: (source: string) => ReactElement,
}) {
const [fullscreen, setFullscreen] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (!fullscreen) return
const originalOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = originalOverflow
}
}, [fullscreen])
return (
<FormField
control={params.control}
name={params.name}
render={({ field }) => (
<FormItem>
<FormLabel>
Description
</FormLabel>
<FormControl>
<MDEditor
value={field.value ? field.value : ""}
onChange={field.onChange}
data-color-mode={params.dataColorMode}
/>
</FormControl>
</FormItem>
)}
render={({ field }) => {
const editor = (
<FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
<div className="flex shrink-0 items-center justify-between gap-2">
<FormLabel>
{params.label}
</FormLabel>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={fullscreen ? "Exit fullscreen editor" : "Open fullscreen editor"}
onClick={() => setFullscreen((value) => !value)}
>
{fullscreen ? <Minimize2 /> : <Maximize2 />}
</Button>
</div>
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
<MDEditor
className={cn(fullscreen && "mde-form-field-editor-fullscreen min-h-0 flex-1")}
height={fullscreen ? "calc(100vh - 72px)" : undefined}
visibleDragbar={!fullscreen}
value={field.value ? field.value : ""}
onChange={field.onChange}
data-color-mode={params.dataColorMode}
commandsFilter={(command) => command.name === "fullscreen" ? false : command}
components={{
textarea: (props) => (
<InternalLinkTextarea
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)}
suggestions={params.autocompleteSuggestions ?? []}
triggerConfigs={params.triggerConfigs}
/>
),
preview: params.renderPreview
? (source) => params.renderPreview?.(source) ?? <></>
: undefined,
}}
/>
</FormControl>
</FormItem>
)
if (fullscreen && mounted) {
return createPortal(editor, document.body)
}
return editor
}}
/>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { useEffect, useState } from 'react'
import { MDXRemote } from 'next-mdx-remote'
import { serialize } from 'next-mdx-remote/serialize'
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
import { mdxComponents } from '~/app/blog/_components/mdx-components'
export default function BlogMdxEditorPreview(params: { source: string }) {
const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const timeout = window.setTimeout(() => {
void serialize(params.source, {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [],
},
})
.then((result) => {
if (cancelled) return
setCompiled(result)
setError(null)
})
.catch((nextError: unknown) => {
if (cancelled) return
setCompiled(null)
setError(nextError instanceof Error ? nextError.message : 'Failed to compile MDX preview')
})
}, 200)
return () => {
cancelled = true
window.clearTimeout(timeout)
}
}, [params.source])
if (error) {
return (
<div className='rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive'>
{error}
</div>
)
}
if (!compiled) {
return <div className='text-muted-foreground p-4 text-sm'>Rendering preview...</div>
}
return (
<article className='prose dark:prose-invert max-w-none'>
<MDXRemote {...compiled} components={mdxComponents} />
</article>
)
}

View File

@@ -10,6 +10,15 @@ import { TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
import { usePathname, useRouter } from 'next/navigation'
import { useTheme } from 'next-themes'
import type { RouterOutputs } from '~/server/routers/_app'
import MdxComponentReference from './MdxComponentReference'
import BlogMdxEditorPreview from './BlogMdxEditorPreview'
import {
AUTOCOMPLETE_CURSOR_MARKER,
linkSuggestionsToAutocomplete,
type AutocompleteTriggerConfig,
type InternalLinkSuggestion,
type MdeAutocompleteSuggestion,
} from '~/app/_components/Form/Fields/InternalLinkTextarea'
type BlogPost = RouterOutputs['blog']['bySlug']
@@ -18,9 +27,124 @@ const blogPostSchema = z.object({
title: z.string().min(1),
date: z.string().optional(),
description: z.string().optional(),
tags: z.string().optional(),
content: z.string(),
})
function parseTags(value: string | undefined): string[] {
return value?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? []
}
function internalLinkSuggestions(params: {
posts?: RouterOutputs['blog']['list'],
projects?: RouterOutputs['projectv2']['listWithStack'],
}): InternalLinkSuggestion[] {
const postLinks = params.posts?.map((post) => ({
label: post.title,
href: `/blog/${post.slug}`,
group: 'Blog',
})) ?? []
const projectLinks = params.projects?.map((project) => ({
label: project.title,
href: `/projects#${project.id}`,
group: 'Project',
})) ?? []
return [...postLinks, ...projectLinks]
}
const blogAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [
{
label: 'Lead',
value: `<Lead>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Lead>`,
detail: 'Intro paragraph with larger muted text.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout note',
value: `<Callout title="Heads up" variant="note">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted note block.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout tip',
value: `<Callout title="Tip" variant="tip">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted tip block.',
group: 'Component',
trigger: '<',
},
{
label: 'Callout warning',
value: `<Callout title="Careful" variant="warning">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
detail: 'Highlighted warning block.',
group: 'Component',
trigger: '<',
},
{
label: 'ButtonLink',
value: `<ButtonLink href="${AUTOCOMPLETE_CURSOR_MARKER}">\nView projects\n</ButtonLink>`,
detail: 'Button-styled internal or external link.',
group: 'Component',
trigger: '<',
},
{
label: 'Figure',
value: `<Figure\n src="${AUTOCOMPLETE_CURSOR_MARKER}"\n alt="Describe the image"\n caption="Optional caption"\n/>`,
detail: 'Image with optional caption.',
group: 'Component',
trigger: '<',
},
{
label: 'PullQuote',
value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`,
detail: 'Large emphasized quote.',
group: 'Component',
trigger: '<',
},
{
label: 'TagList',
value: `<TagList tags={[${AUTOCOMPLETE_CURSOR_MARKER}]} />`,
detail: 'Inline list of tag badges.',
group: 'Component',
trigger: '<',
},
{
label: 'Badge',
value: `<Badge variant="outline">${AUTOCOMPLETE_CURSOR_MARKER}</Badge>`,
detail: 'Small inline label.',
group: 'Component',
trigger: '<',
},
{
label: 'Image',
value: `![Image](${AUTOCOMPLETE_CURSOR_MARKER})`,
detail: 'Markdown image',
group: 'Markdown',
trigger: '!',
},
]
const blogTriggerConfigs: AutocompleteTriggerConfig[] = [
{
trigger: '[[',
label: 'Internal links',
isQueryValid: (query) => !query.includes(']'),
},
{
trigger: '<',
label: 'MDX components',
isQueryValid: (query) => !/[\s>]/.test(query),
},
{
trigger: '!',
label: 'Markdown',
isQueryValid: (query) => !/[\s\)]/.test(query),
},
]
export default function CreateUpdateBlogForm(params: { className?: string, entity?: BlogPost }) {
const [slug, setSlug] = useState<string | undefined>(params.entity?.slug)
const [originalSlug, setOriginalSlug] = useState<string | undefined>(params.entity?.slug)
@@ -32,11 +156,18 @@ export default function CreateUpdateBlogForm(params: { className?: string, entit
title: params.entity?.title ?? '',
date: params.entity?.date ?? '',
description: params.entity?.description ?? '',
tags: params.entity?.tags?.join(', ') ?? '',
content: params.entity?.content ?? '',
},
})
const path = usePathname()
const router = useRouter()
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
const projects = trpc.projectv2.listWithStack.useQuery()
const autocompleteSuggestions = [
...linkSuggestionsToAutocomplete(internalLinkSuggestions({ posts: posts.data, projects: projects.data })),
...blogAutocompleteSuggestions,
]
const createMutation = trpc.blog.insert.useMutation({
onSuccess: (data) => {
@@ -62,10 +193,11 @@ export default function CreateUpdateBlogForm(params: { className?: string, entit
})
function onSubmit(values: z.infer<typeof blogPostSchema>) {
const input = { ...values, tags: parseTags(values.tags) }
if (slug && originalSlug) {
updateMutation.mutate({ ...values, originalSlug })
updateMutation.mutate({ ...input, originalSlug })
} else {
createMutation.mutate(values)
createMutation.mutate(input)
}
}
@@ -75,19 +207,31 @@ export default function CreateUpdateBlogForm(params: { className?: string, entit
updateMutation: updateMutation,
deleteMutation: deleteMutation,
}}>
<FormScaffold
form={form}
onSubmit={onSubmit}
title='Blog Post'
id={slug}
className={params.className}
>
<TextInputFormField control={form.control} name='slug' label='Slug' />
<TextInputFormField control={form.control} name='title' label='Title' />
<TextInputFormField control={form.control} name='date' label='Date (YYYY-MM-DD)' />
<TextInputFormField control={form.control} name='description' label='Description' />
<MdeFormField control={form.control} name='content' label='Content' dataColorMode={(theme as 'dark' | 'light') ?? 'dark'} />
</FormScaffold>
<div className='flex flex-col gap-4'>
<MdxComponentReference />
<FormScaffold
form={form}
onSubmit={onSubmit}
title='Blog Post'
id={slug}
className={params.className}
>
<TextInputFormField control={form.control} name='slug' label='Slug' />
<TextInputFormField control={form.control} name='title' label='Title' />
<TextInputFormField control={form.control} name='date' label='Date (YYYY-MM-DD)' />
<TextInputFormField control={form.control} name='description' label='Description' />
<TextInputFormField control={form.control} name='tags' label='Tags (comma separated)' />
<MdeFormField
control={form.control}
name='content'
label='Content'
dataColorMode={(theme as 'dark' | 'light') ?? 'dark'}
autocompleteSuggestions={autocompleteSuggestions}
triggerConfigs={blogTriggerConfigs}
renderPreview={(source) => <BlogMdxEditorPreview source={source} />}
/>
</FormScaffold>
</div>
</FormMutationContextProvider>
)
}

View File

@@ -0,0 +1,91 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
const examples = [
{
name: "Lead",
description: "Intro paragraph with larger muted text.",
code: `<Lead>
Short opening summary for the post.
</Lead>`,
},
{
name: "Callout",
description: "Highlighted note, tip, or warning block.",
code: `<Callout title="Heads up" variant="note">
Important context for readers.
</Callout>
<Callout title="Tip" variant="tip">
A practical recommendation.
</Callout>
<Callout title="Careful" variant="warning">
A caveat or tradeoff.
</Callout>`,
},
{
name: "ButtonLink",
description: "Button-styled internal or external link.",
code: `<ButtonLink href="/projects">
View projects
</ButtonLink>
<ButtonLink href="https://example.com" variant="outline">
External resource
</ButtonLink>`,
},
{
name: "Figure",
description: "Image with optional caption.",
code: `<Figure
src="https://example.com/image.jpg"
alt="Describe the image"
caption="Optional caption"
/>`,
},
{
name: "PullQuote",
description: "Large emphasized quote or takeaway.",
code: `<PullQuote>
A highlighted quote or strong takeaway.
</PullQuote>`,
},
{
name: "TagList",
description: "Inline list of tag badges inside the post body.",
code: `<TagList tags={["nextjs", "mdx", "uploadthing"]} />`,
},
{
name: "Badge",
description: "Small inline label.",
code: `<Badge variant="outline">Next.js</Badge>`,
},
];
export default function MdxComponentReference() {
return (
<section className="rounded-lg border p-4">
<h2 className="text-base font-semibold">MDX Components</h2>
<p className="text-muted-foreground mt-1 text-sm">
Components available inside blog post content. Type <code className="rounded bg-muted px-1">[[</code> for internal links or <code className="rounded bg-muted px-1">&lt;</code> for component snippets.
</p>
<Accordion type="single" collapsible className="mt-3">
{examples.map((example) => (
<AccordionItem key={example.name} value={example.name}>
<AccordionTrigger>
<span>
<span className="block">{example.name}</span>
<span className="text-muted-foreground block text-xs font-normal">{example.description}</span>
</span>
</AccordionTrigger>
<AccordionContent>
<pre className="bg-muted overflow-x-auto rounded-md p-3 text-xs">
<code>{example.code}</code>
</pre>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</section>
);
}

View File

@@ -1,23 +1,37 @@
'use client'
import Link from 'next/link'
import { trpc } from '~/app/_trpc/Client'
import { useGSAP } from '@gsap/react'
import { useRef } from 'react'
import * as Card from '~/components/ui/card'
import { useGsapContext } from '~/app/_providers/GsapProvicer'
import { CollapsibleForm } from '~/app/_components/Form/Components'
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
import { Badge } from '~/components/ui/badge'
import { Button } from '~/components/ui/button'
import { RefreshCw } from 'lucide-react'
export default function BlogListPage() {
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
const gsap = useGsapContext()
const container = useRef<HTMLDivElement>(null)
useGSAP(() => {
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } })
}, { scope: container, dependencies: [posts.status], revertOnUpdate: true })
const syncMutation = trpc.blog.syncFromUploadThing.useMutation({
onSuccess: () => posts.refetch(),
})
return (
<div ref={container} className='w-5/6 lg:w-1/2 flex flex-col gap-3'>
<div className='w-5/6 lg:w-1/2 flex flex-col gap-3'>
<div className='flex justify-end'>
<Button
type='button'
variant='outline'
onClick={() => syncMutation.mutate(undefined)}
disabled={syncMutation.status === 'pending'}
>
<RefreshCw />
Sync
</Button>
</div>
{syncMutation.data && (
<p className='text-sm text-muted-foreground'>
Synced {syncMutation.data.created} created, {syncMutation.data.updated} updated, {syncMutation.data.skipped} skipped.
</p>
)}
{posts.data == undefined ?
<div className='gsapan' /> :
<>
@@ -28,6 +42,13 @@ export default function BlogListPage() {
<Card.CardTitle>{post.title}</Card.CardTitle>
{post.date && <p className='text-sm text-muted-foreground'>{post.date}</p>}
{post.description && <p className='text-sm text-muted-foreground'>{post.description}</p>}
{post.tags && post.tags.length > 0 && (
<div className='flex flex-wrap gap-1.5'>
{post.tags.map((tag) => (
<Badge key={tag} variant='outline'>{tag}</Badge>
))}
</div>
)}
</Card.CardHeader>
</Link>
</Card.Card>

View File

@@ -2,6 +2,8 @@ import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { TRPCError } from "@trpc/server";
import { servTrpc } from "~/app/_trpc/ServerClient";
import { Badge } from "~/components/ui/badge";
import { mdxComponents } from "../_components/mdx-components";
type Props = {
params: Promise<{ slug: string }>;
@@ -31,9 +33,16 @@ export default async function BlogPostPage({ params }: Props) {
})}
</time>
)}
{post.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{post.tags.map((tag) => (
<Badge key={tag} variant="outline">{tag}</Badge>
))}
</div>
)}
</header>
<article className="prose dark:prose-invert max-w-none">
<MDXRemote source={post.content} />
<MDXRemote source={post.content} components={mdxComponents} />
</article>
</main>
);

View File

@@ -0,0 +1,128 @@
import Link from "next/link";
import { Children, isValidElement, type ComponentPropsWithoutRef, type ReactNode } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
type CalloutVariant = "note" | "tip" | "warning";
const calloutStyles: Record<CalloutVariant, string> = {
note: "border-sky-500/40 bg-sky-500/10 text-sky-950 dark:text-sky-100",
tip: "border-emerald-500/40 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100",
warning: "border-amber-500/40 bg-amber-500/10 text-amber-950 dark:text-amber-100",
};
function Callout({
title,
variant = "note",
children,
}: {
title?: string;
variant?: CalloutVariant;
children: ReactNode;
}) {
return (
<aside className={cn("my-6 rounded-md border px-4 py-3", calloutStyles[variant])}>
{title && <p className="mb-2 font-semibold">{title}</p>}
<div className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{children}</div>
</aside>
);
}
function Lead({ children }: { children: ReactNode }) {
return <span className="text-muted-foreground my-6 block text-lg leading-8">{children}</span>;
}
function TagList({ tags }: { tags: string[] }) {
return (
<div className="my-4 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
);
}
function ButtonLink({
href,
children,
variant = "default",
}: {
href: string;
children: ReactNode;
variant?: ComponentPropsWithoutRef<typeof Button>["variant"];
}) {
const isExternal = /^https?:\/\//.test(href);
return (
<Button asChild variant={variant}>
{isExternal ? (
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
) : (
<Link href={href}>{children}</Link>
)}
</Button>
);
}
function Figure({
src,
alt,
caption,
}: {
src: string;
alt: string;
caption?: string;
}) {
return (
<figure className="my-8">
<img src={src} alt={alt} className="w-full rounded-md border object-cover" />
{caption && <figcaption className="text-muted-foreground mt-2 text-center text-sm">{caption}</figcaption>}
</figure>
);
}
function PullQuote({ children }: { children: ReactNode }) {
return (
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
{children}
</blockquote>
);
}
function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
const href = props.href ?? "";
const isExternal = /^https?:\/\//.test(href);
if (!isExternal) return <a {...props} />;
return <a {...props} target="_blank" rel="noreferrer" />;
}
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]);
function Paragraph({ children }: { children: ReactNode }) {
const containsBlockComponent = Children.toArray(children).some(
(child) => isValidElement(child) && blockComponents.has(child.type),
);
if (containsBlockComponent) return <>{children}</>;
return <p>{children}</p>;
}
export const mdxComponents = {
a: ExternalLink,
p: Paragraph,
Badge,
ButtonLink,
Callout,
Figure,
Lead,
PullQuote,
TagList,
};

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import { servTrpc } from "~/app/_trpc/ServerClient";
import { Badge } from "~/components/ui/badge";
export default async function BlogPage() {
const posts = await servTrpc.blog.list();
@@ -27,6 +28,13 @@ export default async function BlogPage() {
{post.description && (
<p className="text-muted-foreground mt-1">{post.description}</p>
)}
{post.tags && post.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{post.tags.map((tag) => (
<Badge key={tag} variant="outline">{tag}</Badge>
))}
</div>
)}
</Link>
</li>
))}

View File

@@ -37,7 +37,7 @@ export default function ProjectsPage() {
<AnimatedPageTitle position={0}><span>Project I've Been</span><span> Working on</span> </AnimatedPageTitle>
<div className="pt-10" />
{projects.map((project, i) => (
<div key={i}>
<div id={project.id} key={i} className="scroll-mt-10">
<Card.AnimatedCard position={i + 1.2} key={project.id}>
<Card.CardHeader>
<div className="flex items-start justify-between gap-2 flex-wrap">

View File

@@ -8,7 +8,7 @@ export const env = createEnv({
*/
server: {
UPLOADTHING_TOKEN: z.string(),
BLOG_MDX_FOLDER: z.string().default("blog"),
BLOG_MDX_PREFIX: z.string().default("blog"),
DATABASE_URL: z.string().url(),
DATABASE_URL_UNPOOLED: z.string().url(),
@@ -30,7 +30,6 @@ export const env = createEnv({
CLERK_SECRET_KEY: z.string(),
ADMIN_USER_CLERK_ID: z.string(),
UPLOADTHING_TOKEN: z.string(),
OPENAI_API_KEY: z.string(),
NODE_ENV: z
.enum(["development", "test", "production"])
@@ -54,7 +53,7 @@ export const env = createEnv({
*/
runtimeEnv: {
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
BLOG_MDX_FOLDER: process.env.BLOG_MDX_FOLDER,
BLOG_MDX_PREFIX: process.env.BLOG_MDX_PREFIX,
DATABASE_URL: process.env.DATABASE_URL,
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL,
@@ -72,7 +71,6 @@ export const env = createEnv({
POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL,
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,

View File

@@ -2,7 +2,7 @@
// https://orm.drizzle.team/docs/sql-schema-declaration
import { relations, sql } from "drizzle-orm";
import { index, pgEnum, pgSchema, pgTableCreator } from "drizzle-orm/pg-core";
import { index, pgEnum, pgSchema, pgTableCreator, uniqueIndex } from "drizzle-orm/pg-core";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
@@ -104,6 +104,33 @@ export const music = createTable(
})
)
export const blogPost = createTable(
"blog_post",
(d) => ({
id: d.uuid().primaryKey().defaultRandom(),
slug: d.varchar({ length: 200 }).notNull(),
title: d.varchar({ length: 200 }).notNull(),
date: d.varchar({ length: 20 }),
description: d.text(),
tags: d.text().array(),
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
fileName: d.varchar("file_name", { length: 255 }).notNull(),
customId: d.varchar("custom_id", { length: 255 }).notNull(),
createdAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull()
.$type<Date>(),
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
}),
(t) => [
uniqueIndex("blog_post_slug_idx").on(t.slug),
uniqueIndex("blog_post_file_key_idx").on(t.fileKey),
uniqueIndex("blog_post_custom_id_idx").on(t.customId),
],
)
export const messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
export const chatSession = createTable(

View File

@@ -1,142 +1,360 @@
import { TRPCError } from "@trpc/server";
import { desc, eq, or } from "drizzle-orm";
import matter from "gray-matter";
import { UTApi } from "uploadthing/server";
import { UTApi, UTFile } from "uploadthing/server";
import z from "zod";
import { isAdmin } from "~/app/actions";
import { env } from "~/env.js";
import { db } from "~/server/db";
import { blogPost } from "~/server/dbschema/schema";
import { publicProcedure, router } from "~/server/trpc";
const utapi = new UTApi({ token: env.UPLOADTHING_TOKEN });
function fileToSlug(name: string, folder: string): string {
const prefix = folder.endsWith("/") ? folder : `${folder}/`;
const withoutFolder = name.startsWith(prefix) ? name.slice(prefix.length) : name;
return withoutFolder.replace(/\.mdx?$/, "");
const blogPostInput = z.object({
slug: z.string().min(1),
title: z.string().min(1),
date: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
content: z.string(),
});
type BlogPostInput = z.infer<typeof blogPostInput>;
type UploadThingFile = Awaited<ReturnType<typeof utapi.listFiles>>["files"][number];
function cleanPrefix(): string {
return env.BLOG_MDX_PREFIX.trim().replace(/^\/+|\/+$/g, "");
}
function fileUrl(key: string): string {
return `https://utfs.io/f/${key}`;
function blogFileName(slug: string): string {
const prefix = cleanPrefix();
return prefix ? `${prefix}-${slug}.mdx` : `${slug}.mdx`;
}
async function fetchMdx(key: string): Promise<string> {
const res = await fetch(fileUrl(key), { next: { revalidate: 3600 } });
function blogCustomId(slug: string): string {
const prefix = cleanPrefix();
const id = crypto.randomUUID();
return prefix ? `${prefix}:${slug}:${id}` : `${slug}:${id}`;
}
function optionalText(value: string | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function frontmatterText(value: unknown): string | null {
if (value instanceof Date) return value.toISOString().slice(0, 10);
return optionalText(typeof value === "string" ? value : undefined);
}
function normalizeTags(tags: unknown): string[] {
const values = Array.isArray(tags)
? tags
: typeof tags === "string"
? tags.split(",")
: [];
return Array.from(
new Set(
values
.map((tag) => String(tag).trim())
.filter(Boolean),
),
);
}
function createMdxContent(input: BlogPostInput): string {
const date = optionalText(input.date);
const description = optionalText(input.description);
const tags = normalizeTags(input.tags);
const frontmatter: Record<string, unknown> = { slug: input.slug, title: input.title };
if (date) frontmatter.date = date;
if (description) frontmatter.description = description;
if (tags.length > 0) frontmatter.tags = tags;
return matter.stringify(input.content, frontmatter);
}
function summaryFromInput(input: BlogPostInput) {
return {
slug: input.slug,
title: input.title,
date: optionalText(input.date),
description: optionalText(input.description),
tags: normalizeTags(input.tags),
};
}
async function assertAdmin() {
const admin = await isAdmin();
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
}
async function uploadMdx(input: BlogPostInput) {
const mdxContent = createMdxContent(input);
const customId = blogCustomId(input.slug);
const file = new UTFile([mdxContent], blogFileName(input.slug), {
customId,
type: "text/plain",
});
const result = await utapi.uploadFiles(file);
if (result.error) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
}
if (!result.data.ufsUrl) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "UploadThing did not return a file URL" });
}
return {
fileKey: result.data.key,
fileUrl: result.data.ufsUrl,
fileName: result.data.name,
customId: result.data.customId ?? customId,
};
}
async function fetchMdx(fileUrl: string): Promise<string> {
const res = await fetch(fileUrl, { next: { revalidate: 3600 } });
if (!res.ok) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch MDX file" });
return res.text();
}
async function getBlogFiles() {
const folder = env.BLOG_MDX_FOLDER;
const { files } = await utapi.listFiles();
return files.filter(
(f) => f.name.startsWith(`${folder}/`) && /\.mdx?$/.test(f.name),
);
function fileUrl(file: Pick<UploadThingFile, "key">): string {
return `https://utfs.io/f/${file.key}`;
}
function slugFromFileName(name: string): string {
const prefix = cleanPrefix();
const withoutExtension = name.replace(/\.mdx?$/, "");
if (prefix && withoutExtension.startsWith(`${prefix}-`)) return withoutExtension.slice(prefix.length + 1);
if (prefix && withoutExtension.startsWith(`${prefix}/`)) return withoutExtension.slice(prefix.length + 1);
return withoutExtension;
}
function slugFromCustomId(customId: string): string {
const prefix = cleanPrefix();
const value = prefix && customId.startsWith(`${prefix}:`)
? customId.slice(prefix.length + 1)
: customId;
return value.split(":")[0] ?? value;
}
function fileMatchesPrefix(file: Pick<UploadThingFile, "name">): boolean {
if (!/\.mdx?$/.test(file.name)) return false;
const prefix = cleanPrefix();
if (!prefix) return true;
return file.name.startsWith(`${prefix}-`) || file.name.startsWith(`${prefix}/`);
}
function metadataFromFile(file: UploadThingFile, raw: string) {
const parsed = matter(raw);
const fallbackSlug = file.customId ? slugFromCustomId(file.customId) : slugFromFileName(file.name);
return {
slug: String(parsed.data.slug ?? fallbackSlug),
title: String(parsed.data.title ?? fallbackSlug),
date: frontmatterText(parsed.data.date),
description: frontmatterText(parsed.data.description),
tags: normalizeTags(parsed.data.tags),
fileKey: file.key,
fileUrl: fileUrl(file),
fileName: file.name,
customId: file.customId ?? blogCustomId(fallbackSlug),
};
}
async function listAllFiles(): Promise<readonly UploadThingFile[]> {
const files: UploadThingFile[] = [];
let offset = 0;
const limit = 500;
let hasMore = true;
while (hasMore) {
const page = await utapi.listFiles({ limit, offset });
files.push(...page.files);
hasMore = page.hasMore;
offset += limit;
}
return files;
}
export const blogRouter = router({
insert: publicProcedure
.input(
z.object({
slug: z.string().min(1),
title: z.string().min(1),
date: z.string().optional(),
description: z.string().optional(),
content: z.string(),
}),
)
.input(blogPostInput)
.mutation(async ({ input }) => {
const folder = env.BLOG_MDX_FOLDER;
const frontmatter: Record<string, unknown> = { title: input.title };
if (input.date) frontmatter.date = input.date;
if (input.description) frontmatter.description = input.description;
const mdxContent = matter.stringify(input.content, frontmatter);
const file = new File([mdxContent], `${folder}/${input.slug}.mdx`, { type: "text/plain" });
const result = await utapi.uploadFiles(file);
if (result.error) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
return [{ slug: input.slug, title: input.title, date: input.date, description: input.description }];
await assertAdmin();
const existing = await db.query.blogPost.findFirst({
where(fields, operators) {
return operators.eq(fields.slug, input.slug);
},
});
if (existing) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
const uploaded = await uploadMdx(input);
try {
await db.insert(blogPost).values({
slug: input.slug,
title: input.title,
date: optionalText(input.date),
description: optionalText(input.description),
tags: normalizeTags(input.tags),
...uploaded,
});
return [summaryFromInput(input)];
} catch (error) {
await utapi.deleteFiles(uploaded.fileKey);
throw error;
}
}),
update: publicProcedure
.input(
z.object({
slug: z.string().min(1),
originalSlug: z.string().min(1),
title: z.string().min(1),
date: z.string().optional(),
description: z.string().optional(),
content: z.string(),
}),
)
.input(blogPostInput.extend({ originalSlug: z.string().min(1) }))
.mutation(async ({ input }) => {
const folder = env.BLOG_MDX_FOLDER;
const files = await getBlogFiles();
const old = files.find(
(f) => f.name === `${folder}/${input.originalSlug}.mdx` || f.name === `${folder}/${input.originalSlug}.md`,
);
if (old) await utapi.deleteFiles(old.key);
const frontmatter: Record<string, unknown> = { title: input.title };
if (input.date) frontmatter.date = input.date;
if (input.description) frontmatter.description = input.description;
const mdxContent = matter.stringify(input.content, frontmatter);
const file = new File([mdxContent], `${folder}/${input.slug}.mdx`, { type: "text/plain" });
const result = await utapi.uploadFiles(file);
if (result.error) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
return [{ slug: input.slug, title: input.title, date: input.date, description: input.description, content: input.content }];
await assertAdmin();
const existing = await db.query.blogPost.findFirst({
where(fields, operators) {
return operators.eq(fields.slug, input.originalSlug);
},
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.originalSlug}" not found` });
if (input.slug !== input.originalSlug) {
const slugConflict = await db.query.blogPost.findFirst({
where(fields, operators) {
return operators.eq(fields.slug, input.slug);
},
});
if (slugConflict) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
}
const uploaded = await uploadMdx(input);
try {
await db.update(blogPost).set({
slug: input.slug,
title: input.title,
date: optionalText(input.date),
description: optionalText(input.description),
tags: normalizeTags(input.tags),
...uploaded,
}).where(eq(blogPost.id, existing.id));
await utapi.deleteFiles(existing.fileKey);
return [summaryFromInput(input)];
} catch (error) {
await utapi.deleteFiles(uploaded.fileKey);
throw error;
}
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
const folder = env.BLOG_MDX_FOLDER;
const files = await getBlogFiles();
const file = files.find(
(f) => f.name === `${folder}/${input.id}.mdx` || f.name === `${folder}/${input.id}.md`,
);
if (!file) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` });
await utapi.deleteFiles(file.key);
await assertAdmin();
const post = await db.query.blogPost.findFirst({
where(fields, operators) {
return operators.eq(fields.slug, input.id);
},
});
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` });
await db.delete(blogPost).where(eq(blogPost.id, post.id));
await utapi.deleteFiles(post.fileKey);
return [];
}),
list: publicProcedure.query(async () => {
const folder = env.BLOG_MDX_FOLDER;
const files = await getBlogFiles();
const posts = await Promise.all(
files.map(async (file) => {
const raw = await fetchMdx(file.key);
const { data } = matter(raw);
return {
slug: fileToSlug(file.name, folder),
title: (data.title as string | undefined) ?? fileToSlug(file.name, folder),
date: data.date as string | undefined,
description: data.description as string | undefined,
};
}),
);
return posts.sort((a, b) => {
if (!a.date || !b.date) return 0;
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
return db.select({
slug: blogPost.slug,
title: blogPost.title,
date: blogPost.date,
description: blogPost.description,
tags: blogPost.tags,
}).from(blogPost).orderBy(desc(blogPost.date), desc(blogPost.createdAt));
}),
bySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
const folder = env.BLOG_MDX_FOLDER;
const files = await getBlogFiles();
const post = await db.query.blogPost.findFirst({
where(fields, operators) {
return operators.eq(fields.slug, slug);
},
});
const file = files.find(
(f) => f.name === `${folder}/${slug}.mdx` || f.name === `${folder}/${slug}.md`,
);
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
if (!file) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
const raw = await fetchMdx(file.key);
const raw = await fetchMdx(post.fileUrl);
const { content, data } = matter(raw);
return {
slug,
slug: post.slug,
content,
title: (data.title as string | undefined) ?? slug,
date: data.date as string | undefined,
description: data.description as string | undefined,
title: (data.title as string | undefined) ?? post.title,
date: frontmatterText(data.date) ?? post.date,
description: frontmatterText(data.description) ?? post.description,
tags: normalizeTags(data.tags).length > 0 ? normalizeTags(data.tags) : (post.tags ?? []),
};
}),
syncFromUploadThing: publicProcedure.mutation(async () => {
await assertAdmin();
const files = (await listAllFiles()).filter(fileMatchesPrefix);
const seenFileKeys = new Set<string>();
const seenSlugs = new Set<string>();
let created = 0;
let updated = 0;
let skipped = 0;
let deleted = 0;
for (const file of files) {
try {
const raw = await fetchMdx(fileUrl(file));
const metadata = metadataFromFile(file, raw);
seenFileKeys.add(file.key);
seenSlugs.add(metadata.slug);
const existing = await db.query.blogPost.findFirst({
where(fields, operators) {
return or(operators.eq(fields.fileKey, file.key), operators.eq(fields.slug, metadata.slug));
},
});
if (existing) {
await db.update(blogPost).set(metadata).where(eq(blogPost.id, existing.id));
updated += 1;
} else {
await db.insert(blogPost).values(metadata);
created += 1;
}
} catch {
skipped += 1;
}
}
const posts = await db.select({
id: blogPost.id,
fileKey: blogPost.fileKey,
slug: blogPost.slug,
}).from(blogPost);
const stalePostIds = posts
.filter((post) => !seenFileKeys.has(post.fileKey) && !seenSlugs.has(post.slug))
.map((post) => post.id);
for (const id of stalePostIds) {
await db.delete(blogPost).where(eq(blogPost.id, id));
deleted += 1;
}
return { created, updated, skipped, deleted };
}),
});

View File

@@ -144,3 +144,43 @@
.cl-button__google {
display: none
}
.mde-form-field-fullscreen {
position: fixed;
inset: 0;
z-index: 100000;
display: flex !important;
height: 100vh;
width: 100vw;
flex-direction: column;
overflow: hidden;
background: var(--background);
padding: 1rem;
}
.mde-form-field-editor-fullscreen.w-md-editor {
flex: 1 1 auto;
min-height: 0;
height: calc(100vh - 72px) !important;
}
.mde-form-field-editor-fullscreen .w-md-editor-toolbar {
flex: 0 0 auto;
}
.mde-form-field-editor-fullscreen .w-md-editor-content {
flex: 1 1 auto;
min-height: 0;
height: auto !important;
overflow: hidden;
}
.mde-form-field-editor-fullscreen .w-md-editor-input,
.mde-form-field-editor-fullscreen .w-md-editor-area,
.mde-form-field-editor-fullscreen .w-md-editor-text,
.mde-form-field-editor-fullscreen .w-md-editor-text-pre,
.mde-form-field-editor-fullscreen .w-md-editor-text-input,
.mde-form-field-editor-fullscreen .w-md-editor-preview {
min-height: 0;
height: 100%;
}