diff --git a/000.env b/000.env new file mode 100644 index 0000000..5b1ac88 --- /dev/null +++ b/000.env @@ -0,0 +1 @@ +VITE_ANTHROPIC_API_KEY=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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ce14dca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# Project Permissions + +- You have permission to read, write, and edit any files in this project +- No need to ask for approval before making code changes +- Focus on implementing solutions efficiently + +# Development Commands + +- `npm run dev` - Start development server +- `npm run build` - Build for production +- `npm run lint` - Run linting +- `npm run typecheck` - Run TypeScript checks + +# Project Notes + +This is a React/TypeScript NAO (Network of Authentic Others) community management application with features for: +- Contact management with NAO membership status +- Group management and invitations +- Vouch/praise system for reputation +- Network visualization +- Content feeds and messaging \ No newline at end of file diff --git a/index.html b/index.html index 5327f3f..01c18e3 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ - + diff --git a/package.json b/package.json index 8b7b171..2cc6c5d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host 0.0.0.0 --port 5173", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" diff --git a/public/contacts.json b/public/contacts.json index c56b1fc..d72d5d3 100644 --- a/public/contacts.json +++ b/public/contacts.json @@ -73,9 +73,10 @@ "company": "Wilson Consulting", "position": "Business Consultant", "source": "contacts", - "profileImage": "https://i.pravatar.cc/150?img=5", + "profileImage": "https://i.pravatar.cc/150?img=8", "notes": "Helped with business strategy", "tags": ["consulting", "strategy", "business"], + "naoStatus": "not_invited", "createdAt": "2023-06-05T14:00:00Z", "updatedAt": "2023-07-15T09:30:00Z" }, @@ -87,10 +88,11 @@ "company": "Marketing Co", "position": "Marketing Director", "source": "linkedin", - "profileImage": "https://i.pravatar.cc/150?img=6", + "profileImage": "https://i.pravatar.cc/150?img=9", "linkedinUrl": "https://linkedin.com/in/lisathompson", "notes": "Great at digital marketing strategies", "tags": ["marketing", "digital", "strategy"], + "naoStatus": "not_invited", "createdAt": "2023-05-12T12:00:00Z", "updatedAt": "2023-06-20T15:45:00Z" } diff --git a/src/App.css b/src/App.css index b9d355d..2af6c37 100644 --- a/src/App.css +++ b/src/App.css @@ -1,8 +1,8 @@ #root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; + width: 100%; + margin: 0; + padding: 0; + text-align: left; } .logo { diff --git a/src/App.tsx b/src/App.tsx index 9a01342..95bede7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import FeedPage from './pages/FeedPage'; import PostsOffersPage from './pages/PostsOffersPage'; import MessagesPage from './pages/MessagesPage'; import AccountPage from './pages/AccountPage'; +import NotificationsPage from './pages/NotificationsPage'; import { createAppTheme } from './theme/theme'; const theme = createAppTheme('light'); @@ -45,6 +46,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/account/MyCollectionPage.tsx b/src/components/account/MyCollectionPage.tsx index e31b207..c29106b 100644 --- a/src/components/account/MyCollectionPage.tsx +++ b/src/components/account/MyCollectionPage.tsx @@ -518,7 +518,7 @@ const MyCollectionPage = ({}: MyCollectionPageProps) => { - My Collection + My Bookmarks - - - - - + + + + + J + + + John Doe + + + Product Manager at TechCorp + + + + + + + @@ -354,7 +392,7 @@ const AccountPage = () => { {/* My Cards Tab */} - + {/* rCard List */} @@ -460,33 +498,36 @@ const AccountPage = () => { - {/* My Home Tab */} + {/* My Webpage Tab */} - - + + + + + My Webpage + + + Customizable web page, where you choose what to show - maybe 3 blocks e.g widgets, possibly including the last 5 things from your stream. + + + Coming soon... + + + - {/* My Collection Tab */} + {/* My Stream Tab */} - - + + - {/* Account Settings Tab */} + {/* My Bookmarks Tab */} - - - - - Account Settings - - - Account settings coming soon... - - - + + @@ -499,7 +540,7 @@ const AccountPage = () => { onDelete={handleRCardDelete} editingRCard={editingRCard || undefined} /> - + ); }; diff --git a/src/pages/ContactListPage.tsx b/src/pages/ContactListPage.tsx index 0dc2857..e101c49 100644 --- a/src/pages/ContactListPage.tsx +++ b/src/pages/ContactListPage.tsx @@ -32,7 +32,9 @@ import { Favorite, CheckCircle, Schedule, - Send + Send, + ArrowUpward, + ArrowDownward } from '@mui/icons-material'; import { dataService } from '../services/dataService'; import type { Contact } from '../types/contact'; @@ -152,11 +154,79 @@ const ContactListPage = () => { navigate(`/invite?inviteeName=${encodeURIComponent(contact.name)}&inviteeEmail=${encodeURIComponent(contact.email)}`); }; + const getVouchPraiseCounts = (contact: Contact) => { + // Based on the contact detail page data, return appropriate counts + // This would normally come from a real data service, but for demo purposes: + + if (contact.naoStatus === 'member') { + // For NAO members, show sent/received counts + if (contact.name === 'Sarah Johnson') { + return { + vouchesSent: 1, // What I sent to Sarah + vouchesReceived: 2, // What Sarah sent to me + praisesSent: 2, // What I sent to Sarah + praisesReceived: 1 // What Sarah sent to me + }; + } else if (contact.name === 'Emily Rodriguez') { + return { + vouchesSent: 1, + vouchesReceived: 2, + praisesSent: 2, + praisesReceived: 1 + }; + } + } else { + // For non-members, show what I've sent (hidden from them) + return { + vouchesSent: 1, // What I sent to them (hidden) + vouchesReceived: 0, // They can't send until they join + praisesSent: 1, // What I sent to them (hidden) + praisesReceived: 0 // They can't send until they join + }; + } + + return { + vouchesSent: 0, + vouchesReceived: 0, + praisesSent: 0, + praisesReceived: 0 + }; + }; + return ( - - - - + + + + {isSelectionMode ? 'Select Contact to Invite' : 'Contacts'} {isSelectionMode && ( @@ -166,12 +236,27 @@ const ContactListPage = () => { )} {!isSelectionMode && ( - + @@ -179,7 +264,15 @@ const ContactListPage = () => { variant="outlined" startIcon={} onClick={handleInvite} - sx={{ borderRadius: 2 }} + sx={{ + borderRadius: 2, + flex: { xs: 1, md: 'none' }, + minWidth: 0, + fontSize: { xs: '0.75rem', md: '0.875rem' }, + px: { xs: 0.5, md: 2 }, + py: { xs: 0.5, md: 1 }, + maxWidth: { xs: 'calc(33.33% - 4px)', md: 'none' } + }} > Invite @@ -187,19 +280,44 @@ const ContactListPage = () => { variant="contained" startIcon={} onClick={handleAddContact} - sx={{ borderRadius: 2 }} + sx={{ + borderRadius: 2, + flex: { xs: 1, md: 'none' }, + minWidth: 0, + fontSize: { xs: '0.75rem', md: '0.875rem' }, + px: { xs: 0.5, md: 2 }, + py: { xs: 0.5, md: 1 }, + maxWidth: { xs: 'calc(33.33% - 4px)', md: 'none' } + }} > - Add Contacts + {/* Shorter text on mobile */} + + Add Contacts + + + Add + )} - + { minHeight: 56, textTransform: 'none', fontWeight: 500, + minWidth: { xs: 80, sm: 120 }, + fontSize: { xs: '0.75rem', sm: '0.875rem' } } }} > @@ -223,7 +343,7 @@ const ContactListPage = () => { - + { } : {}, }} > - - - - {!contact.profileImage && contact.name.charAt(0)} - - - - - - {contact.name} + + + {/* Top row on mobile: Avatar + Name + Action Button */} + + + {!contact.profileImage && contact.name.charAt(0)} + + + + + + {contact.name} + + {getSourceIcon(contact.source)} + + + + {contact.position} {contact.company && `at ${contact.company}`} - {getSourceIcon(contact.source)} + + + {/* Action buttons - always visible on the right */} + + {/* Select button for selection mode */} + {isSelectionMode && ( + + )} + + {/* Invite to NAO button for non-members (not in selection mode) */} + {!isSelectionMode && contact.naoStatus === 'not_invited' && ( + + )} + + + + {/* Bottom section: Email + Status chips + Tags */} + + + {contact.email} + + + {/* Compact status and metrics chips */} + + {contact.groupIds && contact.groupIds.length > 0 && ( + } + label={contact.groupIds.length} + size="small" + variant="outlined" + sx={{ + fontSize: '0.65rem', + height: 18, + borderRadius: 1, + backgroundColor: alpha(theme.palette.success.main, 0.04), + borderColor: alpha(theme.palette.success.main, 0.12), + color: 'success.main', + '& .MuiChip-icon': { fontSize: 12 }, + }} + /> + )} + + {/* NAO Status Indicator */} + {(() => { + const naoStatus = getNaoStatusIndicator(contact); + return naoStatus ? ( + + ) : null; + })()} + + {/* Simplified Vouch and Praise counts */} + {(() => { + const counts = getVouchPraiseCounts(contact); + const totalVouches = counts.vouchesSent + counts.vouchesReceived; + const totalPraises = counts.praisesSent + counts.praisesReceived; + + return ( + <> + {totalVouches > 0 && ( + } + label={totalVouches} + size="small" + variant="outlined" + sx={{ + fontSize: '0.65rem', + height: 18, + borderRadius: 1, + backgroundColor: alpha(theme.palette.primary.main, 0.04), + borderColor: alpha(theme.palette.primary.main, 0.12), + color: 'primary.main', + '& .MuiChip-icon': { fontSize: 12 }, + }} + /> + )} + {totalPraises > 0 && ( + } + label={totalPraises} + size="small" + variant="outlined" + sx={{ + fontSize: '0.65rem', + height: 18, + borderRadius: 1, + backgroundColor: alpha('#f8bbd9', 0.3), + borderColor: alpha('#d81b60', 0.3), + color: '#d81b60', + '& .MuiChip-icon': { fontSize: 12 }, + }} + /> + )} + + ); + })()} + + + {/* Tags - only show first 2 on mobile */} + {contact.tags && contact.tags.length > 0 && ( + + {contact.tags.slice(0, 2).map((tag) => ( + + ))} + {contact.tags.length > 2 && ( + + )} + + )} + + + {/* Desktop layout (hidden on mobile) */} + + {contact.groupIds && contact.groupIds.length > 0 && ( } @@ -318,9 +675,7 @@ const ContactListPage = () => { backgroundColor: alpha(theme.palette.success.main, 0.04), borderColor: alpha(theme.palette.success.main, 0.12), color: 'success.main', - '& .MuiChip-icon': { - fontSize: 14, - }, + '& .MuiChip-icon': { fontSize: 14 }, }} /> )} @@ -341,58 +696,108 @@ const ContactListPage = () => { backgroundColor: naoStatus.bgColor, borderColor: naoStatus.borderColor, color: naoStatus.color, - '& .MuiChip-icon': { - fontSize: 14, - }, + '& .MuiChip-icon': { fontSize: 14 }, }} /> ) : null; })()} - {/* Vouch and Praise Indicators - Show received vouches/praises for everyone */} - } - label="2" - size="small" - variant="outlined" - title={contact.naoStatus === 'member' ? 'Vouches given and received' : 'Vouches received from NAO members'} - sx={{ - fontSize: '0.75rem', - height: 20, - borderRadius: 1, - backgroundColor: alpha(theme.palette.primary.main, 0.04), - borderColor: alpha(theme.palette.primary.main, 0.12), - color: 'primary.main', - '& .MuiChip-icon': { - fontSize: 14, - }, - }} - /> - } - label="3" - size="small" - variant="outlined" - title={contact.naoStatus === 'member' ? 'Praises given and received' : 'Praises received from NAO members'} - sx={{ - fontSize: '0.75rem', - height: 20, - borderRadius: 1, - backgroundColor: alpha('#f8bbd9', 0.3), - borderColor: alpha('#d81b60', 0.3), - color: '#d81b60', - '& .MuiChip-icon': { - fontSize: 14, - }, - }} - /> + {/* Vouch and Praise Indicators with directional arrows - desktop only */} + {(() => { + const counts = getVouchPraiseCounts(contact); + const totalVouches = counts.vouchesSent + counts.vouchesReceived; + const totalPraises = counts.praisesSent + counts.praisesReceived; + + return ( + <> + } + label={ + + {counts.vouchesReceived > 0 && ( + <> + + {counts.vouchesReceived} + + )} + {counts.vouchesSent > 0 && counts.vouchesReceived > 0 && ( + + )} + {counts.vouchesSent > 0 && ( + <> + + {counts.vouchesSent} + + )} + {totalVouches === 0 && 0} + + } + size="small" + variant="outlined" + title={contact.naoStatus === 'member' + ? `Vouches: ${counts.vouchesReceived} received, ${counts.vouchesSent} sent` + : `Vouches: ${counts.vouchesSent} sent (hidden until they join)`} + sx={{ + fontSize: '0.75rem', + height: 20, + borderRadius: 1, + backgroundColor: alpha(theme.palette.primary.main, 0.04), + borderColor: alpha(theme.palette.primary.main, 0.12), + color: 'primary.main', + '& .MuiChip-icon': { fontSize: 14 }, + '& .MuiChip-label': { + fontSize: '0.75rem', + padding: '0 4px', + }, + }} + /> + } + label={ + + {counts.praisesReceived > 0 && ( + <> + + {counts.praisesReceived} + + )} + {counts.praisesSent > 0 && counts.praisesReceived > 0 && ( + + )} + {counts.praisesSent > 0 && ( + <> + + {counts.praisesSent} + + )} + {totalPraises === 0 && 0} + + } + size="small" + variant="outlined" + title={contact.naoStatus === 'member' + ? `Praises: ${counts.praisesReceived} received, ${counts.praisesSent} sent` + : `Praises: ${counts.praisesSent} sent (hidden until they join)`} + sx={{ + fontSize: '0.75rem', + height: 20, + borderRadius: 1, + backgroundColor: alpha('#f8bbd9', 0.3), + borderColor: alpha('#d81b60', 0.3), + color: '#d81b60', + '& .MuiChip-icon': { fontSize: 14 }, + '& .MuiChip-label': { + fontSize: '0.75rem', + padding: '0 4px', + }, + }} + /> + + ); + })()} - {contact.position} {contact.company && `at ${contact.company}`} - - - {contact.email} @@ -418,49 +823,6 @@ const ContactListPage = () => { Added {formatDate(contact.createdAt)} - - {/* Action buttons */} - - {/* Select button for selection mode */} - {isSelectionMode && ( - - )} - - {/* Invite to NAO button for non-members (not in selection mode) */} - {!isSelectionMode && contact.naoStatus === 'not_invited' && ( - - )} - diff --git a/src/pages/ContactViewPage.tsx b/src/pages/ContactViewPage.tsx index a28a517..796180d 100644 --- a/src/pages/ContactViewPage.tsx +++ b/src/pages/ContactViewPage.tsx @@ -28,7 +28,9 @@ import { Add, Send, VerifiedUser, - Favorite + Favorite, + CheckCircle, + PersonOutline } from '@mui/icons-material'; import { dataService } from '../services/dataService'; import type { Contact } from '../types/contact'; @@ -85,6 +87,44 @@ const ContactViewPage = () => { navigate('/contacts'); }; + const handleInviteToNao = () => { + if (contact) { + navigate(`/invite?inviteeName=${encodeURIComponent(contact.name)}&inviteeEmail=${encodeURIComponent(contact.email)}`); + } + }; + + const getNaoStatusIndicator = (contact: Contact) => { + switch (contact.naoStatus) { + case 'member': + return { + icon: , + label: 'NAO Member', + color: theme.palette.success.main, + bgColor: alpha(theme.palette.success.main, 0.08), + borderColor: alpha(theme.palette.success.main, 0.2), + description: 'This person is already a member of the NAO network' + }; + case 'invited': + return { + icon: , + label: 'Invited to NAO', + color: theme.palette.warning.main, + bgColor: alpha(theme.palette.warning.main, 0.08), + borderColor: alpha(theme.palette.warning.main, 0.2), + description: 'This person has been invited to NAO but hasn\'t joined yet' + }; + default: + return { + icon: , + label: 'Not on NAO', + color: theme.palette.text.secondary, + bgColor: alpha(theme.palette.grey[500], 0.08), + borderColor: alpha(theme.palette.grey[500], 0.2), + description: 'This person is not yet part of the NAO network' + }; + } + }; + const formatDate = (date: Date) => { return new Intl.DateTimeFormat('en-US', { year: 'numeric', @@ -217,13 +257,31 @@ const ContactViewPage = () => { )} - + + + {/* NAO Status Indicator */} + {(() => { + const naoStatus = getNaoStatusIndicator(contact); + return ( + + ); + })()} @@ -248,7 +306,7 @@ const ContactViewPage = () => { /> - {/* Vouch and Praise Actions */} + {/* Action Buttons */} { justifyContent: { xs: 'center', sm: 'flex-start' }, mt: 2 }}> + {/* Invite to NAO button for non-members */} + {contact.naoStatus === 'not_invited' && ( + + )} + + {/* Vouch and Praise buttons */} + )} + + )} - 2 vouches • 1 praise received + {contact.naoStatus === 'member' ? '2 vouches • 1 praise received' : 'No vouches or praises yet'} diff --git a/src/pages/FeedPage.tsx b/src/pages/FeedPage.tsx index ed6fd3c..558ed27 100644 --- a/src/pages/FeedPage.tsx +++ b/src/pages/FeedPage.tsx @@ -1,27 +1,41 @@ -import { Box, Typography, Container } from '@mui/material'; -import PostCreateButton from '../components/PostCreateButton'; +import { Box, Typography } from '@mui/material'; const FeedPage = () => { - const handleCreatePost = (type: 'post' | 'offer' | 'want') => { - console.log(`Creating ${type} in main feed`); - // TODO: Implement post creation logic - }; - return ( - <> - - - - Feed - - - Your personalized network feed will appear here. - - - - - - + + + + Home + + + Your personalized dashboard, where you can place your own selection of widgets... + + + ); }; diff --git a/src/pages/GroupDetailPage.tsx b/src/pages/GroupDetailPage.tsx index f286ea3..72cf6a4 100644 --- a/src/pages/GroupDetailPage.tsx +++ b/src/pages/GroupDetailPage.tsx @@ -17,7 +17,12 @@ import { Dialog, DialogTitle, DialogContent, - TextField + TextField, + keyframes, + FormControl, + InputLabel, + Select, + MenuItem } from '@mui/material'; import { ArrowBack, @@ -33,7 +38,17 @@ import { Description, TableChart, PersonAdd, - AutoAwesome + AutoAwesome, + AccountTree, + TrendingUp, + LocationOn, + Favorite, + VerifiedUser, + FilterList, + ExpandMore, + ExpandLess, + Image, + Close } from '@mui/icons-material'; import { dataService } from '../services/dataService'; import type { Group, GroupPost, GroupLink } from '../types/group'; @@ -42,6 +57,22 @@ import GroupTour from '../components/tour/GroupTour'; import AIResponseRatingComponent, { type AIResponseRating } from '../components/ai/AIResponseRating'; import InviteForm, { type InviteFormData } from '../components/invite/InviteForm'; +// Keyframes for pulse animation +const pulse = keyframes` + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7); + } + 70% { + transform: scale(1.05); + box-shadow: 0 0 0 10px rgba(76, 175, 80, 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); + } +`; + const GroupDetailPage = () => { const { groupId } = useParams<{ groupId: string }>(); const navigate = useNavigate(); @@ -51,7 +82,7 @@ const GroupDetailPage = () => { const [group, setGroup] = useState(null); const [posts, setPosts] = useState([]); const [links, setLinks] = useState([]); - const [tabValue, setTabValue] = useState(0); + const [tabValue, setTabValue] = useState(0); // Default to Network tab const [isLoading, setIsLoading] = useState(true); const [showAIAssistant, setShowAIAssistant] = useState(false); const [showGroupTour, setShowGroupTour] = useState(false); @@ -62,6 +93,9 @@ const GroupDetailPage = () => { const [showInviteForm, setShowInviteForm] = useState(false); const [userFirstName, setUserFirstName] = useState(); const [selectedContact, setSelectedContact] = useState<{name: string; email: string} | undefined>(); + const [selectedPersonFilter, setSelectedPersonFilter] = useState('all'); + const [selectedTopicFilter, setSelectedTopicFilter] = useState('all'); + const [expandedPosts, setExpandedPosts] = useState>(new Set()); useEffect(() => { const loadGroupData = async () => { @@ -100,9 +134,9 @@ const GroupDetailPage = () => { setSearchParams(newSearchParams); } - // Only show AI assistant automatically for new users who just joined from an invitation + // Handle new members who just joined from an invitation if ((fromInvite || newMember) && groupData) { - // Mark as visited and open AI assistant directly + // Mark as visited localStorage.setItem(hasVisitedKey, 'true'); // Check if this is an existing member who just selected their rCard @@ -115,9 +149,7 @@ const GroupDetailPage = () => { console.log(`User joined ${groupData.name} with rCard: ${selectedRCard}`); } - setTimeout(() => setShowAIAssistant(true), 1000); // Small delay for better UX - - // Clean up URL parameters after showing the welcome + // Clean up URL parameters after processing if (fromInvite || newMember) { const newSearchParams = new URLSearchParams(searchParams); newSearchParams.delete('fromInvite'); @@ -130,31 +162,83 @@ const GroupDetailPage = () => { } } - // Mock data for posts and links until we have real data - const mockPosts: GroupPost[] = [ + // Mock data for posts with rich content, images, and topics + const mockPosts: (GroupPost & { topic?: string; images?: string[]; isLong?: boolean })[] = [ { id: '1', groupId: groupId, - authorId: 'user1', - authorName: 'John Doe', - authorAvatar: undefined, - content: 'Welcome to our group! Looking forward to collaborating with everyone.', + authorId: 'sarah-j', + authorName: 'Sarah Johnson', + authorAvatar: 'https://i.pravatar.cc/150?img=2', + content: 'Great turnout at today\'s garden workday! We managed to plant 3 new beds with tomatoes, peppers, and herbs. The community spirit was amazing - had over 20 volunteers show up. Next week we\'ll be focusing on the composting area setup.', + topic: 'Garden Planning', + images: [ + 'https://images.unsplash.com/photo-1416879595882-3373a0480b5b?w=400', + 'https://images.unsplash.com/photo-1461354464878-ad92f492a5a0?w=400' + ], createdAt: new Date(Date.now() - 1000 * 60 * 30), // 30 minutes ago updatedAt: new Date(Date.now() - 1000 * 60 * 30), - likes: 5, - comments: 2, + likes: 12, + comments: 5, }, { id: '2', groupId: groupId, - authorId: 'user2', - authorName: 'Jane Smith', - authorAvatar: undefined, - content: 'Just shared some useful resources in our Files section. Check them out!', - createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago - updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 2), - likes: 3, - comments: 1, + authorId: 'mike-c', + authorName: 'Mike Chen', + authorAvatar: 'https://i.pravatar.cc/150?img=3', + content: 'Quick reminder: please bring your own tools to tomorrow\'s session. We\'ll have some extras but not enough for everyone.', + topic: 'Tool Sharing', + createdAt: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago + updatedAt: new Date(Date.now() - 1000 * 60 * 60), + likes: 8, + comments: 3, + }, + { + id: '3', + groupId: groupId, + authorId: 'emily-r', + authorName: 'Emily Rodriguez', + authorAvatar: 'https://i.pravatar.cc/150?img=4', + content: 'I\'ve been researching the best composting methods for our community garden and wanted to share some findings. After reviewing multiple academic papers and speaking with local agricultural extension services, here are the key recommendations I\'ve compiled:\n\n1. Three-bin system works best for our volume\n2. Carbon to nitrogen ratio should be 30:1\n3. Regular turning every 2-3 weeks\n4. Moisture content around 50-60%\n5. Temperature monitoring is crucial\n\nI\'ve also been in contact with the city\'s waste management department about getting bulk brown materials delivered. They\'re willing to drop off wood chips and dried leaves monthly at no cost to our group. This could save us significant money on soil amendments.\n\nWhat are everyone\'s thoughts on implementing this system? I\'m happy to lead the composting committee if there\'s interest.', + topic: 'Composting', + isLong: true, + images: ['https://images.unsplash.com/photo-1611273426858-450d8e3c9fce?w=400'], + createdAt: new Date(Date.now() - 1000 * 60 * 120), // 2 hours ago + updatedAt: new Date(Date.now() - 1000 * 60 * 120), + likes: 15, + comments: 8, + }, + { + id: '4', + groupId: groupId, + authorId: 'lisa-t', + authorName: 'Lisa Thompson', + authorAvatar: 'https://i.pravatar.cc/150?img=9', + content: 'Amazing harvest festival photos! Thanks to everyone who made it such a success. 🌽🥕🍅', + topic: 'Community Events', + images: [ + 'https://images.unsplash.com/photo-1500937386664-56d1dfef3854?w=400', + 'https://images.unsplash.com/photo-1542838132-92c53300491e?w=400', + 'https://images.unsplash.com/photo-1459252434994-0ca114ef9aa3?w=400' + ], + createdAt: new Date(Date.now() - 1000 * 60 * 240), // 4 hours ago + updatedAt: new Date(Date.now() - 1000 * 60 * 240), + likes: 24, + comments: 12, + }, + { + id: '5', + groupId: groupId, + authorId: 'david-w', + authorName: 'David Wilson', + authorAvatar: 'https://i.pravatar.cc/150?img=8', + content: 'Update on our fundraising efforts: We\'ve raised $2,400 towards our $5,000 goal for the new greenhouse! Big thanks to everyone who\'s contributed. We\'re planning a bake sale for next weekend to help us get closer to the target.', + topic: 'Fundraising', + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 6), // 6 hours ago + updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 6), + likes: 18, + comments: 7, } ]; @@ -174,6 +258,7 @@ const GroupDetailPage = () => { setPosts(mockPosts); setLinks(mockLinks); + console.log('Posts loaded:', mockPosts.length, 'posts'); } catch (error) { console.error('Failed to load group data:', error); } finally { @@ -378,21 +463,868 @@ const GroupDetailPage = () => { ); - const renderMembersTab = () => ( - - - - - Members ({group?.memberCount || 0}) - - - Member management coming soon... - - - - + // Mock member data with relationships, activities, and locations + const getMockMembers = () => [ + { + id: 'current-user', + name: 'You', + initials: 'OS', + avatar: null, + relationshipStrength: 1.0, + position: { x: 0, y: 0 }, // Center + activities: [ + { topic: 'Community Events', count: 15, lastActive: '2 hours ago' }, + { topic: 'Sustainability', count: 8, lastActive: '1 day ago' } + ], + location: { lat: 40.7128, lng: -74.0060, visible: true }, + vouches: 12, + praises: 18, + connections: ['sarah-j', 'mike-c', 'lisa-t', 'david-w'] + }, + { + id: 'sarah-j', + name: 'Sarah Johnson', + initials: 'SJ', + avatar: 'https://i.pravatar.cc/150?img=2', + relationshipStrength: 0.9, + position: { x: -120, y: -80 }, + activities: [ + { topic: 'Garden Planning', count: 22, lastActive: '30 minutes ago' }, + { topic: 'Community Events', count: 12, lastActive: '3 hours ago' } + ], + location: { lat: 40.7158, lng: -74.0090, visible: true }, + vouches: 8, + praises: 14, + connections: ['current-user', 'mike-c', 'emily-r'] + }, + { + id: 'mike-c', + name: 'Mike Chen', + initials: 'MC', + avatar: 'https://i.pravatar.cc/150?img=3', + relationshipStrength: 0.7, + position: { x: 100, y: -100 }, + activities: [ + { topic: 'Tool Sharing', count: 18, lastActive: '1 hour ago' }, + { topic: 'Organic Methods', count: 9, lastActive: '2 days ago' } + ], + location: { lat: 40.7098, lng: -74.0030, visible: true }, + vouches: 6, + praises: 11, + connections: ['current-user', 'sarah-j', 'david-w'] + }, + { + id: 'emily-r', + name: 'Emily Rodriguez', + initials: 'ER', + avatar: 'https://i.pravatar.cc/150?img=4', + relationshipStrength: 0.8, + position: { x: -80, y: 120 }, + activities: [ + { topic: 'Composting', count: 14, lastActive: '4 hours ago' }, + { topic: 'Pest Control', count: 7, lastActive: '1 day ago' } + ], + location: { lat: 40.7098, lng: -74.0090, visible: true }, + vouches: 9, + praises: 13, + connections: ['current-user', 'sarah-j', 'lisa-t'] + }, + { + id: 'david-w', + name: 'David Wilson', + initials: 'DW', + avatar: 'https://i.pravatar.cc/150?img=8', + relationshipStrength: 0.5, + position: { x: 140, y: 90 }, + activities: [ + { topic: 'Fundraising', count: 11, lastActive: '6 hours ago' }, + { topic: 'Community Outreach', count: 5, lastActive: '3 days ago' } + ], + location: { lat: 40.7068, lng: -74.0040, visible: false }, // Privacy setting + vouches: 4, + praises: 7, + connections: ['current-user', 'mike-c'] + }, + { + id: 'lisa-t', + name: 'Lisa Thompson', + initials: 'LT', + avatar: 'https://i.pravatar.cc/150?img=9', + relationshipStrength: 0.6, + position: { x: -140, y: 60 }, + activities: [ + { topic: 'Social Media', count: 25, lastActive: '15 minutes ago' }, + { topic: 'Event Photography', count: 8, lastActive: '2 hours ago' } + ], + location: { lat: 40.7138, lng: -74.0070, visible: true }, + vouches: 7, + praises: 16, + connections: ['current-user', 'emily-r'] + } + ]; + + const renderNetworkTab = () => { + const members = getMockMembers(); + return renderNetworkView(members); + }; + + const renderActivityTab = () => { + const members = getMockMembers(); + + // Get all unique people and topics for filters + const allPeople = ['all', ...Array.from(new Set(posts.map((p: any) => p.authorName)))]; + const allTopics = ['all', ...Array.from(new Set(posts.map((p: any) => p.topic).filter(Boolean)))]; + + // Filter posts based on selected filters + const filteredPosts = posts.filter((post: any) => { + const personMatch = selectedPersonFilter === 'all' || post.authorName === selectedPersonFilter; + const topicMatch = selectedTopicFilter === 'all' || post.topic === selectedTopicFilter; + return personMatch && topicMatch; + }); + + const togglePostExpansion = (postId: string) => { + const newExpanded = new Set(expandedPosts); + if (newExpanded.has(postId)) { + newExpanded.delete(postId); + } else { + newExpanded.add(postId); + } + setExpandedPosts(newExpanded); + }; + + const renderPost = (post: any) => { + const isExpanded = expandedPosts.has(post.id); + const isLongPost = post.isLong; + const shouldTruncate = isLongPost && !isExpanded; + const truncatedContent = shouldTruncate + ? post.content.substring(0, 200) + '...' + : post.content; + + return ( + + + {/* Post Header */} + + + {post.authorName.charAt(0)} + + + + {post.authorName} + + + + {formatDate(post.createdAt)} + + {post.topic && ( + <> + + + + )} + + + + + + + + {/* Post Content */} + + {truncatedContent} + + + {/* Expand/Collapse button for long posts */} + {isLongPost && ( + + )} + + {/* Post Images */} + {post.images && post.images.length > 0 && ( + + + {post.images.map((image: string, index: number) => ( + + ))} + + + )} + + {/* Post Actions */} + + + + + + + + ); + }; + + return ( + + {/* Filter Controls */} + + + {/* Person Filter */} + + Filter by Person + + + + {/* Topic Filter */} + + Filter by Topic + + + + + + {/* Posts Feed */} + {filteredPosts.length === 0 ? ( + + + + No posts match your filters + + + Try adjusting your filter selection to see more posts. + + + + ) : ( + + {filteredPosts.map(renderPost)} + + )} + + ); + }; + + const renderMapTab = () => { + const members = getMockMembers(); + return ( + + + + Member Locations + + + + {renderMapView(members)} + + + + ); + }; + + const renderFeedContent = () => ( + posts.length === 0 ? ( + + + No posts yet + + + Be the first to share something with the group! + + + ) : ( + + {posts.map((post) => ( + + + + + {post.authorName.charAt(0)} + + + + {post.authorName} + + + {formatDate(post.createdAt)} + + + + + + + + + {post.content} + + + + + + + + + + ))} + + ) ); + const renderNetworkView = (members: any[]) => { + // Shared position calculation function for perfect alignment + const getNodePosition = (member: any) => { + const centerX = 400; // Center of 800px viewBox + const centerY = 240; // Center of 680px viewBox, positioned to minimize top space + const scale = 1.8; // Increased scale for better visibility + + const x = centerX + member.position.x * scale; + const y = centerY + member.position.y * scale; + + return { x, y }; + }; + + return ( + + {/* SVG Network Graph */} + + {/* Connection lines between members */} + {members.map(member => + member.connections + ?.filter((connId: string) => connId !== 'current-user') + .map((connId: string) => { + const connectedMember = members.find(m => m.id === connId); + if (!connectedMember) return null; + + // Use shared position calculation - these should be the center points of the circles + const startPos = getNodePosition(member); + const endPos = getNodePosition(connectedMember); + const startX = startPos.x; + const startY = startPos.y; + const endX = endPos.x; + const endY = endPos.y; + + const isCurrentUserConnection = member.id === 'current-user' || connId === 'current-user'; + const strength = isCurrentUserConnection + ? Math.max(member.relationshipStrength, connectedMember.relationshipStrength) + : 0.3; // Faint lines between other members + + return ( + + ); + }) + )} + {/* Member nodes positioned as SVG elements for perfect alignment */} + {members.map(member => { + const nodePos = getNodePosition(member); + return ( + +
{ + e.currentTarget.style.transform = 'scale(1.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + }} + > + {/* Avatar with relationship strength indicator */} +
+ {!member.avatar && member.initials} +
+ + {/* Name label */} +
+ {member.name} +
+ + {/* Activity indicators for current user */} + {member.id === 'current-user' && ( +
+
+ ✓ {member.vouches} +
+
+ ♥ {member.praises} +
+
+ )} +
+
+ ); + })} +
+ +
+ ); + }; + + const renderActivityView = (members: any[]) => { + // Get all unique topics and their activity leaders + const topicAnalysis: any = {}; + + members.forEach(member => { + member.activities?.forEach((activity: any) => { + if (!topicAnalysis[activity.topic]) { + topicAnalysis[activity.topic] = []; + } + topicAnalysis[activity.topic].push({ + member, + count: activity.count, + lastActive: activity.lastActive + }); + }); + }); + + // Sort topics by total activity + const sortedTopics = Object.entries(topicAnalysis) + .map(([topic, activities]: [string, any]) => ({ + topic, + activities: activities.sort((a: any, b: any) => b.count - a.count), + totalActivity: activities.reduce((sum: number, a: any) => sum + a.count, 0) + })) + .sort((a, b) => b.totalActivity - a.totalActivity); + + return ( + + + + Topic Activity Leaders + + + + {sortedTopics.map(({ topic, activities, totalActivity }) => ( + + + + + {topic} + + + + + + {activities.slice(0, 3).map((activity: any, index: number) => ( + + + #{index + 1} + + + + {activity.member.initials} + + + + + {activity.member.name} + + + Last active: {activity.lastActive} + + + + + + {activity.count} posts + + + + + ))} + + + + ))} + + + ); + }; + + const renderMapView = (members: any[]) => { + const visibleMembers = members.filter(m => m.location?.visible); + + return ( + + + + Anyville Community Garden Network + + + {/* Mock map view */} + + {/* Garden plots and features */} + + + + + {/* Member locations */} + {visibleMembers.map((member, index) => { + // Convert lat/lng to approximate positions on our mock map + const x = 50 + (member.location.lng + 74.0060) * 2000; + const y = 50 + (40.7128 - member.location.lat) * 2000; + + return ( + + + {member.initials} + + + {/* Name tooltip */} + + {member.name} + + + ); + })} + + {/* Map legend */} + + + Location Sharing + + + + + {visibleMembers.length} members visible + + + + {members.length - visibleMembers.length} members private + + + + + + {/* Privacy notice */} + + + 📍 Location Privacy: Only members who have enabled location sharing are visible on the map. + + + + ); + }; + const renderChatTab = () => ( @@ -534,37 +1466,73 @@ const GroupDetailPage = () => { } return ( - + {/* Header */} - + {/* Top row with back button, avatar, and title/description */} - - + + {group.name.charAt(0)} - - + + {group.name} - + - + {group.memberCount} members {group.isPrivate && ( @@ -572,7 +1540,19 @@ const GroupDetailPage = () => { )} {group.description && ( - + {group.description} )} @@ -581,7 +1561,8 @@ const GroupDetailPage = () => { @@ -620,48 +1611,86 @@ const GroupDetailPage = () => { variant="contained" startIcon={} onClick={handleInviteToGroup} - sx={{ borderRadius: 2, flex: 1 }} + sx={{ + borderRadius: 2, + flex: 1, + minWidth: 0, + fontSize: '0.7rem', + px: 0.5, + maxWidth: 'calc(50% - 4px)' + }} > - Invite to Group + Invite {/* Navigation Tabs */} - + - } label="Feed" /> - } label="Members" /> + } label="Network" /> + } label="Activity" /> + } label="Map" /> } label="Chat" /> } label="Files" /> } label="Links" /> {/* Tab Content */} - - {tabValue === 0 && renderFeedTab()} - {tabValue === 1 && renderMembersTab()} - {tabValue === 2 && renderChatTab()} - {tabValue === 3 && renderFilesTab()} - {tabValue === 4 && renderLinksTab()} + + {tabValue === 0 && renderNetworkTab()} + {tabValue === 1 && renderActivityTab()} + {tabValue === 2 && renderMapTab()} + {tabValue === 3 && renderChatTab()} + {tabValue === 4 && renderFilesTab()} + {tabValue === 5 && renderLinksTab()} - {/* Show Post Create Button only on Feed tab */} - {tabValue === 0 && ( + {/* Show Post Create Button only on Activity tab */} + {tabValue === 1 && ( { }; return ( - - - - + + + + Groups - + - - + + { + const theme = useTheme(); + const [notifications, setNotifications] = useState([]); + const [notificationSummary, setNotificationSummary] = useState({ + total: 0, + unread: 0, + pending: 0, + byType: { vouch: 0, praise: 0, connection: 0, group_invite: 0, message: 0, system: 0 } + }); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadNotifications = async () => { + setIsLoading(true); + try { + const [notificationData, summaryData] = await Promise.all([ + notificationService.getNotifications('current-user'), + notificationService.getNotificationSummary('current-user') + ]); + setNotifications(notificationData); + setNotificationSummary(summaryData); + } catch (error) { + console.error('Failed to load notifications:', error); + } finally { + setIsLoading(false); + } + }; + + loadNotifications(); + }, []); + + const handleMarkAsRead = async (notificationId: string) => { + try { + await notificationService.markAsRead(notificationId); + setNotifications(prev => + prev.map(n => n.id === notificationId ? { ...n, isRead: true } : n) + ); + setNotificationSummary(prev => ({ + ...prev, + unread: Math.max(0, prev.unread - 1) + })); + } catch (error) { + console.error('Failed to mark notification as read:', error); + } + }; + + const handleMarkAllAsRead = async () => { + try { + await notificationService.markAllAsRead('current-user'); + setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); + setNotificationSummary(prev => ({ ...prev, unread: 0 })); + } catch (error) { + console.error('Failed to mark all notifications as read:', error); + } + }; + + const handleAcceptVouch = async (notificationId: string, vouchId: string) => { + try { + await notificationService.acceptVouch(notificationId, vouchId); + setNotifications(prev => + prev.map(n => n.id === notificationId ? { ...n, status: 'accepted', isActionable: true } : n) + ); + setNotificationSummary(prev => ({ + ...prev, + pending: Math.max(0, prev.pending - 1) + })); + } catch (error) { + console.error('Failed to accept vouch:', error); + } + }; + + const handleRejectVouch = async (notificationId: string, vouchId: string) => { + try { + await notificationService.rejectVouch(notificationId, vouchId); + setNotifications(prev => + prev.map(n => n.id === notificationId ? { ...n, status: 'rejected', isActionable: false } : n) + ); + setNotificationSummary(prev => ({ + ...prev, + pending: Math.max(0, prev.pending - 1) + })); + } catch (error) { + console.error('Failed to reject vouch:', error); + } + }; + + const handleAcceptPraise = async (notificationId: string, praiseId: string) => { + try { + await notificationService.acceptPraise(notificationId, praiseId); + setNotifications(prev => + prev.map(n => n.id === notificationId ? { ...n, status: 'accepted', isActionable: true } : n) + ); + setNotificationSummary(prev => ({ + ...prev, + pending: Math.max(0, prev.pending - 1) + })); + } catch (error) { + console.error('Failed to accept praise:', error); + } + }; + + const handleRejectPraise = async (notificationId: string, praiseId: string) => { + try { + await notificationService.rejectPraise(notificationId, praiseId); + setNotifications(prev => + prev.map(n => n.id === notificationId ? { ...n, status: 'rejected', isActionable: false } : n) + ); + setNotificationSummary(prev => ({ + ...prev, + pending: Math.max(0, prev.pending - 1) + })); + } catch (error) { + console.error('Failed to reject praise:', error); + } + }; + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'vouch': + return ; + case 'praise': + return ; + case 'group_invite': + return ; + case 'message': + return ; + case 'system': + return ; + default: + return ; + } + }; + + const formatDate = (date: Date) => { + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(date); + }; + + return ( + + {/* Header */} + + + + Notifications + + {notificationSummary.unread > 0 && ( + + You have {notificationSummary.unread} unread notification{notificationSummary.unread !== 1 ? 's' : ''} + + )} + + {notificationSummary.unread > 0 && ( + + )} + + + {/* Notifications List */} + + + {isLoading ? ( + + + Loading notifications... + + + ) : notifications.length === 0 ? ( + + + No notifications yet + + + You'll see notifications here when you receive vouches, praises, and other updates. + + + ) : ( + + {notifications.map((notification, index) => ( + + + {/* Notification Icon */} + + {getNotificationIcon(notification.type)} + + + {/* Main Content */} + + {/* Sender Info */} + + + {notification.sender?.name?.charAt(0)} + + + {notification.sender?.name} + + + {formatDate(notification.createdAt)} + + {!notification.isRead && ( + + )} + + + {/* Message */} + + {notification.message} + + + {/* Status and Actions */} + + {notification.status && ( + : } + label={notification.status} + size="small" + variant="outlined" + sx={{ + fontSize: '0.75rem', + height: 20, + textTransform: 'capitalize', + ...(notification.status === 'accepted' && { + backgroundColor: alpha(theme.palette.success.main, 0.08), + borderColor: alpha(theme.palette.success.main, 0.2), + color: 'success.main' + }) + }} + /> + )} + + {/* Action Buttons */} + {notification.isActionable && notification.status === 'pending' && ( + + {notification.type === 'vouch' && ( + <> + + + + )} + {notification.type === 'praise' && ( + <> + + + + )} + + )} + + {/* Mark as Read Button */} + {!notification.isRead && ( + handleMarkAsRead(notification.id)} + sx={{ ml: 'auto' }} + > + + + )} + + + + {index < notifications.length - 1 && } + + ))} + + )} + + + + ); +}; + +export default NotificationsPage; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 94ba456..49d222f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,4 +5,10 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], assetsInclude: ['**/*.json'], + server: { + host: '0.0.0.0', + port: 5173, + strictPort: true, + open: false + } })