This commit is contained in:
2026-04-23 11:08:45 +02:00
parent c527391259
commit daab745c13
13 changed files with 504 additions and 25 deletions

View File

@@ -25,7 +25,8 @@ export default function AdminSideBar() {
<Link href={"/admin/music"}> Manage Music </Link>
</SimpleSidebarGroup>
<SimpleSidebarGroup lable="Blog">
<Link href={"/"}> Some Blog Action </Link>
<Link href={"/admin/blog/create"}> Create Post </Link>
<Link href={"/admin/blog/list"}> Post List </Link>
</SimpleSidebarGroup>
<SimpleSidebarGroup lable="Chat">
<Link href={"/admin/chat"}> System Prompt </Link>

View File

@@ -0,0 +1,11 @@
'use client'
import { trpc } from '~/app/_trpc/Client'
import { useParams } from 'next/navigation'
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
export default function Page() {
const { slug } = useParams<{ slug: string }>()
const { data } = trpc.blog.bySlug.useQuery(slug)
if (data) return <CreateUpdateBlogForm entity={data} />
return <></>
}

View File

@@ -0,0 +1,93 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { trpc } from '~/app/_trpc/Client'
import { FormScaffold } from '~/app/_components/Form/Components'
import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider'
import { useState } from 'react'
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'
type BlogPost = RouterOutputs['blog']['bySlug']
const blogPostSchema = z.object({
slug: z.string().min(1),
title: z.string().min(1),
date: z.string().optional(),
description: z.string().optional(),
content: z.string(),
})
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)
const { theme } = useTheme()
const form = useForm<z.infer<typeof blogPostSchema>>({
resolver: zodResolver(blogPostSchema),
defaultValues: {
slug: params.entity?.slug ?? '',
title: params.entity?.title ?? '',
date: params.entity?.date ?? '',
description: params.entity?.description ?? '',
content: params.entity?.content ?? '',
},
})
const path = usePathname()
const router = useRouter()
const createMutation = trpc.blog.insert.useMutation({
onSuccess: (data) => {
if (data[0]) {
setSlug(data[0].slug)
setOriginalSlug(data[0].slug)
}
},
})
const updateMutation = trpc.blog.update.useMutation({
onSuccess: (data) => {
if (data[0]) {
setSlug(data[0].slug)
setOriginalSlug(data[0].slug)
}
},
})
const deleteMutation = trpc.blog.delete.useMutation({
onSuccess: () => {
if (path.includes('list')) { router.refresh(); return }
router.back()
},
})
function onSubmit(values: z.infer<typeof blogPostSchema>) {
if (slug && originalSlug) {
updateMutation.mutate({ ...values, originalSlug })
} else {
createMutation.mutate(values)
}
}
return (
<FormMutationContextProvider value={{
createMutation: createMutation,
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>
</FormMutationContextProvider>
)
}

View File

@@ -0,0 +1,6 @@
'use client'
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
export default function Page() {
return <CreateUpdateBlogForm />
}

View File

@@ -0,0 +1,40 @@
'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'
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 })
return (
<div ref={container} className='w-5/6 lg:w-1/2 flex flex-col gap-3'>
{posts.data == undefined ?
<div className='gsapan' /> :
<>
{posts.data.map((post) => (
<Card.Card className='gsapan' key={post.slug}>
<Link href={`/admin/blog/${post.slug}`}>
<Card.CardHeader>
<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>}
</Card.CardHeader>
</Link>
</Card.Card>
))}
<CollapsibleForm entityName='Blog Post' form={CreateUpdateBlogForm} />
</>
}
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { TRPCError } from "@trpc/server";
import { servTrpc } from "~/app/_trpc/ServerClient";
type Props = {
params: Promise<{ slug: string }>;
};
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
let post: Awaited<ReturnType<typeof servTrpc.blog.bySlug>>;
try {
post = await servTrpc.blog.bySlug(slug);
} catch (e) {
if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound();
throw e;
}
return (
<main className="mx-auto max-w-2xl px-4 py-12">
<header className="mb-8">
<h1 className="text-3xl font-bold">{post.title}</h1>
{post.date && (
<time className="text-muted-foreground text-sm">
{new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
)}
</header>
<article className="prose dark:prose-invert max-w-none">
<MDXRemote source={post.content} />
</article>
</main>
);
}

