Added Collaborator Facepile to Collection Header

🎯 Moved collaboration display from bottom section to hero header:
- Created CollaboratorFacepile component with hover tooltips
- Shows creator + active collaborators in compact format
- Color-coded avatars by role (owner=purple, editor=blue, viewer=green)
- Displays up to 4 faces, then '+N more' for additional collaborators
- Rich hover tooltips showing email and role information
- Responsive text: 'Crafted by X & N others'

🔧 Technical improvements:
- Fixed favorites system database migration (separated SQL commands)
- Fixed favorites API SQL syntax errors
- Integrated facepile into collection metadata section
- Removed redundant CollaborationManager from bottom
- Clean component architecture with proper loading states

🎨 UX enhancements:
- Smooth hover animations with scale effects
- Professional tooltips with arrows
- Proper z-index layering for overlapping elements
- Loading skeleton while fetching collaborators
- Accessible color contrast and typography

Perfect for showing collaboration at a glance! 👥
This commit is contained in:
Randall Stillwell 2025-07-25 22:38:03 -05:00
parent 2a76666f79
commit faab506b25
4 changed files with 146 additions and 47 deletions

View file

@ -0,0 +1,133 @@
import { useState, useEffect } from 'react';
export default function CollaboratorFacepile({ collectionId, creatorEmail }) {
const [collaborators, setCollaborators] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (collectionId) {
fetchCollaborators();
}
}, [collectionId]);
const fetchCollaborators = async () => {
try {
const response = await fetch(`/api/collections/${collectionId}/permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
}
});
if (response.ok) {
const data = await response.json();
setCollaborators(data.permissions || []);
}
} catch (error) {
console.error('Error fetching collaborators:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center space-x-2">
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Crafted by {creatorEmail}
</span>
<div className="flex -space-x-2">
<div className="w-6 h-6 bg-gray-200 rounded-full animate-pulse"></div>
</div>
</div>
);
}
// Include creator and active collaborators
const allUsers = [
{ email: creatorEmail, role: 'owner', status: 'active' },
...collaborators.filter(c => c.status === 'active')
];
const visibleUsers = allUsers.slice(0, 4); // Show max 4 faces
const remainingCount = Math.max(0, allUsers.length - 4);
const getRoleColor = (role) => {
switch (role) {
case 'owner': return 'bg-purple-600';
case 'editor': return 'bg-blue-600';
case 'viewer': return 'bg-green-600';
default: return 'bg-gray-600';
}
};
const getRoleLabel = (role) => {
switch (role) {
case 'owner': return 'Owner';
case 'editor': return 'Collaborator';
case 'viewer': return 'Viewer';
default: return 'Member';
}
};
return (
<div className="flex items-center space-x-2">
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Crafted by {creatorEmail}
{allUsers.length > 1 && (
<span className="ml-1">
& {allUsers.length - 1} other{allUsers.length > 2 ? 's' : ''}
</span>
)}
</span>
{allUsers.length > 1 && (
<div className="flex -space-x-2">
{visibleUsers.map((user, index) => (
<div
key={user.email || index}
className="relative group"
>
<div
className={`w-6 h-6 ${getRoleColor(user.role)} rounded-full flex items-center justify-center text-white text-xs font-bold border-2 border-white hover:z-10 transition-transform hover:scale-110 cursor-pointer`}
>
{user.email ? user.email.charAt(0).toUpperCase() : '?'}
</div>
{/* Tooltip */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-20">
<div className="bg-gray-900 text-white text-xs rounded-lg py-2 px-3 whitespace-nowrap">
<div className="font-medium">{user.email}</div>
<div className="text-gray-300">{getRoleLabel(user.role)}</div>
{/* Arrow */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900"></div>
</div>
</div>
</div>
))}
{remainingCount > 0 && (
<div className="relative group">
<div className="w-6 h-6 bg-gray-400 rounded-full flex items-center justify-center text-white text-xs font-bold border-2 border-white hover:z-10 transition-transform hover:scale-110 cursor-pointer">
+{remainingCount}
</div>
{/* Tooltip for remaining users */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-20">
<div className="bg-gray-900 text-white text-xs rounded-lg py-2 px-3 whitespace-nowrap max-w-48">
<div className="font-medium mb-1">{remainingCount} more collaborator{remainingCount > 1 ? 's' : ''}</div>
{allUsers.slice(4).map((user, index) => (
<div key={user.email || index} className="text-gray-300">
{user.email} ({getRoleLabel(user.role)})
</div>
))}
{/* Arrow */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900"></div>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -33,7 +33,7 @@ export default async function handler(req, res) {
// Get user's favorites // Get user's favorites
const { type } = req.query; // Optional filter by type const { type } = req.query; // Optional filter by type
let query = sql` const result = await sql`
SELECT uf.*, SELECT uf.*,
CASE CASE
WHEN uf.item_type = 'collection' THEN c.name WHEN uf.item_type = 'collection' THEN c.name
@ -45,28 +45,10 @@ export default async function handler(req, res) {
LEFT JOIN cards ON uf.item_type = 'card' AND uf.item_id = cards.id LEFT JOIN cards ON uf.item_type = 'card' AND uf.item_id = cards.id
LEFT JOIN decks d ON uf.item_type = 'deck' AND uf.item_id = d.id LEFT JOIN decks d ON uf.item_type = 'deck' AND uf.item_id = d.id
WHERE uf.user_id = ${user.userId} WHERE uf.user_id = ${user.userId}
${type ? sql`AND uf.item_type = ${type}` : sql``}
ORDER BY uf.created_at DESC
`; `;
if (type) {
query = sql`
SELECT uf.*,
CASE
WHEN uf.item_type = 'collection' THEN c.name
WHEN uf.item_type = 'card' THEN cards.name
WHEN uf.item_type = 'deck' THEN d.name
END as item_name
FROM user_favorites uf
LEFT JOIN collections c ON uf.item_type = 'collection' AND uf.item_id = c.id
LEFT JOIN cards ON uf.item_type = 'card' AND uf.item_id = cards.id
LEFT JOIN decks d ON uf.item_type = 'deck' AND uf.item_id = d.id
WHERE uf.user_id = ${user.userId} AND uf.item_type = ${type}
`;
}
query = sql`${query} ORDER BY uf.created_at DESC`;
const result = await query;
res.status(200).json({ res.status(200).json({
favorites: result.rows favorites: result.rows
}); });

View file

@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import CollaborationManager from '../../components/CollaborationManager';
import UploadImageModal from '../../components/UploadImageModal'; import UploadImageModal from '../../components/UploadImageModal';
import ShareModal from '../../components/ShareModal'; import ShareModal from '../../components/ShareModal';
import CollaboratorFacepile from '../../components/CollaboratorFacepile';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
export default function CollectionView() { export default function CollectionView() {
@ -385,17 +385,10 @@ export default function CollectionView() {
{/* Creator and Stats */} {/* Creator and Stats */}
<div className="flex items-center space-x-6 text-sm" style={{ color: 'var(--text-secondary)' }}> <div className="flex items-center space-x-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center space-x-2"> <CollaboratorFacepile
<span>Crafted by</span> collectionId={id}
<div className="flex items-center space-x-1"> creatorEmail={collection.creator_email}
<div className="w-6 h-6 bg-purple-600 rounded-full flex items-center justify-center"> />
<span className="text-white text-xs font-bold">
{collection.creator_email?.charAt(0).toUpperCase()}
</span>
</div>
<span className="font-medium">{collection.creator_email}</span>
</div>
</div>
<div>Cards: <span className="font-medium">{cards.length}</span></div> <div>Cards: <span className="font-medium">{cards.length}</span></div>
<div>Cost: <span className="font-medium">${collection.totalValue || '0'}</span></div> <div>Cost: <span className="font-medium">${collection.totalValue || '0'}</span></div>
</div> </div>
@ -644,14 +637,7 @@ export default function CollectionView() {
</div> </div>
)} )}
{/* Collaboration Manager */}
<div className="p-6 border-t" style={{ borderColor: 'var(--border)' }}>
<CollaborationManager
collectionId={id}
userRole={collection.userRole}
isPublic={collection.is_public}
/>
</div>
{/* Upload Image Modal */} {/* Upload Image Modal */}
<UploadImageModal <UploadImageModal

View file

@ -21,12 +21,10 @@ async function addFavoritesSystem() {
console.log('✅ Created user_favorites table'); console.log('✅ Created user_favorites table');
// Create indexes for performance // Create indexes for performance
await sql` await sql`CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites(user_id)`;
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites(user_id); await sql`CREATE INDEX IF NOT EXISTS idx_user_favorites_item_type ON user_favorites(item_type)`;
CREATE INDEX IF NOT EXISTS idx_user_favorites_item_type ON user_favorites(item_type); await sql`CREATE INDEX IF NOT EXISTS idx_user_favorites_item_id ON user_favorites(item_id)`;
CREATE INDEX IF NOT EXISTS idx_user_favorites_item_id ON user_favorites(item_id); await sql`CREATE INDEX IF NOT EXISTS idx_user_favorites_user_type ON user_favorites(user_id, item_type)`;
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_type ON user_favorites(user_id, item_type);
`;
console.log('⚡ Created indexes for user_favorites'); console.log('⚡ Created indexes for user_favorites');
console.log('\n🎉 Favorites system added successfully!\n'); console.log('\n🎉 Favorites system added successfully!\n');