✨ 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
|
// 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,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 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue