diff --git a/bun.lock b/bun.lock
index 3dd9863..6df0565 100644
--- a/bun.lock
+++ b/bun.lock
@@ -68,6 +68,7 @@
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.55.0",
+ "react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"remeda": "^2.21.2",
@@ -755,6 +756,8 @@
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+ "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
+
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="],
@@ -1043,6 +1046,8 @@
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
+ "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+
"react-medium-image-zoom": ["react-medium-image-zoom@5.2.14", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-nfTVYcAUnBzXQpPDcZL+cG/e6UceYUIG+zDcnemL7jtAqbJjVVkA85RgneGtJeni12dTyiRPZVM6Szkmwd/o8w=="],
"react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="],
diff --git a/package.json b/package.json
index 07fb90b..bac583d 100644
--- a/package.json
+++ b/package.json
@@ -83,6 +83,7 @@
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.55.0",
+ "react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"remeda": "^2.21.2",
diff --git a/src/app/(home)/admin/blog/edit/[id]/page.tsx b/src/app/(home)/admin/blog/edit/[id]/page.tsx
new file mode 100644
index 0000000..4d7fcb6
--- /dev/null
+++ b/src/app/(home)/admin/blog/edit/[id]/page.tsx
@@ -0,0 +1,220 @@
+'use client'
+
+import { MarkdownEditor } from '@/components/markdown-editor'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Textarea } from '@/components/ui/textarea'
+import { api } from '@/trpc/react'
+import { useParams } from 'next/navigation'
+import { useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+
+export default function EditBlogPostPage() {
+ const params = useParams<{
+ id: string
+ }>()
+ const id = Number.parseInt(params.id, 10)
+ const router = useRouter()
+
+ const [title, setTitle] = useState('')
+ const [content, setContent] = useState('')
+ const [excerpt, setExcerpt] = useState('')
+ const [published, setPublished] = useState(false)
+ const [updateSlug, setUpdateSlug] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isLoading, setIsLoading] = useState(true)
+
+ // Fetch post data
+ const { data: posts, isLoading: isFetching } = api.blog.getAll.useQuery()
+
+ useEffect(() => {
+ if (posts) {
+ const currentPost = posts.find((p) => p.id === id)
+ if (currentPost) {
+ setTitle(currentPost.title)
+ setContent(currentPost.content)
+ setExcerpt(currentPost.excerpt || '')
+ setPublished(currentPost.published)
+ setIsLoading(false)
+ } else {
+ toast.error('Blog post not found')
+ router.push('/admin/blog')
+ }
+ }
+ }, [posts, id, router])
+
+ // Update post mutation
+ const updatePost = api.blog.update.useMutation({
+ onSuccess: () => {
+ toast.success('Blog post updated successfully')
+ router.push('/admin/blog')
+ router.refresh()
+ },
+ onError: (error) => {
+ toast.error(`Error updating blog post: ${error.message}`)
+ setIsSubmitting(false)
+ },
+ })
+
+ // Delete post mutation
+ const deletePost = api.blog.delete.useMutation({
+ onSuccess: () => {
+ toast.success('Blog post deleted successfully')
+ router.push('/admin/blog')
+ router.refresh()
+ },
+ onError: (error) => {
+ toast.error(`Error deleting blog post: ${error.message}`)
+ },
+ })
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!title.trim()) {
+ toast.error('Title is required')
+ return
+ }
+
+ if (!content.trim()) {
+ toast.error('Content is required')
+ return
+ }
+
+ setIsSubmitting(true)
+
+ updatePost.mutate({
+ id,
+ title,
+ content,
+ excerpt: excerpt || undefined,
+ published,
+ updateSlug,
+ })
+ }
+
+ const handleDelete = () => {
+ deletePost.mutate({ id })
+ }
+
+ if (isLoading || isFetching) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
Edit Blog Post
+
+
+
+
+
+
+
+ Are you sure?
+
+ This action cannot be undone. This will permanently delete the
+ blog post.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(home)/admin/blog/new/page.tsx b/src/app/(home)/admin/blog/new/page.tsx
new file mode 100644
index 0000000..7a8d80b
--- /dev/null
+++ b/src/app/(home)/admin/blog/new/page.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { api } from '@/trpc/react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { MarkdownEditor } from '@/components/markdown-editor'
+import { toast } from 'sonner'
+
+export default function NewBlogPostPage() {
+ const router = useRouter()
+ const [title, setTitle] = useState('')
+ const [content, setContent] = useState('')
+ const [excerpt, setExcerpt] = useState('')
+ const [published, setPublished] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const createPost = api.blog.create.useMutation({
+ onSuccess: () => {
+ toast.success('Blog post created successfully')
+ router.push('/admin/blog')
+ router.refresh()
+ },
+ onError: (error) => {
+ toast.error(`Error creating blog post: ${error.message}`)
+ setIsSubmitting(false)
+ },
+ })
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!title.trim()) {
+ toast.error('Title is required')
+ return
+ }
+
+ if (!content.trim()) {
+ toast.error('Content is required')
+ return
+ }
+
+ setIsSubmitting(true)
+
+ createPost.mutate({
+ title,
+ content,
+ excerpt: excerpt || undefined,
+ published,
+ })
+ }
+
+ return (
+
+
Create New Blog Post
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/(home)/admin/blog/page.tsx b/src/app/(home)/admin/blog/page.tsx
new file mode 100644
index 0000000..cc1fada
--- /dev/null
+++ b/src/app/(home)/admin/blog/page.tsx
@@ -0,0 +1,78 @@
+import { Button } from '@/components/ui/button'
+import { formatDate } from '@/lib/utils'
+import { auth } from '@/server/auth'
+import { api } from '@/trpc/server'
+import Link from 'next/link'
+import { redirect } from 'next/navigation'
+
+export default async function AdminBlogPage() {
+ const session = await auth()
+
+ // Redirect if not authenticated or not an admin
+ if (!session?.user || session.user.role !== 'admin') {
+ redirect('/')
+ }
+
+ const posts = await api.blog.getAll()
+
+ return (
+
+
+
Manage Blog Posts
+
+
+
+ {posts.length === 0 ? (
+
+ No blog posts yet. Create your first post!
+
+ ) : (
+
+
+
+
+ | Title |
+ Author |
+ Date |
+ Status |
+ Actions |
+
+
+
+ {posts.map((post) => (
+
+ |
+
+ {post.title}
+
+ |
+ {post.author?.name || 'Anonymous'} |
+ {formatDate(post.createdAt)} |
+
+
+ {post.published ? 'Published' : 'Draft'}
+
+ |
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/src/app/(home)/blog/[slug]/page.tsx b/src/app/(home)/blog/[slug]/page.tsx
new file mode 100644
index 0000000..620c2cb
--- /dev/null
+++ b/src/app/(home)/blog/[slug]/page.tsx
@@ -0,0 +1,52 @@
+import { formatDate } from '@/lib/utils'
+import { api } from '@/trpc/server'
+import type { Metadata } from 'next'
+import { notFound } from 'next/navigation'
+import ReactMarkdown from 'react-markdown'
+
+type Props = {
+ params: Promise<{
+ slug: string
+ }>
+}
+
+export async function generateMetadata({ params }: Props): Promise {
+ try {
+ const post = await api.blog.getBySlug({ slug: (await params).slug })
+ return {
+ title: post.title,
+ description: post.excerpt || `${post.content.substring(0, 160)}...`,
+ }
+ } catch (error) {
+ return {
+ title: 'Blog Post Not Found',
+ description: 'The requested blog post could not be found.',
+ }
+ }
+}
+
+export default async function BlogPostPage({ params }: Props) {
+ try {
+ const post = await api.blog.getBySlug({ slug: (await params).slug })
+
+ return (
+
+
+ {post.title}
+
+
+
+ {post.author?.name || 'Anonymous'} • {formatDate(post.createdAt)}
+
+
+
+
+ {post.content}
+
+
+
+ )
+ } catch (error) {
+ notFound()
+ }
+}
diff --git a/src/app/(home)/blog/page.tsx b/src/app/(home)/blog/page.tsx
new file mode 100644
index 0000000..8424c2c
--- /dev/null
+++ b/src/app/(home)/blog/page.tsx
@@ -0,0 +1,49 @@
+import { formatDate } from '@/lib/utils'
+import { api } from '@/trpc/server'
+import type { Metadata } from 'next'
+import Link from 'next/link'
+
+export const metadata: Metadata = {
+ title: 'Blog',
+ description: 'Latest news and updates about Balatro Multiplayer',
+}
+
+export default async function BlogPage() {
+ const posts = await api.blog.getAllPublished()
+
+ return (
+
+
Blog
+
+ {posts.length === 0 ? (
+
+ No blog posts yet. Check back soon!
+
+ ) : (
+
+ {posts.map((post) => (
+
+
+
+ {post.title}
+
+ {post.excerpt && (
+ {post.excerpt}
+ )}
+
+
+ {post.author?.name || 'Anonymous'} •{' '}
+ {formatDate(post.createdAt)}
+
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/app/_components/header.tsx b/src/app/_components/header.tsx
index 9a8ff3a..0535e3a 100644
--- a/src/app/_components/header.tsx
+++ b/src/app/_components/header.tsx
@@ -68,6 +68,12 @@ export function Header({
+
+ Blog
+
+ Manage blog posts
+
+
Logs
@@ -201,6 +207,9 @@ export function Header({
Admin
+
+ Blog
+
Logs
@@ -216,6 +225,11 @@ export function Header({
)}
+
+
+ Blog
+
+
{menuItems.filter(isSecondary).map((item, i) => (
diff --git a/src/app/layout.config.tsx b/src/app/layout.config.tsx
index 645ea00..f67e195 100644
--- a/src/app/layout.config.tsx
+++ b/src/app/layout.config.tsx
@@ -1,6 +1,13 @@
import type { LinkItemType } from 'fumadocs-ui/layouts/links'
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'
-import { BarChart3, BookOpen, CircleDollarSign, Trophy, Upload } from 'lucide-react'
+import {
+ BarChart3,
+ BookOpen,
+ CircleDollarSign,
+ Rss,
+ Trophy,
+ Upload,
+} from 'lucide-react'
import { Header } from './_components/header'
const links = [
@@ -18,6 +25,11 @@ const links = [
text: 'Major League Balatro',
url: '/major-league-balatro',
},
+ {
+ text: 'Blog',
+ url: '/blog',
+ icon:
,
+ },
{
text: 'Support Us',
url: '/support-us',
diff --git a/src/components/header.tsx b/src/components/header.tsx
deleted file mode 100644
index 97f06b3..0000000
--- a/src/components/header.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-'use client'
-
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
-import { Button } from '@/components/ui/button'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu'
-import { LogIn, LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react'
-import { signIn, signOut, useSession } from 'next-auth/react'
-import { useTheme } from 'next-themes'
-import Link from 'next/link'
-import { useState } from 'react'
-
-export function MainHeader() {
- const { setTheme, theme } = useTheme()
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
- const { data: session, status } = useSession()
- const isAuthenticated = status === 'authenticated'
- return (
-
- )
-}
diff --git a/src/components/markdown-editor.tsx b/src/components/markdown-editor.tsx
new file mode 100644
index 0000000..503e957
--- /dev/null
+++ b/src/components/markdown-editor.tsx
@@ -0,0 +1,97 @@
+'use client'
+
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Textarea } from '@/components/ui/textarea'
+import { useEffect, useState } from 'react'
+import ReactMarkdown from 'react-markdown'
+
+interface MarkdownEditorProps {
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ minHeight?: string
+}
+
+export function MarkdownEditor({
+ value,
+ onChange,
+ placeholder = 'Write your content here...',
+ minHeight = '400px',
+}: MarkdownEditorProps) {
+ const [activeTab, setActiveTab] = useState
('write')
+ const [previewContent, setPreviewContent] = useState(value)
+
+ // Always update preview content when value changes
+ useEffect(() => {
+ setPreviewContent(value)
+ }, [value])
+
+ return (
+
+ {/* Tabs are only visible on mobile */}
+
+
+
+ Write
+ Preview
+
+
+
+
+
+
+
+ {previewContent ? (
+
{previewContent}
+ ) : (
+
Nothing to preview
+ )}
+
+
+
+
+
+ {/* Side-by-side layout for desktop */}
+
+
+
Write
+
+
+
Preview
+
+ {previewContent ? (
+
{previewContent}
+ ) : (
+
Nothing to preview
+ )}
+
+
+
+
+ )
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index bd0c391..5086f22 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -4,3 +4,12 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+export function formatDate(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date
+ return d.toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ })
+}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 65d8e24..0557393 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -1,4 +1,5 @@
import { branchesRouter } from '@/server/api/routers/branches'
+import { blogRouter } from '@/server/api/routers/blog'
import { discord_router } from '@/server/api/routers/discord'
import { history_router } from '@/server/api/routers/history'
import { leaderboard_router } from '@/server/api/routers/leaderboard'
@@ -13,6 +14,7 @@ import { createCallerFactory, createTRPCRouter } from '@/server/api/trpc'
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
+ blog: blogRouter,
branches: branchesRouter,
history: history_router,
discord: discord_router,
diff --git a/src/server/api/routers/blog.ts b/src/server/api/routers/blog.ts
new file mode 100644
index 0000000..2793b60
--- /dev/null
+++ b/src/server/api/routers/blog.ts
@@ -0,0 +1,200 @@
+import {
+ adminProcedure,
+ createTRPCRouter,
+ publicProcedure,
+} from '@/server/api/trpc'
+import { db } from '@/server/db'
+import { blogPosts } from '@/server/db/schema'
+import { TRPCError } from '@trpc/server'
+import { eq } from 'drizzle-orm'
+import { z } from 'zod'
+
+// Helper function to generate a slug from a title
+function generateSlug(title: string): string {
+ return title
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '') // Remove special characters
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
+ .replace(/-+/g, '-') // Replace multiple hyphens with a single hyphen
+ .trim()
+}
+
+export const blogRouter = createTRPCRouter({
+ // Get all published blog posts (public)
+ getAllPublished: publicProcedure.query(async () => {
+ const posts = await db.query.blogPosts.findMany({
+ where: eq(blogPosts.published, true),
+ orderBy: (blogPosts, { desc }) => [desc(blogPosts.createdAt)],
+ with: {
+ author: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ },
+ })
+ return posts
+ }),
+
+ // Get a single blog post by slug (public)
+ getBySlug: publicProcedure
+ .input(z.object({ slug: z.string() }))
+ .query(async ({ input }) => {
+ const post = await db.query.blogPosts.findFirst({
+ where: eq(blogPosts.slug, input.slug),
+ with: {
+ author: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ },
+ })
+
+ if (!post || !post.published) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Blog post not found',
+ })
+ }
+
+ return post
+ }),
+
+ // Get all blog posts (admin only)
+ getAll: adminProcedure.query(async () => {
+ const posts = await db.query.blogPosts.findMany({
+ orderBy: (blogPosts, { desc }) => [desc(blogPosts.createdAt)],
+ with: {
+ author: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ },
+ })
+ return posts
+ }),
+
+ // Create a new blog post (admin only)
+ create: adminProcedure
+ .input(
+ z.object({
+ title: z.string().min(1),
+ content: z.string().min(1),
+ excerpt: z.string().optional(),
+ published: z.boolean().default(false),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const slug = generateSlug(input.title)
+
+ // Check if slug already exists
+ const existingPost = await db.query.blogPosts.findFirst({
+ where: eq(blogPosts.slug, slug),
+ })
+
+ if (existingPost) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'A post with a similar title already exists',
+ })
+ }
+
+ const post = await db
+ .insert(blogPosts)
+ .values({
+ title: input.title,
+ slug,
+ content: input.content,
+ excerpt: input.excerpt || null,
+ published: input.published,
+ authorId: ctx.session.user.id,
+ })
+ .returning()
+
+ return post[0]
+ }),
+
+ // Update a blog post (admin only)
+ update: adminProcedure
+ .input(
+ z.object({
+ id: z.number(),
+ title: z.string().min(1),
+ content: z.string().min(1),
+ excerpt: z.string().optional(),
+ published: z.boolean(),
+ updateSlug: z.boolean().default(false),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const post = await db.query.blogPosts.findFirst({
+ where: eq(blogPosts.id, input.id),
+ })
+
+ if (!post) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Blog post not found',
+ })
+ }
+
+ let slug = post.slug
+ if (input.updateSlug) {
+ slug = generateSlug(input.title)
+
+ // Check if new slug already exists (and it's not the current post)
+ const existingPost = await db.query.blogPosts.findFirst({
+ where: eq(blogPosts.slug, slug),
+ })
+
+ if (existingPost && existingPost.id !== input.id) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'A post with a similar title already exists',
+ })
+ }
+ }
+
+ const updatedPost = await db
+ .update(blogPosts)
+ .set({
+ title: input.title,
+ slug,
+ content: input.content,
+ excerpt: input.excerpt || null,
+ published: input.published,
+ })
+ .where(eq(blogPosts.id, input.id))
+ .returning()
+
+ return updatedPost[0]
+ }),
+
+ // Delete a blog post (admin only)
+ delete: adminProcedure
+ .input(z.object({ id: z.number() }))
+ .mutation(async ({ input }) => {
+ const post = await db.query.blogPosts.findFirst({
+ where: eq(blogPosts.id, input.id),
+ })
+
+ if (!post) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Blog post not found',
+ })
+ }
+
+ await db.delete(blogPosts).where(eq(blogPosts.id, input.id))
+
+ return { success: true }
+ }),
+})
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 4198f46..0e06cd7 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -1,5 +1,6 @@
import { relations, sql } from 'drizzle-orm'
import {
+ boolean,
index,
integer,
json,
@@ -9,6 +10,7 @@ import {
text,
timestamp,
uniqueIndex,
+ varchar,
} from 'drizzle-orm/pg-core'
import type { AdapterAccount } from 'next-auth/adapters'
@@ -196,3 +198,25 @@ export const transcripts = pgTable('transcripts', {
content: text('content').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
+
+export const blogPosts = pgTable('blog_posts', {
+ id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
+ title: varchar('title', { length: 255 }).notNull(),
+ slug: varchar('slug', { length: 255 }).notNull().unique(),
+ content: text('content').notNull(),
+ excerpt: text('excerpt'),
+ published: boolean('published').notNull().default(false),
+ authorId: varchar('author_id', { length: 255 }).references(() => users.id).notNull(),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+ updatedAt: timestamp('updated_at')
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+})
+
+export const blogPostsRelations = relations(blogPosts, ({ one }) => ({
+ author: one(users, {
+ fields: [blogPosts.authorId],
+ references: [users.id],
+ }),
+}))