Skip to content

Commit 6c8ad08

Browse files
committed
feat: Implement a unified PostEditor page with rich text capabilities and upload progress indication, and refactor post-related UI and API queries.
1 parent a27d60f commit 6c8ad08

16 files changed

Lines changed: 1208 additions & 731 deletions

File tree

client/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,16 @@
3535
"@radix-ui/react-tooltip": "^1.2.8",
3636
"@simplewebauthn/browser": "^11.0.0",
3737
"@tanstack/react-query": "^5.90.18",
38+
"@tiptap/extension-link": "^3.19.0",
39+
"@tiptap/extension-placeholder": "^3.19.0",
40+
"@tiptap/pm": "^3.19.0",
41+
"@tiptap/react": "^3.19.0",
42+
"@tiptap/starter-kit": "^3.19.0",
3843
"axios": "^1.7.7",
3944
"class-variance-authority": "^0.7.1",
4045
"clsx": "^2.1.1",
4146
"cmdk": "1.1.1",
47+
"dompurify": "^3.3.1",
4248
"framer-motion": "^12.23.26",
4349
"he": "^1.2.0",
4450
"html5-qrcode": "^2.3.8",

client/src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import { PlayerProvider } from "./Context/PlayerContext"
99
import { ThemeProvider } from "./Context/ThemeProvider"
1010
import { Toaster } from "./components/ui/sonner"
1111
import { ProtectedRoutes, PublicRoutes, privateRoutes, publicRoutes } from "./Routes"
12+
import { setQueryClient } from "./stores/uploadStore"
1213

1314
const queryClient = new QueryClient()
15+
setQueryClient(queryClient)
1416