View File

@@ -1,10 +1,3 @@
'use client'
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode}>) {
return (
<>
{children}
</>
)
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -1,12 +1,37 @@
'use client'
import Link from "next/link";
import { servTrpc } from "~/app/_trpc/ServerClient";
import { usePathname } from "next/navigation"
export default async function BlogPage() {
const posts = await servTrpc.blog.list();
export default function Page() {
const pathName = usePathname()
return (
<div>
{pathName}
</div>
)
<main className="mx-auto max-w-2xl px-4 py-12">
<h1 className="mb-8 text-3xl font-bold">Blog</h1>
{posts.length === 0 ? (
<p className="text-muted-foreground">No posts yet.</p>
) : (
<ul className="space-y-6">
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`} className="group block">
<h2 className="text-xl font-semibold group-hover:underline">{post.title}</h2>
{post.date && (
<time className="text-muted-foreground text-sm">
{new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
)}
{post.description && (
<p className="text-muted-foreground mt-1">{post.description}</p>
)}
</Link>
</li>
))}
</ul>
)}
</main>
);
}

View File

@@ -7,6 +7,9 @@ export const env = createEnv({
* isn't built with invalid env vars.
*/
server: {
UPLOADTHING_TOKEN: z.string(),
BLOG_MDX_FOLDER: z.string().default("blog"),
DATABASE_URL: z.string().url(),
DATABASE_URL_UNPOOLED: z.string().url(),
@@ -50,6 +53,9 @@ export const env = createEnv({
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
BLOG_MDX_FOLDER: process.env.BLOG_MDX_FOLDER,
DATABASE_URL: process.env.DATABASE_URL,
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL,
PGHOST: process.env.PGHOST,

View File

@@ -1,6 +1,7 @@
import type { inferRouterOutputs } from "@trpc/server";
import { router } from "../trpc";
import type { inferReactQueryProcedureOptions } from "@trpc/react-query";
import { blogRouter } from "./blog";
import { projectRouter } from "./project";
import { techStackRouter } from "./techStack";
import { cvCategoryRouter } from "./cvCategory";
@@ -11,6 +12,7 @@ import { cvCategory } from "../dbschema/schema";
import { chatRouter } from "./chat";
export const trpcRouter = router({
blog: blogRouter,
project: trpcCrudRouterFromDrizzleEntity('project').router,
projectv2: projectRouter,
techStack: trpcCrudRouterFromDrizzleEntity('techStack').router,

142
src/server/routers/blog.ts Normal file
View File

@@ -0,0 +1,142 @@
import { TRPCError } from "@trpc/server";
import matter from "gray-matter";
import { UTApi } from "uploadthing/server";
import z from "zod";
import { env } from "~/env.js";
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?$/, "");
}
function fileUrl(key: string): string {
return `https://utfs.io/f/${key}`;
}
async function fetchMdx(key: string): Promise<string> {
const res = await fetch(fileUrl(key), { 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),
);
}
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(),
}),
)
.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 }];
}),
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(),
}),
)
.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 }];
}),
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);
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();
});
}),
bySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
const folder = env.BLOG_MDX_FOLDER;
const files = await getBlogFiles();
const file = files.find(
(f) => f.name === `${folder}/${slug}.mdx` || f.name === `${folder}/${slug}.md`,
);
if (!file) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
const raw = await fetchMdx(file.key);
const { content, data } = matter(raw);
return {
slug,
content,
title: (data.title as string | undefined) ?? slug,
date: data.date as string | undefined,
description: data.description as string | undefined,
};
}),
});