- Rename navigation from Feed to Home with Dashboard icon - Convert notifications from popup to dedicated page - Restructure Account page tabs: Profile, My Cards, My Webpage, My Stream, My Bookmarks - Fix mobile layout with edge-to-edge design and proper padding - Remove scroll buttons from tabs to eliminate left margin gap - Update My Bookmarks filters to display side-by-side on mobile 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>main
parent
f3097d7e3c
commit
530938ea6f
@ -0,0 +1 @@ |
|||||||
|
VITE_ANTHROPIC_API_KEY=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 |
@ -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 |
@ -1,27 +1,41 @@ |
|||||||
import { Box, Typography, Container } from '@mui/material'; |
import { Box, Typography } from '@mui/material'; |
||||||
import PostCreateButton from '../components/PostCreateButton'; |
|
||||||
|
|
||||||
const FeedPage = () => { |
const FeedPage = () => { |
||||||
const handleCreatePost = (type: 'post' | 'offer' | 'want') => { |
|
||||||
console.log(`Creating ${type} in main feed`); |
|
||||||
// TODO: Implement post creation logic
|
|
||||||
}; |
|
||||||
|
|
||||||
return ( |
return ( |
||||||
<> |
<Box sx={{
|
||||||
<Container maxWidth="lg"> |
height: '100%', |
||||||
<Box sx={{ py: 4 }}> |
width: '100%', |
||||||
<Typography variant="h4" sx={{ mb: 3, fontWeight: 600 }}> |
maxWidth: { xs: '100vw', md: '100%' }, |
||||||
Feed |
overflow: 'hidden', |
||||||
</Typography> |
boxSizing: 'border-box', |
||||||
<Typography variant="body1" color="text.secondary"> |
p: { xs: '10px', md: 0 }, |
||||||
Your personalized network feed will appear here. |
mx: { xs: 0, md: 'auto' } |
||||||
</Typography> |
}}> |
||||||
</Box> |
<Box sx={{
|
||||||
</Container> |
mb: { xs: 2, md: 3 }, |
||||||
|
width: '100%', |
||||||
<PostCreateButton onCreatePost={handleCreatePost} /> |
overflow: 'hidden', |
||||||
</> |
minWidth: 0 |
||||||
|
}}> |
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
mb: 1, |
||||||
|
fontSize: { xs: '1.5rem', md: '2.125rem' }, |
||||||
|
overflow: 'hidden', |
||||||
|
textOverflow: 'ellipsis', |
||||||
|
whiteSpace: 'nowrap' |
||||||
|
}} |
||||||
|
> |
||||||
|
Home |
||||||
|
</Typography> |
||||||
|
<Typography variant="body1" color="text.secondary"> |
||||||
|
Your personalized dashboard, where you can place your own selection of widgets... |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
); |
); |
||||||
}; |
}; |
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,415 @@ |
|||||||
|
import { useState, useEffect } from 'react'; |
||||||
|
import { |
||||||
|
Typography, |
||||||
|
Box, |
||||||
|
Card, |
||||||
|
CardContent, |
||||||
|
Button, |
||||||
|
Chip, |
||||||
|
Avatar, |
||||||
|
IconButton, |
||||||
|
Divider, |
||||||
|
Badge, |
||||||
|
alpha, |
||||||
|
useTheme |
||||||
|
} from '@mui/material'; |
||||||
|
import { |
||||||
|
Notifications, |
||||||
|
VerifiedUser, |
||||||
|
Favorite, |
||||||
|
Group, |
||||||
|
Message, |
||||||
|
Settings, |
||||||
|
CheckCircle, |
||||||
|
Schedule, |
||||||
|
Close, |
||||||
|
MarkEmailRead |
||||||
|
} from '@mui/icons-material'; |
||||||
|
import { notificationService } from '../services/notificationService'; |
||||||
|
import type { Notification, NotificationSummary } from '../types/notification'; |
||||||
|
|
||||||
|
const NotificationsPage = () => { |
||||||
|
const theme = useTheme(); |
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]); |
||||||
|
const [notificationSummary, setNotificationSummary] = useState<NotificationSummary>({ |
||||||
|
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 <VerifiedUser sx={{ fontSize: 20, color: 'primary.main' }} />; |
||||||
|
case 'praise': |
||||||
|
return <Favorite sx={{ fontSize: 20, color: '#d81b60' }} />; |
||||||
|
case 'group_invite': |
||||||
|
return <Group sx={{ fontSize: 20, color: 'success.main' }} />; |
||||||
|
case 'message': |
||||||
|
return <Message sx={{ fontSize: 20, color: 'info.main' }} />; |
||||||
|
case 'system': |
||||||
|
return <Settings sx={{ fontSize: 20, color: 'warning.main' }} />; |
||||||
|
default: |
||||||
|
return <Notifications sx={{ fontSize: 20 }} />; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
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 ( |
||||||
|
<Box sx={{
|
||||||
|
height: '100%', |
||||||
|
width: '100%', |
||||||
|
maxWidth: { xs: '100vw', md: '100%' }, |
||||||
|
overflow: 'hidden', |
||||||
|
boxSizing: 'border-box', |
||||||
|
p: { xs: '10px', md: 0 }, |
||||||
|
mx: { xs: 0, md: 'auto' } |
||||||
|
}}> |
||||||
|
{/* Header */} |
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: { xs: 2, md: 3 }, |
||||||
|
width: '100%', |
||||||
|
overflow: 'hidden', |
||||||
|
minWidth: 0 |
||||||
|
}}> |
||||||
|
<Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden' }}> |
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
mb: 1, |
||||||
|
fontSize: { xs: '1.5rem', md: '2.125rem' }, |
||||||
|
overflow: 'hidden', |
||||||
|
textOverflow: 'ellipsis', |
||||||
|
whiteSpace: 'nowrap' |
||||||
|
}} |
||||||
|
> |
||||||
|
Notifications |
||||||
|
</Typography> |
||||||
|
{notificationSummary.unread > 0 && ( |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
You have {notificationSummary.unread} unread notification{notificationSummary.unread !== 1 ? 's' : ''} |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
{notificationSummary.unread > 0 && ( |
||||||
|
<Button |
||||||
|
variant="outlined" |
||||||
|
startIcon={<MarkEmailRead />} |
||||||
|
onClick={handleMarkAllAsRead} |
||||||
|
sx={{
|
||||||
|
borderRadius: 2, |
||||||
|
fontSize: { xs: '0.75rem', md: '0.875rem' }, |
||||||
|
px: { xs: 1, md: 2 }, |
||||||
|
py: { xs: 0.5, md: 1 } |
||||||
|
}} |
||||||
|
> |
||||||
|
Mark All Read |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Notifications List */} |
||||||
|
<Card sx={{
|
||||||
|
width: { xs: 'calc(100% + 20px)', md: '100%' },
|
||||||
|
maxWidth: { xs: 'calc(100vw - 0px)', md: '100%' },
|
||||||
|
overflow: 'hidden', |
||||||
|
mx: { xs: '-10px', md: 0 }, |
||||||
|
borderRadius: { xs: 0, md: 1 }, |
||||||
|
boxSizing: 'border-box' |
||||||
|
}}> |
||||||
|
<Box sx={{ p: { xs: '10px', md: 3 } }}> |
||||||
|
{isLoading ? ( |
||||||
|
<Box sx={{ textAlign: 'center', py: 8 }}> |
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom> |
||||||
|
Loading notifications... |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
) : notifications.length === 0 ? ( |
||||||
|
<Box sx={{ textAlign: 'center', py: 8 }}> |
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom> |
||||||
|
No notifications yet |
||||||
|
</Typography> |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
You'll see notifications here when you receive vouches, praises, and other updates. |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
) : ( |
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}> |
||||||
|
{notifications.map((notification, index) => ( |
||||||
|
<Box key={notification.id}> |
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 2,
|
||||||
|
py: 2, |
||||||
|
backgroundColor: notification.isRead ? 'transparent' : alpha(theme.palette.primary.main, 0.02), |
||||||
|
borderRadius: 1, |
||||||
|
position: 'relative' |
||||||
|
}}> |
||||||
|
{/* Notification Icon */} |
||||||
|
<Box sx={{ flexShrink: 0, mt: 0.5 }}> |
||||||
|
{getNotificationIcon(notification.type)} |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Main Content */} |
||||||
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}> |
||||||
|
{/* Sender Info */} |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}> |
||||||
|
<Avatar |
||||||
|
src={notification.sender?.avatar} |
||||||
|
alt={notification.sender?.name} |
||||||
|
sx={{ width: 24, height: 24, fontSize: '0.75rem' }} |
||||||
|
> |
||||||
|
{notification.sender?.name?.charAt(0)} |
||||||
|
</Avatar> |
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> |
||||||
|
{notification.sender?.name} |
||||||
|
</Typography> |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
{formatDate(notification.createdAt)} |
||||||
|
</Typography> |
||||||
|
{!notification.isRead && ( |
||||||
|
<Box sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'primary.main', |
||||||
|
ml: 'auto'
|
||||||
|
}} /> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Message */} |
||||||
|
<Typography variant="body2" sx={{ mb: 1, lineHeight: 1.5 }}> |
||||||
|
{notification.message} |
||||||
|
</Typography> |
||||||
|
|
||||||
|
{/* Status and Actions */} |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> |
||||||
|
{notification.status && ( |
||||||
|
<Chip |
||||||
|
icon={notification.status === 'accepted' ? <CheckCircle /> : <Schedule />} |
||||||
|
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' && ( |
||||||
|
<Box sx={{ display: 'flex', gap: 1, ml: 'auto' }}> |
||||||
|
{notification.type === 'vouch' && ( |
||||||
|
<> |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="outlined" |
||||||
|
onClick={() => handleRejectVouch(notification.id, notification.metadata?.vouchId || '')} |
||||||
|
sx={{
|
||||||
|
minWidth: 60, |
||||||
|
fontSize: '0.75rem', |
||||||
|
py: 0.25 |
||||||
|
}} |
||||||
|
> |
||||||
|
Reject |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="contained" |
||||||
|
onClick={() => handleAcceptVouch(notification.id, notification.metadata?.vouchId || '')} |
||||||
|
sx={{
|
||||||
|
minWidth: 60, |
||||||
|
fontSize: '0.75rem', |
||||||
|
py: 0.25 |
||||||
|
}} |
||||||
|
> |
||||||
|
Accept |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
{notification.type === 'praise' && ( |
||||||
|
<> |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="outlined" |
||||||
|
onClick={() => handleRejectPraise(notification.id, notification.metadata?.praiseId || '')} |
||||||
|
sx={{
|
||||||
|
minWidth: 60, |
||||||
|
fontSize: '0.75rem', |
||||||
|
py: 0.25 |
||||||
|
}} |
||||||
|
> |
||||||
|
Reject |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
variant="contained" |
||||||
|
onClick={() => handleAcceptPraise(notification.id, notification.metadata?.praiseId || '')} |
||||||
|
sx={{
|
||||||
|
minWidth: 60, |
||||||
|
fontSize: '0.75rem', |
||||||
|
py: 0.25 |
||||||
|
}} |
||||||
|
> |
||||||
|
Accept |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Mark as Read Button */} |
||||||
|
{!notification.isRead && ( |
||||||
|
<IconButton |
||||||
|
size="small" |
||||||
|
onClick={() => handleMarkAsRead(notification.id)} |
||||||
|
sx={{ ml: 'auto' }} |
||||||
|
> |
||||||
|
<Close sx={{ fontSize: 16 }} /> |
||||||
|
</IconButton> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
{index < notifications.length - 1 && <Divider />} |
||||||
|
</Box> |
||||||
|
))} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</Card> |
||||||
|
</Box> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default NotificationsPage; |
Loading…
Reference in new issue