Convert frontend from Git submodule to a regular monorepo directory for simplified development workflow. Changes: - Remove frontend submodule tracking (mode 160000 gitlink) - Add all frontend source files directly to main repository - Remove frontend/.git directory - Update CLAUDE.md to clarify true monorepo structure - Update Frontend Architecture documentation (React Router v6, Socket.IO, Leaflet, ErrorBoundary) Benefits of Monorepo: - Single git clone for entire project - Unified commit history - Simpler CI/CD pipeline - Easier for new developers - No submodule sync issues - Atomic commits across frontend and backend Frontend Files Added: - All React components (MapView, ErrorBoundary, TaskList, SocialFeed, etc.) - Context providers (AuthContext, SocketContext) - Complete test suite with MSW - Dependencies and configuration files Branch Cleanup: - Using 'main' as default branch (develop deleted) - Frontend no longer has separate Git history 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
313 lines
8.9 KiB
JavaScript
313 lines
8.9 KiB
JavaScript
import React, { useState, useEffect, useContext, useCallback } from "react";
|
|
import axios from "axios";
|
|
import { toast } from "react-toastify";
|
|
|
|
import { AuthContext } from "../context/AuthContext";
|
|
import { SocketContext } from "../context/SocketContext";
|
|
|
|
/**
|
|
* SocialFeed component displays community posts and allows creating new posts
|
|
* Includes real-time updates via Socket.IO
|
|
*/
|
|
const SocialFeed = () => {
|
|
const { auth } = useContext(AuthContext);
|
|
const { socket, connected, on, off } = useContext(SocketContext);
|
|
const [posts, setPosts] = useState([]);
|
|
const [content, setContent] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [likingPostId, setLikingPostId] = useState(null);
|
|
|
|
// Load posts from API
|
|
const loadPosts = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const res = await axios.get("/api/posts");
|
|
setPosts(res.data);
|
|
} catch (err) {
|
|
console.error("Error loading posts:", err);
|
|
const errorMessage =
|
|
err.response?.data?.msg ||
|
|
err.response?.data?.message ||
|
|
"Failed to load posts. Please try again later.";
|
|
setError(errorMessage);
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadPosts();
|
|
}, [loadPosts]);
|
|
|
|
// Handle real-time post updates via Socket.IO
|
|
useEffect(() => {
|
|
if (!socket || !connected) return;
|
|
|
|
const handleNewPost = (data) => {
|
|
console.log("Received new post:", data);
|
|
setPosts((prevPosts) => [data.post, ...prevPosts]);
|
|
toast.info("New post added to feed!");
|
|
};
|
|
|
|
const handlePostUpdate = (data) => {
|
|
console.log("Received post update:", data);
|
|
|
|
if (data.type === "post_liked") {
|
|
// Update specific post likes
|
|
setPosts((prevPosts) =>
|
|
prevPosts.map((post) =>
|
|
post._id === data.postId ? { ...post, likes: data.likes } : post
|
|
)
|
|
);
|
|
} else if (data.type === "post_updated") {
|
|
// Update existing post
|
|
setPosts((prevPosts) =>
|
|
prevPosts.map((post) =>
|
|
post._id === data.post._id ? data.post : post
|
|
)
|
|
);
|
|
} else if (data.type === "post_deleted") {
|
|
// Remove deleted post
|
|
setPosts((prevPosts) =>
|
|
prevPosts.filter((post) => post._id !== data.postId)
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleNewComment = (data) => {
|
|
console.log("Received new comment:", data);
|
|
// Update post with new comment
|
|
setPosts((prevPosts) =>
|
|
prevPosts.map((post) =>
|
|
post._id === data.postId
|
|
? { ...post, comments: [...(post.comments || []), data.comment] }
|
|
: post
|
|
)
|
|
);
|
|
};
|
|
|
|
// Subscribe to post events
|
|
on("newPost", handleNewPost);
|
|
on("postUpdate", handlePostUpdate);
|
|
on("newComment", handleNewComment);
|
|
|
|
// Cleanup on unmount
|
|
return () => {
|
|
off("newPost", handleNewPost);
|
|
off("postUpdate", handlePostUpdate);
|
|
off("newComment", handleNewComment);
|
|
};
|
|
}, [socket, connected, on, off]);
|
|
|
|
// Like a post
|
|
const likePost = async (id) => {
|
|
if (!auth.isAuthenticated) {
|
|
toast.warning("Please login to like posts");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLikingPostId(id);
|
|
const token = localStorage.getItem("token");
|
|
const res = await axios.put(
|
|
`/api/posts/like/${id}`,
|
|
{},
|
|
{
|
|
headers: {
|
|
"x-auth-token": token,
|
|
},
|
|
}
|
|
);
|
|
setPosts(
|
|
posts.map((post) =>
|
|
post._id === id ? { ...post, likes: res.data } : post
|
|
)
|
|
);
|
|
} catch (err) {
|
|
console.error("Error liking post:", err);
|
|
const errorMessage =
|
|
err.response?.data?.msg ||
|
|
err.response?.data?.message ||
|
|
"Failed to like post. Please try again.";
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setLikingPostId(null);
|
|
}
|
|
};
|
|
|
|
// Submit a new post
|
|
const onSubmit = async (e) => {
|
|
e.preventDefault();
|
|
|
|
if (!auth.isAuthenticated) {
|
|
toast.warning("Please login to create posts");
|
|
return;
|
|
}
|
|
|
|
if (!content.trim()) {
|
|
toast.warning("Post content cannot be empty");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSubmitting(true);
|
|
const token = localStorage.getItem("token");
|
|
const res = await axios.post(
|
|
"/api/posts",
|
|
{ content },
|
|
{
|
|
headers: {
|
|
"x-auth-token": token,
|
|
},
|
|
}
|
|
);
|
|
setPosts([res.data, ...posts]);
|
|
setContent("");
|
|
toast.success("Post created successfully!");
|
|
} catch (err) {
|
|
console.error("Error creating post:", err);
|
|
const errorMessage =
|
|
err.response?.data?.msg ||
|
|
err.response?.data?.message ||
|
|
"Failed to create post. Please try again.";
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="text-center mt-5">
|
|
<div className="spinner-border" role="status">
|
|
<span className="sr-only">Loading...</span>
|
|
</div>
|
|
<p>Loading posts...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="alert alert-danger m-3" role="alert">
|
|
<h4 className="alert-heading">Error Loading Posts</h4>
|
|
<p>{error}</p>
|
|
<hr />
|
|
<button className="btn btn-primary" onClick={loadPosts}>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
|
<h1>Social Feed</h1>
|
|
{connected && (
|
|
<span className="badge badge-success">
|
|
<span className="mr-1">●</span> Live Updates
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{auth.isAuthenticated && (
|
|
<div className="card mb-4">
|
|
<div className="card-body">
|
|
<h5 className="card-title">Create a Post</h5>
|
|
<form onSubmit={onSubmit}>
|
|
<div className="form-group">
|
|
<textarea
|
|
className="form-control"
|
|
rows="3"
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
placeholder="What's on your mind?"
|
|
required
|
|
disabled={submitting}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary"
|
|
disabled={submitting || !content.trim()}
|
|
>
|
|
{submitting ? (
|
|
<>
|
|
<span
|
|
className="spinner-border spinner-border-sm mr-2"
|
|
role="status"
|
|
aria-hidden="true"
|
|
></span>
|
|
Posting...
|
|
</>
|
|
) : (
|
|
"Post"
|
|
)}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{posts.length === 0 ? (
|
|
<div className="alert alert-info">
|
|
No posts yet. Be the first to share something!
|
|
</div>
|
|
) : (
|
|
<ul className="list-group">
|
|
{posts.map((post) => (
|
|
<li key={post._id} className="list-group-item">
|
|
<div className="mb-2">
|
|
<p className="mb-2">{post.content}</p>
|
|
<small className="text-muted">
|
|
By: <strong>{post.user?.name || "Unknown User"}</strong>
|
|
{post.createdAt && (
|
|
<span className="ml-2">
|
|
{new Date(post.createdAt).toLocaleDateString()}{" "}
|
|
{new Date(post.createdAt).toLocaleTimeString()}
|
|
</span>
|
|
)}
|
|
</small>
|
|
</div>
|
|
<button
|
|
className="btn btn-sm btn-outline-primary"
|
|
onClick={() => likePost(post._id)}
|
|
disabled={!auth.isAuthenticated || likingPostId === post._id}
|
|
>
|
|
{likingPostId === post._id ? (
|
|
<>
|
|
<span
|
|
className="spinner-border spinner-border-sm mr-1"
|
|
role="status"
|
|
aria-hidden="true"
|
|
></span>
|
|
Liking...
|
|
</>
|
|
) : (
|
|
<>
|
|
Like ({post.likes?.length || 0})
|
|
</>
|
|
)}
|
|
</button>
|
|
{post.comments && post.comments.length > 0 && (
|
|
<div className="mt-2">
|
|
<small className="text-muted">
|
|
{post.comments.length} comment{post.comments.length !== 1 ? "s" : ""}
|
|
</small>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SocialFeed;
|