Untitled
user_1740036
plain_text
2 months ago
17 kB
4
Indexable
import React, { useState, useEffect } from "react"; import { collection, query, orderBy, onSnapshot, serverTimestamp, addDoc, doc, updateDoc, arrayUnion, arrayRemove, getDoc, } from "firebase/firestore"; import { getStorage, ref, uploadBytes, getDownloadURL } from "firebase/storage"; import { getAuth } from "firebase/auth"; import { db } from "../firebase"; import { motion, AnimatePresence } from "framer-motion"; import { Heart, MessageSquare, Plus, Loader2, Image, Lock, Globe, UserPlus, X, } from "lucide-react"; // Reusable Alert Component const Alert = ({ children, variant = "default", onClose }) => { const bgColor = variant === "destructive" ? "bg-red-50 border-red-200 text-red-600" : "bg-green-50 border-green-200 text-green-600"; return ( <div className={`p-4 rounded-lg border ${bgColor} relative`}> {children} {onClose && ( <button onClick={onClose} className="absolute top-2 right-2 text-gray-500 hover:text-gray-700" > <X size={16} /> </button> )} </div> ); }; // Helper: Format Firestore Timestamp const formatTimestamp = (timestamp) => { if (!timestamp) return new Date().toLocaleString(); return timestamp.toDate ? timestamp.toDate().toLocaleString() : new Date(timestamp).toLocaleString(); }; function SpacesFeed() { // STATE VARIABLES const [posts, setPosts] = useState([]); const [text, setText] = useState(""); const [loading, setLoading] = useState(true); const [isPosting, setIsPosting] = useState(false); const [error, setError] = useState(null); // Image selection const [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); // Privacy & notifications const [privacy, setPrivacy] = useState("private"); const [notification, setNotification] = useState(null); // Comments const [activeCommentPostId, setActiveCommentPostId] = useState(null); const [commentText, setCommentText] = useState(""); const currentUser = getAuth().currentUser; const storage = getStorage(); const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB limit // ------------------ REAL-TIME FETCH POSTS ------------------ useEffect(() => { const colRef = collection(db, "spaces"); const q = query(colRef, orderBy("createdAt", "desc")); const unsubscribe = onSnapshot( q, (snapshot) => { try { const postsArr = snapshot.docs .map((docSnap) => { const data = docSnap.data(); if (!data) return null; return { id: docSnap.id, text: data.text || "", userId: data.userId || "", userName: data.userName || "Anonymous", userAvatar: data.userAvatar || "/default-avatar.png", privacy: data.privacy || "private", imageUrl: data.imageUrl || null, createdAt: data.createdAt ? data.createdAt.toDate() : new Date(), likes: typeof data.likes === "number" ? data.likes : 0, likedBy: Array.isArray(data.likedBy) ? data.likedBy : [], comments: Array.isArray(data.comments) ? data.comments : [], }; }) .filter(Boolean); setPosts(postsArr); setLoading(false); } catch (err) { console.error("Data processing error:", err); setError("Failed to process post data"); setLoading(false); } }, (err) => { console.error("Snapshot error:", err); setError("Failed to load posts: " + err.message); setLoading(false); } ); return () => unsubscribe(); }, []); // ------------------ IMAGE SELECTION HANDLER ------------------ const handleImageSelect = (e) => { const file = e.target.files[0]; if (!file) return; if (file.size > MAX_FILE_SIZE) { alert("Image must be under 2MB!"); return; } setSelectedImage(file); const reader = new FileReader(); reader.onloadend = () => setImagePreview(reader.result); reader.readAsDataURL(file); }; // ------------------ CREATE NEW POST ------------------ const handleCreatePost = async () => { if (!currentUser) { setError("Please sign in to create a post!"); return; } if (!text.trim() && !selectedImage) { setError("Post must contain text or an image!"); return; } setIsPosting(true); setError(null); try { let imageUrl = null; if (selectedImage) { // Upload image to Storage const imageRef = ref( storage, `spaces/${currentUser.uid}/${Date.now()}_${selectedImage.name}` ); await uploadBytes(imageRef, selectedImage); imageUrl = await getDownloadURL(imageRef); } const newPost = { userId: currentUser.uid, userName: currentUser.displayName || "Anonymous", userAvatar: currentUser.photoURL || "/default-avatar.png", text: text.trim(), imageUrl, createdAt: serverTimestamp(), privacy, likes: 0, likedBy: [], comments: [], }; await addDoc(collection(db, "spaces"), newPost); // Reset the form setText(""); setSelectedImage(null); setImagePreview(null); setPrivacy("private"); // Show success notification setNotification({ type: "success", message: "Post created successfully!", }); setTimeout(() => setNotification(null), 3000); } catch (err) { console.error("Post creation error:", err); setError("Failed to create post. Please try again."); } finally { setIsPosting(false); } }; // ------------------ LIKE / UNLIKE HANDLER ------------------ const handleToggleLike = async (post) => { if (!currentUser) { setError("Please sign in to like a post!"); return; } try { const postRef = doc(db, "spaces", post.id); const alreadyLiked = post.likedBy.includes(currentUser.uid); // If user has already liked, we remove them; else we add them const newLikes = alreadyLiked ? post.likes - 1 : post.likes + 1; await updateDoc(postRef, { likes: newLikes, likedBy: alreadyLiked ? arrayRemove(currentUser.uid) : arrayUnion(currentUser.uid), }); } catch (err) { console.error("Error toggling like:", err); setError("Failed to update like. Please try again."); } }; // ------------------ TOGGLE COMMENT SECTION ------------------ const handleToggleComments = (postId) => { setActiveCommentPostId((prev) => (prev === postId ? null : postId)); setCommentText(""); }; // ------------------ ADD COMMENT ------------------ const handleAddComment = async (postId) => { if (!currentUser) { setError("Please sign in to comment!"); return; } if (!commentText.trim()) { setError("Comment cannot be empty!"); return; } const postRef = doc(db, "spaces", postId); const newComment = { userId: currentUser.uid, userName: currentUser.displayName || "Anonymous", text: commentText.trim(), createdAt: serverTimestamp(), }; try { await updateDoc(postRef, { comments: arrayUnion(newComment), }); setCommentText(""); setError(null); } catch (err) { console.error("Failed to add comment:", err); setError("Failed to add comment. Please try again."); } }; // ------------------ RENDER ------------------ if (loading) { return ( <div className="flex items-center justify-center min-h-screen bg-pink-50"> <Loader2 className="w-8 h-8 animate-spin text-pink-500" /> </div> ); } return ( <div className="min-h-screen p-4 bg-gradient-to-br from-pink-50 to-white"> <div className="max-w-lg mx-auto"> <h1 className="text-2xl font-bold mb-6 text-gray-800">My Spaces</h1> {/* Notification */} {notification && ( <div className="mb-4"> <Alert onClose={() => setNotification(null)}> {notification.message} </Alert> </div> )} {/* Error Alert */} {error && ( <div className="mb-4"> <Alert variant="destructive" onClose={() => setError(null)}> {error} </Alert> </div> )} {/* CREATE POST FORM */} <div className="mb-6 p-4 bg-white rounded-lg shadow-lg"> <textarea value={text} onChange={(e) => setText(e.target.value)} rows="3" placeholder="Share your thoughts, experiences, or stories..." className="w-full p-3 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-200 transition-all" disabled={isPosting} /> {/* IMAGE PREVIEW */} {imagePreview && ( <div className="relative mt-2"> <img src={imagePreview} alt="Preview" className="max-h-48 rounded-lg object-cover" /> <button onClick={() => { setSelectedImage(null); setImagePreview(null); }} className="absolute top-2 right-2 p-1 bg-gray-800 bg-opacity-50 rounded-full text-white hover:bg-opacity-70" > × </button> </div> )} <div className="flex justify-between items-center mt-3"> <div className="flex items-center gap-4"> {/* Image Input */} <label className="cursor-pointer text-pink-500 hover:text-pink-600"> <Image size={20} /> <input type="file" accept="image/*" className="hidden" onChange={handleImageSelect} disabled={isPosting} /> </label> {/* Privacy Selection */} <select value={privacy} onChange={(e) => setPrivacy(e.target.value)} className="text-sm border rounded-lg px-2 py-1" > <option value="private">Private</option> <option value="friends">Friends</option> <option value="public">Public</option> </select> </div> {/* Post Button */} <button onClick={handleCreatePost} disabled={isPosting || (!text.trim() && !selectedImage)} className="px-4 py-2 bg-pink-500 text-white rounded-lg hover:bg-pink-600 transition-all flex items-center gap-2 disabled:opacity-50" > {isPosting ? ( <Loader2 className="w-4 h-4 animate-spin" /> ) : ( <Plus size={16} /> )} <span>{isPosting ? "Posting..." : "Post"}</span> </button> </div> </div> {/* POSTS LIST */} <AnimatePresence> {posts.map((post) => ( <motion.div key={post.id} className="mb-4 bg-white rounded-lg shadow-lg p-4" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} > {/* Post Header */} <div className="flex items-center justify-between mb-3"> <div className="flex items-center gap-3"> <img src={post.userAvatar} alt={`${post.userName}'s avatar`} className="w-10 h-10 rounded-full object-cover border-2 border-pink-100" /> <div> <p className="font-semibold text-gray-800">{post.userName}</p> <div className="flex items-center gap-1 text-xs text-gray-500"> <span>{formatTimestamp(post.createdAt)}</span> {post.privacy === "private" && <Lock size={12} />} {post.privacy === "friends" && <UserPlus size={12} />} {post.privacy === "public" && <Globe size={12} />} </div> </div> </div> </div> {/* Post Text */} <p className="text-gray-700 mb-4 whitespace-pre-wrap"> {post.text} </p> {/* Post Image */} {post.imageUrl && ( <img src={post.imageUrl} alt="Post content" className="mb-4 rounded-lg max-h-96 w-full object-cover" /> )} {/* Like / Comment Buttons */} <div className="flex items-center gap-6 text-sm text-gray-500"> <button className="flex items-center gap-1 hover:text-pink-500 transition-colors" onClick={() => handleToggleLike(post)} > <Heart size={18} className={ post.likedBy?.includes(currentUser?.uid) ? "fill-pink-500 text-pink-500" : "" } /> <span>{post.likes}</span> </button> <button className="flex items-center gap-1 hover:text-pink-500 transition-colors" onClick={() => handleToggleComments(post.id)} > <MessageSquare size={18} /> <span>{post.comments?.length}</span> </button> </div> {/* Comments Section */} {activeCommentPostId === post.id && ( <div className="mt-3 bg-pink-50 rounded-lg p-3"> {post.comments?.length > 0 ? ( post.comments.map((c, idx) => { const commentDate = formatTimestamp(c.createdAt); return ( <div key={idx} className="mb-2 bg-white p-2 rounded shadow-sm" > <p className="text-sm font-semibold text-gray-700"> {c.userName}{" "} <span className="text-xs text-gray-400 ml-2"> {commentDate} </span> </p> <p className="text-sm text-gray-600">{c.text}</p> </div> ); }) ) : ( <p className="text-gray-500 text-sm"> No comments yet. Be the first to comment! </p> )} <div className="flex items-center gap-2 mt-2"> <input type="text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder="Write a comment..." className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded" /> <button onClick={() => handleAddComment(post.id)} className="px-3 py-2 bg-pink-500 text-white rounded hover:bg-pink-600 transition-colors text-sm" > Post </button> </div> </div> )} </motion.div> ))} </AnimatePresence> {/* Fallback when no posts exist */} {!loading && posts.length === 0 && ( <div className="text-center py-10"> <p className="text-gray-500"> No posts yet. Be the first to share something! </p> </div> )} </div> </div> ); } export default SpacesFeed;
Editor is loading...
Leave a Comment