add blog management system with admin tools

This commit is contained in:
2025-07-11 14:02:58 +02:00
parent b10493629c
commit 5b1827809c
15 changed files with 885 additions and 201 deletions

View File

@@ -68,6 +68,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"remeda": "^2.21.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=="], "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=="], "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=="], "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-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-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=="], "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=="],

View File

@@ -83,6 +83,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"remeda": "^2.21.2", "remeda": "^2.21.2",

View File

@@ -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 (
<div className='container py-10'>
<h1 className='mb-8 font-bold text-4xl'>Edit Blog Post</h1>
<div className='flex items-center justify-center p-8'>
<p>Loading...</p>
</div>
</div>
)
}
return (
<div className='container py-10'>
<div className='mb-8 flex items-center justify-between'>
<h1 className='font-bold text-4xl'>Edit Blog Post</h1>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant='destructive'>Delete Post</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
blog post.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<form onSubmit={handleSubmit} className='space-y-8'>
<div className='space-y-2'>
<Label htmlFor='title'>Title</Label>
<Input
id='title'
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder='Enter post title'
required
/>
</div>
<div className='space-y-2'>
<Label htmlFor='excerpt'>Excerpt (optional)</Label>
<Textarea
id='excerpt'
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
placeholder='Brief summary of the post'
className='h-24'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='content'>Content</Label>
<MarkdownEditor
value={content}
onChange={setContent}
placeholder='Write your blog post content here...'
minHeight='500px'
/>
</div>
<div className='flex flex-col gap-4'>
<div className='flex items-center space-x-2'>
<Switch
id='published'
checked={published}
onCheckedChange={setPublished}
/>
<Label htmlFor='published'>Published</Label>
</div>
<div className='flex items-center space-x-2'>
<Switch
id='updateSlug'
checked={updateSlug}
onCheckedChange={setUpdateSlug}
/>
<Label htmlFor='updateSlug'>Update URL slug from title</Label>
</div>
</div>
<div className='flex gap-4'>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
<Button
type='button'
variant='outline'
onClick={() => router.push('/admin/blog')}
>
Cancel
</Button>
</div>
</form>
</div>
)
}

View File

@@ -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 (
<div className="container py-10">
<h1 className="mb-8 text-4xl font-bold">Create New Blog Post</h1>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter post title"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="excerpt">Excerpt (optional)</Label>
<Textarea
id="excerpt"
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
placeholder="Brief summary of the post"
className="h-24"
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">Content</Label>
<MarkdownEditor
value={content}
onChange={setContent}
placeholder="Write your blog post content here..."
minHeight="500px"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="published"
checked={published}
onCheckedChange={setPublished}
/>
<Label htmlFor="published">Publish immediately</Label>
</div>
<div className="flex gap-4">
<Button
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? 'Creating...' : 'Create Post'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push('/admin/blog')}
>
Cancel
</Button>
</div>
</form>
</div>
)
}

View File

