Untitled
user_1740036
plain_text
10 months ago
17 kB
7
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