Skip to content

Commit 5ca107e

Browse files
authored
Merge pull request #257 from codeunia-dev/feat/blogtexteditor
feat(blog): Add rich text editor for blog post content management
2 parents ccbd19d + b80bbac commit 5ca107e

File tree

7 files changed

+1226
-101
lines changed

7 files changed

+1226
-101
lines changed

app/admin/blog-posts/page.tsx

Lines changed: 42 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
"use client"
22

3-
import { useState, useEffect, useCallback, useMemo, useRef } from "react"
3+
import { useState, useEffect, useCallback, useMemo } from "react"
44
import { createClient } from "@/lib/supabase/client"
55
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
66
import { Badge } from "@/components/ui/badge"
77
import { Button } from "@/components/ui/button"
88
import { Input } from "@/components/ui/input"
9-
import { Textarea } from "@/components/ui/textarea"
109
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
1110
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
1211
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -17,6 +16,7 @@ import { AlertCircle, FileText, Search, MoreHorizontal, Edit, Star, Trash2, Plus
1716
import { Alert, AlertDescription } from "@/components/ui/alert"
1817
import { categories, BlogPost } from "@/components/data/blog-posts"
1918
import { OptimizedImage } from '@/components/ui/optimized-image'
19+
import { RichTextEditor } from '@/components/ui/rich-text-editor'
2020

2121
// types
2222
interface BlogFormData {
@@ -86,17 +86,17 @@ const useBlogPosts = () => {
8686
try {
8787
setIsLoading(true)
8888
setError(null)
89-
89+
9090
// First, fetch all blog posts
9191
const { data: postsData, error: fetchError } = await supabase
9292
.from("blogs")
9393
.select("*")
9494
.order("date", { ascending: false })
95-
95+
9696
if (fetchError) {
9797
throw new Error(fetchError.message)
9898
}
99-
99+
100100
if (postsData) {
101101
// Fetch real like counts and ensure views are up-to-date for each blog post
102102
const postsWithRealCounts = await Promise.all(
@@ -106,30 +106,30 @@ const useBlogPosts = () => {
106106
.from('blog_likes')
107107
.select('*', { count: 'exact', head: true })
108108
.eq('blog_slug', post.slug || post.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''))
109-
109+
110110
if (likeError) {
111111
console.error('Error fetching likes for post:', post.title, likeError)
112112
}
113-
113+
114114
// Get the most up-to-date view count from blogs table
115115
const { data: viewData, error: viewError } = await supabase
116116
.from('blogs')
117117
.select('views')
118118
.eq('id', post.id)
119119
.single()
120-
120+
121121
if (viewError) {
122122
console.error('Error fetching views for post:', post.title, viewError)
123123
}
124-
125-
return {
126-
...post,
124+
125+
return {
126+
...post,
127127
likes: likeCount || 0,
128128
views: viewData?.views?.toString() || post.views || '0'
129129
}
130130
})
131131
)
132-
132+
133133
setBlogPosts(postsWithRealCounts as BlogPost[])
134134
}
135135
} catch (err) {
@@ -168,8 +168,8 @@ const FeaturedBadge = ({ featured }: { featured: boolean }) => (
168168
)
169169
)
170170

171-
const BlogPostForm = ({
172-
formData,
171+
const BlogPostForm = ({
172+
formData,
173173
onFormChange
174174
}: {
175175
formData: BlogFormData;
@@ -189,9 +189,6 @@ const BlogPostForm = ({
189189
onFormChange({ featured: checked })
190190
}
191191

192-
// ref for the content textarea
193-
const contentRef = useRef<HTMLTextAreaElement>(null);
194-
195192
// Article image upload handler (for main blog image)
196193
const handleArticleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
197194
const file = e.target.files?.[0];
@@ -220,48 +217,6 @@ const BlogPostForm = ({
220217
}
221218
}
222219

223-
// image upload handler for inserting into content
224-
const handleContentImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
225-
const file = e.target.files?.[0];
226-
if (!file) return;
227-
228-
const supabase = createClient();
229-
const filePath = `public/${Date.now()}-${file.name}`;
230-
231-
// upload to supabase storage
232-
const { error } = await supabase.storage
233-
.from('blog-images')
234-
.upload(filePath, file);
235-
236-
if (error) {
237-
alert("Image upload failed: " + error.message);
238-
return;
239-
}
240-
241-
// Get public url
242-
const { data: publicUrlData } = supabase.storage
243-
.from('blog-images')
244-
.getPublicUrl(filePath);
245-
246-
if (publicUrlData?.publicUrl) {
247-
// insert html <img> tag at cursor in content
248-
if (contentRef.current) {
249-
const textarea = contentRef.current;
250-
const start = textarea.selectionStart;
251-
const end = textarea.selectionEnd;
252-
const before = formData.content.slice(0, start);
253-
const after = formData.content.slice(end);
254-
const htmlImg = `<img src="${publicUrlData.publicUrl}" alt="Alt text" style="max-width:100%;height:auto;" />\n`;
255-
const newContent = before + htmlImg + after;
256-
onFormChange({ content: newContent });
257-
setTimeout(() => {
258-
textarea.focus();
259-
textarea.selectionStart = textarea.selectionEnd = start + htmlImg.length;
260-
}, 0);
261-
}
262-
}
263-
}
264-
265220
return (
266221
<div className="grid gap-4 py-4">
267222
{/* Article Image upload section */}
@@ -312,25 +267,16 @@ const BlogPostForm = ({
312267
/>
313268
</div>
314269

315-
{/* Content image upload button */}
270+
{/* Rich Text Editor for Content */}
316271
<div className="grid gap-2">
317272
<Label htmlFor="content">Content *</Label>
318-
<Textarea
319-
id="content"
320-
ref={contentRef}
321-
placeholder="Write your blog post content here..."
322-
value={formData.content}
323-
onChange={handleInputChange('content')}
324-
className="text-sm min-h-[120px]"
273+
<RichTextEditor
274+
content={formData.content}
275+
onChange={(content) => onFormChange({ content })}
325276
/>
326-
<Input
327-
id="content-image"
328-
type="file"
329-
accept="image/*"
330-
onChange={handleContentImageUpload}
331-
className="text-sm mt-2"
332-
/>
333-
<span className="text-xs text-muted-foreground">Upload and insert image at cursor in content</span>
277+
<p className="text-xs text-muted-foreground">
278+
Use the toolbar to format text and insert images directly into your content.
279+
</p>
334280
</div>
335281

336282
<div className="grid grid-cols-2 gap-4">
@@ -372,8 +318,8 @@ const BlogPostForm = ({
372318

373319
<div className="grid gap-2">
374320
<Label htmlFor="category">Category</Label>
375-
<Select
376-
value={formData.category}
321+
<Select
322+
value={formData.category}
377323
onValueChange={handleSelectChange('category')}
378324
>
379325
<SelectTrigger className="text-sm">
@@ -436,7 +382,7 @@ const EmptyState = ({ title, description, action }: {
436382
export default function AdminBlogPage() {
437383
const { blogPosts, isLoading, error, refetch } = useBlogPosts()
438384
const supabase = useSupabase()
439-
385+
440386

441387
const [searchTerm, setSearchTerm] = useState("")
442388
const [categoryFilter, setCategoryFilter] = useState("All")
@@ -455,12 +401,12 @@ export default function AdminBlogPage() {
455401
post.excerpt,
456402
post.author,
457403
...parseTags(post.tags)
458-
].some(field =>
404+
].some(field =>
459405
field.toLowerCase().includes(searchTerm.toLowerCase())
460406
)
461-
407+
462408
const matchesCategory = categoryFilter === "All" || post.category === categoryFilter
463-
409+
464410
return matchesSearch && matchesCategory
465411
})
466412
}, [blogPosts, searchTerm, categoryFilter])
@@ -750,7 +696,7 @@ export default function AdminBlogPage() {
750696
<EmptyState
751697
title={blogPosts.length === 0 ? "No blog posts yet" : "No posts match your filters"}
752698
description={
753-
blogPosts.length === 0
699+
blogPosts.length === 0
754700
? "Create your first blog post to get started."
755701
: "Try adjusting your search or filter criteria."
756702
}
@@ -828,8 +774,8 @@ export default function AdminBlogPage() {
828774
Edit Post
829775
</DropdownMenuItem>
830776
<DropdownMenuSeparator />
831-
<DropdownMenuItem
832-
className="text-xs text-red-600 focus:text-red-600"
777+
<DropdownMenuItem
778+
className="text-xs text-red-600 focus:text-red-600"
833779
onClick={() => setShowDelete(post)}
834780
>
835781
<Trash2 className="mr-2 h-4 w-4" />
@@ -856,7 +802,7 @@ export default function AdminBlogPage() {
856802
Fill out the form below to create a new blog post.
857803
</DialogDescription>
858804
</DialogHeader>
859-
805+
860806
{formError && (
861807
<Alert variant="destructive">
862808
<AlertCircle className="h-4 w-4" />
@@ -868,7 +814,7 @@ export default function AdminBlogPage() {
868814
formData={formData}
869815
onFormChange={handleFormChange}
870816
/>
871-
817+
872818
<DialogFooter>
873819
<Button variant="outline" onClick={closeCreate} disabled={formLoading}>
874820
Cancel
@@ -890,7 +836,7 @@ export default function AdminBlogPage() {
890836
Update the blog post information below.
891837
</DialogDescription>
892838
</DialogHeader>
893-
839+
894840
{formError && (
895841
<Alert variant="destructive">
896842
<AlertCircle className="h-4 w-4" />
@@ -902,7 +848,7 @@ export default function AdminBlogPage() {
902848
formData={formData}
903849
onFormChange={handleFormChange}
904850
/>
905-
851+
906852
<DialogFooter>
907853
<Button variant="outline" onClick={closeEdit} disabled={formLoading}>
908854
Cancel
@@ -924,7 +870,7 @@ export default function AdminBlogPage() {
924870
This action cannot be undone. The blog post will be permanently deleted.
925871
</DialogDescription>
926872
</DialogHeader>
927-
873+
928874
<div className="py-4">
929875
<div className="p-4 bg-muted rounded-lg">
930876
<p className="font-medium text-sm">{showDelete?.title}</p>
@@ -933,18 +879,18 @@ export default function AdminBlogPage() {
933879
</p>
934880
</div>
935881
</div>
936-
882+
937883
<DialogFooter>
938-
<Button
939-
variant="outline"
940-
onClick={() => setShowDelete(null)}
884+
<Button
885+
variant="outline"
886+
onClick={() => setShowDelete(null)}
941887
disabled={formLoading}
942888
>
943889
Cancel
944890
</Button>
945-
<Button
946-
onClick={handleDelete}
947-
disabled={formLoading}
891+
<Button
892+
onClick={handleDelete}
893+
disabled={formLoading}
948894
variant="destructive"
949895
>
950896
{formLoading && <Loader2 className="animate-spin h-4 w-4 mr-2" />}

app/admin/newsletter/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export default function NewsletterAdminPage() {
115115
<h2 className="text-xl font-bold text-red-500 mb-2">Error Loading Subscribers</h2>
116116
<p className="text-foreground/70">{error}</p>
117117
<p className="text-sm text-foreground/50 mt-4">
118-
Make sure you're logged in as an admin and RLS policies are configured correctly.
118+
Make sure you&apos;re logged in as an admin and RLS policies are configured correctly.
119119
</p>
120120
</div>
121121
</div>

0 commit comments

Comments
 (0)