@@ -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 (
<div className='container py-10'>
<div className='mb-8 flex items-center justify-between'>
<h1 className='font-bold text-4xl'>Manage Blog Posts</h1>
<Button asChild>
<Link href='/admin/blog/new'>Create New Post</Link>
</Button>
</div>
{posts.length === 0 ? (
<p className='text-muted-foreground'>
No blog posts yet. Create your first post!
</p>
) : (
<div className='rounded-md border'>
<table className='w-full'>
<thead>
<tr className='border-b bg-muted/50'>
<th className='p-4 text-left font-medium'>Title</th>
<th className='p-4 text-left font-medium'>Author</th>
<th className='p-4 text-left font-medium'>Date</th>
<th className='p-4 text-left font-medium'>Status</th>
<th className='p-4 text-left font-medium'>Actions</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id} className='border-b'>
<td className='p-4'>
<Link
href={`/blog/${post.slug}`}
className='font-medium hover:underline'
>
{post.title}
</Link>
</td>
<td className='p-4'>{post.author?.name || 'Anonymous'}</td>
<td className='p-4'>{formatDate(post.createdAt)}</td>
<td className='p-4'>
<span
className={`inline-flex rounded-full px-2 py-1 font-medium text-xs ${post.published ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}
>
{post.published ? 'Published' : 'Draft'}
</span>
</td>
<td className='p-4'>
<div className='flex gap-2'>
<Button variant='outline' size='sm' asChild>
<Link href={`/admin/blog/edit/${post.id}`}>Edit</Link>
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -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<Metadata> {
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 (
<div className='container py-10'>
<article className='prose prose-lg dark:prose-invert mx-auto'>
<h1>{post.title}</h1>
<div className='flex items-center gap-2 text-muted-foreground text-sm'>
<span>
{post.author?.name || 'Anonymous'} {formatDate(post.createdAt)}
</span>
</div>
<div className='mt-8'>
<ReactMarkdown>{post.content}</ReactMarkdown>
</div>
</article>
</div>
)
} catch (error) {
notFound()
}
}

View File

@@ -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 (
<div className='container py-10'>
<h1 className='mb-8 font-bold text-4xl'>Blog</h1>
{posts.length === 0 ? (
<p className='text-muted-foreground'>
No blog posts yet. Check back soon!
</p>
) : (
<div className='grid gap-8 md:grid-cols-2 lg:grid-cols-3'>
{posts.map((post) => (
<article
key={post.id}
className='group flex flex-col rounded-lg border p-4 transition-colors hover:bg-muted/50'
>
<Link href={`/blog/${post.slug}`} className='flex-1'>
<h2 className='mb-2 font-semibold text-2xl group-hover:underline'>
{post.title}
</h2>
{post.excerpt && (
<p className='mb-4 text-muted-foreground'>{post.excerpt}</p>
)}
<div className='mt-auto flex items-center gap-2 text-muted-foreground text-sm'>
<span>
{post.author?.name || 'Anonymous'} {' '}
{formatDate(post.createdAt)}
</span>
</div>
</Link>
</article>
))}
</div>
)}
</div>
)
}

View File

@@ -68,6 +68,12 @@ export function Header({
</div> </div>
</NavbarMenuTrigger> </NavbarMenuTrigger>
<NavbarMenuContent> <NavbarMenuContent>
<NavbarMenuLink href='/admin/blog'>
<p className='-mb-1 font-medium text-sm'>Blog</p>
<p className='text-[13px] text-fd-muted-foreground'>
Manage blog posts
</p>
</NavbarMenuLink>
<NavbarMenuLink href='/admin/logs'> <NavbarMenuLink href='/admin/logs'>
<p className='-mb-1 font-medium text-sm'>Logs</p> <p className='-mb-1 font-medium text-sm'>Logs</p>
<p className='text-[13px] text-fd-muted-foreground'> <p className='text-[13px] text-fd-muted-foreground'>
@@ -201,6 +207,9 @@ export function Header({
<span>Admin</span> <span>Admin</span>
</div> </div>
<div className='ml-4 flex flex-col gap-1'> <div className='ml-4 flex flex-col gap-1'>
<Link href='/admin/blog' className='px-3 py-1 text-sm'>
Blog
</Link>
<Link href='/admin/logs' className='px-3 py-1 text-sm'> <Link href='/admin/logs' className='px-3 py-1 text-sm'>
Logs Logs
</Link> </Link>
@@ -216,6 +225,11 @@ export function Header({
</div> </div>
</div> </div>
)} )}
<div className='sm:hidden'>
<Link href='/blog' className='px-3 py-2 font-medium text-sm'>
Blog
</Link>
</div>
<div className='-ms-1.5 flex flex-row items-center gap-1.5 max-sm:mt-2'> <div className='-ms-1.5 flex flex-row items-center gap-1.5 max-sm:mt-2'>
{menuItems.filter(isSecondary).map((item, i) => ( {menuItems.filter(isSecondary).map((item, i) => (
<MenuLinkItem key={i} item={item} className='-me-1.5' /> <MenuLinkItem key={i} item={item} className='-me-1.5' />

View File

@@ -1,6 +1,13 @@
import type { LinkItemType } from 'fumadocs-ui/layouts/links' import type { LinkItemType } from 'fumadocs-ui/layouts/links'
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared' 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' import { Header } from './_components/header'
const links = [ const links = [
@@ -18,6 +25,11 @@ const links = [
text: 'Major League Balatro', text: 'Major League Balatro',
url: '/major-league-balatro', url: '/major-league-balatro',
}, },
{
text: 'Blog',
url: '/blog',
icon: <Rss />,
},
{ {
text: 'Support Us', text: 'Support Us',
url: '/support-us', url: '/support-us',

View File

@@ -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 (
<header className='sticky top-0 z-40 border-gray-200 border-b bg-white dark:border-zinc-800 dark:bg-zinc-900'>
<div className='container mx-auto px-4'>
<div className='flex h-16 items-center justify-between'>
{/* Logo and Brand */}
<div className='flex items-center'>
<Link href='/' className='flex items-center gap-2'>
<span className='hidden font-bold text-xl sm:inline-block'>
Balatro Multiplayer
</span>
</Link>
</div>
{/* Desktop Navigation */}
<nav className='hidden items-center space-x-6 md:flex'>
<Link
href='/'
className='font-medium text-gray-700 text-sm transition-colors hover:text-violet-500 dark:text-zinc-300 dark:hover:text-violet-400'
>
Home
</Link>
<Link
href='/leaderboards'
className='font-medium text-gray-700 text-sm transition-colors hover:text-violet-500 dark:text-zinc-300 dark:hover:text-violet-400'
>
Leaderboards
</Link>
{/*<Link*/}
{/* href='/matches'*/}
{/* className='font-medium text-gray-700 text-sm transition-colors hover:text-violet-500 dark:text-zinc-300 dark:hover:text-violet-400'*/}
{/*>*/}
{/* Matches*/}
{/*</Link>*/}
<Link
href='/about'
className='font-medium text-gray-700 text-sm transition-colors hover:text-violet-500 dark:text-zinc-300 dark:hover:text-violet-400'
>
About
</Link>
</nav>
{/* Actions: Theme Toggle, Sign In/User Menu */}
<div className='flex items-center gap-2'>
{/* Theme Toggle */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='icon' className='h-9 w-9'>
<Sun className='dark:-rotate-90 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:scale-0' />
<Moon className='absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' />
<span className='sr-only'>Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Sign In Button or User Menu */}
{isAuthenticated && session?.user && session.user.name ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='relative h-9 w-9 rounded-full'
>
<Avatar className='h-9 w-9'>
<AvatarImage
src={session.user.image ?? ''}
alt={session.user.name}
/>
<AvatarFallback className='bg-violet-50 text-violet-600 dark:bg-violet-900/50 dark:text-violet-300'>
{session.user.name?.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' forceMount>
<div className='flex items-center justify-start gap-2 p-2'>
<div className='flex flex-col space-y-1 leading-none'>
<p className='font-medium'>{session.user.name}</p>
</div>
</div>
<DropdownMenuItem asChild>
<Link
href={`/players/${session.user.discord_id}`}
className='flex w-full items-center'
>
<User className='mr-2 h-4 w-4' />
<span>Profile</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href='/settings' className='flex w-full items-center'>
<Settings className='mr-2 h-4 w-4' />
<span>Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => signOut()}>
<LogOut className='mr-2 h-4 w-4' />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant='default'
size='sm'
className='bg-violet-600 hover:bg-violet-700 dark:text-zinc-100'
onClick={() => signIn('discord')}
>
<LogIn className='mr-2 h-4 w-4' />
Sign In
</Button>
)}
{/* Mobile Menu Toggle */}
<Button
variant='ghost'
size='icon'
className='h-9 w-9 md:hidden'
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? (
<X className='h-5 w-5' />
) : (
<Menu className='h-5 w-5' />
)}
<span className='sr-only'>Toggle menu</span>
</Button>
</div>
</div>
</div>
{/* Mobile Navigation */}
{mobileMenuOpen && (
<div className='border-gray-200 border-t px-4 py-4 md:hidden dark:border-zinc-800'>
<nav className='flex flex-col space-y-4'>
<Link
href='/'
className='font-medium text-gray-700 text-sm transition-colors hover:text-violet-500 dark:text-zinc-300 dark:hover:text-violet-400'
onClick={() => setMobileMenuOpen(false)}
>
Home
</Link>
<Link
href='/leaderboards'
className='font-medium text-gray-700 text-sm transition-colors hover:text-violet-500 dark:text-zinc-300 dark:hover:text-violet-400'
onClick={() => setMobileMenuOpen(false)}
>
Leaderboards
</Link>
{/*<Link*/}
{/* href='/matches'*/}
{/* className='font-medium text-gray-700 text-sm transition-colors hover:text-violet-500 dark:text-zinc-300 dark:hover:text-violet-400'*/}
{/* onClick={() => setMobileMenuOpen(false)}*/}
{/*>*/}
{/* Matches*/}
{/*</Link>*/}
<Link
href='/about'
className='font-medium text-gray-700 text-sm transition-colors hover:text-violet-500 dark:text-zinc-300 dark:hover:text-violet-400'
onClick={() => setMobileMenuOpen(false)}
>
About
</Link>
</nav>
</div>
)}
</header>
)
}

View File

@@ -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<string>('write')
const [previewContent, setPreviewContent] = useState<string>(value)
// Always update preview content when value changes
useEffect(() => {
setPreviewContent(value)
}, [value])
return (
<div className='w-full'>
{/* Tabs are only visible on mobile */}
<div className='md:hidden'>
<Tabs
defaultValue='write'
value={activeTab}
onValueChange={setActiveTab}
className='w-full'
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='write'>Write</TabsTrigger>
<TabsTrigger value='preview'>Preview</TabsTrigger>
</TabsList>
<TabsContent value='write' className='mt-2'>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className='min-h-[400px] font-mono'
style={{ minHeight }}
/>
</TabsContent>
<TabsContent value='preview' className='mt-2'>
<div
className='prose prose-sm dark:prose-invert w-full max-w-none rounded-md border p-4'
style={{ minHeight }}
>
{previewContent ? (
<ReactMarkdown>{previewContent}</ReactMarkdown>
) : (
<p className='text-muted-foreground'>Nothing to preview</p>
)}
</div>
</TabsContent>
</Tabs>
</div>
{/* Side-by-side layout for desktop */}
<div className='hidden md:grid md:grid-cols-2 md:gap-4'>
<div className='w-full'>
<div className='mb-2 font-medium'>Write</div>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className='min-h-[400px] font-mono'
style={{ minHeight }}
/>
</div>
<div className='w-full'>
<div className='mb-2 font-medium'>Preview</div>
<div
className='prose prose-sm dark:prose-invert w-full max-w-none rounded-md border p-4'
style={{ minHeight }}
>
{previewContent ? (
<ReactMarkdown>{previewContent}</ReactMarkdown>
) : (
<p className='text-muted-foreground'>Nothing to preview</p>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -4,3 +4,12 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) 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',
})
}

View File

@@ -1,4 +1,5 @@
import { branchesRouter } from '@/server/api/routers/branches' import { branchesRouter } from '@/server/api/routers/branches'
import { blogRouter } from '@/server/api/routers/blog'
import { discord_router } from '@/server/api/routers/discord' import { discord_router } from '@/server/api/routers/discord'
import { history_router } from '@/server/api/routers/history' import { history_router } from '@/server/api/routers/history'
import { leaderboard_router } from '@/server/api/routers/leaderboard' 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. * All routers added in /api/routers should be manually added here.
*/ */
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
blog: blogRouter,
branches: branchesRouter, branches: branchesRouter,
history: history_router, history: history_router,
discord: discord_router, discord: discord_router,

View File

@@ -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 }
}),
})

View File

@@ -1,5 +1,6 @@
import { relations, sql } from 'drizzle-orm' import { relations, sql } from 'drizzle-orm'
import { import {
boolean,
index, index,
integer, integer,
json, json,
@@ -9,6 +10,7 @@ import {
text, text,
timestamp, timestamp,
uniqueIndex, uniqueIndex,
varchar,
} from 'drizzle-orm/pg-core' } from 'drizzle-orm/pg-core'
import type { AdapterAccount } from 'next-auth/adapters' import type { AdapterAccount } from 'next-auth/adapters'
@@ -196,3 +198,25 @@ export const transcripts = pgTable('transcripts', {
content: text('content').notNull(), content: text('content').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(), 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],
}),
}))