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

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>