mirror of
https://github.com/ershisan99/www.git
synced 2025-12-17 05:19:23 +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-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=="],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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>
|
</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' />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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[]) {
|
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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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 { 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],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user