slop
This commit is contained in:
@@ -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>
|
||||
|
||||
11
src/app/admin/blog/[slug]/page.tsx
Normal file
11
src/app/admin/blog/[slug]/page.tsx
Normal 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 <></>
|
||||
}
|
||||
93
src/app/admin/blog/_components/CreateUpdateForm.tsx
Normal file
93
src/app/admin/blog/_components/CreateUpdateForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
src/app/admin/blog/create/page.tsx
Normal file
6
src/app/admin/blog/create/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
'use client'
|
||||
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||
|
||||
export default function Page() {
|
||||
return <CreateUpdateBlogForm />
|
||||
}
|
||||
40
src/app/admin/blog/list/page.tsx
Normal file
40
src/app/admin/blog/list/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/app/blog/[slug]/page.tsx
Normal file
40
src/app/blog/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
142
src/server/routers/blog.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user