farheen_blog_app 2.0.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.css +9 -0
- package/src/App.jsx +4 -1
- package/src/components/CommentTable.jsx +148 -21
- package/src/components/EditBlogModal.jsx +246 -0
- package/src/components/FormField.jsx +60 -0
- package/src/components/Navbar.jsx +53 -36
- package/src/components/Popup.jsx +47 -0
- package/src/components/Postcard.jsx +71 -48
- package/src/components/ProfileModal.jsx +156 -0
- package/src/components/admin/AdminSidebar.jsx +0 -1
- package/src/context/ToastContext.jsx +31 -0
- package/src/context/UserContext.jsx +10 -1
- package/src/middleware/ProtectedRoute.jsx +2 -2
- package/src/pages/CreatePost.jsx +199 -151
- package/src/pages/Login.jsx +119 -118
- package/src/pages/Register.jsx +120 -147
- package/src/pages/SinglePost.jsx +155 -24
- package/src/pages/admin/ManageBlogs.jsx +42 -10
- package/src/services/authService.js +2 -2
- package/src/services/postService.js +58 -26
- package/src/services/userService.js +9 -0
- package/src/utils/checkPermission.js +25 -3
package/package.json
CHANGED
package/src/App.css
CHANGED
|
@@ -9,4 +9,13 @@
|
|
|
9
9
|
.animate-fade-in-up {
|
|
10
10
|
opacity: 0;
|
|
11
11
|
animation: fadeInUp 0.5s ease forwards;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@keyframes slideInRight {
|
|
15
|
+
from { opacity: 0; transform: translateX(100%); }
|
|
16
|
+
to { opacity: 1; transform: translateX(0); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.animate-slide-in-right {
|
|
20
|
+
animation: slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
12
21
|
}
|
package/src/App.jsx
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import './App.css'
|
|
2
2
|
import AppRoutes from './routes/AppRoutes'
|
|
3
3
|
import { UserProvider } from './context/UserContext'
|
|
4
|
+
import { ToastProvider } from './context/ToastContext'
|
|
4
5
|
|
|
5
6
|
function App() {
|
|
6
7
|
return (
|
|
7
8
|
<UserProvider>
|
|
8
|
-
<
|
|
9
|
+
<ToastProvider>
|
|
10
|
+
<AppRoutes />
|
|
11
|
+
</ToastProvider>
|
|
9
12
|
</UserProvider>
|
|
10
13
|
)
|
|
11
14
|
}
|
|
@@ -1,8 +1,147 @@
|
|
|
1
|
-
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useUser } from "../context/UserContext";
|
|
3
|
+
import { Trash2, Reply, CornerDownRight, X, MessageSquare } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
const CommentNode = ({ comment, onAddReply, onDeleteComment, depth = 0 }) => {
|
|
6
|
+
const { user } = useUser();
|
|
7
|
+
const [isReplying, setIsReplying] = useState(false);
|
|
8
|
+
const [replyText, setReplyText] = useState("");
|
|
9
|
+
|
|
10
|
+
const handleReplySubmit = (e) => {
|
|
11
|
+
e.preventDefault();
|
|
12
|
+
if (!replyText.trim()) return;
|
|
13
|
+
onAddReply(comment._id, replyText);
|
|
14
|
+
setReplyText("");
|
|
15
|
+
setIsReplying(false);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const authorName = comment.author?.name || "Anonymous";
|
|
19
|
+
const avatarLetter = authorName.charAt(0).toUpperCase();
|
|
20
|
+
const isAuthor = user && comment.author && (comment.author._id === user.id || comment.author === user.id);
|
|
21
|
+
const isAdmin = user && (user.isAdmin || user.role === "Admin" || user.role === "admin");
|
|
22
|
+
const canDelete = isAuthor || isAdmin;
|
|
23
|
+
|
|
24
|
+
const displayAvatar = comment.author?.profilePicture
|
|
25
|
+
? `http://localhost:3000${comment.author.profilePicture}`
|
|
26
|
+
: null;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex flex-col">
|
|
30
|
+
{/* Comment Body */}
|
|
31
|
+
<div className="flex gap-4 group">
|
|
32
|
+
<div className="flex-shrink-0 relative">
|
|
33
|
+
{displayAvatar ? (
|
|
34
|
+
<img
|
|
35
|
+
src={displayAvatar}
|
|
36
|
+
alt={authorName}
|
|
37
|
+
className="w-10 h-10 rounded-full object-cover border border-indigo-100 shadow-sm"
|
|
38
|
+
onError={(e) => {
|
|
39
|
+
e.target.onerror = null;
|
|
40
|
+
e.target.style.display = "none";
|
|
41
|
+
}}
|
|
42
|
+
/>
|
|
43
|
+
) : (
|
|
44
|
+
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 border border-indigo-100 flex items-center justify-center text-white font-bold text-base shadow-sm">
|
|
45
|
+
{avatarLetter}
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div className="flex-1 bg-gray-50 hover:bg-gray-100/70 rounded-2xl rounded-tl-none p-4 shadow-sm border border-gray-100 transition-all duration-200">
|
|
51
|
+
<div className="flex items-center justify-between mb-2">
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<h4 className="font-semibold text-gray-900 text-sm">{authorName}</h4>
|
|
54
|
+
{comment.author?.role === "Admin" && (
|
|
55
|
+
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-50 text-rose-600 border border-rose-100 uppercase">Admin</span>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
<span className="text-xs text-gray-500 font-medium bg-white px-2 py-0.5 rounded-full border border-gray-100 shadow-[0_1px_2px_rgba(0,0,0,0.02)]">
|
|
59
|
+
{comment.createdAt ? new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : 'Just now'}
|
|
60
|
+
</span>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<p className="text-gray-700 leading-relaxed text-sm whitespace-pre-wrap">
|
|
64
|
+
{comment.content}
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-200/50">
|
|
68
|
+
{user && (
|
|
69
|
+
<button
|
|
70
|
+
onClick={() => setIsReplying(!isReplying)}
|
|
71
|
+
className="inline-flex items-center text-xs font-semibold text-indigo-600 hover:text-indigo-800 transition-colors gap-1 cursor-pointer"
|
|
72
|
+
>
|
|
73
|
+
<Reply className="w-3 h-3" />
|
|
74
|
+
Reply
|
|
75
|
+
</button>
|
|
76
|
+
)}
|
|
77
|
+
{canDelete && (
|
|
78
|
+
<button
|
|
79
|
+
onClick={() => onDeleteComment(comment._id)}
|
|
80
|
+
className="inline-flex items-center text-xs font-semibold text-rose-600 hover:text-rose-800 transition-colors gap-1 ml-auto md:opacity-0 group-hover:opacity-100 cursor-pointer"
|
|
81
|
+
>
|
|
82
|
+
<Trash2 className="w-3 h-3" />
|
|
83
|
+
Delete
|
|
84
|
+
</button>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Inline Reply Form */}
|
|
91
|
+
{isReplying && (
|
|
92
|
+
<div className="flex gap-4 mt-3 ml-6 pl-4 border-l-2 border-dashed border-indigo-200">
|
|
93
|
+
<div className="w-6 h-6 rounded-full bg-indigo-50 flex items-center justify-center text-indigo-500 text-xs font-bold border border-indigo-100">
|
|
94
|
+
<CornerDownRight className="w-3 h-3" />
|
|
95
|
+
</div>
|
|
96
|
+
<form onSubmit={handleReplySubmit} className="flex-1 flex gap-2">
|
|
97
|
+
<input
|
|
98
|
+
type="text"
|
|
99
|
+
value={replyText}
|
|
100
|
+
onChange={(e) => setReplyText(e.target.value)}
|
|
101
|
+
placeholder={`Reply to ${authorName}...`}
|
|
102
|
+
className="flex-1 bg-white border border-gray-300 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500"
|
|
103
|
+
autoFocus
|
|
104
|
+
/>
|
|
105
|
+
<button
|
|
106
|
+
type="submit"
|
|
107
|
+
className="bg-indigo-600 text-white rounded-xl px-4 py-2 text-xs font-semibold hover:bg-indigo-700 active:bg-indigo-800 transition-colors cursor-pointer"
|
|
108
|
+
>
|
|
109
|
+
Reply
|
|
110
|
+
</button>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={() => setIsReplying(false)}
|
|
114
|
+
className="bg-gray-100 text-gray-500 rounded-xl px-3 py-2 text-xs font-semibold hover:bg-gray-200 transition-colors cursor-pointer"
|
|
115
|
+
>
|
|
116
|
+
<X className="w-3.5 h-3.5" />
|
|
117
|
+
</button>
|
|
118
|
+
</form>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{/* Render Nested Replies */}
|
|
123
|
+
{comment.replies && comment.replies.length > 0 && (
|
|
124
|
+
<div className="ml-6 md:ml-10 border-l border-indigo-100 pl-4 md:pl-6 mt-3 space-y-4">
|
|
125
|
+
{comment.replies.map((reply) => (
|
|
126
|
+
<CommentNode
|
|
127
|
+
key={reply._id}
|
|
128
|
+
comment={reply}
|
|
129
|
+
onAddReply={onAddReply}
|
|
130
|
+
onDeleteComment={onDeleteComment}
|
|
131
|
+
depth={depth + 1}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const CommentTable = ({ comments, onAddReply, onDeleteComment }) => {
|
|
2
141
|
if (!comments || comments.length === 0) {
|
|
3
142
|
return (
|
|
4
143
|
<div className="text-center py-12 px-4 rounded-2xl border border-dashed border-gray-300 bg-gray-50">
|
|
5
|
-
<
|
|
144
|
+
<MessageSquare className="w-12 h-12 mx-auto text-gray-300 mb-3" />
|
|
6
145
|
<h3 className="text-lg font-medium text-gray-900">No comments yet</h3>
|
|
7
146
|
<p className="text-gray-500 mt-1">Be the first to share your thoughts!</p>
|
|
8
147
|
</div>
|
|
@@ -11,25 +150,13 @@ const CommentTable = ({ comments }) => {
|
|
|
11
150
|
|
|
12
151
|
return (
|
|
13
152
|
<div className="space-y-6">
|
|
14
|
-
{comments.map((comment
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
<div className="flex-1 bg-gray-50 rounded-2xl rounded-tl-none p-5 shadow-sm border border-gray-100">
|
|
22
|
-
<div className="flex items-center justify-between mb-2">
|
|
23
|
-
<h4 className="font-semibold text-gray-900">{comment.user?.name || "Anonymous"}</h4>
|
|
24
|
-
<span className="text-xs font-medium text-gray-500 bg-white px-2 py-1 rounded-full shadow-sm border border-gray-100">
|
|
25
|
-
{comment.createdAt ? new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : 'Just now'}
|
|
26
|
-
</span>
|
|
27
|
-
</div>
|
|
28
|
-
<p className="text-gray-700 leading-relaxed text-sm">
|
|
29
|
-
{comment.text}
|
|
30
|
-
</p>
|
|
31
|
-
</div>
|
|
32
|
-
</div>
|
|
153
|
+
{comments.map((comment) => (
|
|
154
|
+
<CommentNode
|
|
155
|
+
key={comment._id}
|
|
156
|
+
comment={comment}
|
|
157
|
+
onAddReply={onAddReply}
|
|
158
|
+
onDeleteComment={onDeleteComment}
|
|
159
|
+
/>
|
|
33
160
|
))}
|
|
34
161
|
</div>
|
|
35
162
|
);
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { useForm } from "react-hook-form";
|
|
3
|
+
import { updatePost } from "../services/postService";
|
|
4
|
+
import { useToast } from "../context/ToastContext";
|
|
5
|
+
import FormField from "./FormField";
|
|
6
|
+
import { X, Image as ImageIcon, Save, Loader2, AlertCircle } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
const EditBlogModal = ({ blog, isOpen, onClose, onSuccess }) => {
|
|
9
|
+
const { showToast } = useToast();
|
|
10
|
+
const [loading, setLoading] = useState(false);
|
|
11
|
+
const [existingImages, setExistingImages] = useState([]);
|
|
12
|
+
const [newPreviewUrls, setNewPreviewUrls] = useState([]);
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
register,
|
|
16
|
+
handleSubmit,
|
|
17
|
+
watch,
|
|
18
|
+
reset,
|
|
19
|
+
formState: { errors }
|
|
20
|
+
} = useForm();
|
|
21
|
+
|
|
22
|
+
const newImageFiles = watch("images");
|
|
23
|
+
|
|
24
|
+
// Populate form fields when blog changes
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (blog && isOpen) {
|
|
27
|
+
reset({
|
|
28
|
+
title: blog.title || "",
|
|
29
|
+
description: blog.description || "",
|
|
30
|
+
content: blog.content || "",
|
|
31
|
+
images: null
|
|
32
|
+
});
|
|
33
|
+
setExistingImages(blog.images || []);
|
|
34
|
+
setNewPreviewUrls([]);
|
|
35
|
+
}
|
|
36
|
+
}, [blog, isOpen, reset]);
|
|
37
|
+
|
|
38
|
+
// Handle previewing newly selected files
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (newImageFiles && newImageFiles.length > 0) {
|
|
41
|
+
const urls = Array.from(newImageFiles).map(file => URL.createObjectURL(file));
|
|
42
|
+
setNewPreviewUrls(urls);
|
|
43
|
+
return () => {
|
|
44
|
+
urls.forEach(url => URL.revokeObjectURL(url));
|
|
45
|
+
};
|
|
46
|
+
} else {
|
|
47
|
+
setNewPreviewUrls([]);
|
|
48
|
+
}
|
|
49
|
+
}, [newImageFiles]);
|
|
50
|
+
|
|
51
|
+
if (!isOpen || !blog) return null;
|
|
52
|
+
|
|
53
|
+
const onSubmit = async (data) => {
|
|
54
|
+
try {
|
|
55
|
+
setLoading(true);
|
|
56
|
+
|
|
57
|
+
const formData = new FormData();
|
|
58
|
+
formData.append("title", data.title);
|
|
59
|
+
formData.append("description", data.description);
|
|
60
|
+
formData.append("content", data.content);
|
|
61
|
+
|
|
62
|
+
if (data.images && data.images.length > 0) {
|
|
63
|
+
Array.from(data.images).forEach((file) => {
|
|
64
|
+
formData.append("images", file);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const id = blog._id || blog.id;
|
|
69
|
+
await updatePost(id, formData);
|
|
70
|
+
showToast("Blog post updated successfully!", "success");
|
|
71
|
+
onSuccess();
|
|
72
|
+
onClose();
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error("Update blog error:", error);
|
|
75
|
+
showToast(
|
|
76
|
+
error.response?.data?.message ||
|
|
77
|
+
error.response?.message ||
|
|
78
|
+
"Failed to update post",
|
|
79
|
+
"error"
|
|
80
|
+
);
|
|
81
|
+
} finally {
|
|
82
|
+
setLoading(false);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
|
|
88
|
+
<div className="bg-white rounded-3xl shadow-2xl border border-slate-100 max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col animate-fade-in-up">
|
|
89
|
+
|
|
90
|
+
{/* Header */}
|
|
91
|
+
<div className="px-6 py-5 border-b border-slate-100 flex items-center justify-between">
|
|
92
|
+
<div>
|
|
93
|
+
<h2 className="text-xl font-bold text-slate-800">Edit Blog Post</h2>
|
|
94
|
+
<p className="text-xs text-slate-500 mt-0.5">Modify the title, description, content, or cover images</p>
|
|
95
|
+
</div>
|
|
96
|
+
<button
|
|
97
|
+
onClick={onClose}
|
|
98
|
+
className="text-slate-400 hover:text-slate-600 hover:bg-slate-50 p-2 rounded-xl transition-all cursor-pointer"
|
|
99
|
+
>
|
|
100
|
+
<X className="w-5 h-5" />
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Form Body */}
|
|
105
|
+
<form onSubmit={handleSubmit(onSubmit)} className="flex-1 overflow-y-auto p-6 space-y-6">
|
|
106
|
+
<FormField
|
|
107
|
+
label="Post Title"
|
|
108
|
+
placeholder="Catchy blog title"
|
|
109
|
+
error={errors.title}
|
|
110
|
+
{...register("title", {
|
|
111
|
+
required: "Title is required",
|
|
112
|
+
minLength: {
|
|
113
|
+
value: 5,
|
|
114
|
+
message: "Title must be at least 5 characters"
|
|
115
|
+
}
|
|
116
|
+
})}
|
|
117
|
+
/>
|
|
118
|
+
|
|
119
|
+
<FormField
|
|
120
|
+
label="Post Description"
|
|
121
|
+
placeholder="Brief summary"
|
|
122
|
+
error={errors.description}
|
|
123
|
+
{...register("description", {
|
|
124
|
+
maxLength: {
|
|
125
|
+
value: 200,
|
|
126
|
+
message: "Description cannot exceed 200 characters"
|
|
127
|
+
}
|
|
128
|
+
})}
|
|
129
|
+
/>
|
|
130
|
+
|
|
131
|
+
<FormField
|
|
132
|
+
label="Post Content"
|
|
133
|
+
type="textarea"
|
|
134
|
+
placeholder="Post content..."
|
|
135
|
+
error={errors.content}
|
|
136
|
+
className="h-44"
|
|
137
|
+
{...register("content", {
|
|
138
|
+
required: "Post content is required",
|
|
139
|
+
minLength: {
|
|
140
|
+
value: 20,
|
|
141
|
+
message: "Post content must be at least 20 characters"
|
|
142
|
+
}
|
|
143
|
+
})}
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
{/* Existing Images */}
|
|
147
|
+
{existingImages.length > 0 && (
|
|
148
|
+
<div>
|
|
149
|
+
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
|
150
|
+
Current Cover Images
|
|
151
|
+
</label>
|
|
152
|
+
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3 bg-slate-50 p-3 rounded-2xl border border-slate-100">
|
|
153
|
+
{existingImages.map((img, idx) => (
|
|
154
|
+
<div key={idx} className="relative aspect-video rounded-xl overflow-hidden border border-slate-200 shadow-sm">
|
|
155
|
+
<img src={`http://localhost:3000${img}`} alt="Current" className="w-full h-full object-cover" />
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{/* New Upload Input */}
|
|
163
|
+
<div>
|
|
164
|
+
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
|
165
|
+
Upload New Cover Images (Optional)
|
|
166
|
+
</label>
|
|
167
|
+
|
|
168
|
+
{newPreviewUrls.length > 0 && (
|
|
169
|
+
<div className="bg-amber-50 border border-amber-200/60 rounded-xl p-3.5 flex items-start gap-2.5 text-amber-800 text-xs font-medium mb-3">
|
|
170
|
+
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
171
|
+
<span>Note: Uploading new images will replace all current cover images for this blog post.</span>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
<div className="flex items-center justify-center w-full">
|
|
176
|
+
<label className="flex flex-col items-center justify-center w-full min-h-32 border-2 border-slate-200 border-dashed rounded-xl cursor-pointer bg-slate-50 hover:bg-slate-100 transition-colors group overflow-hidden relative">
|
|
177
|
+
{newPreviewUrls.length > 0 ? (
|
|
178
|
+
<div className="w-full p-4 flex flex-col gap-3">
|
|
179
|
+
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
|
180
|
+
{newPreviewUrls.map((url, idx) => (
|
|
181
|
+
<div key={idx} className="relative aspect-video rounded-xl overflow-hidden shadow-sm border border-slate-200">
|
|
182
|
+
<img src={url} alt={`Preview ${idx + 1}`} className="w-full h-full object-cover" />
|
|
183
|
+
</div>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
<div className="text-center">
|
|
187
|
+
<span className="text-[11px] font-semibold text-indigo-700 bg-indigo-50 border border-indigo-100 rounded-lg px-2.5 py-1 inline-block">
|
|
188
|
+
Change New Images ({newPreviewUrls.length} selected)
|
|
189
|
+
</span>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
) : (
|
|
193
|
+
<div className="flex flex-col items-center justify-center py-4">
|
|
194
|
+
<ImageIcon className="w-6 h-6 text-slate-400 mb-1 group-hover:scale-110 transition-transform" />
|
|
195
|
+
<p className="text-xs text-slate-500 font-medium">
|
|
196
|
+
Click to replace cover images
|
|
197
|
+
</p>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
<input
|
|
201
|
+
type="file"
|
|
202
|
+
className="hidden"
|
|
203
|
+
accept="image/*"
|
|
204
|
+
multiple
|
|
205
|
+
{...register("images")}
|
|
206
|
+
/>
|
|
207
|
+
</label>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</form>
|
|
211
|
+
|
|
212
|
+
{/* Footer Actions */}
|
|
213
|
+
<div className="px-6 py-4 border-t border-slate-100 bg-slate-50/50 flex justify-end gap-3">
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
onClick={onClose}
|
|
217
|
+
className="px-4 py-2 border border-slate-200 text-slate-600 rounded-xl text-sm font-semibold hover:bg-slate-100 active:bg-slate-200 transition-colors cursor-pointer"
|
|
218
|
+
>
|
|
219
|
+
Cancel
|
|
220
|
+
</button>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
onClick={handleSubmit(onSubmit)}
|
|
224
|
+
disabled={loading}
|
|
225
|
+
className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-semibold flex items-center gap-1.5 shadow-md shadow-indigo-100 disabled:opacity-50 cursor-pointer"
|
|
226
|
+
>
|
|
227
|
+
{loading ? (
|
|
228
|
+
<>
|
|
229
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
230
|
+
Saving...
|
|
231
|
+
</>
|
|
232
|
+
) : (
|
|
233
|
+
<>
|
|
234
|
+
<Save className="w-4 h-4" />
|
|
235
|
+
Save Changes
|
|
236
|
+
</>
|
|
237
|
+
)}
|
|
238
|
+
</button>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export default EditBlogModal;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
const FormField = React.forwardRef(({
|
|
4
|
+
label,
|
|
5
|
+
name,
|
|
6
|
+
type = "text",
|
|
7
|
+
placeholder,
|
|
8
|
+
error,
|
|
9
|
+
className = "",
|
|
10
|
+
...props
|
|
11
|
+
}, ref) => {
|
|
12
|
+
return (
|
|
13
|
+
<div className={`flex flex-col gap-1.5 w-full ${className}`}>
|
|
14
|
+
{label && (
|
|
15
|
+
<label className="block text-sm font-semibold text-slate-700 ml-1">
|
|
16
|
+
{label}
|
|
17
|
+
</label>
|
|
18
|
+
)}
|
|
19
|
+
|
|
20
|
+
{type === "textarea" ? (
|
|
21
|
+
<textarea
|
|
22
|
+
id={name}
|
|
23
|
+
name={name}
|
|
24
|
+
placeholder={placeholder}
|
|
25
|
+
ref={ref}
|
|
26
|
+
className={`w-full px-4 py-3 bg-gray-50/50 border rounded-xl focus:outline-none focus:ring-2 focus:bg-white transition-all shadow-sm text-sm resize-y leading-relaxed ${
|
|
27
|
+
error
|
|
28
|
+
? "border-rose-300 focus:ring-rose-500/20 focus:border-rose-500 bg-rose-50/30"
|
|
29
|
+
: "border-gray-200 focus:ring-indigo-500/50 focus:border-indigo-500"
|
|
30
|
+
}`}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
) : (
|
|
34
|
+
<input
|
|
35
|
+
id={name}
|
|
36
|
+
name={name}
|
|
37
|
+
type={type}
|
|
38
|
+
placeholder={placeholder}
|
|
39
|
+
ref={ref}
|
|
40
|
+
className={`w-full px-4 py-3 bg-gray-50/50 border rounded-xl focus:outline-none focus:ring-2 focus:bg-white transition-all shadow-sm text-sm ${
|
|
41
|
+
error
|
|
42
|
+
? "border-rose-300 focus:ring-rose-500/20 focus:border-rose-500 bg-rose-50/30"
|
|
43
|
+
: "border-gray-200 focus:ring-indigo-500/50 focus:border-indigo-500"
|
|
44
|
+
}`}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
)}
|
|
48
|
+
|
|
49
|
+
{error && (
|
|
50
|
+
<span className="text-xs font-medium text-rose-600 ml-1 mt-0.5 animate-fade-in-up">
|
|
51
|
+
{error.message}
|
|
52
|
+
</span>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
FormField.displayName = "FormField";
|
|
59
|
+
|
|
60
|
+
export default FormField;
|
|
@@ -1,37 +1,54 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { Search, Bell } from "lucide-react";
|
|
4
|
+
import { useUser } from "../context/UserContext";
|
|
5
|
+
import ProfileModal from "./ProfileModal";
|
|
6
|
+
|
|
7
|
+
const Navbar = () => {
|
|
8
|
+
const { user } = useUser();
|
|
9
|
+
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
|
10
|
+
|
|
11
|
+
const displayAvatar = user?.profilePicture
|
|
12
|
+
? `http://localhost:3000${user.profilePicture}`
|
|
13
|
+
: `https://ui-avatars.com/api/?name=${encodeURIComponent(user?.name || "User")}&background=6366f1&color=fff`;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
<header className="bg-white h-20 border-b border-slate-200 flex justify-between items-center px-8 shadow-[0_2px_10px_rgba(0,0,0,0.02)] z-10 sticky top-0 flex-shrink-0">
|
|
18
|
+
<h1 className="text-xl font-semibold text-slate-800">
|
|
19
|
+
{/* Optional page title could go here */}
|
|
20
|
+
</h1>
|
|
21
|
+
|
|
22
|
+
<div className="flex items-center gap-6">
|
|
23
|
+
<div className="relative hidden md:block">
|
|
24
|
+
<input
|
|
25
|
+
type="text"
|
|
26
|
+
placeholder="Search anything..."
|
|
27
|
+
className="bg-slate-50 border border-slate-200 text-sm rounded-full pl-11 pr-4 py-2.5 w-64 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:bg-white transition-all"
|
|
28
|
+
/>
|
|
29
|
+
<Search className="absolute left-4 top-2.5 text-slate-400" size={18} />
|
|
30
|
+
</div>
|
|
31
|
+
<button className="relative text-slate-500 hover:text-indigo-600 transition-colors p-2 rounded-full hover:bg-indigo-50">
|
|
32
|
+
<Bell size={20} />
|
|
33
|
+
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full border border-white"></span>
|
|
34
|
+
</button>
|
|
35
|
+
|
|
36
|
+
<div
|
|
37
|
+
onClick={() => setIsProfileOpen(true)}
|
|
38
|
+
className="flex items-center gap-3 pl-4 border-l border-slate-200 cursor-pointer group"
|
|
39
|
+
>
|
|
40
|
+
<img
|
|
41
|
+
src={displayAvatar}
|
|
42
|
+
alt="profile"
|
|
43
|
+
className="w-10 h-10 rounded-full border-2 border-indigo-100 group-hover:border-indigo-300 transition-all shadow-sm object-cover"
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</header>
|
|
48
|
+
|
|
49
|
+
<ProfileModal isOpen={isProfileOpen} onClose={() => setIsProfileOpen(false)} />
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
37
54
|
export default Navbar;
|