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 */} +
+

Share

+ +
+ + {/* 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

+ +
+ +
+ {/* Upload Method Tabs */} +
+ + +
+ + {uploadMethod === 'url' ? ( +
+ + setImageUrl(e.target.value)} + placeholder="https://example.com/image.jpg" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +
+ ) : ( +
+ +
+ + + +

Drag and drop an image here, or

+ +

PNG, JPG, GIF up to 10MB

+
+
+ )} + + {/* Preview */} + {imageUrl && ( +
+ +
+ Preview { + e.target.src = 'https://via.placeholder.com/400x128/f3f4f6/6b7280?text=Invalid+Image'; + }} + /> +
+
+ )} + + {/* Actions */} +
+ + +
+
+
+
+
+ ); +} \ 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 */}
- -
-
- - -
- Public - -
- -
- Activity - 123 -
+
+ Activity + 123
@@ -522,6 +652,24 @@ export default function CollectionView() { isPublic={collection.is_public} /> + + {/* 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