1517
function App() {
1618
return (
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { ArrowLeft, ImagePlus, Loader2, X } from "lucide-react"
2+
import { useContext, useEffect, useRef, useState } from "react"
3+
import { useNavigate, useParams } from "react-router-dom"
4+
import { toast } from "sonner"
5+
import { Button } from "@/components/ui/button"
6+
import RichTextEditor from "@/components/ui/RichTextEditor"
7+
import { cn } from "@/lib/utils"
8+
import { getOptimizedImageUrl, getProfileCloudinaryUrl } from "@/Utils/Cloudinary"
9+
import { Context } from "@/Context/Context"
10+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
11+
import { Card } from "@/components/ui/card"
12+
import { usePostDetailQuery } from "@/hooks/queries/usePostQueries"
13+
import useUploadStore from "@/stores/uploadStore"
14+
15+
const MAX_FILE_SIZE = 5 * 1024 * 1024
16+
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
17+
const MAX_IMAGES = 4
18+
19+
const PostEditor = () => {
20+
const { postid } = useParams()
21+
const isEditMode = Boolean(postid)
22+
const navigate = useNavigate()
23+
const { user } = useContext(Context)
24+
const submitPost = useUploadStore((state) => state.submitPost)
25+
26+
const [content, setContent] = useState("")
27+
const [images, setImages] = useState([])
28+
const [existingImages, setExistingImages] = useState([])
29+
const [submitting, setSubmitting] = useState(false)
30+
const [isDragging, setIsDragging] = useState(false)
31+
const fileInputRef = useRef(null)
32+
33+
const { data: postData, isLoading: isLoadingPost } = usePostDetailQuery(postid, {
34+
enabled: isEditMode,
35+
})
36+
37+
useEffect(() => {
38+
if (postData?.post) {
39+
setContent(postData.post.title || "")
40+
setExistingImages(postData.post.images || [])
41+
}
42+
}, [postData])
43+
44+
const validateImage = (file) => {
45+
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
46+
toast.error(`${file.name} is not a supported image type`)
47+
return false
48+
}
49+
if (file.size > MAX_FILE_SIZE) {
50+
toast.error(`${file.name} exceeds 5MB limit`)
51+
return false
52+
}
53+
return true
54+
}
55+
56+
const totalImages = images.length + existingImages.length
57+
58+
const handleDragEnter = (e) => {
59+
e.preventDefault()
60+
e.stopPropagation()
61+
setIsDragging(true)
62+
}
63+
64+
const handleDragLeave = (e) => {
65+
e.preventDefault()
66+
e.stopPropagation()
67+
setIsDragging(false)
68+
}
69+
70+
const handleDragOver = (e) => {
71+
e.preventDefault()
72+
e.stopPropagation()
73+
setIsDragging(true)
74+
}
75+
76+
const handleDrop = (e) => {
77+
e.preventDefault()
78+
e.stopPropagation()
79+
setIsDragging(false)
80+
81+
const files = Array.from(e.dataTransfer.files)
82+
const imageFiles = files.filter(validateImage).slice(0, MAX_IMAGES - totalImages)
83+
84+
if (imageFiles.length + totalImages > MAX_IMAGES) {
85+
toast.warning(`Maximum ${MAX_IMAGES} images allowed`)
86+
}
87+
88+
setImages((prev) => [...prev, ...imageFiles])
89+
}
90+
91+
const handleImageUpload = (event) => {
92+
const newImages = Array.from(event.target.files)
93+
.filter(validateImage)
94+
.slice(0, MAX_IMAGES - totalImages)
95+
96+
if (newImages.length + totalImages > MAX_IMAGES) {
97+
toast.warning(`Maximum ${MAX_IMAGES} images allowed`)
98+
}
99+
100+
setImages((prev) => [...prev, ...newImages])
101+
event.target.value = ""
102+
}
103+
104+
const removeNewImage = (index) => {
105+
setImages((prev) => prev.filter((_, i) => i !== index))
106+
}
107+
108+
const removeExistingImage = (index) => {
109+
setExistingImages((prev) => prev.filter((_, i) => i !== index))
110+
}
111+
112+
const handleSubmit = async () => {
113+
if (!content.trim() && totalImages === 0) {
114+
toast.error("Please add some text or images to your post")
115+
return
116+
}
117+
118+
setSubmitting(true)
119+
120+
submitPost({
121+
content,
122+
images,
123+
existingImages,
124+
isEditMode,
125+
postid,
126+
})
127+
128+
if (isEditMode) {
129+
navigate(-1)
130+
} else {
131+
navigate("/feed")
132+
}
133+
}
134+
135+
if (isEditMode && isLoadingPost) {
136+
return (
137+
<div className="flex items-center justify-center h-[60vh]">
138+
<Loader2 className="w-8 h-8 animate-spin" />
139+
</div>
140+
)
141+
}
142+
143+
return (
144+
<div
145+
className="max-w-2xl mx-auto p-4 pb-20"
146+
onDragEnter={handleDragEnter}
147+
onDragLeave={handleDragLeave}
148+
onDragOver={handleDragOver}
149+
onDrop={handleDrop}
150+
>
151+
<div className="flex items-center gap-3 mb-6">
152+
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
153+
<ArrowLeft className="h-5 w-5" />
154+
</Button>
155+
<h1 className="text-xl font-semibold">{isEditMode ? "Edit Post" : "Create Post"}</h1>
156+
</div>
157+
158+
<Card className={cn("p-4 relative", isDragging && "ring-2 ring-blue-500")}>
159+
<div className="flex items-center gap-3 mb-4">
160+
<Avatar className="h-10 w-10">
161+
<AvatarImage src={getProfileCloudinaryUrl(user?.profilepic)} />
162+
<AvatarFallback>{user?.name?.[0]}</AvatarFallback>
163+
</Avatar>
164+
<div>
165+
<p className="font-medium">{user?.name}</p>
166+
<p className="text-sm text-muted-foreground">@{user?.username}</p>
167+
</div>
168+
</div>
169+
170+
<RichTextEditor
171+
content={content}
172+
onChange={setContent}
173+
placeholder="What's on your mind?"
174+
className="mb-4"
175+
/>
176+
177+
{isDragging && (
178+
<div className="absolute inset-0 bg-blue-500/10 backdrop-blur-sm flex items-center justify-center pointer-events-none rounded-lg z-10">
179+
<div className="text-center space-y-2">
180+
<ImagePlus className="w-12 h-12 mx-auto text-blue-500" />
181+
<p className="text-lg font-medium">Drop images here</p>
182+
</div>
183+
</div>
184+
)}
185+
186+
{(existingImages.length > 0 || images.length > 0) && (
187+
<div className="grid grid-cols-2 gap-2 mb-4">
188+
{existingImages.map((image, index) => (
189+
<div
190+
key={image.id}
191+
className="relative aspect-video rounded-lg overflow-hidden bg-muted"
192+
>
193+
<img
194+
src={getOptimizedImageUrl(image.image, { thumbnail: true })}
195+
alt={`Existing ${index + 1}`}
196+
className="w-full h-full object-cover"
197+
/>
198+
<Button
199+
variant="destructive"
200+
size="icon"
201+
className="absolute top-2 right-2 h-7 w-7"
202+
onClick={() => removeExistingImage(index)}
203+
>
204+
<X className="h-4 w-4" />
205+
</Button>
206+
</div>
207+
))}
208+
{images.map((image, index) => (
209+
<div
210+
key={index}
211+
className="relative aspect-video rounded-lg overflow-hidden bg-muted"
212+
>
213+
<img
214+
src={URL.createObjectURL(image)}
215+
alt={`New ${index + 1}`}
216+
className="w-full h-full object-cover"
217+
/>
218+
<Button
219+
variant="destructive"
220+
size="icon"
221+
className="absolute top-2 right-2 h-7 w-7"
222+
onClick={() => removeNewImage(index)}
223+
>
224+
<X className="h-4 w-4" />
225+
</Button>
226+
</div>
227+
))}
228+
</div>
229+
)}
230+
231+
<div className="flex items-center justify-between border-t pt-4">
232+
<Button
233+
variant="outline"
234+
size="sm"
235+
onClick={() => fileInputRef.current?.click()}
236+
disabled={totalImages >= MAX_IMAGES || submitting}
237+
>
238+
<ImagePlus className="w-4 h-4 mr-2" />
239+
Add Images ({totalImages}/{MAX_IMAGES})
240+
</Button>
241+
242+
<Button
243+
onClick={handleSubmit}
244+
disabled={submitting || (!content.trim() && totalImages === 0)}
245+
className="min-w-[100px]"
246+
>
247+
{submitting ? (
248+
<>
249+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
250+
Submitting...
251+
</>
252+
) : isEditMode ? (
253+
"Update"
254+
) : (
255+
"Post"
256+
)}
257+
</Button>
258+
</div>
259+
</Card>
260+
261+
<input
262+
type="file"
263+
ref={fileInputRef}
264+
onChange={handleImageUpload}
265+
accept={ALLOWED_IMAGE_TYPES.join(",")}
266+
multiple
267+
className="hidden"
268+
/>
269+
</div>
270+
)
271+
}
272+
273+
export default PostEditor

client/src/Routes.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Navbar from "./components/Navbar"
88
import { AppSidebar } from "./components/AppSidebar"
99
import IncomingCallNotification from "./components/Chat/IncomingCall"
1010
import VideoCallUI from "./components/Chat/VideoCall"
11+
import UploadIndicator from "./components/Posts/UploadIndicator"
1112
import { Outlet } from "react-router-dom"
1213
import { useContext } from "react"
1314
import { Context } from "./Context/Context"
@@ -39,7 +40,7 @@ const NotFoundPage = lazy(() => import("./components/NotFound"))
3940
const Dashboard = lazy(() => import("./components/Posts/Dashboard"))
4041
const PostDetail = lazy(() => import("./components/Posts/PostDetail"))
4142
const SearchPost = lazy(() => import("./components/Posts/SearchPost"))
42-
const UpdatePost = lazy(() => import("./components/Posts/UpdatePost"))
43+
const PostEditor = lazy(() => import("./Pages/Posts/PostEditor"))
4344
const UserPosts = lazy(() => import("./components/Posts/UserPosts"))
4445
const StoryViewer = lazy(() => import("./components/Story/StoryViewer"))
4546
const Album = lazy(() => import("./Pages/Music/Album"))
@@ -67,7 +68,8 @@ export const privateRoutes = [
6768
{ path: "/feed", element: <Dashboard /> },
6869
{ path: "/feed/post/:postid", element: <PostDetail /> },
6970
{ path: "/post/search", element: <SearchPost /> },
70-
{ path: "/post/update/:postid", element: <UpdatePost /> },
71+
{ path: "/post/create", element: <PostEditor /> },
72+
{ path: "/post/edit/:postid", element: <PostEditor /> },
7173
{ path: "/chat", element: <Chat /> },
7274
{ path: "/stories/:userid", element: <StoryViewer /> },
7375
{ path: "/music", element: <HomePage /> },
@@ -141,6 +143,7 @@ export const ProtectedRoutes = () => {
141143
endCall={rejectCall}
142144
/>
143145
{isInCall && <VideoCallUI />}
146+
<UploadIndicator />
144147
</>
145148
)
146149
}

client/src/api/posts.js

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const postKeys = {
1010
detail: (id) => [...postKeys.details(), id],
1111
comments: (postId) => [...postKeys.all, "comments", postId],
1212
likeStatus: (postId) => [...postKeys.all, "likeStatus", postId],
13+
userPosts: (userId) => [...postKeys.all, "user", userId],
1314
}
1415

1516
export const fetchPosts = async ({ page = 1, limit = 10 }) => {
@@ -20,6 +21,14 @@ export const fetchPosts = async ({ page = 1, limit = 10 }) => {
2021
return data
2122
}
2223

24+
export const fetchUserPosts = async ({ userId, page = 1, limit = 12 }) => {
25+
const { data } = await axios.get(`${API_URL}/api/user/posts/${userId}`, {
26+
params: { page, limit },
27+
withCredentials: true,
28+
})
29+
return data
30+
}
31+
2332
export const fetchPostById = async (postid) => {
2433
const { data } = await axios.get(`${API_URL}/api/post/${postid}`, {
2534
withCredentials: true,
@@ -39,13 +48,15 @@ export const createPost = async ({ title, images }) => {
3948
return data
4049
}
4150

42-
export const updatePost = async ({ postid, content }) => {
43-
const formData = new FormData()
44-
formData.append("content", content)
45-
const { data } = await axios.patch(`${API_URL}/api/post/update/${postid}`, formData, {
46-
withCredentials: true,
47-
headers: { "Content-Type": "multipart/form-data" },
48-
})
51+
export const updatePost = async ({ postid, title, images }) => {
52+
const { data } = await axios.patch(
53+
`${API_URL}/api/post/update/${postid}`,
54+
{ title, images },
55+
{
56+
withCredentials: true,
57+
headers: { "Content-Type": "application/json" },
58+
},
59+
)
4960
return data
5061
}
5162

@@ -68,13 +79,9 @@ export const hidePost = async (postid) => {
6879
}
6980

7081
export const likeDislikePost = async (postid) => {
71-
const { data } = await axios.post(
72-
`${API_URL}/api/post/likedislike/${postid}`,
73-
{},
74-
{
75-
withCredentials: true,
76-
},
77-
)
82+
const { data } = await axios.get(`${API_URL}/api/post/likedislike/${postid}`, {
83+
withCredentials: true,
84+
})
7885
return data
7986
}
8087

0 commit comments

Comments
 (0)