diff --git a/src/app/_components/Form/Components/FormScaffold.tsx b/src/app/_components/Form/Components/FormScaffold.tsx index 4a3fdd2..e4dbba4 100644 --- a/src/app/_components/Form/Components/FormScaffold.tsx +++ b/src/app/_components/Form/Components/FormScaffold.tsx @@ -15,7 +15,7 @@ export default function FormScaffold(params: { }) { const { form, onSubmit, title, id, className, children } = params return ( - + diff --git a/src/app/admin/_components/MdxComponentReference.tsx b/src/app/admin/_components/MdxComponentReference.tsx new file mode 100644 index 0000000..e44f65a --- /dev/null +++ b/src/app/admin/_components/MdxComponentReference.tsx @@ -0,0 +1,98 @@ +import { ChevronsUpDown } from "lucide-react"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible"; + +const examples = [ + { + name: "Lead", + description: "Intro paragraph with larger muted text.", + code: ` +Short opening summary. +`, + }, + { + name: "Callout", + description: "Highlighted note, tip, or warning block.", + code: ` +Important context for readers. + + + +A practical recommendation. + + + +A caveat or tradeoff. +`, + }, + { + name: "ButtonLink", + description: "Button-styled internal or external link.", + code: ` +View projects + + + +External resource +`, + }, + { + name: "Figure", + description: "Image with optional caption.", + code: `
`, + }, + { + name: "PullQuote", + description: "Large emphasized quote or takeaway.", + code: ` +A highlighted quote or strong takeaway. +`, + }, + { + name: "TagList", + description: "Inline list of tag badges.", + code: ``, + }, + { + name: "Badge", + description: "Small inline label.", + code: `Next.js`, + }, +]; + +export default function MdxComponentReference() { + return ( + + +

MDX Components

+ +
+ +

+ Components available inside MDX content. Type [[ for internal links or < for component snippets. +

+ + {examples.map((example) => ( + + + + {example.name} + {example.description} + + + +
+                  {example.code}
+                
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/app/admin/blog/_components/BlogMdxEditorPreview.tsx b/src/app/admin/_components/MdxEditorPreview.tsx similarity index 95% rename from src/app/admin/blog/_components/BlogMdxEditorPreview.tsx rename to src/app/admin/_components/MdxEditorPreview.tsx index aec9627..3a0fe51 100644 --- a/src/app/admin/blog/_components/BlogMdxEditorPreview.tsx +++ b/src/app/admin/_components/MdxEditorPreview.tsx @@ -6,7 +6,7 @@ import { serialize } from 'next-mdx-remote/serialize' import type { MDXRemoteSerializeResult } from 'next-mdx-remote' import { mdxComponents } from '~/components/mdx-components' -export default function BlogMdxEditorPreview(params: { source: string }) { +export default function MdxEditorPreview(params: { source: string }) { const [compiled, setCompiled] = useState(null) const [error, setError] = useState(null) diff --git a/src/app/admin/_components/useMdxEditorFieldProps.tsx b/src/app/admin/_components/useMdxEditorFieldProps.tsx new file mode 100644 index 0000000..b479d15 --- /dev/null +++ b/src/app/admin/_components/useMdxEditorFieldProps.tsx @@ -0,0 +1,143 @@ +'use client' + +import { trpc } from '~/app/_trpc/Client' +import type { RouterOutputs } from '~/server/routers/_app' +import { + AUTOCOMPLETE_CURSOR_MARKER, + linkSuggestionsToAutocomplete, + type AutocompleteTriggerConfig, + type InternalLinkSuggestion, + type MdeAutocompleteSuggestion, +} from '~/app/_components/Form/Fields/InternalLinkTextarea' +import MdxEditorPreview from './MdxEditorPreview' + +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 mdxAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [ + { + label: 'Lead', + value: `\n${AUTOCOMPLETE_CURSOR_MARKER}\n`, + detail: 'Intro paragraph with larger muted text.', + group: 'Component', + trigger: '<', + }, + { + label: 'Callout note', + value: `\n${AUTOCOMPLETE_CURSOR_MARKER}\n`, + detail: 'Highlighted note block.', + group: 'Component', + trigger: '<', + }, + { + label: 'Callout tip', + value: `\n${AUTOCOMPLETE_CURSOR_MARKER}\n`, + detail: 'Highlighted tip block.', + group: 'Component', + trigger: '<', + }, + { + label: 'Callout warning', + value: `\n${AUTOCOMPLETE_CURSOR_MARKER}\n`, + detail: 'Highlighted warning block.', + group: 'Component', + trigger: '<', + }, + { + label: 'ButtonLink', + value: `\nView projects\n`, + detail: 'Button-styled internal or external link.', + group: 'Component', + trigger: '<', + }, + { + label: 'Figure', + value: ``, + detail: 'Image with optional caption.', + group: 'Component', + trigger: '<', + }, + { + label: 'PullQuote', + value: `\n${AUTOCOMPLETE_CURSOR_MARKER}\n`, + detail: 'Large emphasized quote.', + group: 'Component', + trigger: '<', + }, + { + label: 'TagList', + value: ``, + detail: 'Inline list of tag badges.', + group: 'Component', + trigger: '<', + }, + { + label: 'Badge', + value: `${AUTOCOMPLETE_CURSOR_MARKER}`, + detail: 'Small inline label.', + group: 'Component', + trigger: '<', + }, + { + label: 'Image', + value: `![Image](${AUTOCOMPLETE_CURSOR_MARKER})`, + detail: 'Markdown image', + group: 'Markdown', + trigger: '!', + }, +] + +const mdxTriggerConfigs: 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), + }, +] + +/** + * Shared props for an MDX-aware `MdeFormField`: internal-link + component + * autocomplete, trigger configs, and a live MDX preview. Used by every admin + * form that edits MDX content (blog, project, cv entry). + */ +export function useMdxEditorFieldProps() { + 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 })), + ...mdxAutocompleteSuggestions, + ] + + return { + autocompleteSuggestions, + triggerConfigs: mdxTriggerConfigs, + renderPreview: (source: string) => , + } +} diff --git a/src/app/admin/blog/_components/CreateUpdateForm.tsx b/src/app/admin/blog/_components/CreateUpdateForm.tsx index 0514e08..65791b0 100644 --- a/src/app/admin/blog/_components/CreateUpdateForm.tsx +++ b/src/app/admin/blog/_components/CreateUpdateForm.tsx @@ -10,15 +10,8 @@ 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' +import MdxComponentReference from '~/app/admin/_components/MdxComponentReference' +import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps' type BlogPost = RouterOutputs['blog']['bySlug'] @@ -35,116 +28,6 @@ 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: `\n${AUTOCOMPLETE_CURSOR_MARKER}\n`, - detail: 'Intro paragraph with larger muted text.', - group: 'Component', - trigger: '<', - }, - { - label: 'Callout note', - value: `\n${AUTOCOMPLETE_CURSOR_MARKER}\n`, - detail: 'Highlighted note block.', - group: 'Component', - trigger: '<', - }, - { - label: 'Callout tip', - value: `\n${AUTOCOMPLETE_CURSOR_MARKER}\n`, - detail: 'Highlighted tip block.', - group: 'Component', - trigger: '<', - }, - { - label: 'Callout warning', - value: `\n${AUTOCOMPLETE_CURSOR_MARKER}\n`, - detail: 'Highlighted warning block.', - group: 'Component', - trigger: '<', - }, - { - label: 'ButtonLink', - value: `\nView projects\n`, - detail: 'Button-styled internal or external link.', - group: 'Component', - trigger: '<', - }, - { - label: 'Figure', - value: ``, - detail: 'Image with optional caption.', - group: 'Component', - trigger: '<', - }, - { - label: 'PullQuote', - value: `\n${AUTOCOMPLETE_CURSOR_MARKER}\n`, - detail: 'Large emphasized quote.', - group: 'Component', - trigger: '<', - }, - { - label: 'TagList', - value: ``, - detail: 'Inline list of tag badges.', - group: 'Component', - trigger: '<', - }, - { - label: 'Badge', - value: `${AUTOCOMPLETE_CURSOR_MARKER}`, - 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(params.entity?.slug) const [originalSlug, setOriginalSlug] = useState(params.entity?.slug) @@ -162,12 +45,7 @@ export default function CreateUpdateBlogForm(params: { className?: string, entit }) 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 mdxEditorProps = useMdxEditorFieldProps() const createMutation = trpc.blog.insert.useMutation({ onSuccess: (data) => { @@ -226,9 +104,7 @@ export default function CreateUpdateBlogForm(params: { className?: string, entit name='content' label='Content' dataColorMode={(theme as 'dark' | 'light') ?? 'dark'} - autocompleteSuggestions={autocompleteSuggestions} - triggerConfigs={blogTriggerConfigs} - renderPreview={(source) => } + {...mdxEditorProps} /> diff --git a/src/app/admin/blog/_components/MdxComponentReference.tsx b/src/app/admin/blog/_components/MdxComponentReference.tsx deleted file mode 100644 index a0ac668..0000000 --- a/src/app/admin/blog/_components/MdxComponentReference.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion"; - -const examples = [ - { - name: "Lead", - description: "Intro paragraph with larger muted text.", - code: ` -Short opening summary for the post. -`, - }, - { - name: "Callout", - description: "Highlighted note, tip, or warning block.", - code: ` -Important context for readers. - - - -A practical recommendation. - - - -A caveat or tradeoff. -`, - }, - { - name: "ButtonLink", - description: "Button-styled internal or external link.", - code: ` -View projects - - - -External resource -`, - }, - { - name: "Figure", - description: "Image with optional caption.", - code: `
`, - }, - { - name: "PullQuote", - description: "Large emphasized quote or takeaway.", - code: ` -A highlighted quote or strong takeaway. -`, - }, - { - name: "TagList", - description: "Inline list of tag badges inside the post body.", - code: ``, - }, - { - name: "Badge", - description: "Small inline label.", - code: `Next.js`, - }, -]; - -export default function MdxComponentReference() { - return ( -
-

MDX Components

-

- Components available inside blog post content. Type [[ for internal links or < for component snippets. -

- - {examples.map((example) => ( - - - - {example.name} - {example.description} - - - -
-                {example.code}
-              
-
-
- ))} -
-
- ); -} diff --git a/src/app/admin/cv/entry/_components/CreateUpdateForm.tsx b/src/app/admin/cv/entry/_components/CreateUpdateForm.tsx index a83caa7..43e7617 100644 --- a/src/app/admin/cv/entry/_components/CreateUpdateForm.tsx +++ b/src/app/admin/cv/entry/_components/CreateUpdateForm.tsx @@ -14,6 +14,8 @@ import { useState } from 'react'; import { SelectFormField, TextInputFormField, MdeFormField, CalenderFormField, BooleanFormField } from '~/app/_components/Form/Fields' import { usePathname, useRouter } from 'next/navigation'; import {FormMutationContextProvider, type FormCreateMutationInterface} from '~/app/_components/Form/Components/MutationProvider'; +import MdxComponentReference from '~/app/admin/_components/MdxComponentReference'; +import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps'; export default function CreateUpdateCvEntryForm(params: { className?: string, entity?: IterableElement, isUpdate?: boolean }) { const [id, setId] = useState(params.entity ? params.entity.id : undefined) const { theme } = useTheme() @@ -34,6 +36,7 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en }) let path = usePathname() let router = useRouter() + const mdxEditorProps = useMdxEditorFieldProps() const createMutation = trpc.entry.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) }) const updateMutation = trpc.entry.update.useMutation({ onSuccess: makeOnSuccess('update', form) }) const deleteMutation = trpc.entry.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) }) @@ -51,6 +54,8 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en updateMutation:updateMutation, deleteMutation:deleteMutation }}> +
+ - + +
) } diff --git a/src/app/admin/project/_components/CreateUpdateProjectForm.tsx b/src/app/admin/project/_components/CreateUpdateProjectForm.tsx index b80e678..ee5fb93 100644 --- a/src/app/admin/project/_components/CreateUpdateProjectForm.tsx +++ b/src/app/admin/project/_components/CreateUpdateProjectForm.tsx @@ -14,6 +14,8 @@ import { usePathname, useRouter } from 'next/navigation'; import { useTheme } from 'next-themes'; import { makeUseRelationShipWithNameIndex } from '~/lib/hooks'; import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider'; +import MdxComponentReference from '~/app/admin/_components/MdxComponentReference'; +import { useMdxEditorFieldProps } from '~/app/admin/_components/useMdxEditorFieldProps'; export default function CreateUpdateProjectForm(params: { className?: string, entity?: IterableElement }) { const [id, setId] = useState(params.entity ? params.entity.id : undefined) const { theme } = useTheme() @@ -35,6 +37,7 @@ export default function CreateUpdateProjectForm(params: { className?: string, en }) let path = usePathname() let router = useRouter() + const mdxEditorProps = useMdxEditorFieldProps() const createMutation = trpc.project.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) }) const updateMutation = trpc.project.update.useMutation({ onSuccess: makeOnSuccess('update', form) }) const deleteMutation = trpc.project.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) }) @@ -50,6 +53,8 @@ export default function CreateUpdateProjectForm(params: { className?: string, en updateMutation: updateMutation, deleteMutation: deleteMutation }}> +
+ - + open closed @@ -82,6 +87,7 @@ export default function CreateUpdateProjectForm(params: { className?: string, en +
) }