diff --git a/components/ShareModal.js b/components/ShareModal.js
new file mode 100644
index 0000000..f80373c
--- /dev/null
+++ b/components/ShareModal.js
@@ -0,0 +1,360 @@
+import { useState, useEffect } from 'react';
+
+export default function ShareModal({
+ isOpen,
+ onClose,
+ collectionId,
+ isPublic,
+ onTogglePublic,
+ onInviteUser
+}) {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchResults, setSearchResults] = useState([]);
+ const [invitedUsers, setInvitedUsers] = useState([]);
+ const [currentUser, setCurrentUser] = useState(null);
+ const [copySuccess, setCopySuccess] = useState(false);
+
+ useEffect(() => {
+ if (isOpen) {
+ fetchInvitedUsers();
+ fetchCurrentUser();
+ }
+ }, [isOpen, collectionId]);
+
+ const fetchCurrentUser = async () => {
+ try {
+ const response = await fetch('/api/auth/verify', {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
+ }
+ });
+ if (response.ok) {
+ const data = await response.json();
+ setCurrentUser(data.user);
+ }
+ } catch (error) {
+ console.error('Error fetching current user:', error);
+ }
+ };
+
+ const fetchInvitedUsers = 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();
+ setInvitedUsers(data.permissions || []);
+ }
+ } catch (error) {
+ console.error('Error fetching invited users:', error);
+ }
+ };
+
+ const handleSearch = async (query) => {
+ setSearchQuery(query);
+ if (query.length < 2) {
+ setSearchResults([]);
+ return;
+ }
+
+ try {
+ // Search for users by email
+ const response = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`, {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
+ }
+ });
+ if (response.ok) {
+ const data = await response.json();
+ setSearchResults(data.users || []);
+ }
+ } catch (error) {
+ console.error('Error searching users:', error);
+ }
+ };
+
+ const handleInvite = async (emailOrUser) => {
+ try {
+ const email = typeof emailOrUser === 'string' ? emailOrUser : emailOrUser.email;
+
+ const response = await fetch(`/api/collections/${collectionId}/permissions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
+ },
+ body: JSON.stringify({
+ email,
+ role: 'viewer' // Default to viewer as requested
+ })
+ });
+
+ if (response.ok) {
+ setSearchQuery('');
+ setSearchResults([]);
+ fetchInvitedUsers(); // Refresh the list
+ if (onInviteUser) onInviteUser(email);
+ }
+ } catch (error) {
+ console.error('Error inviting user:', error);
+ }
+ };
+
+ const handleCopyLink = () => {
+ const url = window.location.href;
+ navigator.clipboard.writeText(url).then(() => {
+ setCopySuccess(true);
+ setTimeout(() => setCopySuccess(false), 2000);
+ });
+ };
+
+ const handleSocialShare = (platform) => {
+ const url = window.location.href;
+ const title = `Check out this collection on TCG Vault`;
+
+ const shareUrls = {
+ twitter: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`,
+ facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
+ reddit: `https://reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}`,
+ discord: `https://discord.com/channels/@me` // Discord doesn't have direct share URL
+ };
+
+ if (shareUrls[platform]) {
+ window.open(shareUrls[platform], '_blank', 'width=600,height=400');
+ }
+ };
+
+ const isValidEmail = (email) => {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {/* Public Access Toggle */}
+
+
+
+
+
+
+
Public access
+
Anyone with a link can view
+
+
+
+
+ This collection will be available in the community.
+
+
+
+
+
+ {/* Add People */}
+
+
+
handleSearch(e.target.value)}
+ className="w-full px-4 py-3 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ />
+
+
+
+ {/* Search Results */}
+ {searchResults.length > 0 && (
+
+ {searchResults.map((user) => (
+
handleInvite(user)}
+ className="flex items-center p-3 hover:bg-gray-50 cursor-pointer"
+ >
+
+
+ {user.email.charAt(0).toUpperCase()}
+
+
+
+
{user.email}
+
Click to invite as viewer
+
+
+ ))}
+
+ )}
+
+ {/* Email invite option */}
+ {searchQuery && isValidEmail(searchQuery) && !searchResults.some(u => u.email === searchQuery) && (
+
+
handleInvite(searchQuery)}
+ className="flex items-center p-3 hover:bg-gray-50 cursor-pointer"
+ >
+
+
+
Invite {searchQuery}
+
Send email invitation as viewer
+
+
+
+ )}
+
+
+ {/* Current Permissions */}
+
+
+ Only those invited can view or collaborate on this collection.
+
+
+
+ {/* Current User */}
+ {currentUser && (
+
+
+
+
+ {currentUser.email.charAt(0).toUpperCase()}
+
+
+
+
+ {currentUser.email} (You)
+
+
+
+
+ Owner
+
+
+ )}
+
+ {/* Invited Users */}
+ {invitedUsers.map((permission) => (
+
+
+
+
+ {permission.user_email?.charAt(0).toUpperCase() || '?'}
+
+
+
+
+ {permission.user_email || 'Unknown User'}
+
+
+
+
+ {permission.role === 'editor' ? 'Collaborator' : 'Viewer'}
+
+
+ ))}
+
+
+
+ {/* Share Link */}
+
+
+
+
+
+
+
+ {/* Social Share */}
+
+ {[
+ { name: 'Twitter', icon: 'twitter', platform: 'twitter' },
+ { name: 'Facebook', icon: 'facebook', platform: 'facebook' },
+ { name: 'Reddit', icon: 'reddit', platform: 'reddit' },
+ { name: 'Discord', icon: 'discord', platform: 'discord' }
+ ].map((social) => (
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/UploadImageModal.js b/components/UploadImageModal.js
new file mode 100644
index 0000000..92248fa
--- /dev/null
+++ b/components/UploadImageModal.js
@@ -0,0 +1,198 @@
+import { useState } from 'react';
+
+export default function UploadImageModal({ isOpen, onClose, onUpload, currentImage }) {
+ const [uploadMethod, setUploadMethod] = useState('url'); // 'url' or 'file'
+ const [imageUrl, setImageUrl] = useState(currentImage || '');
+ const [dragActive, setDragActive] = useState(false);
+ const [uploading, setUploading] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!imageUrl.trim()) return;
+
+ setUploading(true);
+ try {
+ await onUpload(imageUrl);
+ onClose();
+ } catch (error) {
+ console.error('Upload failed:', error);
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleDrag = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.type === "dragenter" || e.type === "dragover") {
+ setDragActive(true);
+ } else if (e.type === "dragleave") {
+ setDragActive(false);
+ }
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragActive(false);
+
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
+ handleFileUpload(e.dataTransfer.files[0]);
+ }
+ };
+
+ const handleFileUpload = (file) => {
+ if (file.type.startsWith('image/')) {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ setImageUrl(e.target.result);
+ setUploadMethod('file');
+ };
+ reader.readAsDataURL(file);
+ }
+ };
+
+ const handleFileInput = (e) => {
+ if (e.target.files && e.target.files[0]) {
+ handleFileUpload(e.target.files[0]);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
Upload Hero Image
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/pages/api/favorites.js b/pages/api/favorites.js
new file mode 100644
index 0000000..8d5bd6c
--- /dev/null
+++ b/pages/api/favorites.js
@@ -0,0 +1,138 @@
+import { sql } from '@vercel/postgres';
+import jwt from 'jsonwebtoken';
+
+const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
+
+export default async function handler(req, res) {
+ // Set CORS headers
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
+
+ if (req.method === 'OPTIONS') {
+ res.status(200).end();
+ return;
+ }
+
+ // Verify authentication
+ const authHeader = req.headers.authorization;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+
+ const token = authHeader.substring(7);
+ let user;
+ try {
+ user = jwt.verify(token, JWT_SECRET);
+ } catch (error) {
+ return res.status(401).json({ error: 'Invalid token' });
+ }
+
+ try {
+ if (req.method === 'GET') {
+ // Get user's favorites
+ const { type } = req.query; // Optional filter by type
+
+ let 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}
+ `;
+
+ 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
+ });
+
+ } else if (req.method === 'POST') {
+ // Add favorite
+ const { itemType, itemId } = req.body;
+
+ if (!itemType || !itemId) {
+ return res.status(400).json({ error: 'itemType and itemId are required' });
+ }
+
+ if (!['card', 'collection', 'deck'].includes(itemType)) {
+ return res.status(400).json({ error: 'Invalid itemType. Must be card, collection, or deck' });
+ }
+
+ // Check if already favorited
+ const existing = await sql`
+ SELECT id FROM user_favorites
+ WHERE user_id = ${user.userId} AND item_type = ${itemType} AND item_id = ${itemId}
+ `;
+
+ if (existing.rows.length > 0) {
+ return res.status(409).json({ error: 'Item already favorited' });
+ }
+
+ // Add favorite
+ const result = await sql`
+ INSERT INTO user_favorites (user_id, item_type, item_id)
+ VALUES (${user.userId}, ${itemType}, ${itemId})
+ RETURNING *
+ `;
+
+ res.status(201).json({
+ favorite: result.rows[0]
+ });
+
+ } else if (req.method === 'DELETE') {
+ // Remove favorite
+ const { itemType, itemId } = req.body;
+
+ if (!itemType || !itemId) {
+ return res.status(400).json({ error: 'itemType and itemId are required' });
+ }
+
+ const result = await sql`
+ DELETE FROM user_favorites
+ WHERE user_id = ${user.userId} AND item_type = ${itemType} AND item_id = ${itemId}
+ RETURNING *
+ `;
+
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: 'Favorite not found' });
+ }
+
+ res.status(200).json({
+ message: 'Favorite removed',
+ favorite: result.rows[0]
+ });
+
+ } else {
+ res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ } catch (error) {
+ console.error('Favorites API error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+}
\ No newline at end of file
diff --git a/pages/api/users/search.js b/pages/api/users/search.js
new file mode 100644
index 0000000..72ede3f
--- /dev/null
+++ b/pages/api/users/search.js
@@ -0,0 +1,58 @@
+import { sql } from '@vercel/postgres';
+import jwt from 'jsonwebtoken';
+
+const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
+
+export default async function handler(req, res) {
+ // Set CORS headers
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
+
+ if (req.method === 'OPTIONS') {
+ res.status(200).end();
+ return;
+ }
+
+ if (req.method !== 'GET') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ // Verify authentication
+ const authHeader = req.headers.authorization;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+
+ const token = authHeader.substring(7);
+ try {
+ jwt.verify(token, JWT_SECRET);
+ } catch (error) {
+ return res.status(401).json({ error: 'Invalid token' });
+ }
+
+ const { q: query } = req.query;
+
+ if (!query || query.length < 2) {
+ return res.status(400).json({ error: 'Query must be at least 2 characters' });
+ }
+
+ try {
+ // Search users by email (partial match)
+ const result = await sql`
+ SELECT id, email, role, created_at
+ FROM users
+ WHERE email ILIKE ${`%${query}%`}
+ ORDER BY email
+ LIMIT 10
+ `;
+
+ res.status(200).json({
+ users: result.rows
+ });
+
+ } catch (error) {
+ console.error('User search error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+}
\ No newline at end of file
diff --git a/pages/collection/[id].js b/pages/collection/[id].js
index ed61b6a..739f114 100644
--- a/pages/collection/[id].js
+++ b/pages/collection/[id].js
@@ -2,6 +2,8 @@ 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 Layout from '../../components/Layout';
export default function CollectionView() {
@@ -32,6 +34,7 @@ export default function CollectionView() {
const [searchCards, setSearchCards] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [showSearchResults, setShowSearchResults] = useState(false);
+ const [showUploadModal, setShowUploadModal] = useState(false);
useEffect(() => {
if (id) {
@@ -46,6 +49,9 @@ export default function CollectionView() {
const data = await response.json();
setCollection(data.collection);
setCards(data.cards || []);
+
+ // Check if collection is favorited
+ checkIfFavorited();
} else {
console.error('Failed to fetch collection');
setCollection(null);
@@ -60,6 +66,23 @@ export default function CollectionView() {
}
};
+ const checkIfFavorited = async () => {
+ try {
+ const response = await fetch(`/api/favorites?type=collection`, {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
+ }
+ });
+ if (response.ok) {
+ const data = await response.json();
+ const isFav = data.favorites.some(fav => fav.item_id === parseInt(id));
+ setIsFavorited(isFav);
+ }
+ } catch (error) {
+ console.error('Error checking favorites:', error);
+ }
+ };
+
const handleSearchCards = async (query) => {
if (query.length < 2) {
setSearchResults([]);
@@ -103,16 +126,48 @@ export default function CollectionView() {
}
};
- const handleShare = () => {
- const url = window.location.href;
- navigator.clipboard.writeText(url).then(() => {
- setCopySuccess(true);
- setTimeout(() => setCopySuccess(false), 2000);
- });
- };
- const toggleFavorite = () => {
- setIsFavorited(!isFavorited);
+
+ const toggleFavorite = async () => {
+ try {
+ if (isFavorited) {
+ // Remove from favorites
+ const response = await fetch('/api/favorites', {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
+ },
+ body: JSON.stringify({
+ itemType: 'collection',
+ itemId: parseInt(id)
+ })
+ });
+
+ if (response.ok) {
+ setIsFavorited(false);
+ }
+ } else {
+ // Add to favorites
+ const response = await fetch('/api/favorites', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
+ },
+ body: JSON.stringify({
+ itemType: 'collection',
+ itemId: parseInt(id)
+ })
+ });
+
+ if (response.ok) {
+ setIsFavorited(true);
+ }
+ }
+ } catch (error) {
+ console.error('Error toggling favorite:', error);
+ }
};
const togglePublic = async () => {
@@ -139,6 +194,95 @@ export default function CollectionView() {
}
};
+ const handleImageUpload = async (imageUrl) => {
+ try {
+ const response = await fetch(`/api/collections/${id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
+ },
+ body: JSON.stringify({
+ image: imageUrl
+ })
+ });
+
+ if (response.ok) {
+ setCollection(prev => ({
+ ...prev,
+ image: imageUrl
+ }));
+ }
+ } catch (error) {
+ console.error('Error updating collection image:', error);
+ }
+ };
+
+ const handleDownloadCSV = () => {
+ if (cards.length === 0) {
+ alert('No cards to download');
+ return;
+ }
+
+ // Create CSV headers
+ const headers = [
+ 'Name',
+ 'Set Name',
+ 'Set Code',
+ 'Card Number',
+ 'Rarity',
+ 'Game',
+ 'Mana Cost',
+ 'CMC',
+ 'Type',
+ 'Colors',
+ 'Oracle Text',
+ 'Power',
+ 'Toughness',
+ 'Market Price',
+ 'Quantity',
+ 'Added Date'
+ ];
+
+ // Create CSV rows
+ const csvRows = [
+ headers.join(','), // Header row
+ ...cards.map(card => [
+ `"${(card.name || '').replace(/"/g, '""')}"`,
+ `"${(card.set_name || '').replace(/"/g, '""')}"`,
+ `"${card.set_code || ''}"`,
+ `"${card.card_number || ''}"`,
+ `"${card.rarity || ''}"`,
+ `"${card.game || ''}"`,
+ `"${card.mana_cost || ''}"`,
+ `"${card.cmc || ''}"`,
+ `"${card.card_type || ''}"`,
+ `"${Array.isArray(card.colors) ? card.colors.join(', ') : (card.colors || '')}"`,
+ `"${(card.oracle_text || '').replace(/"/g, '""')}"`,
+ `"${card.power || ''}"`,
+ `"${card.toughness || ''}"`,
+ `"${card.market_price || ''}"`,
+ `"${card.quantity || 1}"`,
+ `"${card.created_at ? new Date(card.created_at).toLocaleDateString() : ''}"`
+ ].join(','))
+ ];
+
+ // Create and download the CSV file
+ const csvContent = csvRows.join('\n');
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
+ const link = document.createElement('a');
+
+ if (link.download !== undefined) {
+ const url = URL.createObjectURL(blob);
+ link.setAttribute('href', url);
+ link.setAttribute('download', `${collection.name || 'collection'}_cards.csv`);
+ link.style.visibility = 'hidden';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ };
+
// Group cards by game
const groupedCards = cards.reduce((acc, card) => {
const game = card.game || 'Other';
@@ -203,7 +347,11 @@ export default function CollectionView() {
{/* Action buttons */}
-
+
+ {/* Upload Image Modal */}
+ setShowUploadModal(false)}
+ onUpload={handleImageUpload}
+ currentImage={collection.image}
+ />
+
+ {/* Share Modal */}
+ setShowShareModal(false)}
+ collectionId={id}
+ isPublic={collection.is_public}
+ onTogglePublic={togglePublic}
+ onInviteUser={(email) => console.log('Invited:', email)}
+ />
);
diff --git a/scripts/add-favorites-system.js b/scripts/add-favorites-system.js
new file mode 100644
index 0000000..c987a80
--- /dev/null
+++ b/scripts/add-favorites-system.js
@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+import { config } from 'dotenv';
+import { sql } from '@vercel/postgres';
+config({ path: '.env.local' });
+
+async function addFavoritesSystem() {
+ try {
+ console.log('ā Adding favorites system to database...');
+
+ // Create user_favorites table for all types of favorites
+ await sql`
+ CREATE TABLE IF NOT EXISTS user_favorites (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ item_type VARCHAR(50) NOT NULL, -- 'card', 'collection', 'deck'
+ item_id INTEGER NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(user_id, item_type, item_id)
+ )
+ `;
+ 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);
+ `;
+ console.log('ā” Created indexes for user_favorites');
+
+ console.log('\nš Favorites system added successfully!\n');
+ console.log('š New Features:');
+ console.log(' ⢠Users can favorite cards, collections, and decks');
+ console.log(' ⢠Unified favorites table with item_type and item_id');
+ console.log(' ⢠Optimized with indexes for fast queries');
+ console.log(' ⢠Unique constraint prevents duplicate favorites');
+
+ } catch (error) {
+ console.error('ā Failed to add favorites system:', error);
+ process.exit(1);
+ }
+}
+
+addFavoritesSystem();
\ No newline at end of file