blog editor
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
224
src/app/_components/Form/Fields/InternalLinkTextarea.tsx
Normal file
224
src/app/_components/Form/Fields/InternalLinkTextarea.tsx
Normal 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'
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
128
src/app/blog/_components/mdx-components.tsx
Normal file
128
src/app/blog/_components/mdx-components.tsx
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user