- Added comprehensive vouch and praise system to ContactViewPage with side-by-side layout - Fixed contact image centering issues using background-image approach - Added vouch/praise indicators to contact list with proper styling - Implemented My Home and My Collection pages with filter dropdowns - Added Personhood Credentials component with QR code verification - Renamed "Privacy & rCards" to "My Cards" and reordered account tabs - Removed "Default" labels from rCard privacy settings - Added Gmail import option alongside LinkedIn and Contacts - Fixed notification dropdown layout issues and text wrapping - Improved responsive design across contact views 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>main
parent
d6e3e54fb6
commit
451d2a2908
@ -0,0 +1,740 @@ |
|||||||
|
import { useState, useEffect } from 'react'; |
||||||
|
import { |
||||||
|
Box, |
||||||
|
Typography, |
||||||
|
Card, |
||||||
|
CardContent, |
||||||
|
Chip, |
||||||
|
Avatar, |
||||||
|
IconButton, |
||||||
|
Menu, |
||||||
|
MenuItem, |
||||||
|
TextField, |
||||||
|
InputAdornment, |
||||||
|
Grid, |
||||||
|
Button, |
||||||
|
Divider, |
||||||
|
Dialog, |
||||||
|
DialogTitle, |
||||||
|
DialogContent, |
||||||
|
DialogActions, |
||||||
|
FormControl, |
||||||
|
InputLabel, |
||||||
|
Select, |
||||||
|
alpha, |
||||||
|
useTheme, |
||||||
|
} from '@mui/material'; |
||||||
|
import { |
||||||
|
Search, |
||||||
|
MoreVert, |
||||||
|
Favorite, |
||||||
|
FavoriteBorder, |
||||||
|
Edit, |
||||||
|
Delete, |
||||||
|
Launch, |
||||||
|
Article, |
||||||
|
Image as ImageIcon, |
||||||
|
Link as LinkIcon, |
||||||
|
AttachFile, |
||||||
|
LocalOffer, |
||||||
|
ShoppingCart, |
||||||
|
PostAdd, |
||||||
|
Visibility, |
||||||
|
FolderOpen, |
||||||
|
QueryStats, |
||||||
|
Psychology, |
||||||
|
Send, |
||||||
|
AutoFixHigh, |
||||||
|
StarOutline, |
||||||
|
AutoAwesome, |
||||||
|
} from '@mui/icons-material'; |
||||||
|
import type { BookmarkedItem, Collection, CollectionFilter, CollectionStats } from '../../types/collection'; |
||||||
|
|
||||||
|
interface MyCollectionPageProps { |
||||||
|
// Props would come from parent component
|
||||||
|
} |
||||||
|
|
||||||
|
const MyCollectionPage = ({}: MyCollectionPageProps) => { |
||||||
|
const theme = useTheme(); |
||||||
|
const [items, setItems] = useState<BookmarkedItem[]>([]); |
||||||
|
const [collections, setCollections] = useState<Collection[]>([]); |
||||||
|
const [filteredItems, setFilteredItems] = useState<BookmarkedItem[]>([]); |
||||||
|
const [filter] = useState<CollectionFilter>({}); |
||||||
|
const [searchQuery, setSearchQuery] = useState(''); |
||||||
|
const [selectedCollection, setSelectedCollection] = useState<string>('all'); |
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('all'); |
||||||
|
const [sortBy, setSortBy] = useState<'recent' | 'title' | 'author'>('recent'); |
||||||
|
const [menuAnchor, setMenuAnchor] = useState<{ [key: string]: HTMLElement | null }>({}); |
||||||
|
const [showQueryDialog, setShowQueryDialog] = useState(false); |
||||||
|
const [queryText, setQueryText] = useState(''); |
||||||
|
const [stats, setStats] = useState<CollectionStats>({ |
||||||
|
totalItems: 0, |
||||||
|
unreadItems: 0, |
||||||
|
favoriteItems: 0, |
||||||
|
byType: {}, |
||||||
|
byCategory: {}, |
||||||
|
recentlyAdded: 0, |
||||||
|
}); |
||||||
|
|
||||||
|
// Mock data
|
||||||
|
useEffect(() => { |
||||||
|
const mockCollections: Collection[] = [ |
||||||
|
{ |
||||||
|
id: 'reading-list', |
||||||
|
name: 'Reading List', |
||||||
|
description: 'Articles to read later', |
||||||
|
items: [], |
||||||
|
isDefault: true, |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), |
||||||
|
updatedAt: new Date(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'design-inspiration', |
||||||
|
name: 'Design Inspiration', |
||||||
|
description: 'Design ideas and inspiration', |
||||||
|
items: [], |
||||||
|
isDefault: false, |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 20), |
||||||
|
updatedAt: new Date(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'tech-resources', |
||||||
|
name: 'Tech Resources', |
||||||
|
description: 'Useful development resources', |
||||||
|
items: [], |
||||||
|
isDefault: false, |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 15), |
||||||
|
updatedAt: new Date(), |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
const mockItems: BookmarkedItem[] = [ |
||||||
|
{ |
||||||
|
id: '1', |
||||||
|
originalId: 'article-123', |
||||||
|
type: 'article', |
||||||
|
title: 'The Future of Web Development', |
||||||
|
description: 'An in-depth look at emerging trends in web development including AI integration and new frameworks.', |
||||||
|
content: 'Web development is evolving rapidly with new technologies...', |
||||||
|
author: { |
||||||
|
id: 'author-1', |
||||||
|
name: 'Sarah Johnson', |
||||||
|
avatar: '/api/placeholder/40/40', |
||||||
|
}, |
||||||
|
source: 'TechBlog', |
||||||
|
bookmarkedAt: new Date(Date.now() - 1000 * 60 * 60 * 2), |
||||||
|
tags: ['web-development', 'ai', 'trends'], |
||||||
|
notes: 'Good insights on AI integration. Need to research the frameworks mentioned.', |
||||||
|
category: 'Technology', |
||||||
|
isRead: false, |
||||||
|
isFavorite: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '2', |
||||||
|
originalId: 'post-456', |
||||||
|
type: 'post', |
||||||
|
title: 'Remote Work Best Practices', |
||||||
|
description: 'Tips for staying productive while working remotely', |
||||||
|
content: 'Working remotely requires discipline and the right tools...', |
||||||
|
author: { |
||||||
|
id: 'author-2', |
||||||
|
name: 'Mike Chen', |
||||||
|
avatar: '/api/placeholder/40/40', |
||||||
|
}, |
||||||
|
source: 'LinkedIn', |
||||||
|
bookmarkedAt: new Date(Date.now() - 1000 * 60 * 60 * 24), |
||||||
|
tags: ['remote-work', 'productivity', 'tips'], |
||||||
|
category: 'Work', |
||||||
|
isRead: true, |
||||||
|
isFavorite: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '3', |
||||||
|
originalId: 'link-789', |
||||||
|
type: 'link', |
||||||
|
title: 'Design System Component Library', |
||||||
|
url: 'https://designsystem.example.com', |
||||||
|
description: 'Comprehensive component library for modern design systems', |
||||||
|
author: { |
||||||
|
id: 'author-3', |
||||||
|
name: 'Design Team', |
||||||
|
avatar: '/api/placeholder/40/40', |
||||||
|
}, |
||||||
|
source: 'Design Community', |
||||||
|
bookmarkedAt: new Date(Date.now() - 1000 * 60 * 60 * 48), |
||||||
|
tags: ['design-system', 'components', 'ui'], |
||||||
|
notes: 'Great reference for our upcoming design system project', |
||||||
|
category: 'Design', |
||||||
|
isRead: false, |
||||||
|
isFavorite: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '4', |
||||||
|
originalId: 'offer-101', |
||||||
|
type: 'offer', |
||||||
|
title: 'Freelance React Developer Available', |
||||||
|
description: 'Experienced React developer offering freelance services', |
||||||
|
author: { |
||||||
|
id: 'author-4', |
||||||
|
name: 'Alex Rodriguez', |
||||||
|
avatar: '/api/placeholder/40/40', |
||||||
|
}, |
||||||
|
source: 'Freelance Board', |
||||||
|
bookmarkedAt: new Date(Date.now() - 1000 * 60 * 60 * 72), |
||||||
|
tags: ['react', 'freelance', 'development'], |
||||||
|
category: 'Opportunities', |
||||||
|
isRead: true, |
||||||
|
isFavorite: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '5', |
||||||
|
originalId: 'image-202', |
||||||
|
type: 'image', |
||||||
|
title: 'Modern Office Interior Design', |
||||||
|
imageUrl: '/api/placeholder/600/400', |
||||||
|
description: 'Beautiful modern office space with natural lighting', |
||||||
|
author: { |
||||||
|
id: 'author-5', |
||||||
|
name: 'Interior Design Studio', |
||||||
|
avatar: '/api/placeholder/40/40', |
||||||
|
}, |
||||||
|
source: 'Design Portfolio', |
||||||
|
bookmarkedAt: new Date(Date.now() - 1000 * 60 * 60 * 96), |
||||||
|
tags: ['office', 'interior', 'modern'], |
||||||
|
category: 'Design', |
||||||
|
isRead: true, |
||||||
|
isFavorite: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '6', |
||||||
|
originalId: 'file-303', |
||||||
|
type: 'file', |
||||||
|
title: 'Product Strategy Template', |
||||||
|
description: 'Comprehensive template for product strategy documentation', |
||||||
|
author: { |
||||||
|
id: 'author-6', |
||||||
|
name: 'Product Manager', |
||||||
|
avatar: '/api/placeholder/40/40', |
||||||
|
}, |
||||||
|
source: 'Product Community', |
||||||
|
bookmarkedAt: new Date(Date.now() - 1000 * 60 * 60 * 120), |
||||||
|
tags: ['product', 'strategy', 'template'], |
||||||
|
notes: 'Use this for Q2 strategy planning', |
||||||
|
category: 'Product', |
||||||
|
isRead: false, |
||||||
|
isFavorite: false, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
setCollections(mockCollections); |
||||||
|
setItems(mockItems); |
||||||
|
setFilteredItems(mockItems); |
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const categories = [...new Set(mockItems.map(item => item.category).filter(Boolean))]; |
||||||
|
const types = [...new Set(mockItems.map(item => item.type))]; |
||||||
|
const recentDate = new Date(Date.now() - 1000 * 60 * 60 * 24 * 7); |
||||||
|
|
||||||
|
const newStats: CollectionStats = { |
||||||
|
totalItems: mockItems.length, |
||||||
|
unreadItems: mockItems.filter(item => !item.isRead).length, |
||||||
|
favoriteItems: mockItems.filter(item => item.isFavorite).length, |
||||||
|
byType: types.reduce((acc, type) => ({ |
||||||
|
...acc, |
||||||
|
[type]: mockItems.filter(item => item.type === type).length |
||||||
|
}), {}), |
||||||
|
byCategory: categories.reduce((acc, category) => ({ |
||||||
|
...acc, |
||||||
|
[category || 'Uncategorized']: mockItems.filter(item => item.category === category).length |
||||||
|
}), {}), |
||||||
|
recentlyAdded: mockItems.filter(item => item.bookmarkedAt > recentDate).length, |
||||||
|
}; |
||||||
|
setStats(newStats); |
||||||
|
}, []); |
||||||
|
|
||||||
|
// Filter and sort items
|
||||||
|
useEffect(() => { |
||||||
|
let filtered = [...items]; |
||||||
|
|
||||||
|
// Filter by collection
|
||||||
|
if (selectedCollection !== 'all') { |
||||||
|
// In a real implementation, this would filter by collection membership
|
||||||
|
filtered = filtered; // For now, show all items
|
||||||
|
} |
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if (selectedCategory !== 'all') { |
||||||
|
filtered = filtered.filter(item => item.category === selectedCategory); |
||||||
|
} |
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery) { |
||||||
|
filtered = filtered.filter(item => |
||||||
|
item.title.toLowerCase().includes(searchQuery.toLowerCase()) || |
||||||
|
item.description?.toLowerCase().includes(searchQuery.toLowerCase()) || |
||||||
|
item.content?.toLowerCase().includes(searchQuery.toLowerCase()) || |
||||||
|
item.author.name.toLowerCase().includes(searchQuery.toLowerCase()) || |
||||||
|
item.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase())) || |
||||||
|
item.notes?.toLowerCase().includes(searchQuery.toLowerCase()) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// Sort items
|
||||||
|
filtered.sort((a, b) => { |
||||||
|
switch (sortBy) { |
||||||
|
case 'title': |
||||||
|
return a.title.localeCompare(b.title); |
||||||
|
case 'author': |
||||||
|
return a.author.name.localeCompare(b.author.name); |
||||||
|
case 'recent': |
||||||
|
default: |
||||||
|
return b.bookmarkedAt.getTime() - a.bookmarkedAt.getTime(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
setFilteredItems(filtered); |
||||||
|
}, [items, selectedCollection, selectedCategory, searchQuery, sortBy, filter]); |
||||||
|
|
||||||
|
const handleMenuOpen = (itemId: string, event: React.MouseEvent<HTMLElement>) => { |
||||||
|
setMenuAnchor({ ...menuAnchor, [itemId]: event.currentTarget }); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleMenuClose = (itemId: string) => { |
||||||
|
setMenuAnchor({ ...menuAnchor, [itemId]: null }); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleToggleFavorite = (itemId: string) => { |
||||||
|
setItems(prev => prev.map(item => |
||||||
|
item.id === itemId ? { ...item, isFavorite: !item.isFavorite } : item |
||||||
|
)); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleMarkAsRead = (itemId: string) => { |
||||||
|
setItems(prev => prev.map(item => |
||||||
|
item.id === itemId ? { ...item, isRead: true, lastViewedAt: new Date() } : item |
||||||
|
)); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleRunQuery = () => { |
||||||
|
// In a real implementation, this would use AI to query the bookmarked content
|
||||||
|
console.log('Running query:', queryText); |
||||||
|
setShowQueryDialog(false); |
||||||
|
setQueryText(''); |
||||||
|
}; |
||||||
|
|
||||||
|
const getContentIcon = (type: string) => { |
||||||
|
switch (type) { |
||||||
|
case 'post': return <PostAdd />; |
||||||
|
case 'offer': return <LocalOffer />; |
||||||
|
case 'want': return <ShoppingCart />; |
||||||
|
case 'image': return <ImageIcon />; |
||||||
|
case 'link': return <LinkIcon />; |
||||||
|
case 'file': return <AttachFile />; |
||||||
|
case 'article': return <Article />; |
||||||
|
default: return <PostAdd />; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const formatDate = (date: Date) => { |
||||||
|
const now = new Date(); |
||||||
|
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60); |
||||||
|
|
||||||
|
if (diffInHours < 24) { |
||||||
|
return `${Math.floor(diffInHours)}h ago`; |
||||||
|
} else if (diffInHours < 168) { |
||||||
|
return `${Math.floor(diffInHours / 24)}d ago`; |
||||||
|
} else { |
||||||
|
return date.toLocaleDateString(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const categories = [...new Set(items.map(item => item.category).filter(Boolean))]; |
||||||
|
|
||||||
|
const renderBookmarkedItem = (item: BookmarkedItem) => ( |
||||||
|
<Card key={item.id} sx={{ mb: 2 }}> |
||||||
|
<CardContent> |
||||||
|
{/* Header */} |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> |
||||||
|
<Avatar sx={{ bgcolor: 'primary.main' }}> |
||||||
|
{getContentIcon(item.type)} |
||||||
|
</Avatar> |
||||||
|
<Box> |
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}> |
||||||
|
{item.title} |
||||||
|
</Typography> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}> |
||||||
|
<Chip
|
||||||
|
label={item.type.charAt(0).toUpperCase() + item.type.slice(1)}
|
||||||
|
size="small"
|
||||||
|
variant="outlined" |
||||||
|
/> |
||||||
|
{item.category && ( |
||||||
|
<Chip
|
||||||
|
label={item.category}
|
||||||
|
size="small"
|
||||||
|
variant="outlined" |
||||||
|
color="secondary" |
||||||
|
/> |
||||||
|
)} |
||||||
|
{!item.isRead && ( |
||||||
|
<Chip
|
||||||
|
label="Unread"
|
||||||
|
size="small"
|
||||||
|
color="warning" |
||||||
|
/> |
||||||
|
)} |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
{formatDate(item.bookmarkedAt)} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleToggleFavorite(item.id)} |
||||||
|
color={item.isFavorite ? 'error' : 'default'} |
||||||
|
> |
||||||
|
{item.isFavorite ? <Favorite /> : <FavoriteBorder />} |
||||||
|
</IconButton> |
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => handleMenuOpen(item.id, e)} |
||||||
|
> |
||||||
|
<MoreVert /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Author */} |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> |
||||||
|
<Avatar src={item.author.avatar} sx={{ width: 24, height: 24 }}> |
||||||
|
{item.author.name.charAt(0)} |
||||||
|
</Avatar> |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
by {item.author.name} • {item.source} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Content */} |
||||||
|
{item.description && ( |
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> |
||||||
|
{item.description} |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Image for image type */} |
||||||
|
{item.type === 'image' && item.imageUrl && ( |
||||||
|
<Box |
||||||
|
component="img" |
||||||
|
src={item.imageUrl} |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
maxHeight: 200, |
||||||
|
objectFit: 'cover', |
||||||
|
borderRadius: 1, |
||||||
|
mb: 2, |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* User Notes */} |
||||||
|
{item.notes && ( |
||||||
|
<Box sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: alpha(theme.palette.primary.main, 0.04),
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 2, |
||||||
|
borderLeft: 4, |
||||||
|
borderColor: 'primary.main' |
||||||
|
}}> |
||||||
|
<Typography variant="body2" sx={{ fontStyle: 'italic' }}> |
||||||
|
"{item.notes}" |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Tags */} |
||||||
|
{item.tags && item.tags.length > 0 && ( |
||||||
|
<Box sx={{ mb: 2 }}> |
||||||
|
{item.tags.map((tag) => ( |
||||||
|
<Chip
|
||||||
|
key={tag}
|
||||||
|
label={tag}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mr: 0.5, mb: 0.5 }} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} /> |
||||||
|
|
||||||
|
{/* Actions */} |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> |
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}> |
||||||
|
{!item.isRead && ( |
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleMarkAsRead(item.id)} |
||||||
|
startIcon={<Visibility />} |
||||||
|
> |
||||||
|
Mark as Read |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
<Button size="small" startIcon={<Launch />}> |
||||||
|
Open |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
Saved {formatDate(item.bookmarkedAt)} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</CardContent> |
||||||
|
|
||||||
|
{/* Menu */} |
||||||
|
<Menu |
||||||
|
anchorEl={menuAnchor[item.id]} |
||||||
|
open={Boolean(menuAnchor[item.id])} |
||||||
|
onClose={() => handleMenuClose(item.id)} |
||||||
|
> |
||||||
|
<MenuItem onClick={() => handleMenuClose(item.id)}> |
||||||
|
<Edit sx={{ mr: 1 }} /> Edit Notes |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleMenuClose(item.id)}> |
||||||
|
<FolderOpen sx={{ mr: 1 }} /> Move to Collection |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleMenuClose(item.id)}> |
||||||
|
<Launch sx={{ mr: 1 }} /> Open Original |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleMenuClose(item.id)}> |
||||||
|
<Delete sx={{ mr: 1 }} /> Remove |
||||||
|
</MenuItem> |
||||||
|
</Menu> |
||||||
|
</Card> |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box> |
||||||
|
{/* Header */} |
||||||
|
<Box sx={{ mb: 4 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> |
||||||
|
<Typography variant="h5" sx={{ fontWeight: 600 }}> |
||||||
|
My Collection |
||||||
|
</Typography> |
||||||
|
<Button |
||||||
|
variant="contained" |
||||||
|
startIcon={<AutoAwesome />} |
||||||
|
onClick={() => setShowQueryDialog(true)} |
||||||
|
> |
||||||
|
Query Collection |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Search and Filters */} |
||||||
|
<Box sx={{ mb: 3 }}> |
||||||
|
<TextField |
||||||
|
fullWidth |
||||||
|
placeholder="Search your bookmarks..." |
||||||
|
value={searchQuery} |
||||||
|
onChange={(e) => setSearchQuery(e.target.value)} |
||||||
|
InputProps={{ |
||||||
|
startAdornment: ( |
||||||
|
<InputAdornment position="start"> |
||||||
|
<Search /> |
||||||
|
</InputAdornment> |
||||||
|
), |
||||||
|
}} |
||||||
|
sx={{ mb: 2 }} |
||||||
|
/> |
||||||
|
|
||||||
|
{/* Filters */} |
||||||
|
<Grid container spacing={2} sx={{ mb: 2 }}> |
||||||
|
<Grid size={{ xs: 12, md: 4 }}> |
||||||
|
<FormControl fullWidth size="small"> |
||||||
|
<InputLabel>Collection</InputLabel> |
||||||
|
<Select |
||||||
|
value={selectedCollection} |
||||||
|
label="Collection" |
||||||
|
onChange={(e) => setSelectedCollection(e.target.value)} |
||||||
|
> |
||||||
|
<MenuItem value="all">All Collections</MenuItem> |
||||||
|
{collections.map((collection) => ( |
||||||
|
<MenuItem key={collection.id} value={collection.id}> |
||||||
|
{collection.name} |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
</Grid> |
||||||
|
<Grid size={{ xs: 12, md: 4 }}> |
||||||
|
<FormControl fullWidth size="small"> |
||||||
|
<InputLabel>Category</InputLabel> |
||||||
|
<Select |
||||||
|
value={selectedCategory} |
||||||
|
label="Category" |
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)} |
||||||
|
> |
||||||
|
<MenuItem value="all">All Categories</MenuItem> |
||||||
|
{categories.map((category) => ( |
||||||
|
<MenuItem key={category} value={category}> |
||||||
|
{category} |
||||||
|
</MenuItem> |
||||||
|
))} |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
</Grid> |
||||||
|
<Grid size={{ xs: 12, md: 4 }}> |
||||||
|
<FormControl fullWidth size="small"> |
||||||
|
<InputLabel>Sort By</InputLabel> |
||||||
|
<Select |
||||||
|
value={sortBy} |
||||||
|
label="Sort By" |
||||||
|
onChange={(e) => setSortBy(e.target.value as any)} |
||||||
|
> |
||||||
|
<MenuItem value="recent">Recently Added</MenuItem> |
||||||
|
<MenuItem value="title">Title</MenuItem> |
||||||
|
<MenuItem value="author">Author</MenuItem> |
||||||
|
</Select> |
||||||
|
</FormControl> |
||||||
|
</Grid> |
||||||
|
</Grid> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Content List */} |
||||||
|
<Box> |
||||||
|
{filteredItems.length === 0 ? ( |
||||||
|
<Card> |
||||||
|
<CardContent sx={{ textAlign: 'center', py: 8 }}> |
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom> |
||||||
|
No bookmarks found |
||||||
|
</Typography> |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
{searchQuery
|
||||||
|
? `No bookmarks match "${searchQuery}"` |
||||||
|
: "You haven't bookmarked any content yet" |
||||||
|
} |
||||||
|
</Typography> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
) : ( |
||||||
|
filteredItems.map(renderBookmarkedItem) |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Query Dialog */} |
||||||
|
<Dialog open={showQueryDialog} onClose={() => setShowQueryDialog(false)} maxWidth="md" fullWidth> |
||||||
|
<DialogTitle> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
<AutoAwesome /> |
||||||
|
AI Query Assistant |
||||||
|
</Box> |
||||||
|
</DialogTitle> |
||||||
|
<DialogContent sx={{ p: 0 }}> |
||||||
|
{/* Chat-like interface */} |
||||||
|
<Box sx={{
|
||||||
|
height: 400,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column', |
||||||
|
bgcolor: alpha(theme.palette.background.default, 0.5) |
||||||
|
}}> |
||||||
|
{/* Messages area */} |
||||||
|
<Box sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
p: 3,
|
||||||
|
overflowY: 'auto', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: 2 |
||||||
|
}}> |
||||||
|
{/* AI intro message */} |
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}> |
||||||
|
<Avatar sx={{ bgcolor: 'primary.main', width: 32, height: 32 }}> |
||||||
|
<AutoAwesome fontSize="small" /> |
||||||
|
</Avatar> |
||||||
|
<Box sx={{
|
||||||
|
bgcolor: 'background.paper', |
||||||
|
p: 2, |
||||||
|
borderRadius: 2, |
||||||
|
border: 1, |
||||||
|
borderColor: 'divider', |
||||||
|
maxWidth: '80%' |
||||||
|
}}> |
||||||
|
<Typography variant="body2"> |
||||||
|
Hi! I'm your AI assistant. I can help you search and analyze your bookmarked content.
|
||||||
|
Ask me anything about your saved articles, posts, and notes. |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Input area */} |
||||||
|
<Box sx={{
|
||||||
|
p: 2,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: 'divider', |
||||||
|
bgcolor: 'background.paper' |
||||||
|
}}> |
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}> |
||||||
|
<TextField |
||||||
|
fullWidth |
||||||
|
multiline |
||||||
|
maxRows={4} |
||||||
|
placeholder="Ask me about your collection... (e.g., Show me all articles about design systems from the last month)" |
||||||
|
value={queryText} |
||||||
|
onChange={(e) => setQueryText(e.target.value)} |
||||||
|
variant="outlined" |
||||||
|
size="small" |
||||||
|
sx={{ |
||||||
|
'& .MuiOutlinedInput-root': { |
||||||
|
borderRadius: 3, |
||||||
|
bgcolor: alpha(theme.palette.background.default, 0.5), |
||||||
|
'&:hover': { |
||||||
|
bgcolor: alpha(theme.palette.background.default, 0.7), |
||||||
|
}, |
||||||
|
'&.Mui-focused': { |
||||||
|
bgcolor: 'background.paper', |
||||||
|
} |
||||||
|
} |
||||||
|
}} |
||||||
|
onKeyDown={(e) => { |
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { |
||||||
|
e.preventDefault(); |
||||||
|
if (queryText.trim()) { |
||||||
|
handleRunQuery(); |
||||||
|
} |
||||||
|
} |
||||||
|
}} |
||||||
|
/> |
||||||
|
<Button |
||||||
|
variant="contained" |
||||||
|
onClick={handleRunQuery} |
||||||
|
disabled={!queryText.trim()} |
||||||
|
sx={{
|
||||||
|
minWidth: 'auto', |
||||||
|
px: 2, |
||||||
|
borderRadius: 3, |
||||||
|
height: 40 |
||||||
|
}} |
||||||
|
> |
||||||
|
<Send fontSize="small" /> |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}> |
||||||
|
Press Enter to send, Shift+Enter for new line |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</DialogContent> |
||||||
|
<DialogActions sx={{ p: 2 }}> |
||||||
|
<Button onClick={() => setShowQueryDialog(false)}>Close</Button> |
||||||
|
</DialogActions> |
||||||
|
</Dialog> |
||||||
|
</Box> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default MyCollectionPage; |
@ -0,0 +1,640 @@ |
|||||||
|
import { useState, useEffect } from 'react'; |
||||||
|
import { |
||||||
|
Box, |
||||||
|
Typography, |
||||||
|
Card, |
||||||
|
CardContent, |
||||||
|
Chip, |
||||||
|
Avatar, |
||||||
|
IconButton, |
||||||
|
Menu, |
||||||
|
MenuItem, |
||||||
|
TextField, |
||||||
|
InputAdornment, |
||||||
|
Grid, |
||||||
|
Button, |
||||||
|
Divider, |
||||||
|
} from '@mui/material'; |
||||||
|
import { |
||||||
|
Search, |
||||||
|
FilterList, |
||||||
|
MoreVert, |
||||||
|
Visibility, |
||||||
|
VisibilityOff, |
||||||
|
Edit, |
||||||
|
Delete, |
||||||
|
Share, |
||||||
|
ThumbUp, |
||||||
|
Comment, |
||||||
|
Download, |
||||||
|
Launch, |
||||||
|
Article, |
||||||
|
Image as ImageIcon, |
||||||
|
Link as LinkIcon, |
||||||
|
AttachFile, |
||||||
|
LocalOffer, |
||||||
|
ShoppingCart, |
||||||
|
PostAdd, |
||||||
|
} from '@mui/icons-material'; |
||||||
|
import type { UserContent, ContentFilter, ContentStats, ContentType } from '../../types/userContent'; |
||||||
|
|
||||||
|
interface MyHomePageProps { |
||||||
|
// Props would come from parent component
|
||||||
|
} |
||||||
|
|
||||||
|
const MyHomePage = ({}: MyHomePageProps) => { |
||||||
|
const [content, setContent] = useState<UserContent[]>([]); |
||||||
|
const [filteredContent, setFilteredContent] = useState<UserContent[]>([]); |
||||||
|
const [filter] = useState<ContentFilter>({}); |
||||||
|
const [searchQuery, setSearchQuery] = useState(''); |
||||||
|
const [selectedTab, setSelectedTab] = useState<'all' | ContentType>('all'); |
||||||
|
const [menuAnchor, setMenuAnchor] = useState<{ [key: string]: HTMLElement | null }>({}); |
||||||
|
const [filterMenuAnchor, setFilterMenuAnchor] = useState<HTMLElement | null>(null); |
||||||
|
const [stats, setStats] = useState<ContentStats>({ |
||||||
|
totalItems: 0, |
||||||
|
byType: { |
||||||
|
post: 0, |
||||||
|
offer: 0, |
||||||
|
want: 0, |
||||||
|
image: 0, |
||||||
|
link: 0, |
||||||
|
file: 0, |
||||||
|
article: 0, |
||||||
|
}, |
||||||
|
byVisibility: { |
||||||
|
public: 0, |
||||||
|
network: 0, |
||||||
|
private: 0, |
||||||
|
}, |
||||||
|
totalViews: 0, |
||||||
|
totalLikes: 0, |
||||||
|
totalComments: 0, |
||||||
|
}); |
||||||
|
|
||||||
|
// Mock data
|
||||||
|
useEffect(() => { |
||||||
|
const mockContent: UserContent[] = [ |
||||||
|
{ |
||||||
|
id: '1', |
||||||
|
type: 'post', |
||||||
|
title: 'Thoughts on Remote Work Culture', |
||||||
|
content: 'After working remotely for 3 years, I\'ve learned that the key to success is creating boundaries and maintaining human connections...', |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 2), |
||||||
|
tags: ['remote-work', 'productivity', 'culture'], |
||||||
|
visibility: 'public', |
||||||
|
viewCount: 245, |
||||||
|
likeCount: 18, |
||||||
|
commentCount: 7, |
||||||
|
rCardIds: ['business', 'colleague'], |
||||||
|
attachments: [], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '2', |
||||||
|
type: 'offer', |
||||||
|
title: 'UI/UX Design Consultation', |
||||||
|
description: 'Offering design consultation services for early-stage startups', |
||||||
|
content: 'I\'m offering UI/UX design consultation for early-stage startups. 10+ years experience with SaaS products.', |
||||||
|
category: 'Design Services', |
||||||
|
price: '$150/hour', |
||||||
|
availability: 'available', |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24), // 1 day ago
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24), |
||||||
|
tags: ['design', 'consultation', 'startup'], |
||||||
|
visibility: 'network', |
||||||
|
viewCount: 89, |
||||||
|
likeCount: 12, |
||||||
|
commentCount: 3, |
||||||
|
rCardIds: ['business', 'colleague'], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '3', |
||||||
|
type: 'want', |
||||||
|
title: 'Looking for React Native Developer', |
||||||
|
description: 'Need an experienced React Native developer for mobile app project', |
||||||
|
content: 'Looking for an experienced React Native developer to help with a mobile app project. 3-month contract, remote work possible.', |
||||||
|
category: 'Development', |
||||||
|
budget: '$5000-8000', |
||||||
|
urgency: 'high', |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48), // 2 days ago
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 48), |
||||||
|
tags: ['react-native', 'mobile', 'contract'], |
||||||
|
visibility: 'public', |
||||||
|
viewCount: 156, |
||||||
|
likeCount: 8, |
||||||
|
commentCount: 15, |
||||||
|
rCardIds: ['business'], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '4', |
||||||
|
type: 'link', |
||||||
|
title: 'Great Article on Design Systems', |
||||||
|
url: 'https://designsystems.com/article', |
||||||
|
linkTitle: 'Building Scalable Design Systems', |
||||||
|
linkDescription: 'A comprehensive guide to creating and maintaining design systems that scale with your organization.', |
||||||
|
domain: 'designsystems.com', |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 72), // 3 days ago
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 72), |
||||||
|
tags: ['design-systems', 'article', 'resource'], |
||||||
|
visibility: 'public', |
||||||
|
viewCount: 67, |
||||||
|
likeCount: 14, |
||||||
|
commentCount: 2, |
||||||
|
rCardIds: ['business', 'colleague'], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '5', |
||||||
|
type: 'image', |
||||||
|
title: 'Office Setup 2024', |
||||||
|
imageUrl: '/api/placeholder/600/400', |
||||||
|
imageAlt: 'Modern home office setup with dual monitors', |
||||||
|
caption: 'Finally got my home office setup just right! Dual 4K monitors and a standing desk make all the difference.', |
||||||
|
dimensions: { width: 600, height: 400 }, |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 96), // 4 days ago
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 96), |
||||||
|
tags: ['office', 'setup', 'workspace'], |
||||||
|
visibility: 'network', |
||||||
|
viewCount: 123, |
||||||
|
likeCount: 24, |
||||||
|
commentCount: 9, |
||||||
|
rCardIds: ['colleague', 'friend'], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '6', |
||||||
|
type: 'file', |
||||||
|
title: 'Product Requirements Template', |
||||||
|
fileName: 'PRD_Template_v2.pdf', |
||||||
|
fileUrl: '/files/prd-template.pdf', |
||||||
|
fileSize: 2048576, // 2MB
|
||||||
|
fileType: 'application/pdf', |
||||||
|
downloadCount: 45, |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 120), // 5 days ago
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 120), |
||||||
|
tags: ['template', 'product', 'documentation'], |
||||||
|
visibility: 'public', |
||||||
|
viewCount: 89, |
||||||
|
likeCount: 16, |
||||||
|
commentCount: 4, |
||||||
|
rCardIds: ['business'], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: '7', |
||||||
|
type: 'article', |
||||||
|
title: 'The Future of Product Management', |
||||||
|
content: 'In this comprehensive article, I explore how AI and automation are reshaping the role of product managers...', |
||||||
|
excerpt: 'AI and automation are reshaping product management. Here\'s what PMs need to know about the future.', |
||||||
|
readTime: 8, |
||||||
|
publishedAt: new Date(Date.now() - 1000 * 60 * 60 * 168), // 1 week ago
|
||||||
|
featuredImage: '/api/placeholder/400/200', |
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 168), |
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 168), |
||||||
|
tags: ['product-management', 'ai', 'future'], |
||||||
|
visibility: 'public', |
||||||
|
viewCount: 342, |
||||||
|
likeCount: 28, |
||||||
|
commentCount: 12, |
||||||
|
rCardIds: ['business', 'colleague'], |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
setContent(mockContent); |
||||||
|
setFilteredContent(mockContent); |
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const newStats: ContentStats = { |
||||||
|
totalItems: mockContent.length, |
||||||
|
byType: { |
||||||
|
post: mockContent.filter(c => c.type === 'post').length, |
||||||
|
offer: mockContent.filter(c => c.type === 'offer').length, |
||||||
|
want: mockContent.filter(c => c.type === 'want').length, |
||||||
|
image: mockContent.filter(c => c.type === 'image').length, |
||||||
|
link: mockContent.filter(c => c.type === 'link').length, |
||||||
|
file: mockContent.filter(c => c.type === 'file').length, |
||||||
|
article: mockContent.filter(c => c.type === 'article').length, |
||||||
|
}, |
||||||
|
byVisibility: { |
||||||
|
public: mockContent.filter(c => c.visibility === 'public').length, |
||||||
|
network: mockContent.filter(c => c.visibility === 'network').length, |
||||||
|
private: mockContent.filter(c => c.visibility === 'private').length, |
||||||
|
}, |
||||||
|
totalViews: mockContent.reduce((sum, c) => sum + c.viewCount, 0), |
||||||
|
totalLikes: mockContent.reduce((sum, c) => sum + c.likeCount, 0), |
||||||
|
totalComments: mockContent.reduce((sum, c) => sum + c.commentCount, 0), |
||||||
|
}; |
||||||
|
setStats(newStats); |
||||||
|
}, []); |
||||||
|
|
||||||
|
// Filter content
|
||||||
|
useEffect(() => { |
||||||
|
let filtered = [...content]; |
||||||
|
|
||||||
|
// Filter by type
|
||||||
|
if (selectedTab !== 'all') { |
||||||
|
filtered = filtered.filter(item => item.type === selectedTab); |
||||||
|
} |
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery) { |
||||||
|
filtered = filtered.filter(item => |
||||||
|
item.title.toLowerCase().includes(searchQuery.toLowerCase()) || |
||||||
|
item.description?.toLowerCase().includes(searchQuery.toLowerCase()) || |
||||||
|
item.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase())) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
setFilteredContent(filtered); |
||||||
|
}, [content, selectedTab, searchQuery, filter]); |
||||||
|
|
||||||
|
const handleMenuOpen = (contentId: string, event: React.MouseEvent<HTMLElement>) => { |
||||||
|
setMenuAnchor({ ...menuAnchor, [contentId]: event.currentTarget }); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleMenuClose = (contentId: string) => { |
||||||
|
setMenuAnchor({ ...menuAnchor, [contentId]: null }); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleFilterMenuOpen = (event: React.MouseEvent<HTMLElement>) => { |
||||||
|
setFilterMenuAnchor(event.currentTarget); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleFilterMenuClose = () => { |
||||||
|
setFilterMenuAnchor(null); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleFilterSelect = (filterType: 'all' | ContentType) => { |
||||||
|
setSelectedTab(filterType); |
||||||
|
handleFilterMenuClose(); |
||||||
|
}; |
||||||
|
|
||||||
|
const getContentIcon = (type: ContentType) => { |
||||||
|
switch (type) { |
||||||
|
case 'post': return <PostAdd />; |
||||||
|
case 'offer': return <LocalOffer />; |
||||||
|
case 'want': return <ShoppingCart />; |
||||||
|
case 'image': return <ImageIcon />; |
||||||
|
case 'link': return <LinkIcon />; |
||||||
|
case 'file': return <AttachFile />; |
||||||
|
case 'article': return <Article />; |
||||||
|
default: return <PostAdd />; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const getVisibilityIcon = (visibility: string) => { |
||||||
|
switch (visibility) { |
||||||
|
case 'public': return <Visibility />; |
||||||
|
case 'network': return <VisibilityOff />; |
||||||
|
case 'private': return <VisibilityOff />; |
||||||
|
default: return <Visibility />; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => { |
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']; |
||||||
|
if (bytes === 0) return '0 Bytes'; |
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024)); |
||||||
|
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; |
||||||
|
}; |
||||||
|
|
||||||
|
const formatDate = (date: Date) => { |
||||||
|
const now = new Date(); |
||||||
|
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60); |
||||||
|
|
||||||
|
if (diffInHours < 24) { |
||||||
|
return `${Math.floor(diffInHours)}h ago`; |
||||||
|
} else if (diffInHours < 168) { |
||||||
|
return `${Math.floor(diffInHours / 24)}d ago`; |
||||||
|
} else { |
||||||
|
return date.toLocaleDateString(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const renderContentItem = (item: UserContent) => ( |
||||||
|
<Card key={item.id} sx={{ mb: 2 }}> |
||||||
|
<CardContent> |
||||||
|
{/* Header */} |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> |
||||||
|
<Avatar sx={{ bgcolor: 'primary.main' }}> |
||||||
|
{getContentIcon(item.type)} |
||||||
|
</Avatar> |
||||||
|
<Box> |
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}> |
||||||
|
{item.title} |
||||||
|
</Typography> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}> |
||||||
|
<Chip
|
||||||
|
label={item.type.charAt(0).toUpperCase() + item.type.slice(1)}
|
||||||
|
size="small"
|
||||||
|
variant="outlined" |
||||||
|
/> |
||||||
|
<Chip
|
||||||
|
icon={getVisibilityIcon(item.visibility)} |
||||||
|
label={item.visibility.charAt(0).toUpperCase() + item.visibility.slice(1)}
|
||||||
|
size="small"
|
||||||
|
variant="outlined" |
||||||
|
/> |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
{formatDate(item.createdAt)} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => handleMenuOpen(item.id, e)} |
||||||
|
> |
||||||
|
<MoreVert /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Content based on type */} |
||||||
|
{(item.type === 'post' || item.type === 'article') && ( |
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> |
||||||
|
{'content' in item ? item.content.substring(0, 200) + (item.content.length > 200 ? '...' : '') : ''} |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
|
||||||
|
{item.type === 'offer' && 'price' in item && ( |
||||||
|
<Box sx={{ mb: 2 }}> |
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> |
||||||
|
{item.content} |
||||||
|
</Typography> |
||||||
|
<Chip label={item.price} color="success" size="small" /> |
||||||
|
<Chip label={item.availability} color="primary" size="small" sx={{ ml: 1 }} /> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
{item.type === 'want' && 'budget' in item && ( |
||||||
|
<Box sx={{ mb: 2 }}> |
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> |
||||||
|
{item.content} |
||||||
|
</Typography> |
||||||
|
<Chip label={item.budget} color="info" size="small" /> |
||||||
|
<Chip label={`${item.urgency} priority`} color="warning" size="small" sx={{ ml: 1 }} /> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
{item.type === 'link' && 'url' in item && ( |
||||||
|
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}> |
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> |
||||||
|
{item.linkTitle} |
||||||
|
</Typography> |
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> |
||||||
|
{item.linkDescription} |
||||||
|
</Typography> |
||||||
|
<Typography variant="caption" color="primary.main"> |
||||||
|
{item.domain} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
{item.type === 'image' && 'imageUrl' in item && ( |
||||||
|
<Box sx={{ mb: 2 }}> |
||||||
|
<Box |
||||||
|
component="img" |
||||||
|
src={item.imageUrl} |
||||||
|
alt={item.imageAlt} |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
maxHeight: 300, |
||||||
|
objectFit: 'cover', |
||||||
|
borderRadius: 1, |
||||||
|
mb: 1, |
||||||
|
}} |
||||||
|
/> |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
{item.caption} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
{item.type === 'file' && 'fileName' in item && ( |
||||||
|
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1, display: 'flex', alignItems: 'center', gap: 2 }}> |
||||||
|
<AttachFile /> |
||||||
|
<Box sx={{ flexGrow: 1 }}> |
||||||
|
<Typography variant="subtitle2">{item.fileName}</Typography> |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
{formatFileSize(item.fileSize)} • {item.downloadCount} downloads |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
<Button size="small" startIcon={<Download />}> |
||||||
|
Download |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
{item.type === 'article' && 'readTime' in item && ( |
||||||
|
<Box sx={{ mb: 2 }}> |
||||||
|
{'featuredImage' in item && item.featuredImage && ( |
||||||
|
<Box |
||||||
|
component="img" |
||||||
|
src={item.featuredImage} |
||||||
|
sx={{ |
||||||
|
width: '100%', |
||||||
|
height: 200, |
||||||
|
objectFit: 'cover', |
||||||
|
borderRadius: 1, |
||||||
|
mb: 2, |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> |
||||||
|
{item.excerpt} |
||||||
|
</Typography> |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
{item.readTime} min read |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Tags */} |
||||||
|
{item.tags && item.tags.length > 0 && ( |
||||||
|
<Box sx={{ mb: 2 }}> |
||||||
|
{item.tags.map((tag) => ( |
||||||
|
<Chip
|
||||||
|
key={tag}
|
||||||
|
label={tag}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mr: 0.5, mb: 0.5 }} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} /> |
||||||
|
|
||||||
|
{/* Stats */} |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> |
||||||
|
<Comment fontSize="small" /> |
||||||
|
<Typography variant="caption">{item.commentCount}</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
<Button size="small" startIcon={<Share />}> |
||||||
|
Share |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
</CardContent> |
||||||
|
|
||||||
|
{/* Menu */} |
||||||
|
<Menu |
||||||
|
anchorEl={menuAnchor[item.id]} |
||||||
|
open={Boolean(menuAnchor[item.id])} |
||||||
|
onClose={() => handleMenuClose(item.id)} |
||||||
|
> |
||||||
|
<MenuItem onClick={() => handleMenuClose(item.id)}> |
||||||
|
<Edit sx={{ mr: 1 }} /> Edit |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleMenuClose(item.id)}> |
||||||
|
<Launch sx={{ mr: 1 }} /> View Details |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleMenuClose(item.id)}> |
||||||
|
<Delete sx={{ mr: 1 }} /> Delete |
||||||
|
</MenuItem> |
||||||
|
</Menu> |
||||||
|
</Card> |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box> |
||||||
|
{/* Header */} |
||||||
|
<Box sx={{ mb: 4 }}> |
||||||
|
<Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}> |
||||||
|
My Content |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Search and Filters */} |
||||||
|
<Box sx={{ mb: 3 }}> |
||||||
|
<TextField |
||||||
|
fullWidth |
||||||
|
placeholder="Search your content..." |
||||||
|
value={searchQuery} |
||||||
|
onChange={(e) => setSearchQuery(e.target.value)} |
||||||
|
InputProps={{ |
||||||
|
startAdornment: ( |
||||||
|
<InputAdornment position="start"> |
||||||
|
<Search /> |
||||||
|
</InputAdornment> |
||||||
|
), |
||||||
|
endAdornment: ( |
||||||
|
<InputAdornment position="end"> |
||||||
|
<IconButton size="small" onClick={handleFilterMenuOpen}> |
||||||
|
<FilterList /> |
||||||
|
</IconButton> |
||||||
|
</InputAdornment> |
||||||
|
), |
||||||
|
}} |
||||||
|
sx={{ mb: 2 }} |
||||||
|
/> |
||||||
|
|
||||||
|
{/* Filter Menu */} |
||||||
|
<Menu |
||||||
|
anchorEl={filterMenuAnchor} |
||||||
|
open={Boolean(filterMenuAnchor)} |
||||||
|
onClose={handleFilterMenuClose} |
||||||
|
PaperProps={{ |
||||||
|
sx: { mt: 1 } |
||||||
|
}} |
||||||
|
> |
||||||
|
<MenuItem onClick={() => handleFilterSelect('all')}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}> |
||||||
|
<Typography>All</Typography> |
||||||
|
<Chip size="small" label={stats.totalItems} variant="outlined" /> |
||||||
|
</Box> |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleFilterSelect('post')}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
<PostAdd fontSize="small" /> |
||||||
|
<Typography>Posts</Typography> |
||||||
|
</Box> |
||||||
|
<Chip size="small" label={stats.byType.post} variant="outlined" /> |
||||||
|
</Box> |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleFilterSelect('offer')}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
<LocalOffer fontSize="small" /> |
||||||
|
<Typography>Offers</Typography> |
||||||
|
</Box> |
||||||
|
<Chip size="small" label={stats.byType.offer} variant="outlined" /> |
||||||
|
</Box> |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleFilterSelect('want')}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
<ShoppingCart fontSize="small" /> |
||||||
|
<Typography>Wants</Typography> |
||||||
|
</Box> |
||||||
|
<Chip size="small" label={stats.byType.want} variant="outlined" /> |
||||||
|
</Box> |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleFilterSelect('image')}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
<ImageIcon fontSize="small" /> |
||||||
|
<Typography>Images</Typography> |
||||||
|
</Box> |
||||||
|
<Chip size="small" label={stats.byType.image} variant="outlined" /> |
||||||
|
</Box> |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleFilterSelect('link')}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
<LinkIcon fontSize="small" /> |
||||||
|
<Typography>Links</Typography> |
||||||
|
</Box> |
||||||
|
<Chip size="small" label={stats.byType.link} variant="outlined" /> |
||||||
|
</Box> |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleFilterSelect('file')}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
<AttachFile fontSize="small" /> |
||||||
|
<Typography>Files</Typography> |
||||||
|
</Box> |
||||||
|
<Chip size="small" label={stats.byType.file} variant="outlined" /> |
||||||
|
</Box> |
||||||
|
</MenuItem> |
||||||
|
<MenuItem onClick={() => handleFilterSelect('article')}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
<Article fontSize="small" /> |
||||||
|
<Typography>Articles</Typography> |
||||||
|
</Box> |
||||||
|
<Chip size="small" label={stats.byType.article} variant="outlined" /> |
||||||
|
</Box> |
||||||
|
</MenuItem> |
||||||
|
</Menu> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Content List */} |
||||||
|
<Box> |
||||||
|
{filteredContent.length === 0 ? ( |
||||||
|
<Card> |
||||||
|
<CardContent sx={{ textAlign: 'center', py: 8 }}> |
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom> |
||||||
|
No content found |
||||||
|
</Typography> |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
{searchQuery
|
||||||
|
? `No content matches "${searchQuery}"` |
||||||
|
: selectedTab === 'all'
|
||||||
|
? "You haven't shared any content yet" |
||||||
|
: `No ${selectedTab} content found` |
||||||
|
} |
||||||
|
</Typography> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
) : ( |
||||||
|
filteredContent.map(renderContentItem) |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default MyHomePage; |
@ -0,0 +1,345 @@ |
|||||||
|
import { useState } from 'react'; |
||||||
|
import { |
||||||
|
Card, |
||||||
|
CardContent, |
||||||
|
Typography, |
||||||
|
Box, |
||||||
|
Avatar, |
||||||
|
Chip, |
||||||
|
Button, |
||||||
|
Grid, |
||||||
|
LinearProgress, |
||||||
|
IconButton, |
||||||
|
Dialog, |
||||||
|
DialogTitle, |
||||||
|
DialogContent, |
||||||
|
DialogActions, |
||||||
|
List, |
||||||
|
ListItem, |
||||||
|
ListItemAvatar, |
||||||
|
ListItemText, |
||||||
|
Divider, |
||||||
|
Badge, |
||||||
|
Tooltip, |
||||||
|
useTheme, |
||||||
|
alpha, |
||||||
|
} from '@mui/material'; |
||||||
|
import { |
||||||
|
VerifiedUser, |
||||||
|
QrCode, |
||||||
|
Share, |
||||||
|
Timeline, |
||||||
|
LocationOn, |
||||||
|
CalendarToday, |
||||||
|
TrendingUp, |
||||||
|
Security, |
||||||
|
People, |
||||||
|
Star, |
||||||
|
Refresh, |
||||||
|
Info, |
||||||
|
Close, |
||||||
|
} from '@mui/icons-material'; |
||||||
|
import { QRCodeSVG } from 'qrcode.react'; |
||||||
|
import type { PersonhoodCredentials, PersonhoodVerification } from '../../types/personhood'; |
||||||
|
|
||||||
|
interface PersonhoodCredentialsProps { |
||||||
|
credentials: PersonhoodCredentials; |
||||||
|
onGenerateQR: () => void; |
||||||
|
onRefreshCredentials: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
const PersonhoodCredentials = ({
|
||||||
|
credentials,
|
||||||
|
onGenerateQR,
|
||||||
|
onRefreshCredentials
|
||||||
|
}: PersonhoodCredentialsProps) => { |
||||||
|
const theme = useTheme(); |
||||||
|
const [showQRDialog, setShowQRDialog] = useState(false); |
||||||
|
const [showHistoryDialog, setShowHistoryDialog] = useState(false); |
||||||
|
|
||||||
|
const getCredibilityLevel = (score: number) => { |
||||||
|
if (score >= 90) return { label: 'Excellent', color: 'success' as const }; |
||||||
|
if (score >= 75) return { label: 'High', color: 'info' as const }; |
||||||
|
if (score >= 60) return { label: 'Good', color: 'warning' as const }; |
||||||
|
if (score >= 40) return { label: 'Fair', color: 'warning' as const }; |
||||||
|
return { label: 'Low', color: 'error' as const }; |
||||||
|
}; |
||||||
|
|
||||||
|
const formatDate = (date: Date) => { |
||||||
|
return new Intl.DateTimeFormat('en-US', { |
||||||
|
year: 'numeric', |
||||||
|
month: 'short', |
||||||
|
day: 'numeric', |
||||||
|
}).format(date); |
||||||
|
}; |
||||||
|
|
||||||
|
const formatRelativeTime = (date: Date) => { |
||||||
|
const now = new Date(); |
||||||
|
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); |
||||||
|
|
||||||
|
if (diffInDays === 0) return 'Today'; |
||||||
|
if (diffInDays === 1) return 'Yesterday'; |
||||||
|
if (diffInDays < 7) return `${diffInDays} days ago`; |
||||||
|
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`; |
||||||
|
if (diffInDays < 365) return `${Math.floor(diffInDays / 30)} months ago`; |
||||||
|
return `${Math.floor(diffInDays / 365)} years ago`; |
||||||
|
}; |
||||||
|
|
||||||
|
const credibilityLevel = getCredibilityLevel(credentials.credibilityScore); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> |
||||||
|
<VerifiedUser color="primary" sx={{ fontSize: 32 }} /> |
||||||
|
<Box> |
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}> |
||||||
|
Personhood Credentials |
||||||
|
</Typography> |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
Verified human authenticity through real-world connections |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}> |
||||||
|
<Tooltip title="Generate verification QR code"> |
||||||
|
<Button |
||||||
|
variant="outlined" |
||||||
|
startIcon={<QrCode />} |
||||||
|
onClick={() => setShowQRDialog(true)} |
||||||
|
size="small" |
||||||
|
> |
||||||
|
My QR |
||||||
|
</Button> |
||||||
|
</Tooltip> |
||||||
|
<Tooltip title="Refresh credentials"> |
||||||
|
<IconButton onClick={onRefreshCredentials} size="small"> |
||||||
|
<Refresh /> |
||||||
|
</IconButton> |
||||||
|
</Tooltip> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{/* Total Verifications Card */} |
||||||
|
<Card sx={{ mb: 3, background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.1)}, ${alpha(theme.palette.secondary.main, 0.1)})` }}> |
||||||
|
<CardContent> |
||||||
|
<Box sx={{ textAlign: 'center' }}> |
||||||
|
<Typography variant="h4" color="success.main" sx={{ fontWeight: 700 }}> |
||||||
|
{credentials.totalVerifications} |
||||||
|
</Typography> |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
Total Verifications |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
|
||||||
|
{/* Recent Verifications */} |
||||||
|
<Card sx={{ mb: 3 }}> |
||||||
|
<CardContent> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> |
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}> |
||||||
|
Recent Verifications |
||||||
|
</Typography> |
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
endIcon={<Timeline />} |
||||||
|
onClick={() => setShowHistoryDialog(true)} |
||||||
|
> |
||||||
|
View All |
||||||
|
</Button> |
||||||
|
</Box> |
||||||
|
|
||||||
|
{credentials.verifications.slice(0, 3).map((verification) => ( |
||||||
|
<Box key={verification.id} sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 1 }}> |
||||||
|
<Avatar src={verification.verifierAvatar} sx={{ width: 40, height: 40 }}> |
||||||
|
{verification.verifierName.charAt(0)} |
||||||
|
</Avatar> |
||||||
|
<Box sx={{ flexGrow: 1 }}> |
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> |
||||||
|
{verification.verifierName} |
||||||
|
</Typography> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
{verification.location && ( |
||||||
|
<Chip
|
||||||
|
icon={<LocationOn />} |
||||||
|
label={`${verification.location.city}, ${verification.location.country}`} |
||||||
|
size="small" |
||||||
|
variant="outlined" |
||||||
|
/> |
||||||
|
)} |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
{formatRelativeTime(verification.verifiedAt)} |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
<Box sx={{ textAlign: 'right' }}> |
||||||
|
<Chip
|
||||||
|
label={`${verification.trustScore}%`} |
||||||
|
size="small" |
||||||
|
color={verification.trustScore >= 80 ? 'success' : verification.trustScore >= 60 ? 'warning' : 'error'} |
||||||
|
/> |
||||||
|
{verification.isReciprocal && ( |
||||||
|
<Tooltip title="Reciprocal verification"> |
||||||
|
<Star sx={{ color: 'gold', fontSize: 16, ml: 0.5 }} /> |
||||||
|
</Tooltip> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
))} |
||||||
|
|
||||||
|
{credentials.verifications.length === 0 && ( |
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}> |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
No verifications yet. Share your QR code with trusted contacts to start building your personhood credentials. |
||||||
|
</Typography> |
||||||
|
</Box> |
||||||
|
)} |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
|
||||||
|
{/* Certificates */} |
||||||
|
{credentials.certificates.length > 0 && ( |
||||||
|
<Card> |
||||||
|
<CardContent> |
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}> |
||||||
|
Earned Certificates |
||||||
|
</Typography> |
||||||
|
<Grid container spacing={2}> |
||||||
|
{credentials.certificates.map((certificate) => ( |
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={certificate.id}> |
||||||
|
<Card variant="outlined"> |
||||||
|
<CardContent sx={{ textAlign: 'center', py: 2 }}> |
||||||
|
<Badge color="success" variant="dot" invisible={!certificate.isActive}> |
||||||
|
<Security sx={{ fontSize: 40, color: 'primary.main', mb: 1 }} /> |
||||||
|
</Badge> |
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> |
||||||
|
{certificate.name} |
||||||
|
</Typography> |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
Issued {formatDate(certificate.issuedAt)} |
||||||
|
</Typography> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
</Grid> |
||||||
|
))} |
||||||
|
</Grid> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* QR Code Dialog */} |
||||||
|
<Dialog open={showQRDialog} onClose={() => setShowQRDialog(false)} maxWidth="sm" fullWidth> |
||||||
|
<DialogTitle> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> |
||||||
|
<Typography variant="h6">My Verification QR Code</Typography> |
||||||
|
<IconButton onClick={() => setShowQRDialog(false)} size="small"> |
||||||
|
<Close /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
</DialogTitle> |
||||||
|
<DialogContent> |
||||||
|
<Box sx={{ textAlign: 'center', py: 2 }}> |
||||||
|
<Box sx={{ display: 'inline-block', p: 2, bgcolor: 'white', borderRadius: 2, mb: 2 }}> |
||||||
|
<QRCodeSVG |
||||||
|
value={credentials.qrCode} |
||||||
|
size={200} |
||||||
|
level="M" |
||||||
|
includeMargin={true} |
||||||
|
bgColor="#ffffff" |
||||||
|
fgColor="#000000" |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1 }}> |
||||||
|
Scan to Verify My Personhood |
||||||
|
</Typography> |
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> |
||||||
|
Ask trusted contacts to scan this QR code to verify that you are a real human being. Each verification strengthens your credibility score. |
||||||
|
</Typography> |
||||||
|
<Chip
|
||||||
|
icon={<Info />} |
||||||
|
label="QR code expires in 24 hours" |
||||||
|
size="small" |
||||||
|
variant="outlined" |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</DialogContent> |
||||||
|
<DialogActions> |
||||||
|
<Button onClick={() => setShowQRDialog(false)}>Close</Button> |
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Share />} |
||||||
|
onClick={onGenerateQR} |
||||||
|
> |
||||||
|
Share QR Code |
||||||
|
</Button> |
||||||
|
</DialogActions> |
||||||
|
</Dialog> |
||||||
|
|
||||||
|
{/* Verification History Dialog */} |
||||||
|
<Dialog open={showHistoryDialog} onClose={() => setShowHistoryDialog(false)} maxWidth="md" fullWidth> |
||||||
|
<DialogTitle> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> |
||||||
|
<Typography variant="h6">Verification History</Typography> |
||||||
|
<IconButton onClick={() => setShowHistoryDialog(false)} size="small"> |
||||||
|
<Close /> |
||||||
|
</IconButton> |
||||||
|
</Box> |
||||||
|
</DialogTitle> |
||||||
|
<DialogContent> |
||||||
|
<List> |
||||||
|
{credentials.verifications.map((verification, index) => ( |
||||||
|
<Box key={verification.id}> |
||||||
|
<ListItem> |
||||||
|
<ListItemAvatar> |
||||||
|
<Avatar src={verification.verifierAvatar}> |
||||||
|
{verification.verifierName.charAt(0)} |
||||||
|
</Avatar> |
||||||
|
</ListItemAvatar> |
||||||
|
<ListItemText |
||||||
|
primary={verification.verifierName} |
||||||
|
secondary={ |
||||||
|
<Box> |
||||||
|
<Typography variant="body2" color="text.secondary"> |
||||||
|
{formatDate(verification.verifiedAt)} • Trust Score: {verification.trustScore}% |
||||||
|
</Typography> |
||||||
|
{verification.location && ( |
||||||
|
<Typography variant="caption" color="text.secondary"> |
||||||
|
{verification.location.city}, {verification.location.country} |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
{verification.notes && ( |
||||||
|
<Typography variant="caption" sx={{ fontStyle: 'italic', display: 'block' }}> |
||||||
|
"{verification.notes}" |
||||||
|
</Typography> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
} |
||||||
|
/> |
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> |
||||||
|
<Chip
|
||||||
|
label={verification.verificationMethod} |
||||||
|
size="small" |
||||||
|
variant="outlined" |
||||||
|
/> |
||||||
|
{verification.isReciprocal && ( |
||||||
|
<Tooltip title="Reciprocal verification"> |
||||||
|
<Star sx={{ color: 'gold' }} /> |
||||||
|
</Tooltip> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</ListItem> |
||||||
|
{index < credentials.verifications.length - 1 && <Divider />} |
||||||
|
</Box> |
||||||
|
))} |
||||||
|
</List> |
||||||
|
</DialogContent> |
||||||
|
<DialogActions> |
||||||
|
<Button onClick={() => setShowHistoryDialog(false)}>Close</Button> |
||||||
|
</DialogActions> |
||||||
|
</Dialog> |
||||||
|
</Box> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default PersonhoodCredentials; |
@ -0,0 +1,56 @@ |
|||||||
|
export interface BookmarkedItem { |
||||||
|
id: string; |
||||||
|
originalId: string; // ID of the original content
|
||||||
|
type: 'post' | 'article' | 'link' | 'image' | 'file' | 'offer' | 'want'; |
||||||
|
title: string; |
||||||
|
description?: string; |
||||||
|
content?: string; |
||||||
|
url?: string; |
||||||
|
imageUrl?: string; |
||||||
|
author: { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
avatar?: string; |
||||||
|
}; |
||||||
|
source: string; // Where it was bookmarked from
|
||||||
|
bookmarkedAt: Date; |
||||||
|
tags: string[]; |
||||||
|
notes?: string; // User's personal notes
|
||||||
|
category?: string; // User-defined category
|
||||||
|
isRead: boolean; |
||||||
|
isFavorite: boolean; |
||||||
|
lastViewedAt?: Date; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Collection { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
description?: string; |
||||||
|
items: BookmarkedItem[]; |
||||||
|
isDefault: boolean; |
||||||
|
createdAt: Date; |
||||||
|
updatedAt: Date; |
||||||
|
} |
||||||
|
|
||||||
|
export interface CollectionFilter { |
||||||
|
type?: string; |
||||||
|
category?: string; |
||||||
|
author?: string; |
||||||
|
isRead?: boolean; |
||||||
|
isFavorite?: boolean; |
||||||
|
dateRange?: { |
||||||
|
start: Date; |
||||||
|
end: Date; |
||||||
|
}; |
||||||
|
searchQuery?: string; |
||||||
|
tags?: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface CollectionStats { |
||||||
|
totalItems: number; |
||||||
|
unreadItems: number; |
||||||
|
favoriteItems: number; |
||||||
|
byType: Record<string, number>; |
||||||
|
byCategory: Record<string, number>; |
||||||
|
recentlyAdded: number; // Added in last 7 days
|
||||||
|
} |
@ -0,0 +1,78 @@ |
|||||||
|
export interface PersonhoodVerification { |
||||||
|
id: string; |
||||||
|
verifierId: string; |
||||||
|
verifierName: string; |
||||||
|
verifierAvatar?: string; |
||||||
|
verifiedAt: Date; |
||||||
|
location?: { |
||||||
|
city: string; |
||||||
|
country: string; |
||||||
|
coordinates?: { |
||||||
|
lat: number; |
||||||
|
lng: number; |
||||||
|
}; |
||||||
|
}; |
||||||
|
verificationMethod: 'qr_scan' | 'nfc_tap' | 'biometric' | 'manual'; |
||||||
|
trustScore: number; // 0-100
|
||||||
|
isReciprocal: boolean; // If the verifier also got verified by this person
|
||||||
|
notes?: string; |
||||||
|
expiresAt?: Date; |
||||||
|
isActive: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export interface PersonhoodCredentials { |
||||||
|
userId: string; |
||||||
|
totalVerifications: number; |
||||||
|
uniqueVerifiers: number; |
||||||
|
reciprocalVerifications: number; |
||||||
|
averageTrustScore: number; |
||||||
|
credibilityScore: number; // Calculated score based on various factors
|
||||||
|
verificationStreak: number; // Days since last verification
|
||||||
|
lastVerificationAt?: Date; |
||||||
|
firstVerificationAt?: Date; |
||||||
|
verifications: PersonhoodVerification[]; |
||||||
|
certificates: PersonhoodCertificate[]; |
||||||
|
qrCode: string; // QR code data for verification
|
||||||
|
} |
||||||
|
|
||||||
|
export interface PersonhoodCertificate { |
||||||
|
id: string; |
||||||
|
type: 'basic' | 'advanced' | 'premium' | 'community'; |
||||||
|
name: string; |
||||||
|
description: string; |
||||||
|
requiredVerifications: number; |
||||||
|
issuedAt: Date; |
||||||
|
expiresAt?: Date; |
||||||
|
isActive: boolean; |
||||||
|
badgeUrl?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface PersonhoodStats { |
||||||
|
verificationTrend: { |
||||||
|
period: string; |
||||||
|
count: number; |
||||||
|
}[]; |
||||||
|
topLocations: { |
||||||
|
location: string; |
||||||
|
count: number; |
||||||
|
}[]; |
||||||
|
verificationMethods: { |
||||||
|
method: string; |
||||||
|
count: number; |
||||||
|
percentage: number; |
||||||
|
}[]; |
||||||
|
trustScoreDistribution: { |
||||||
|
range: string; |
||||||
|
count: number; |
||||||
|
}[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface QRCodeSession { |
||||||
|
id: string; |
||||||
|
qrCode: string; |
||||||
|
createdAt: Date; |
||||||
|
expiresAt: Date; |
||||||
|
isActive: boolean; |
||||||
|
scansCount: number; |
||||||
|
successfulVerifications: number; |
||||||
|
} |
@ -0,0 +1,104 @@ |
|||||||
|
export type ContentType = 'post' | 'offer' | 'want' | 'image' | 'link' | 'file' | 'article'; |
||||||
|
|
||||||
|
export interface BaseContent { |
||||||
|
id: string; |
||||||
|
type: ContentType; |
||||||
|
title: string; |
||||||
|
description?: string; |
||||||
|
createdAt: Date; |
||||||
|
updatedAt: Date; |
||||||
|
tags?: string[]; |
||||||
|
visibility: 'public' | 'network' | 'private'; |
||||||
|
viewCount: number; |
||||||
|
likeCount: number; |
||||||
|
commentCount: number; |
||||||
|
rCardIds: string[]; // Which rCards can see this content
|
||||||
|
} |
||||||
|
|
||||||
|
export interface Post extends BaseContent { |
||||||
|
type: 'post'; |
||||||
|
content: string; |
||||||
|
attachments?: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Offer extends BaseContent { |
||||||
|
type: 'offer'; |
||||||
|
content: string; |
||||||
|
category: string; |
||||||
|
price?: string; |
||||||
|
availability: 'available' | 'pending' | 'completed'; |
||||||
|
location?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Want extends BaseContent { |
||||||
|
type: 'want'; |
||||||
|
content: string; |
||||||
|
category: string; |
||||||
|
budget?: string; |
||||||
|
urgency: 'low' | 'medium' | 'high'; |
||||||
|
location?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Image extends BaseContent { |
||||||
|
type: 'image'; |
||||||
|
imageUrl: string; |
||||||
|
imageAlt: string; |
||||||
|
caption?: string; |
||||||
|
dimensions?: { |
||||||
|
width: number; |
||||||
|
height: number; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Link extends BaseContent { |
||||||
|
type: 'link'; |
||||||
|
url: string; |
||||||
|
linkTitle: string; |
||||||
|
linkDescription?: string; |
||||||
|
linkImage?: string; |
||||||
|
domain: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface File extends BaseContent { |
||||||
|
type: 'file'; |
||||||
|
fileUrl: string; |
||||||
|
fileName: string; |
||||||
|
fileSize: number; |
||||||
|
fileType: string; |
||||||
|
downloadCount: number; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Article extends BaseContent { |
||||||
|
type: 'article'; |
||||||
|
content: string; |
||||||
|
excerpt: string; |
||||||
|
readTime: number; // in minutes
|
||||||
|
publishedAt?: Date; |
||||||
|
featuredImage?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export type UserContent = Post | Offer | Want | Image | Link | File | Article; |
||||||
|
|
||||||
|
export interface ContentFilter { |
||||||
|
type?: ContentType; |
||||||
|
visibility?: 'public' | 'network' | 'private'; |
||||||
|
dateRange?: { |
||||||
|
start: Date; |
||||||
|
end: Date; |
||||||
|
}; |
||||||
|
tags?: string[]; |
||||||
|
searchQuery?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface ContentStats { |
||||||
|
totalItems: number; |
||||||
|
byType: Record<ContentType, number>; |
||||||
|
byVisibility: { |
||||||
|
public: number; |
||||||
|
network: number; |
||||||
|
private: number; |
||||||
|
}; |
||||||
|
totalViews: number; |
||||||
|
totalLikes: number; |
||||||
|
totalComments: number; |
||||||
|
} |
Loading…
Reference in new issue