+ {/* 3. Streamlined Auth Buttons */}
+
+
+
+
+ {/* Mobile Menu Overlay */}
+
@@ -155,5 +168,4 @@ const Navbar = () => {
);
};
-export default Navbar;
-
+export default Navbar;
\ No newline at end of file
diff --git a/frontend/src/components/chat/ChatThread.jsx b/frontend/src/components/chat/ChatThread.jsx
new file mode 100644
index 0000000..a656183
--- /dev/null
+++ b/frontend/src/components/chat/ChatThread.jsx
@@ -0,0 +1,162 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { useChat } from '../../contexts/ChatContext';
+import { messagesAPI } from '../../api/api';
+import { useAuth } from '../../contexts/AuthContext';
+import { Send, Smile } from 'lucide-react';
+import EmojiPicker from 'emoji-picker-react';
+import { formatDistanceToNow } from 'date-fns';
+import UserAvatar from '../common/UserAvatar';
+
+const ChatThread = ({ conversation, selectedUser, onMessageSent }) => {
+ const { user } = useAuth();
+ const { socket, joinConversation, leaveConversation } = useChat();
+ const [messages, setMessages] = useState([]);
+ const [newMessage, setNewMessage] = useState('');
+ const [showEmoji, setShowEmoji] = useState(false);
+ const bottomRef = useRef(null);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const fetchMessages = async () => {
+ if (conversation?.id) {
+ try {
+ const res = await messagesAPI.getConversation(conversation.id);
+ if (mounted) setMessages(res);
+ } catch (err) {
+ console.error(err);
+ }
+ } else {
+ setMessages([]);
+ }
+ };
+
+ fetchMessages();
+
+ if (conversation?.id && socket) {
+ joinConversation(conversation.id);
+
+ const handleReceive = (msg) => {
+ setMessages(prev => [...prev, msg]);
+ };
+
+ socket.on('message:receive', handleReceive);
+
+ return () => {
+ mounted = false;
+ socket.off('message:receive', handleReceive);
+ leaveConversation(conversation.id);
+ };
+ }
+
+ return () => { mounted = false; };
+ }, [conversation?.id, socket]);
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages]);
+
+ const handleSend = async (e) => {
+ e.preventDefault();
+ if (!newMessage.trim()) return;
+
+ try {
+ const sentMsg = await messagesAPI.sendMessage({
+ conversationId: conversation?.id,
+ receiverId: selectedUser?.id,
+ content: newMessage
+ });
+
+ setMessages(prev => [...prev, sentMsg]);
+ setNewMessage('');
+ if (onMessageSent) onMessageSent();
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ const onEmojiClick = (emojiData) => {
+ setNewMessage(prev => prev + emojiData.emoji);
+ setShowEmoji(false);
+ };
+
+ const otherUser = selectedUser || conversation?.members?.find(m => m.user.auth0Id !== user?.auth0Id)?.user;
+
+ if (!otherUser) {
+ return (
+
+
+
Select a conversation to start chatting
+
+ );
+ }
+
+ return (
+
+
+
+
+
{otherUser.username}
+
Active Now
+
+
+
+
+ {messages.map((msg, i) => {
+ const isMe = msg.sender.auth0Id === user?.auth0Id;
+ return (
+
+
+ {msg.content}
+
+
+ {formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+};
+
+export default ChatThread;
diff --git a/frontend/src/components/common/UserAvatar.jsx b/frontend/src/components/common/UserAvatar.jsx
new file mode 100644
index 0000000..3415d94
--- /dev/null
+++ b/frontend/src/components/common/UserAvatar.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+const UserAvatar = ({ user, className = 'w-10 h-10', onClick }) => {
+ const username = user?.username || 'User';
+ const avatarUrl = user?.profile?.avatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=EEF2FF&color=4F46E5&bold=true`;
+
+ return (
+

{
+ e.target.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=EEF2FF&color=4F46E5&bold=true`;
+ }}
+ />
+ );
+};
+
+export default UserAvatar;
diff --git a/frontend/src/components/community/CreatePostModal.jsx b/frontend/src/components/community/CreatePostModal.jsx
new file mode 100644
index 0000000..c346cfe
--- /dev/null
+++ b/frontend/src/components/community/CreatePostModal.jsx
@@ -0,0 +1,137 @@
+import React, { useState } from 'react';
+import { useAuth } from '../../contexts/AuthContext';
+import { postsAPI } from '../../api/api';
+import { Image, Tag, Hash, X } from 'lucide-react';
+import EmojiPicker from 'emoji-picker-react';
+import UserAvatar from '../common/UserAvatar';
+
+const CreatePostModal = ({ onClose, onPostCreated }) => {
+ const { user } = useAuth();
+ const [content, setContent] = useState('');
+ const [showEmoji, setShowEmoji] = useState(false);
+ const [tags, setTags] = useState([]);
+ const [tagInput, setTagInput] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!content.trim()) return;
+ setSubmitting(true);
+ try {
+ await postsAPI.createPost({
+ content,
+ tags
+ });
+ onPostCreated();
+ onClose();
+ } catch (err) {
+ console.error(err);
+ setSubmitting(false);
+ }
+ };
+
+ const handleTagAdd = (e) => {
+ e.preventDefault();
+ if (tagInput.trim() && !tags.includes(tagInput.trim())) {
+ setTags([...tags, tagInput.trim()]);
+ setTagInput('');
+ }
+ };
+
+ const removeTag = (tagToRemove) => {
+ setTags(tags.filter(t => t !== tagToRemove));
+ };
+
+ const onEmojiClick = (emojiObject) => {
+ setContent(prevInput => prevInput + emojiObject.emoji);
+ setShowEmoji(false);
+ };
+
+ return (
+
+
+
+
Create Post
+
+
+
+
+
+
+ );
+};
+
+export default CreatePostModal;
diff --git a/frontend/src/components/community/FriendRequestsModal.jsx b/frontend/src/components/community/FriendRequestsModal.jsx
new file mode 100644
index 0000000..577168c
--- /dev/null
+++ b/frontend/src/components/community/FriendRequestsModal.jsx
@@ -0,0 +1,89 @@
+import React, { useState, useEffect } from 'react';
+import { friendsAPI } from '../../api/api';
+import { X, Check, UserX } from 'lucide-react';
+import UserAvatar from '../common/UserAvatar';
+
+const FriendRequestsModal = ({ onClose, onUpdate }) => {
+ const [requests, setRequests] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const fetchRequests = async () => {
+ try {
+ const data = await friendsAPI.getPendingRequests();
+ setRequests(data);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchRequests();
+ }, []);
+
+ const handleAction = async (requestId, action) => {
+ try {
+ await friendsAPI.respondToRequest(requestId, action);
+ setRequests(prev => prev.filter(r => r.id !== requestId));
+ onUpdate(); // Refresh friends list in background
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ return (
+
+
+
+
Friend Requests
+
+
+
+
+ {loading ? (
+
Loading...
+ ) : requests.length > 0 ? (
+
+ {requests.map(req => (
+
+
+
+
+
{req.sender?.username}
+
Connect Request
+
+
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
No pending friend requests.
+
+ )}
+
+
+
+ );
+};
+
+export default FriendRequestsModal;
diff --git a/frontend/src/components/community/PostCard.jsx b/frontend/src/components/community/PostCard.jsx
new file mode 100644
index 0000000..8d8a7b9
--- /dev/null
+++ b/frontend/src/components/community/PostCard.jsx
@@ -0,0 +1,201 @@
+import React, { useState, useEffect } from 'react';
+import { formatDistanceToNow } from 'date-fns';
+import { MessageCircle, Share2, MoreHorizontal, CornerDownRight } from 'lucide-react';
+import { Link } from 'react-router-dom';
+import ReactionBar from './ReactionBar';
+import { useAuth } from '../../contexts/AuthContext';
+import { postsAPI } from '../../api/api';
+import UserAvatar from '../common/UserAvatar';
+
+const CommentItem = ({ comment, isReply = false }) => (
+
+
+
+
+
+
+
+
+ {comment.user?.username}
+
+
+ {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
+
+
+
{comment.content}
+
+
+
+);
+
+const PostCard = ({ post, onUpdate }) => {
+ const { user } = useAuth();
+ const [showComments, setShowComments] = useState(false);
+ const [commentText, setCommentText] = useState('');
+ const [comments, setComments] = useState([]);
+ const [loadingComments, setLoadingComments] = useState(false);
+
+ const fetchComments = async () => {
+ setLoadingComments(true);
+ try {
+ const data = await postsAPI.getComments(post.id);
+ setComments(data);
+ } catch (err) {
+ console.error('Error fetching comments:', err);
+ } finally {
+ setLoadingComments(false);
+ }
+ };
+
+ useEffect(() => {
+ if (showComments && post.id) {
+ fetchComments();
+ }
+ }, [showComments, post.id]);
+
+ const handleCommentSubmit = async (e) => {
+ e.preventDefault();
+ if (!commentText.trim()) return;
+ try {
+ await postsAPI.addComment(post.id, { content: commentText });
+ setCommentText('');
+ fetchComments(); // Refresh comments list
+ onUpdate(); // Refresh post stats (comment count)
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {post.author?.username}
+
+
+ {formatDistanceToNow(new Date(post.createdAt), { addSuffix: true })}
+
+
+
+
+
+
+
+ {post.content}
+
+
+ {post.images && post.images.length > 0 && (
+
+ {post.images.map((img, i) => (
+

+ ))}
+
+ )}
+
+ {post.tags && post.tags.length > 0 && (
+
+ {post.tags.map(t => (
+
+ #{t.tag.name}
+
+ ))}
+
+ )}
+
+ {post.event && (
+
+
+
Event Invite
+
{post.event.name}
+
{post.event.tagline}
+
+ View Event →
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {showComments && (
+
+ {user ? (
+
+ ) : (
+
Log in to comment.
+ )}
+
+
+ {loadingComments ? (
+
+ ) : comments.length > 0 ? (
+ comments.map(comment => (
+
+
+ {comment.replies && comment.replies.length > 0 && (
+
+ {comment.replies.map(reply => (
+
+ ))}
+
+ )}
+
+ ))
+ ) : (
+
No comments yet. Start the conversation!
+ )}
+
+
+ )}
+
+ );
+};
+
+export default PostCard;
diff --git a/frontend/src/components/community/ReactionBar.jsx b/frontend/src/components/community/ReactionBar.jsx
new file mode 100644
index 0000000..94e99a7
--- /dev/null
+++ b/frontend/src/components/community/ReactionBar.jsx
@@ -0,0 +1,56 @@
+import React, { useState } from 'react';
+import { useAuth } from '../../contexts/AuthContext';
+import { formatDistanceToNow } from 'date-fns';
+import { Heart, MessageCircle, Share2, Award, ThumbsUp } from 'lucide-react';
+import { postsAPI } from '../../api/api';
+
+const ReactionBar = ({ post, onReact }) => {
+ const { user } = useAuth();
+
+ const handleReact = async (type) => {
+ try {
+ await postsAPI.reactToPost(post.id, type);
+ onReact();
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ const getReactionCount = (type) => {
+ return post.reactions?.filter(r => r.type === type).length || 0;
+ };
+
+ const hasReacted = (type) => {
+ return post.reactions?.some(r => r.type === type && r.user?.auth0Id === user?.auth0Id);
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default ReactionBar;
diff --git a/frontend/src/contexts/ChatContext.jsx b/frontend/src/contexts/ChatContext.jsx
new file mode 100644
index 0000000..c11f53a
--- /dev/null
+++ b/frontend/src/contexts/ChatContext.jsx
@@ -0,0 +1,59 @@
+import React, { createContext, useContext, useEffect, useState } from 'react';
+import { io } from 'socket.io-client';
+import { useAuth } from './AuthContext';
+
+const ChatContext = createContext(null);
+
+export const useChat = () => {
+ const context = useContext(ChatContext);
+ if (!context) {
+ throw new Error('useChat must be used within a ChatProvider');
+ }
+ return context;
+};
+
+export const ChatProvider = ({ children }) => {
+ const { isAuthenticated } = useAuth();
+ const [socket, setSocket] = useState(null);
+
+ useEffect(() => {
+ let newSocket;
+ if (isAuthenticated) {
+ newSocket = io(import.meta.env.VITE_API_URL || 'http://localhost:4000', {
+ withCredentials: true,
+ });
+
+ newSocket.on('connect', () => {
+ console.log('Socket.IO Connected:', newSocket.id);
+ });
+
+ newSocket.on('disconnect', () => {
+ console.log('Socket.IO Disconnected');
+ });
+
+ setSocket(newSocket);
+ }
+
+ return () => {
+ if (newSocket) newSocket.close();
+ };
+ }, [isAuthenticated]);
+
+ const joinConversation = (conversationId) => {
+ if (socket) {
+ socket.emit('conversation:join', conversationId);
+ }
+ };
+
+ const leaveConversation = (conversationId) => {
+ if (socket) {
+ socket.emit('conversation:leave', conversationId);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/helpers/supabase.js b/frontend/src/helpers/supabase.js
index c62fc93..97bd79e 100644
--- a/frontend/src/helpers/supabase.js
+++ b/frontend/src/helpers/supabase.js
@@ -2,7 +2,8 @@ import { createClient } from '@supabase/supabase-js';
// Replace these with your project's details
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; // Supabase project URL
-const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; // Public API key
-const serviceRoleKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY; // Service Role Key
+// const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
+const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY; // Public API key
+// const serviceRoleKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY; // Service Role Key
-export const supabase = createClient(supabaseUrl, serviceRoleKey);
+export const supabase = createClient(supabaseUrl,SUPABASE_ANON_KEY);
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 42edc47..744a854 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -4,6 +4,10 @@
@tailwind components;
@tailwind utilities;
+html {
+ scroll-behavior: smooth;
+}
+
:root {
--primary-black: #111111;
--secondary-gray: #666666;
diff --git a/frontend/src/pages/ApplyForEvent.jsx b/frontend/src/pages/ApplyForEvent.jsx
index d02c2ea..47cafb9 100644
--- a/frontend/src/pages/ApplyForEvent.jsx
+++ b/frontend/src/pages/ApplyForEvent.jsx
@@ -218,7 +218,8 @@ const ApplyForEvent = () => {
if (error) return
{error}
;
return (
-
+
+
{/* Event Title */}
Apply for Event: {event?.name}
@@ -626,6 +627,7 @@ const ApplyForEvent = () => {
+
);
};
diff --git a/frontend/src/pages/ClubProjects.jsx b/frontend/src/pages/ClubProjects.jsx
new file mode 100644
index 0000000..204c3e0
--- /dev/null
+++ b/frontend/src/pages/ClubProjects.jsx
@@ -0,0 +1,698 @@
+import React, { useState, useEffect } from "react";
+import { useParams, useLocation, useNavigate } from "react-router-dom";
+import {
+ ArrowLeft,
+ Github,
+ ExternalLink,
+ Plus,
+ X,
+ Send,
+ Trash2,
+ MessageCircle,
+ ChevronDown,
+ ChevronUp,
+} from "lucide-react";
+import axios from "axios";
+import { useAuth } from "../contexts/AuthContext";
+
+const AddProjectModal = ({ onClose, onAdd, getAccessToken, clubId }) => {
+ const [form, setForm] = useState({
+ title: "",
+ description: "",
+ githubUrl: "",
+ demoUrl: "",
+ contributors: "",
+ });
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async () => {
+ if (!form.title.trim() || !form.description.trim()) {
+ setError("Title and description are required.");
+ return;
+ }
+ setLoading(true);
+ setError("");
+ try {
+ const token = await getAccessToken();
+ const res = await axios.post(
+ `${import.meta.env.VITE_API_URL}/api/clubs/${clubId}/projects`,
+ {
+ title: form.title.trim(),
+ description: form.description.trim(),
+ githubUrl: form.githubUrl.trim() || null,
+ demoUrl: form.demoUrl.trim() || null,
+ contributors: form.contributors
+ ? form.contributors
+ .split(",")
+ .map((c) => c.trim())
+ .filter(Boolean)
+ : [],
+ },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ withCredentials: true,
+ },
+ );
+ onAdd(res.data);
+ onClose();
+ } catch (err) {
+ setError(err.response?.data?.error || "Failed to add project.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fields = [
+ {
+ label: "Project Name",
+ key: "title",
+ placeholder: "e.g. Smart Grid Monitor",
+ required: true,
+ type: "input",
+ },
+ {
+ label: "Description",
+ key: "description",
+ placeholder: "What does this project do?",
+ required: true,
+ type: "textarea",
+ },
+ {
+ label: "Contributors",
+ key: "contributors",
+ placeholder: "Alice, Bob, Charlie (comma separated)",
+ type: "input",
+ },
+ {
+ label: "GitHub URL",
+ key: "githubUrl",
+ placeholder: "https://github.com/...",
+ type: "input",
+ },
+ {
+ label: "Demo URL",
+ key: "demoUrl",
+ placeholder: "https://...",
+ type: "input",
+ },
+ ];
+
+ return (
+
+
+
+
+
+ Add New Project
+
+
+ Share what your club has been building.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {fields.map((field) => (
+
+
+ {field.type === "textarea" ? (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+};
+
+const CommentItem = ({
+ comment,
+ currentUserId,
+ onDelete,
+ onReply,
+ depth = 0,
+}) => {
+ const [showReplyBox, setShowReplyBox] = useState(false);
+ const [replyText, setReplyText] = useState("");
+ const [showReplies, setShowReplies] = useState(true);
+
+ const handleReply = () => {
+ if (!replyText.trim()) return;
+ onReply(comment.id, replyText.trim());
+ setReplyText("");
+ setShowReplyBox(false);
+ };
+
+ return (
+
0 ? "ml-4 sm:ml-8 border-l border-gray-100 pl-4" : ""}
+ >
+
+
+
+
+
+ {comment.user?.username?.[0]?.toUpperCase() || "?"}
+
+
+
+ {comment.user?.username}
+
+
+ {new Date(comment.createdAt).toLocaleDateString()}
+
+
+ {currentUserId === comment.user?.id && (
+
+ )}
+
+
+
+ {comment.content}
+
+
+
+ {currentUserId && (
+
+ )}
+ {comment.replies?.length > 0 && (
+
+ )}
+
+
+ {showReplyBox && (
+
+ setReplyText(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleReply()}
+ placeholder="Write a reply..."
+ className="flex-1 px-3 py-1.5 border border-gray-100 rounded-2xl text-sm font-light focus:outline-none focus:ring-1 focus:ring-gray-900 bg-gray-50/50"
+ />
+
+
+ )}
+
+
+ {showReplies && comment.replies?.length > 0 && (
+
+ {comment.replies.map((reply) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+const CommentsSection = ({
+ projectId,
+ getAccessToken,
+ currentUserId,
+ isAuthenticated,
+}) => {
+ const [comments, setComments] = useState([]);
+ const [newComment, setNewComment] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [posting, setPosting] = useState(false);
+
+ useEffect(() => {
+ axios
+ .get(`${import.meta.env.VITE_API_URL}/api/clubs/${projectId}/comments`)
+ .then((res) => setComments(res.data))
+ .catch((err) => console.error(err))
+ .finally(() => setLoading(false));
+ }, [projectId]);
+
+ const handlePost = async () => {
+ if (!newComment.trim()) return;
+ setPosting(true);
+ try {
+ const token = await getAccessToken();
+ const res = await axios.post(
+ `${import.meta.env.VITE_API_URL}/api/clubs/${projectId}/comments`,
+ { content: newComment.trim() },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ withCredentials: true,
+ },
+ );
+ setComments((prev) => [res.data, ...prev]);
+ setNewComment("");
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setPosting(false);
+ }
+ };
+
+ const handleReply = async (parentId, content) => {
+ try {
+ const token = await getAccessToken();
+ const res = await axios.post(
+ `${import.meta.env.VITE_API_URL}/api/clubs/${projectId}/comments`,
+ { content, parentId },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ withCredentials: true,
+ },
+ );
+ setComments((prev) =>
+ prev.map((c) =>
+ c.id === parentId
+ ? { ...c, replies: [...(c.replies || []), res.data] }
+ : c,
+ ),
+ );
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ const handleDelete = async (commentId) => {
+ try {
+ const token = await getAccessToken();
+ await axios.delete(
+ `${import.meta.env.VITE_API_URL}/api/clubs/comments/${commentId}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ withCredentials: true,
+ },
+ );
+ setComments((prev) => prev.filter((c) => c.id !== commentId));
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ return (
+
+
+
+
+ Discussion ({comments.length})
+
+
+
+ {isAuthenticated ? (
+
+ setNewComment(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handlePost()}
+ placeholder="Share your thoughts..."
+ className="flex-1 px-4 py-2 border border-gray-100 rounded-2xl text-sm font-light focus:outline-none focus:ring-1 focus:ring-gray-900 bg-gray-50/50"
+ />
+
+
+ ) : (
+
+ Log in to join the discussion.
+
+ )}
+
+
+
+ {loading ? (
+
+ ) : comments.length === 0 ? (
+
+ No comments yet. Be the first!
+
+ ) : (
+
+ {comments.map((comment) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+const CommentsModal = ({
+ onClose,
+ project,
+ getAccessToken,
+ currentUserId,
+ isAuthenticated,
+}) => {
+ return (
+
+
+
+
+
+ Project Discussion
+
+
+ {project.title}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const ProjectCard = ({
+ project,
+ getAccessToken,
+ currentUserId,
+ isAuthenticated,
+}) => {
+ const [showCommentsModal, setShowCommentsModal] = useState(false);
+
+ return (
+ <>
+
+ {/* Animated sweep line — same as DataCard */}
+
+
+ {/* Contributors */}
+ {project.contributors?.length > 0 && (
+
+
+ {project.contributors.map((c, i) => (
+
+
+ {c[0]}
+
+ {c}
+
+ ))}
+
+ )}
+
+
+ {project.title}
+
+
+ {project.description}
+
+
+ {/* Links */}
+
+ {project.githubUrl && (
+
+
+ GitHub
+
+ )}
+ {project.demoUrl && (
+
+
+ Live Demo
+
+ )}
+
+
+
+
+ {showCommentsModal && (
+
setShowCommentsModal(false)}
+ project={project}
+ getAccessToken={getAccessToken}
+ currentUserId={currentUserId}
+ isAuthenticated={isAuthenticated}
+ />
+ )}
+ >
+ );
+};
+
+const ClubProjects = () => {
+ const { id: clubId } = useParams();
+ const { state } = useLocation();
+ const navigate = useNavigate();
+ const { isAuthenticated, getAccessToken, user } = useAuth();
+
+ const [club, setClub] = useState(state?.club || null);
+ const [projects, setProjects] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showAddModal, setShowAddModal] = useState(false);
+ const [currentUserId, setCurrentUserId] = useState(null);
+
+ const isConvener = isAuthenticated && club?.convener?.id === currentUserId;
+
+ useEffect(() => {
+ axios
+ .get(`${import.meta.env.VITE_API_URL}/api/clubs/${clubId}`)
+ .then((res) => {
+ setClub(res.data);
+ setProjects(res.data.projects || []);
+ })
+ .catch((err) => console.error("Error fetching club:", err))
+ .finally(() => setLoading(false));
+ }, [clubId]);
+
+ useEffect(() => {
+ if (!isAuthenticated) return;
+ const fetchUser = async () => {
+ try {
+ const token = await getAccessToken();
+ const res = await axios.get(
+ `${import.meta.env.VITE_API_URL}/api/auth/me`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ withCredentials: true,
+ },
+ );
+ setCurrentUserId(res.data?.id);
+ } catch (err) {
+ console.error(err);
+ }
+ };
+ fetchUser();
+ }, [isAuthenticated]);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ {club?.name?.[0] || "?"}
+
+
+
+
+ {club?.name || "Club"}
+
+
+ {club?.description}
+
+
+
+
+ {isConvener && (
+
+ )}
+
+
+
+
+ {/* Projects */}
+
+ {loading ? (
+
+ ) : projects.length ? (
+ <>
+
+ {projects.length} Project{projects.length !== 1 ? "s" : ""}
+
+
+ {projects.map((project) => (
+
+ ))}
+
+ >
+ ) : (
+
+
+
+
+
+ No projects yet
+
+
+ {isConvener
+ ? "Add your first project using the button above."
+ : "This club hasn't shared any projects yet."}
+
+
+ )}
+
+
+ {showAddModal && (
+
setShowAddModal(false)}
+ onAdd={(p) => setProjects((prev) => [p, ...prev])}
+ getAccessToken={getAccessToken}
+ clubId={clubId}
+ />
+ )}
+
+ );
+};
+
+export default ClubProjects;
diff --git a/frontend/src/pages/Community.jsx b/frontend/src/pages/Community.jsx
new file mode 100644
index 0000000..3951ae9
--- /dev/null
+++ b/frontend/src/pages/Community.jsx
@@ -0,0 +1,237 @@
+import React, { useState, useEffect } from "react";
+import { Link } from "react-router-dom";
+import { postsAPI, friendsAPI } from "../api/api";
+import PostCard from "../components/community/PostCard";
+import CreatePostModal from "../components/community/CreatePostModal";
+import FriendRequestsModal from "../components/community/FriendRequestsModal";
+import { Plus, Bell, Search, UserPlus, Send } from "lucide-react";
+import { useAuth } from "../contexts/AuthContext";
+import UserAvatar from "../components/common/UserAvatar";
+
+const Community = () => {
+ const { user } = useAuth();
+ const [posts, setPosts] = useState([]);
+ const [friends, setFriends] = useState([]);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isRequestsModalOpen, setIsRequestsModalOpen] = useState(false);
+ const [searchResults, setSearchResults] = useState([]);
+ const [pendingCount, setPendingCount] = useState(0);
+ const [loading, setLoading] = useState(true);
+
+ const fetchCommunityData = async () => {
+ try {
+ const [postsRes, friendsRes, requestsRes] = await Promise.all([
+ postsAPI.getPosts(),
+ friendsAPI.getFriends(),
+ friendsAPI.getPendingRequests(),
+ ]);
+ setPosts(postsRes);
+ setFriends(friendsRes);
+ setPendingCount(requestsRes.length);
+ setLoading(false);
+ } catch (err) {
+ console.error(err);
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchCommunityData();
+ }, []);
+
+ return (
+
+
+
Community
+
+
+
+
+ {/* Feed */}
+ {loading ? (
+
+ Loading feed...
+
+ ) : posts.length > 0 ? (
+
+ {posts.map((post) => (
+
+ ))}
+
+ ) : (
+
+
+ No posts yet. Be the first to share something!
+
+
+
+ )}
+
+
+
+ {/* Sidebar */}
+
+
+
+ Messages
+
+
+
+
+
+ Your Friends
+
+ {friends.length > 0 ? (
+
+ {friends.map((friend) => (
+ -
+
+
+
+
+
+
+ {friend.username}
+
+
+ Message
+
+
+
+
+ ))}
+
+ ) : (
+
+ You don't have any friends yet.
+
+ )}
+
+
+
+
+ Discover
+
+
+ {
+ const q = e.target.value;
+ if (q.length > 2) {
+ const results = await friendsAPI.searchUsers(q);
+ setSearchResults(results.filter((u) => u.id !== user.id));
+ } else {
+ setSearchResults([]);
+ }
+ }}
+ className="w-full text-[11px] bg-gray-50 border border-transparent rounded-lg px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-black focus:bg-white transition-all tracking-tight"
+ />
+
+
+ {searchResults.length > 0 && (
+
+ {searchResults.map((u) => (
+
+
+
+
+ {u.username}
+
+
+
+
+ ))}
+
+ )}
+
+
+ Search for event enthusiasts and organizers to expand your
+ network.
+
+
+
+
+
+
+ {isModalOpen && (
+
setIsModalOpen(false)}
+ onPostCreated={fetchCommunityData}
+ />
+ )}
+
+ {isRequestsModalOpen && (
+ setIsRequestsModalOpen(false)}
+ onUpdate={fetchCommunityData}
+ />
+ )}
+
+ );
+};
+
+export default Community;
diff --git a/frontend/src/pages/EventDashboard.jsx b/frontend/src/pages/EventDashboard.jsx
index 419f498..e2c96f9 100644
--- a/frontend/src/pages/EventDashboard.jsx
+++ b/frontend/src/pages/EventDashboard.jsx
@@ -1,455 +1,717 @@
-import React, { useState, useEffect } from 'react';
-import { useParams, Link } from 'react-router-dom';
-import axios from 'axios';
-import { useAuth } from '../contexts/AuthContext';
-import { useNavigate } from 'react-router-dom';
+import React, { useState, useEffect } from "react";
+import { useParams } from "react-router-dom";
+import axios from "axios";
+import { useAuth } from "../contexts/AuthContext";
+import { useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
-import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, LineChart, Line, Legend } from "recharts";
+import {
+ PieChart,
+ Pie,
+ Cell,
+ Tooltip,
+ ResponsiveContainer,
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ LineChart,
+ Line,
+ Legend,
+} from "recharts";
-const EventDashboard = () => {
+const AttendanceSection = ({ applications, id, getAccessToken }) => {
+ const acceptedApps = applications.filter((app) => app.status === "ACCEPTED");
+ const [attendanceMap, setAttendanceMap] = useState(() => {
+ const map = {};
+ acceptedApps.forEach((app) => {
+ map[app.id] = app.attendance || false;
+ });
+ return map;
+ });
+ const [search, setSearch] = useState("");
+ const [loadingId, setLoadingId] = useState(null);
- const { id } = useParams();
- const [event, setEvent] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const { user, getAccessToken } = useAuth();
- const navigate = useNavigate();
- const [activeSection, setActiveSection] = useState('overview');
- const [acceptedApps, setAcceptedApps] = useState([]);
-
- useEffect(() => {
- fetchEvent();
- }, [id]);
-
- const fetchEvent = async () => {
- try {
- setLoading(true);
- const response = await axios.get(
- `${import.meta.env.VITE_API_URL}/api/events/${id}`,
- { withCredentials: true }
- );
- const eventResponse = response.data;
- setEvent(eventResponse);
- setAcceptedApps(eventResponse.applications.filter((app) => app.status === 'ACCEPTED'));
- console.log(response.data)
- } catch (err) {
- console.error('Error fetching event:', err);
- setError('Failed to load event details');
- } finally {
- setLoading(false);
- }
- };
+ const presentCount = Object.values(attendanceMap).filter(Boolean).length;
+ const total = acceptedApps.length;
+ const percentage = total ? Math.round((presentCount / total) * 100) : 0;
- if (loading) {
- return (
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
+ const filtered = acceptedApps.filter((app) =>
+ app.userId.toLowerCase().includes(search.toLowerCase()),
+ );
- if (!event) {
- return (
-
- );
+ const handleToggle = async (app) => {
+ const newVal = !attendanceMap[app.id];
+ setAttendanceMap((prev) => ({ ...prev, [app.id]: newVal }));
+ setLoadingId(app.id);
+ try {
+ const token = await getAccessToken();
+ await axios.patch(
+ `${import.meta.env.VITE_API_URL}/api/events/${id}/attendance`,
+ { applicationId: Number(app.id), attendance: newVal },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ withCredentials: true,
+ },
+ );
+ } catch (err) {
+ console.error("Failed to update attendance", err);
+ setAttendanceMap((prev) => ({ ...prev, [app.id]: !newVal }));
+ } finally {
+ setLoadingId(null);
}
+ };
- const handleSectionChange = (section) => {
- setActiveSection(section);
- };
-
+ const pieData = [
+ { name: "Present", value: presentCount },
+ { name: "Absent", value: total - presentCount },
+ ];
+ const PIE_COLORS = ["#34D399", "#E5E7EB"];
- const ApplicationsTable = ({ applications }) => {
- const [selectedApp, setSelectedApp] = useState(null);
- const handleAccept = async () => {
- console.log(selectedApp)
- const token = await getAccessToken();
- const response = await axios.put(
- `${import.meta.env.VITE_API_URL}/api/events/${id}/application`,
- {...selectedApp, status: "ACCEPTED"},
- {
- headers: {
- Authorization: `Bearer ${token}`,
- 'Content-Type': 'application/json'
- },
- withCredentials: true
- }
- );
- console.log(response.data);
- selectedApp.status = 'ACCEPTED'
- console.log(`Application ${selectedApp.id} Accepted`);
- setAcceptedApps([...acceptedApps, selectedApp]);
- setSelectedApp(null);
- };
-
- const handleReject = async () => {
- selectedApp.status = "REJECTED";
- const token = await getAccessToken();
- const response = await axios.put(
- `${import.meta.env.VITE_API_URL}/api/events/${id}/application`,
- {...selectedApp, status: "REJECTED"},
- {
- headers: {
- Authorization: `Bearer ${token}`,
- 'Content-Type': 'application/json'
- },
- withCredentials: true
- }
- );
- selectedApp.status = 'REJECTED'
- console.log(response.data);
- console.log(`Application ${selectedApp.id} Rejected`);
- setSelectedApp(null);
- };
-
- return (
+ return (
+
+
+ {/* Table */}
+
+
setSearch(e.target.value)}
+ className="mb-4 w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-400 text-sm"
+ />
-
- {/* Table Headers */}
+
-
- {/* Popup Modal */}
- {selectedApp && (
-
-
- {/* Back Button */}
-
-
-
- Application Details
-
-
-
-
- Name: {selectedApp.userData.firstName}{" "}
- {selectedApp.userData.lastName}
-
-
- Team:{" "}
- {selectedApp.team ? selectedApp.team.name : "N/A"}
-
-
- Date Applied:{" "}
- {new Date(selectedApp.createdAt).toLocaleDateString()}
-
-
- Status:{" "}
-
- {selectedApp.status}
-
-
-
-
- {/* Action Buttons */}
-
-
-
-
-
-
- )}
- );
- };
+
- const AcceptedTable = ({ applications }) => {
-
- return (
-
-
- {/* Table Headers */}
-
-
- | Participant Name |
- Team |
- Date |
-
-
-
- {/* Table Body */}
-
- {applications.map((app) => (
-
- |
- {app.userData.firstName + " " + app.userData.lastName}
- |
- {app.team ? app.team.name : "N/A"} |
-
- {new Date(app.createdAt).toLocaleDateString()}
- |
-
+ {/* Pie Chart */}
+
+
+ Attendance
+
+
+
+
+ {pieData.map((_, index) => (
+ |
))}
-
-
+
+
+
+
+
+
{percentage}%
+
+ {presentCount} of {total} present
+
- );
- };
-
-
- const OverviewSection = ({ applications }) => {
- // Calculate Stats
- const totalApplications = applications.length;
- const accepted = applications.filter((app) => app.status === "ACCEPTED").length;
- const rejected = applications.filter((app) => app.status === "REJECTED").length;
- const pending = applications.filter((app) => app.status === "PENDING").length;
-
- // Pie Chart Data
- const statusData = [
- { name: "Accepted", value: accepted },
- { name: "Rejected", value: rejected },
- { name: "Pending", value: pending },
- ];
- const COLORS = ["#34D399", "#EF4444", "#FACC15"];
-
- // Applications Over Time (Line Chart)
- const applicationsOverTime = applications.reduce((acc, app) => {
- const date = new Date(app.createdAt).toLocaleDateString();
- acc[date] = (acc[date] || 0) + 1;
- return acc;
- }, {});
- const lineChartData = Object.entries(applicationsOverTime).map(([date, count]) => ({ date, count }));
-
- // Applicants by Team (Bar Chart)
- const teamData = applications.reduce((acc, app) => {
- const teamName = app.team ? app.team.name : "No Team";
- acc[teamName] = (acc[teamName] || 0) + 1;
- return acc;
- }, {});
- const barChartData = Object.entries(teamData).map(([team, count]) => ({ team, count }));
-
- return (
-
- {/* Stats Cards */}
-
-
-
Total Applications
-
{totalApplications}
-
-
-
Accepted
-
{accepted}
-
-
-
Rejected
-
{rejected}
-
+
+
+
-
- {/* Charts Section */}
-
- {/* Pie Chart for Application Status */}
-
-
Application Status Breakdown
-
-
-
- {statusData.map((_, index) => (
- |
- ))}
-
-
-
-
+
+
+
+
+ );
+};
+
+const EventDashboard = () => {
+ const { id } = useParams();
+ const [event, setEvent] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { user, getAccessToken } = useAuth();
+ const navigate = useNavigate();
+ const [activeSection, setActiveSection] = useState("overview");
+ const [acceptedApps, setAcceptedApps] = useState([]);
+
+ useEffect(() => {
+ fetchEvent();
+ }, [id]);
+
+ const fetchEvent = async () => {
+ try {
+ setLoading(true);
+ const response = await axios.get(
+ `${import.meta.env.VITE_API_URL}/api/events/${id}`,
+ { withCredentials: true },
+ );
+ const eventResponse = response.data;
+ setEvent(eventResponse);
+ setAcceptedApps(
+ eventResponse.applications.filter((app) => app.status === "ACCEPTED"),
+ );
+ } catch (err) {
+ console.error("Error fetching event:", err);
+ setError("Failed to load event details");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!event) {
+ return (
+
+ );
+ }
+
+ const handleSectionChange = (section) => {
+ setActiveSection(section);
+ };
+
+ const ApplicationsTable = ({ applications }) => {
+ const [selectedApp, setSelectedApp] = useState(null);
+
+ const handleAccept = async () => {
+ const token = await getAccessToken();
+ await axios.put(
+ `${import.meta.env.VITE_API_URL}/api/events/${id}/application`,
+ { ...selectedApp, status: "ACCEPTED" },
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ withCredentials: true,
+ },
+ );
+ selectedApp.status = "ACCEPTED";
+ setAcceptedApps([...acceptedApps, selectedApp]);
+ setSelectedApp(null);
+ };
+
+ const handleReject = async () => {
+ const token = await getAccessToken();
+ await axios.put(
+ `${import.meta.env.VITE_API_URL}/api/events/${id}/application`,
+ { ...selectedApp, status: "REJECTED" },
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ withCredentials: true,
+ },
+ );
+ selectedApp.status = "REJECTED";
+ setSelectedApp(null);
+ };
+
+ return (
+
+
+
+
+ |
+ Applicant Name
+ |
+
+ Team
+ |
+
+ Date
+ |
+
+ Status
+ |
+
+
+
+ {applications.map((app) => (
+ setSelectedApp(app)}
+ >
+ |
+ {app.userData.firstName + " " + app.userData.lastName}
+ |
+
+ {app.team ? app.team.name : "N/A"}
+ |
+
+ {new Date(app.createdAt).toLocaleDateString()}
+ |
+
+ {app.status}
+ |
+
+ ))}
+
+
+
+ {/* Modal */}
+ {selectedApp && (
+
+
+
+
+
+ Application Details
+
+
+
+
+ Name:{" "}
+ {selectedApp.userData.firstName}{" "}
+ {selectedApp.userData.lastName}
+
+
+ Team:{" "}
+ {selectedApp.team ? selectedApp.team.name : "N/A"}
+
+
+
+ Date Applied:
+ {" "}
+ {new Date(selectedApp.createdAt).toLocaleDateString()}
+
+
+ Status:{" "}
+
+ {selectedApp.status}
+
+
-
- {/* Line Chart for Applications Over Time */}
-
-
Applications Over Time
-
-
-
-
-
-
-
-
-
+
+
+
+
-
- {/* Bar Chart for Applicants by Team */}
-
-
Applicants by Team
-
-
-
-
-
-
-
-
-
-
-
- );
- };
-
-
-
+ )}
+
+ );
+ };
+
+ const AcceptedTable = ({ applications }) => (
+
+
+
+
+ |
+ Participant Name
+ |
+
+ Team
+ |
+
+ Date
+ |
+
+
+
+ {applications.map((app) => (
+
+ |
+ {app.userData.firstName + " " + app.userData.lastName}
+ |
+
+ {app.team ? app.team.name : "N/A"}
+ |
+
+ {new Date(app.createdAt).toLocaleDateString()}
+ |
+
+ ))}
+
+
+
+ );
+
+ const OverviewSection = ({ applications }) => {
+ const totalApplications = applications.length;
+ const accepted = applications.filter(
+ (app) => app.status === "ACCEPTED",
+ ).length;
+ const rejected = applications.filter(
+ (app) => app.status === "REJECTED",
+ ).length;
+ const pending = applications.filter(
+ (app) => app.status === "PENDING",
+ ).length;
+
+ const statusData = [
+ { name: "Accepted", value: accepted },
+ { name: "Rejected", value: rejected },
+ { name: "Pending", value: pending },
+ ];
+ const COLORS = ["#34D399", "#EF4444", "#FACC15"];
+
+ const applicationsOverTime = applications.reduce((acc, app) => {
+ const date = new Date(app.createdAt).toLocaleDateString();
+ acc[date] = (acc[date] || 0) + 1;
+ return acc;
+ }, {});
+ const lineChartData = Object.entries(applicationsOverTime).map(
+ ([date, count]) => ({ date, count }),
+ );
+
+ const teamData = applications.reduce((acc, app) => {
+ const teamName = app.team ? app.team.name : "No Team";
+ acc[teamName] = (acc[teamName] || 0) + 1;
+ return acc;
+ }, {});
+ const barChartData = Object.entries(teamData).map(([team, count]) => ({
+ team,
+ count,
+ }));
return (
- <>
-
-
-
{event.name}
-
{event.tagline}
-
+
+
+
+
+ Total Applications
+
+
+ {totalApplications}
+
+
+
+
+ Accepted
+
+
+ {accepted}
+
+
+
+
+ Rejected
+
+
+ {rejected}
+
+
+
-
-
-
+
+
+
+ Application Status Breakdown
+
+
+
+
+ {statusData.map((_, index) => (
+ |
+ ))}
+
+
+
+
+
- {activeSection === 'overview' && (
-
- {event.applications.length ?
-
-
-
- :
-
Theres nothing to visualise :/
- }
-
- )}
+
+
+ Applications Over Time
+
+
+
+
+
+
+
+
+
+
+
+
- {activeSection === 'review' && (
-
- {event.applications.length ?
-
-
Number of Applications : {event.applications.length}
-
-
- :
-
Applications not found!
- }
-
- )}
+
+
+ Applicants by Team
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ };
- {activeSection === 'admin' && (
-
- {acceptedApps.length ?
-
-
Number of Participants : {acceptedApps.length}
-
-
-
- :
-
Go Accept Some Applicants!
- }
-
- )}
-
- >
- )
-}
+ return (
+ <>
+
+
+
+ {event.name}
+
+
+ {event.tagline}
+
+
+
+
+
+
+
+ {/* Sections */}
+ {activeSection === "overview" && (
+
+ {event.applications.length ? (
+
+ ) : (
+
+ There's nothing to visualise :/
+
+ )}
+
+ )}
+
+ {activeSection === "review" && (
+
+ {event.applications.length ? (
+
+
+ Number of Applications: {event.applications.length}
+
+
+
+ ) : (
+
+ Applications not found!
+
+ )}
+
+ )}
+
+ {activeSection === "admin" && (
+
+ {acceptedApps.length ? (
+
+
+ Number of Participants: {acceptedApps.length}
+
+
+
+ ) : (
+
+ Go Accept Some Applicants!
+
+ )}
+
+ )}
+
+ {activeSection === "attendance" && (
+
+ {event.applications.filter((a) => a.status === "ACCEPTED")
+ .length ? (
+
+
+ Number of Participants:{" "}
+ {
+ event.applications.filter((a) => a.status === "ACCEPTED")
+ .length
+ }
+
+
+
+ ) : (
+
+ No accepted participants yet!
+
+ )}
+
+ )}
+
+ >
+ );
+};
-export default EventDashboard
\ No newline at end of file
+export default EventDashboard;
diff --git a/frontend/src/pages/EventDetails.jsx b/frontend/src/pages/EventDetails.jsx
index e70cdd8..848a52c 100644
--- a/frontend/src/pages/EventDetails.jsx
+++ b/frontend/src/pages/EventDetails.jsx
@@ -87,13 +87,13 @@ const EventDetails = () => {
return (
-
+
{event.name}
{event.tagline}
-
+