🧹 Major Codebase Cleanup: Remove Legacy React Code
🗑️ Removed Unused Files (11 files): - 6 temporary import result JSON files - 3 placeholder pages (community.js, analytics.js, decks.js) - 3 unused components (CollaborationManager, ActivityLog, BulkInviteModal) - 2 TypeScript config files (tsconfig.json, next-env.d.ts) 📦 Cleaned Up Dependencies: - Removed 5 unused TypeScript packages - Kept resend for future invite/notification features - Removed 8 packages total, reduced bundle size ✨ Benefits: - Cleaner codebase with only active files - Reduced build time and bundle size - Eliminated TypeScript overhead (project uses only JS) - Removed legacy React patterns and unused components - Better maintainability and clarity The codebase is now lean and focused on active features
This commit is contained in:
parent
6097af75a4
commit
308d2de365
16 changed files with 2 additions and 4788 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,172 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function ActivityLog({ collectionId }) {
|
||||
const [activities, setActivities] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (collectionId) {
|
||||
fetchActivities();
|
||||
}
|
||||
}, [collectionId]);
|
||||
|
||||
const fetchActivities = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${collectionId}/activity`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setActivities(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching activities:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityIcon = (action) => {
|
||||
switch (action) {
|
||||
case 'card_added': return '➕';
|
||||
case 'card_removed': return '➖';
|
||||
case 'card_quantity_updated': return '<27><>';
|
||||
case 'collection_updated': return '✏️';
|
||||
case 'collection_deleted': return '🗑️';
|
||||
case 'user_invited': return '📧';
|
||||
case 'invitation_accepted': return '✅';
|
||||
case 'invitation_declined': return '❌';
|
||||
case 'permission_updated': return '🔐';
|
||||
case 'user_removed': return '👋';
|
||||
default: return '📝';
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityDescription = (activity) => {
|
||||
const { action, details } = activity;
|
||||
|
||||
switch (action) {
|
||||
case 'card_added':
|
||||
return `added a card (qty: ${details.quantity})`;
|
||||
case 'card_removed':
|
||||
return details.reason === 'quantity_zero'
|
||||
? 'removed a card by setting quantity to 0'
|
||||
: 'removed a card from the collection';
|
||||
case 'card_quantity_updated':
|
||||
return details.oldQuantity
|
||||
? `updated card quantity from ${details.oldQuantity} to ${details.newQuantity}`
|
||||
: `updated card quantity to ${details.newQuantity}`;
|
||||
case 'collection_updated':
|
||||
return 'updated collection details';
|
||||
case 'collection_deleted':
|
||||
return 'deleted the collection';
|
||||
case 'user_invited':
|
||||
return `invited ${details.email} as ${details.role}`;
|
||||
case 'invitation_accepted':
|
||||
return 'accepted an invitation';
|
||||
case 'invitation_declined':
|
||||
return 'declined an invitation';
|
||||
case 'permission_updated':
|
||||
return `updated user permissions to ${details.role}`;
|
||||
case 'user_removed':
|
||||
return 'removed a user from the collection';
|
||||
default:
|
||||
return action.replace(/_/g, ' ');
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp) => {
|
||||
const now = new Date();
|
||||
const activityTime = new Date(timestamp);
|
||||
const diffMs = now - activityTime;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return activityTime.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-300 rounded w-1/4 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-8 bg-gray-300 rounded"></div>
|
||||
<div className="h-8 bg-gray-300 rounded"></div>
|
||||
<div className="h-8 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayedActivities = showAll ? activities : activities.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
Recent Activity
|
||||
</h3>
|
||||
{activities.length > 5 && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="text-sm font-medium hover:underline"
|
||||
style={{ color: 'var(--text-accent)' }}
|
||||
>
|
||||
{showAll ? 'Show Less' : `View All (${activities.length})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{displayedActivities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-start space-x-3 p-3 rounded-lg"
|
||||
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||||
>
|
||||
<div className="text-lg mt-0.5">
|
||||
{getActivityIcon(activity.action)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||
<span className="font-medium">{activity.user_email || 'System'}</span>
|
||||
{' '}
|
||||
<span>{getActivityDescription(activity)}</span>
|
||||
</p>
|
||||
<span className="text-xs whitespace-nowrap ml-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{formatTimeAgo(activity.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{activity.details && Object.keys(activity.details).length > 0 && (
|
||||
<div className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{activity.action === 'collection_updated' && activity.details.name && (
|
||||
<span>Name: "{activity.details.name}"</span>
|
||||
)}
|
||||
{activity.action === 'user_invited' && activity.details.message && (
|
||||
<span>Message: "{activity.details.message}"</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activities.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-4">📝</div>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
No activity yet. Start collaborating to see updates here!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
export default function BulkInviteModal({ collectionId, isOpen, onClose, onSuccess }) {
|
||||
const [inviteData, setInviteData] = useState({
|
||||
emails: '',
|
||||
role: 'editor',
|
||||
message: ''
|
||||
});
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [results, setResults] = useState(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setProcessing(true);
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
// Parse emails (split by comma, semicolon, or newline)
|
||||
const emailList = inviteData.emails
|
||||
.split(/[,;\n]/)
|
||||
.map(email => email.trim())
|
||||
.filter(email => email && email.includes('@'));
|
||||
|
||||
if (emailList.length === 0) {
|
||||
alert('Please enter at least one valid email address');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send invitations
|
||||
const invitePromises = emailList.map(email =>
|
||||
fetch(`/api/collections/${collectionId}/permissions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
role: inviteData.role,
|
||||
message: inviteData.message
|
||||
})
|
||||
}).then(async response => ({
|
||||
email,
|
||||
success: response.ok,
|
||||
error: response.ok ? null : (await response.json()).error
|
||||
}))
|
||||
);
|
||||
|
||||
const results = await Promise.all(invitePromises);
|
||||
setResults(results);
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
if (successCount > 0 && onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Bulk invite error:', error);
|
||||
alert('An error occurred while sending invitations');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setInviteData({ emails: '', role: 'editor', message: '' });
|
||||
setResults(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const successCount = results ? results.filter(r => r.success).length : 0;
|
||||
const failureCount = results ? results.filter(r => !r.success).length : 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="p-6 rounded-2xl shadow-lg max-w-2xl w-full mx-4 max-h-90vh overflow-y-auto" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Bulk Invite Collaborators
|
||||
</h2>
|
||||
|
||||
{!results ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Email Addresses
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
value={inviteData.emails}
|
||||
onChange={(e) => setInviteData({ ...inviteData, emails: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-lg border transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
rows="6"
|
||||
placeholder="Enter email addresses separated by commas, semicolons, or new lines:
|
||||
user1@example.com, user2@example.com
|
||||
user3@example.com
|
||||
user4@example.com"
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Separate multiple email addresses with commas, semicolons, or new lines
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Permission Level
|
||||
</label>
|
||||
<select
|
||||
value={inviteData.role}
|
||||
onChange={(e) => setInviteData({ ...inviteData, role: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-lg border transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
>
|
||||
<option value="editor">Collaborator - Can add/remove cards and edit collection details</option>
|
||||
<option value="viewer">Viewer - Can only view the collection</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Personal Message (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={inviteData.message}
|
||||
onChange={(e) => setInviteData({ ...inviteData, message: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-lg border transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
rows="3"
|
||||
placeholder="Add a personal message to all invitations..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing || !inviteData.emails.trim()}
|
||||
className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
{processing ? 'Sending Invitations...' : 'Send Invitations'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center py-4">
|
||||
<div className="text-4xl mb-4">
|
||||
{failureCount === 0 ? '🎉' : '⚠️'}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Invitations Sent
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
{successCount} successful, {failureCount} failed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto space-y-2">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 rounded-lg"
|
||||
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-lg">
|
||||
{result.success ? '✅' : '❌'}
|
||||
</span>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{result.email}
|
||||
</span>
|
||||
</div>
|
||||
{result.error && (
|
||||
<span className="text-sm text-red-500">
|
||||
{result.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mt-6">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-6 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import BulkInviteModal from './BulkInviteModal';
|
||||
|
||||
export default function CollaborationManager({ collectionId, isOwner }) {
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [showBulkInviteModal, setShowBulkInviteModal] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
role: 'editor',
|
||||
message: ''
|
||||
});
|
||||
const [inviting, setInviting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (collectionId) {
|
||||
fetchPermissions();
|
||||
}
|
||||
}, [collectionId]);
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${collectionId}/permissions`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPermissions(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setInviting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${collectionId}/permissions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(inviteForm)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setShowInviteModal(false);
|
||||
setInviteForm({ email: '', role: 'editor', message: '' });
|
||||
fetchPermissions(); // Refresh the list
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to send invitation');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error. Please try again.');
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePermission = async (userId, newRole) => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${collectionId}/permissions`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ userId, role: newRole })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchPermissions(); // Refresh the list
|
||||
} else {
|
||||
alert('Failed to update permission');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (userId) => {
|
||||
if (!confirm('Are you sure you want to remove this user from the collection?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${collectionId}/permissions`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ userId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchPermissions(); // Refresh the list
|
||||
} else {
|
||||
alert('Failed to remove user');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleIcon = (role) => {
|
||||
switch (role) {
|
||||
case 'owner': return '👑';
|
||||
case 'editor': return '✏️';
|
||||
case 'viewer': return '👁️';
|
||||
default: return '👤';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const styles = {
|
||||
active: { backgroundColor: '#10b981', color: 'white' },
|
||||
pending: { backgroundColor: '#f59e0b', color: 'white' },
|
||||
declined: { backgroundColor: '#ef4444', color: 'white' }
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-1 rounded-full text-xs font-medium"
|
||||
style={styles[status] || styles.active}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-300 rounded w-1/4 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-12 bg-gray-300 rounded"></div>
|
||||
<div className="h-12 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
Collaboration ({permissions.length})
|
||||
</h3>
|
||||
{isOwner && (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span>Invite Collaborator</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBulkInviteModal(true)}
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium border transition-all duration-200 hover:shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<span>Bulk Invite</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{permissions.map((permission) => (
|
||||
<div
|
||||
key={permission.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border"
|
||||
style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-2xl">{getRoleIcon(permission.role)}</div>
|
||||
<div>
|
||||
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{permission.email}
|
||||
{permission.is_pending && (
|
||||
<span className="ml-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
(Pending Registration)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm capitalize" style={{ color: 'var(--text-secondary)' }}>
|
||||
{permission.role}
|
||||
</span>
|
||||
{getStatusBadge(permission.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOwner && permission.role !== 'owner' && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
value={permission.role}
|
||||
onChange={(e) => handleUpdatePermission(permission.user_id, e.target.value)}
|
||||
className="px-3 py-1 rounded border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRemoveUser(permission.user_id)}
|
||||
className="p-2 rounded text-red-500 hover:bg-red-50 transition-colors"
|
||||
title="Remove user"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{permissions.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-4">👥</div>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
No collaborators yet. Invite others to work on this collection together!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Modal */}
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="p-6 rounded-2xl shadow-lg max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Invite Collaborator
|
||||
</h2>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
Invite someone to help manage and add cards to this collection
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleInviteUser} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, email: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-lg border transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
placeholder="Enter email address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Permission Level
|
||||
</label>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, role: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-lg border transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
>
|
||||
<option value="editor">Collaborator - Can add/remove cards and edit collection details</option>
|
||||
<option value="viewer">Viewer - Can only view the collection</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Personal Message (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={inviteForm.message}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, message: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-lg border transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
rows="3"
|
||||
placeholder="Add a personal message to the invitation..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInviteModal(false)}
|
||||
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviting || !inviteForm.email}
|
||||
className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
{inviting ? 'Sending...' : 'Send Invitation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Invite Modal */}
|
||||
<BulkInviteModal
|
||||
collectionId={collectionId}
|
||||
isOpen={showBulkInviteModal}
|
||||
onClose={() => setShowBulkInviteModal(false)}
|
||||
onSuccess={() => {
|
||||
fetchPermissions(); // Refresh permissions list
|
||||
setShowBulkInviteModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"total": 0,
|
||||
"successful": 3,
|
||||
"failed": 0,
|
||||
"sets": [
|
||||
{
|
||||
"code": "tfc",
|
||||
"name": "The First Chapter",
|
||||
"lorcastCode": "1",
|
||||
"success": true,
|
||||
"importedCount": 0,
|
||||
"skippedCount": 216,
|
||||
"total": 216
|
||||
},
|
||||
{
|
||||
"code": "rotf",
|
||||
"name": "Rise of the Floodborn",
|
||||
"lorcastCode": "2",
|
||||
"success": true,
|
||||
"importedCount": 0,
|
||||
"skippedCount": 216,
|
||||
"total": 216
|
||||
},
|
||||
{
|
||||
"code": "ink",
|
||||
"name": "Into the Inklands",
|
||||
"lorcastCode": "3",
|
||||
"success": true,
|
||||
"importedCount": 0,
|
||||
"skippedCount": 226,
|
||||
"total": 226
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"total": 658,
|
||||
"successful": 3,
|
||||
"failed": 0,
|
||||
"sets": [
|
||||
{
|
||||
"code": "tfc",
|
||||
"name": "The First Chapter",
|
||||
"success": true,
|
||||
"importedCount": 216
|
||||
},
|
||||
{
|
||||
"code": "rotf",
|
||||
"name": "Rise of the Floodborn",
|
||||
"success": true,
|
||||
"importedCount": 216
|
||||
},
|
||||
{
|
||||
"code": "ink",
|
||||
"name": "Into the Inklands",
|
||||
"success": true,
|
||||
"importedCount": 226
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"total": 0,
|
||||
"successful": 3,
|
||||
"failed": 0,
|
||||
"sets": [
|
||||
{
|
||||
"code": "tfc",
|
||||
"name": "The First Chapter",
|
||||
"lorcastCode": "1",
|
||||
"success": true,
|
||||
"importedCount": 0,
|
||||
"skippedCount": 216,
|
||||
"total": 216
|
||||
},
|
||||
{
|
||||
"code": "rotf",
|
||||
"name": "Rise of the Floodborn",
|
||||
"lorcastCode": "2",
|
||||
"success": true,
|
||||
"importedCount": 0,
|
||||
"skippedCount": 216,
|
||||
"total": 216
|
||||
},
|
||||
{
|
||||
"code": "ink",
|
||||
"name": "Into the Inklands",
|
||||
"lorcastCode": "3",
|
||||
"success": true,
|
||||
"importedCount": 0,
|
||||
"skippedCount": 226,
|
||||
"total": 226
|
||||
}
|
||||
]
|
||||
}
|
||||
5
next-env.d.ts
vendored
5
next-env.d.ts
vendored
|
|
@ -1,5 +0,0 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
82
package-lock.json
generated
82
package-lock.json
generated
|
|
@ -21,17 +21,11 @@
|
|||
"resend": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.4.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^4.9.5"
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
|
@ -997,13 +991,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json5": {
|
||||
"version": "0.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||
|
|
@ -1011,24 +998,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "16.18.126",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
|
||||
|
|
@ -1046,34 +1015,6 @@
|
|||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz",
|
||||
|
|
@ -2465,13 +2406,6 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
|
|
@ -7047,20 +6981,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -26,16 +26,10 @@
|
|||
"resend": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.4.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^4.9.5"
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import Layout from '../components/Layout';
|
||||
|
||||
export default function Analytics() {
|
||||
const user = {
|
||||
email: 'me@randallstillwell.com',
|
||||
role: 'user'
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<div className="p-6">
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<h1 className="text-3xl font-bold mb-4" style={{ color: 'var(--text-primary-light)' }}>
|
||||
Analytics
|
||||
</h1>
|
||||
<p className="text-lg mb-6" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
Track your collection performance and insights
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
Coming soon...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import Layout from '../components/Layout';
|
||||
|
||||
export default function Community() {
|
||||
const user = {
|
||||
email: 'me@randallstillwell.com',
|
||||
role: 'user'
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<div className="p-6">
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">👥</div>
|
||||
<h1 className="text-3xl font-bold mb-4" style={{ color: 'var(--text-primary-light)' }}>
|
||||
Community
|
||||
</h1>
|
||||
<p className="text-lg mb-6" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
Connect with other TCG enthusiasts
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
Coming soon...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import Layout from '../components/Layout';
|
||||
|
||||
export default function Decks() {
|
||||
const user = {
|
||||
email: 'me@randallstillwell.com',
|
||||
role: 'user'
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<div className="p-6">
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">🎴</div>
|
||||
<h1 className="text-3xl font-bold mb-4" style={{ color: 'var(--text-primary-light)' }}>
|
||||
Decks
|
||||
</h1>
|
||||
<p className="text-lg mb-6" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
Build and manage your card decks
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
Coming soon...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es6"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue