mirror of
https://github.com/ershisan99/www.git
synced 2025-12-16 21:09:24 +00:00
add blog management system with admin tools
This commit is contained in:
5
bun.lock
5
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
220
src/app/(home)/admin/blog/edit/[id]/page.tsx
Normal file
220
src/app/(home)/admin/blog/edit/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
src/app/(home)/admin/blog/new/page.tsx
Normal file
121
src/app/(home)/admin/blog/new/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
src/app/(home)/admin/blog/page.tsx
Normal file
78
src/app/(home)/admin/blog/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/app/(home)/blog/[slug]/page.tsx
Normal file
52
src/app/(home)/blog/[slug]/page.tsx
Normal 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()
|
||||
}
|
||||
}
|
||||
49
src/app/(home)/blog/page.tsx
Normal file
49
src/app/(home)/blog/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -68,6 +68,12 @@ export function Header({
|
||||
</div>
|
||||
</NavbarMenuTrigger>
|
||||
<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'>
|
||||
<p className='-mb-1 font-medium text-sm'>Logs</p>
|
||||
<p className='text-[13px] text-fd-muted-foreground'>
|
||||
@@ -201,6 +207,9 @@ export function Header({
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
<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'>
|
||||
Logs
|
||||
</Link>
|
||||
@@ -216,6 +225,11 @@ export function Header({
|
||||
</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'>
|
||||
{menuItems.filter(isSecondary).map((item, i) => (
|
||||
<MenuLinkItem key={i} item={item} className='-me-1.5' />
|
||||
|
||||
@@ -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: <Rss />,
|
||||
},
|
||||
{
|
||||
text: 'Support Us',
|
||||
url: '/support-us',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
97
src/components/markdown-editor.tsx
Normal file
97
src/components/markdown-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
200
src/server/api/routers/blog.ts
Normal file
200
src/server/api/routers/blog.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
@@ -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],
|
||||
}),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user