blog editor
This commit is contained in:
58
src/app/admin/blog/_components/BlogMdxEditorPreview.tsx
Normal file
58
src/app/admin/blog/_components/BlogMdxEditorPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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: ``,
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
91
src/app/admin/blog/_components/MdxComponentReference.tsx
Normal file
91
src/app/admin/blog/_components/MdxComponentReference.tsx
Normal 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"><</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user