✨ 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:
parent
2a76666f79
commit
faab506b25
4 changed files with 146 additions and 47 deletions
133
components/CollaboratorFacepile.js
Normal file
133
components/CollaboratorFacepile.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ export default async function handler(req, res) {
|
|||
// Get user's favorites
|
||||
const { type } = req.query; // Optional filter by type
|
||||
|
||||
let query = sql`
|
||||
const result = await sql`
|
||||
SELECT uf.*,
|
||||
CASE
|
||||
WHEN uf.item_type = 'collection' THEN c.name
|
||||
|
|
@ -45,27 +45,9 @@ export default async function handler(req, res) {
|
|||
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}
|
||||
${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({
|
||||
favorites: result.rows
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import CollaborationManager from '../../components/CollaborationManager';
|
||||
import UploadImageModal from '../../components/UploadImageModal';
|
||||
import ShareModal from '../../components/ShareModal';
|
||||
import CollaboratorFacepile from '../../components/CollaboratorFacepile';
|
||||
import Layout from '../../components/Layout';
|
||||
|
||||
export default function CollectionView() {
|
||||
|
|
@ -385,17 +385,10 @@ export default function CollectionView() {
|
|||
|
||||
{/* 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-2">
|
||||
<span>Crafted by</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<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>
|
||||
<CollaboratorFacepile
|
||||
collectionId={id}
|
||||
creatorEmail={collection.creator_email}
|
||||
/>
|
||||
<div>Cards: <span className="font-medium">{cards.length}</span></div>
|
||||
<div>Cost: <span className="font-medium">${collection.totalValue || '0'}</span></div>
|
||||
</div>
|
||||
|
|
@ -644,14 +637,7 @@ export default function CollectionView() {
|
|||
</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 */}
|
||||
<UploadImageModal
|
||||
|
|
|
|||
|
|
@ -21,12 +21,10 @@ async function addFavoritesSystem() {
|
|||
console.log('✅ Created user_favorites table');
|
||||
|
||||
// Create indexes for performance
|
||||
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_item_type ON user_favorites(item_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_item_id ON user_favorites(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_type ON user_favorites(user_id, item_type);
|
||||
`;
|
||||
await sql`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)`;
|
||||
await sql`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)`;
|
||||
console.log('⚡ Created indexes for user_favorites');
|
||||
|
||||
console.log('\n🎉 Favorites system added successfully!\n');
|
||||
|
|
|
|||
Loading…
Reference in a new issue