Files
adopt-a-street/frontend/src/components/SocialFeed.js
William Valentin 2df5a303ed refactor: convert frontend from submodule to true monorepo
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>
2025-11-01 11:01:06 -07:00

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">&#9679;</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;