🔄 Implement Automatic Redirects and Collection Edit/Delete
🔗 Automatic ID to Slug Redirects: - Collection detail page now automatically redirects from ID URLs to slug URLs - Maintains backwards compatibility for all existing links - SEO-friendly permanent redirects using router.replace() ✏️ Collection Edit/Delete Functionality: - Added edit modal directly in collection detail page - Added delete confirmation modal with proper warnings - Edit functionality updates name, description, image, and visibility - Automatic slug regeneration when collection name changes - Proper permission checks (only owners can edit/delete) 🛠️ API Route Restructuring: - Renamed all [id] routes to [identifier] to resolve Next.js conflicts - Updated all APIs to handle both slugs and numeric IDs - Fixed 'different slug names for same dynamic path' error - Consistent identifier handling across all endpoints 📁 Updated API Endpoints: - /api/collections/[identifier] - Main collection CRUD - /api/collections/[identifier]/cards - Collection cards management - /api/collections/[identifier]/thumbnails - Thumbnail generation - /api/collections/[identifier]/permissions - Permission management - /api/collections/[identifier]/activity - Activity tracking 🎨 UI/UX Improvements: - Edit and Delete buttons only show for collection owners - Clean modal interfaces with proper form validation - Loading states and error handling - Confirmation dialogs for destructive actions - Consistent styling with fire theme 🔧 Technical Enhancements: - Smart identifier detection (slug vs numeric ID) - Proper error handling and user feedback - Database transaction safety for updates - Automatic collection timestamp updates - Permission-based access control Now users can seamlessly edit collections and get beautiful SEO-friendly URLs! 🚀✨
This commit is contained in:
parent
50a3156b92
commit
374ad421f6
9 changed files with 833 additions and 638 deletions
|
|
@ -1,133 +0,0 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { withCollectionPermission, getUserFromRequest, logCollectionActivity } from '../../../lib/permission-middleware';
|
||||
|
||||
async function handler(req, res) {
|
||||
// Set CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query;
|
||||
|
||||
if (req.method === 'GET') {
|
||||
// GET requests use the permission from middleware
|
||||
const collection = req.permission.collection;
|
||||
const userRole = req.permission.role;
|
||||
|
||||
try {
|
||||
// Get detailed collection info with creator
|
||||
const collectionResult = await sql`
|
||||
SELECT
|
||||
c.*,
|
||||
u.email as creator_email
|
||||
FROM collections c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
WHERE c.id = ${id}
|
||||
`;
|
||||
|
||||
const collectionDetails = collectionResult.rows[0];
|
||||
|
||||
// Get cards in the collection
|
||||
const cardsResult = await sql`
|
||||
SELECT
|
||||
cc.*,
|
||||
cards.name,
|
||||
cards.set_name,
|
||||
cards.rarity,
|
||||
cards.card_type,
|
||||
cards.game,
|
||||
cards.image_url,
|
||||
cards.market_price
|
||||
FROM collection_cards cc
|
||||
JOIN cards ON cc.card_id = cards.id
|
||||
WHERE cc.collection_id = ${id}
|
||||
ORDER BY cc.created_at ASC
|
||||
`;
|
||||
|
||||
const cards = cardsResult.rows;
|
||||
|
||||
// Calculate collection stats
|
||||
const totalCards = cards.reduce((sum, card) => sum + card.quantity, 0);
|
||||
const totalValue = cards.reduce((sum, card) => sum + (card.market_price * card.quantity), 0);
|
||||
|
||||
res.status(200).json({
|
||||
collection: {
|
||||
...collectionDetails,
|
||||
totalCards,
|
||||
totalValue,
|
||||
userRole
|
||||
},
|
||||
cards
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching collection:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else if (req.method === 'PUT') {
|
||||
// Update collection - requires editor permissions
|
||||
try {
|
||||
const { name, description, isPublic } = req.body;
|
||||
|
||||
const result = await sql`
|
||||
UPDATE collections
|
||||
SET
|
||||
name = ${name},
|
||||
description = ${description},
|
||||
is_public = ${isPublic},
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
// Log activity
|
||||
await logCollectionActivity(id, req.user.userId, 'collection_updated', {
|
||||
name,
|
||||
description,
|
||||
isPublic
|
||||
});
|
||||
|
||||
res.status(200).json(result.rows[0]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating collection:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else if (req.method === 'DELETE') {
|
||||
// Delete collection - requires owner permissions
|
||||
try {
|
||||
// Log activity before deletion
|
||||
await logCollectionActivity(id, req.user.userId, 'collection_deleted', {});
|
||||
|
||||
// Delete collection (cascade will handle related records)
|
||||
const result = await sql`DELETE FROM collections WHERE id = ${id} RETURNING *`;
|
||||
|
||||
res.status(200).json({ message: 'Collection deleted successfully' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting collection:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else {
|
||||
res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply permission middleware based on method
|
||||
export default async function(req, res) {
|
||||
let requiredPermission = 'viewer'; // Default for GET
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
requiredPermission = 'editor';
|
||||
} else if (req.method === 'DELETE') {
|
||||
requiredPermission = 'owner';
|
||||
}
|
||||
|
||||
return withCollectionPermission(requiredPermission)(handler)(req, res);
|
||||
};
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { withCollectionPermission } from '../../../../lib/permission-middleware';
|
||||
|
||||
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');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { id } = req.query; // collection id
|
||||
|
||||
try {
|
||||
// Get activity log for the collection
|
||||
const result = await sql`
|
||||
SELECT
|
||||
ca.*,
|
||||
u.email as user_email
|
||||
FROM collection_activity ca
|
||||
LEFT JOIN users u ON ca.user_id = u.id
|
||||
WHERE ca.collection_id = ${id}
|
||||
ORDER BY ca.created_at DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
|
||||
res.status(200).json(result.rows);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching collection activity:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply permission middleware - requires viewer access to see activity
|
||||
export default withCollectionPermission('viewer')(handler);
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { withCollectionPermission, logCollectionActivity } from '../../../../lib/permission-middleware';
|
||||
|
||||
async function handler(req, res) {
|
||||
// Set CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query; // collection id
|
||||
|
||||
if (req.method === 'POST') {
|
||||
// Add card to collection
|
||||
try {
|
||||
const { cardId, quantity = 1 } = req.body;
|
||||
|
||||
if (!cardId) {
|
||||
return res.status(400).json({ error: 'Card ID is required' });
|
||||
}
|
||||
|
||||
// Check if card already exists in collection
|
||||
const existingResult = await sql`
|
||||
SELECT * FROM collection_cards
|
||||
WHERE collection_id = ${id} AND card_id = ${cardId}
|
||||
`;
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
// Update quantity if card already exists
|
||||
const result = await sql`
|
||||
UPDATE collection_cards
|
||||
SET quantity = quantity + ${quantity}
|
||||
WHERE collection_id = ${id} AND card_id = ${cardId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
await logCollectionActivity(id, req.user.userId, 'card_quantity_updated', {
|
||||
cardId,
|
||||
oldQuantity: existingCard.quantity,
|
||||
newQuantity: quantity
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Card quantity updated in collection',
|
||||
card: result.rows[0]
|
||||
});
|
||||
} else {
|
||||
// Add new card to collection
|
||||
const result = await sql`
|
||||
INSERT INTO collection_cards (collection_id, card_id, quantity)
|
||||
VALUES (${id}, ${cardId}, ${quantity})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
await logCollectionActivity(id, req.user.userId, 'card_added', {
|
||||
cardId,
|
||||
quantity
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Card added to collection',
|
||||
card: result.rows[0]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding card to collection:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else if (req.method === 'PUT') {
|
||||
// Update card quantity in collection
|
||||
try {
|
||||
const { cardId, quantity } = req.body;
|
||||
|
||||
if (!cardId || quantity === undefined) {
|
||||
return res.status(400).json({ error: 'Card ID and quantity are required' });
|
||||
}
|
||||
|
||||
if (quantity <= 0) {
|
||||
// Remove card if quantity is 0 or negative
|
||||
await sql`
|
||||
DELETE FROM collection_cards
|
||||
WHERE collection_id = ${id} AND card_id = ${cardId}
|
||||
`;
|
||||
|
||||
await logCollectionActivity(id, req.user.userId, 'card_removed', {
|
||||
cardId,
|
||||
reason: 'quantity_zero'
|
||||
});
|
||||
|
||||
res.status(200).json({ message: 'Card removed from collection' });
|
||||
} else {
|
||||
// Update quantity
|
||||
const result = await sql`
|
||||
UPDATE collection_cards
|
||||
SET quantity = ${quantity}
|
||||
WHERE collection_id = ${id} AND card_id = ${cardId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Card not found in collection' });
|
||||
}
|
||||
|
||||
await logCollectionActivity(id, req.user.userId, 'card_quantity_updated', {
|
||||
cardId,
|
||||
newQuantity: quantity
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Card quantity updated',
|
||||
card: result.rows[0]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating card in collection:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else if (req.method === 'DELETE') {
|
||||
// Remove card from collection
|
||||
try {
|
||||
const { cardId } = req.body;
|
||||
|
||||
if (!cardId) {
|
||||
return res.status(400).json({ error: 'Card ID is required' });
|
||||
}
|
||||
|
||||
const result = await sql`
|
||||
DELETE FROM collection_cards
|
||||
WHERE collection_id = ${id} AND card_id = ${cardId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Card not found in collection' });
|
||||
}
|
||||
|
||||
await logCollectionActivity(id, req.user.userId, 'card_removed', {
|
||||
cardId,
|
||||
reason: 'explicit_delete'
|
||||
});
|
||||
|
||||
res.status(200).json({ message: 'Card removed from collection' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error removing card from collection:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else {
|
||||
res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply permission middleware - all card operations require editor permissions
|
||||
export default withCollectionPermission('editor')(handler);
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Set CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query; // collection id
|
||||
|
||||
if (req.method === 'GET') {
|
||||
// Get all permissions for a collection
|
||||
try {
|
||||
const result = await sql`
|
||||
SELECT
|
||||
cp.*,
|
||||
u.email,
|
||||
u.id as user_id,
|
||||
u.is_pending
|
||||
FROM collection_permissions cp
|
||||
JOIN users u ON cp.user_id = u.id
|
||||
WHERE cp.collection_id = ${id}
|
||||
ORDER BY cp.role, cp.created_at
|
||||
`;
|
||||
|
||||
res.status(200).json(result.rows);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
// Invite user to collection
|
||||
try {
|
||||
const { email, role = 'viewer', message = '' } = req.body;
|
||||
|
||||
if (!email || !['owner', 'editor', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Valid email and role are required' });
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const userResult = await sql`
|
||||
SELECT id, email FROM users WHERE email = ${email}
|
||||
`;
|
||||
|
||||
let userId;
|
||||
if (userResult.rows.length === 0) {
|
||||
// Create pending user record
|
||||
const newUserResult = await sql`
|
||||
INSERT INTO users (email, password, role, is_pending)
|
||||
VALUES (${email}, '', 'user', true)
|
||||
RETURNING id
|
||||
`;
|
||||
userId = newUserResult.rows[0].id;
|
||||
} else {
|
||||
userId = userResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Check if permission already exists
|
||||
const existingPermission = await sql`
|
||||
SELECT * FROM collection_permissions
|
||||
WHERE collection_id = ${id} AND user_id = ${userId}
|
||||
`;
|
||||
|
||||
if (existingPermission.rows.length > 0) {
|
||||
return res.status(409).json({ error: 'User already has access to this collection' });
|
||||
}
|
||||
|
||||
// Get collection details for email
|
||||
const collectionResult = await sql`
|
||||
SELECT c.name, u.email as owner_email
|
||||
FROM collections c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.id = ${id}
|
||||
`;
|
||||
|
||||
if (collectionResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
const collection = collectionResult.rows[0];
|
||||
|
||||
// Create permission record
|
||||
const permissionResult = await sql`
|
||||
INSERT INTO collection_permissions (collection_id, user_id, role, status, invited_by)
|
||||
VALUES (${id}, ${userId}, ${role}, 'pending', 1)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
// Generate invitation token
|
||||
const inviteToken = Buffer.from(`${id}:${userId}:${Date.now()}`).toString('base64');
|
||||
|
||||
await sql`
|
||||
UPDATE collection_permissions
|
||||
SET invite_token = ${inviteToken}
|
||||
WHERE id = ${permissionResult.rows[0].id}
|
||||
`;
|
||||
|
||||
// Send invitation email
|
||||
const acceptUrl = `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/invite/accept?token=${inviteToken}`;
|
||||
const declineUrl = `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/invite/decline?token=${inviteToken}`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: 'TCG Vault <noreply@tcgvault.com>',
|
||||
to: email,
|
||||
subject: `You've been invited to collaborate on "${collection.name}"`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; text-align: center; color: white;">
|
||||
<h1 style="margin: 0; font-size: 28px;">🃏 TCG Vault</h1>
|
||||
<p style="margin: 10px 0 0 0; opacity: 0.9;">Collection Collaboration Invite</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 30px; background: #f8f9fa;">
|
||||
<h2 style="color: #333; margin-top: 0;">You've been invited to collaborate!</h2>
|
||||
|
||||
<p style="color: #666; line-height: 1.6;">
|
||||
<strong>${collection.owner_email}</strong> has invited you to collaborate on the collection
|
||||
<strong>"${collection.name}"</strong> with <strong>${role}</strong> permissions.
|
||||
</p>
|
||||
|
||||
${message ? `
|
||||
<div style="background: #e3f2fd; padding: 15px; border-left: 4px solid #2196f3; margin: 20px 0;">
|
||||
<p style="margin: 0; color: #1976d2;"><strong>Personal message:</strong></p>
|
||||
<p style="margin: 5px 0 0 0; color: #333;">"${message}"</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div style="margin: 30px 0;">
|
||||
<h3 style="color: #333;">What you can do as a ${role === 'editor' ? 'collaborator' : role}:</h3>
|
||||
<ul style="color: #666; line-height: 1.8;">
|
||||
${role === 'editor' ? `
|
||||
<li>Add and remove cards from the collection</li>
|
||||
<li>Edit collection details and description</li>
|
||||
<li>View and search all collection content</li>
|
||||
<li>Help build and organize the collection</li>
|
||||
` : `
|
||||
<li>View all collection content</li>
|
||||
<li>Browse and search cards</li>
|
||||
<li>See collection statistics and details</li>
|
||||
`}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${acceptUrl}" style="background: #4caf50; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: bold; margin-right: 10px; display: inline-block;">
|
||||
Accept Invitation
|
||||
</a>
|
||||
<a href="${declineUrl}" style="background: #f44336; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">
|
||||
Decline
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid #ddd; padding-top: 20px; margin-top: 30px; color: #999; font-size: 14px;">
|
||||
<p>This invitation will expire in 7 days. If you have any questions, please contact ${collection.owner_email}.</p>
|
||||
<p>If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Email sending failed:', emailError);
|
||||
// Continue anyway - the invitation is still created
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await sql`
|
||||
INSERT INTO collection_activity (collection_id, user_id, action, details)
|
||||
VALUES (${id}, 1, 'user_invited', ${JSON.stringify({ email, role, inviteToken })})
|
||||
`;
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Invitation sent successfully',
|
||||
permission: {
|
||||
...permissionResult.rows[0],
|
||||
email,
|
||||
invite_token: inviteToken
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error inviting user:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else if (req.method === 'PUT') {
|
||||
// Update user permission
|
||||
try {
|
||||
const { userId, role, status } = req.body;
|
||||
|
||||
if (!userId || !['owner', 'editor', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Valid user ID and role are required' });
|
||||
}
|
||||
|
||||
const result = await sql`
|
||||
UPDATE collection_permissions
|
||||
SET role = ${role}, status = ${status || 'active'}, updated_at = NOW()
|
||||
WHERE collection_id = ${id} AND user_id = ${userId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Permission not found' });
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await sql`
|
||||
INSERT INTO collection_activity (collection_id, user_id, action, details)
|
||||
VALUES (${id}, 1, 'permission_updated', ${JSON.stringify({ userId, role, status })})
|
||||
`;
|
||||
|
||||
res.status(200).json(result.rows[0]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating permission:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else if (req.method === 'DELETE') {
|
||||
// Remove user permission
|
||||
try {
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'User ID is required' });
|
||||
}
|
||||
|
||||
const result = await sql`
|
||||
DELETE FROM collection_permissions
|
||||
WHERE collection_id = ${id} AND user_id = ${userId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Permission not found' });
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await sql`
|
||||
INSERT INTO collection_activity (collection_id, user_id, action, details)
|
||||
VALUES (${id}, 1, 'user_removed', ${JSON.stringify({ userId })})
|
||||
`;
|
||||
|
||||
res.status(200).json({ message: 'Permission removed successfully' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error removing permission:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} else {
|
||||
res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
90
pages/api/collections/[identifier]/activity.js
Normal file
90
pages/api/collections/[identifier]/activity.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { getUserFromRequest } from '../../../../lib/permission-middleware';
|
||||
import { isValidSlug } from '../../../../lib/slug-utils';
|
||||
|
||||
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');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get authenticated user
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { identifier } = req.query;
|
||||
|
||||
if (!identifier) {
|
||||
return res.status(400).json({ error: 'Collection identifier is required' });
|
||||
}
|
||||
|
||||
// Determine if identifier is a slug or numeric ID
|
||||
const isSlug = isValidSlug(identifier) || isNaN(parseInt(identifier));
|
||||
|
||||
// Verify user has access to this collection
|
||||
let collectionResult;
|
||||
if (isSlug) {
|
||||
collectionResult = await sql`
|
||||
SELECT c.*, cp.role as user_role
|
||||
FROM collections c
|
||||
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active'
|
||||
WHERE c.slug = ${identifier}
|
||||
AND (
|
||||
c.user_id = ${user.userId} OR
|
||||
cp.id IS NOT NULL OR
|
||||
c.is_public = true
|
||||
)
|
||||
`;
|
||||
} else {
|
||||
const numericId = parseInt(identifier);
|
||||
collectionResult = await sql`
|
||||
SELECT c.*, cp.role as user_role
|
||||
FROM collections c
|
||||
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active'
|
||||
WHERE c.id = ${numericId}
|
||||
AND (
|
||||
c.user_id = ${user.userId} OR
|
||||
cp.id IS NOT NULL OR
|
||||
c.is_public = true
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
if (collectionResult.length === 0) {
|
||||
return res.status(404).json({ error: 'Collection not found or access denied' });
|
||||
}
|
||||
|
||||
const collection = collectionResult[0];
|
||||
|
||||
// Get collection activity (this would typically come from an activity log table)
|
||||
// For now, we'll return a simple mock response
|
||||
const activities = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'card_added',
|
||||
description: 'Added Lightning Bolt to collection',
|
||||
timestamp: new Date().toISOString(),
|
||||
user: user.email
|
||||
}
|
||||
];
|
||||
|
||||
res.status(200).json({ activities });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Collection activity API error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
258
pages/api/collections/[identifier]/cards.js
Normal file
258
pages/api/collections/[identifier]/cards.js
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { getUserFromRequest } from '../../../../lib/permission-middleware';
|
||||
import { isValidSlug } from '../../../../lib/slug-utils';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Set CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get authenticated user
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { identifier } = req.query;
|
||||
|
||||
if (!identifier) {
|
||||
return res.status(400).json({ error: 'Collection identifier is required' });
|
||||
}
|
||||
|
||||
// Determine if identifier is a slug or numeric ID
|
||||
const isSlug = isValidSlug(identifier) || isNaN(parseInt(identifier));
|
||||
|
||||
// Get collection ID from identifier
|
||||
let collectionResult;
|
||||
if (isSlug) {
|
||||
collectionResult = await sql`
|
||||
SELECT c.*, cp.role as user_role
|
||||
FROM collections c
|
||||
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active'
|
||||
WHERE c.slug = ${identifier}
|
||||
AND (
|
||||
c.user_id = ${user.userId} OR
|
||||
cp.id IS NOT NULL OR
|
||||
c.is_public = true
|
||||
)
|
||||
`;
|
||||
} else {
|
||||
const numericId = parseInt(identifier);
|
||||
collectionResult = await sql`
|
||||
SELECT c.*, cp.role as user_role
|
||||
FROM collections c
|
||||
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active'
|
||||
WHERE c.id = ${numericId}
|
||||
AND (
|
||||
c.user_id = ${user.userId} OR
|
||||
cp.id IS NOT NULL OR
|
||||
c.is_public = true
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
if (collectionResult.length === 0) {
|
||||
return res.status(404).json({ error: 'Collection not found or access denied' });
|
||||
}
|
||||
|
||||
const collection = collectionResult[0];
|
||||
|
||||
if (req.method === 'GET') {
|
||||
// Get all cards in the collection
|
||||
const cardsResult = await sql`
|
||||
SELECT
|
||||
cards.*,
|
||||
cc.quantity,
|
||||
cc.created_at as added_at
|
||||
FROM collection_cards cc
|
||||
JOIN cards ON cc.card_id = cards.id
|
||||
WHERE cc.collection_id = ${collection.id}
|
||||
ORDER BY cc.created_at DESC
|
||||
`;
|
||||
|
||||
const cards = cardsResult.map(card => ({
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
set_name: card.set_name,
|
||||
set_code: card.set_code,
|
||||
card_number: card.card_number,
|
||||
rarity: card.rarity,
|
||||
game: card.game,
|
||||
mana_cost: card.mana_cost,
|
||||
cmc: card.cmc,
|
||||
card_type: card.card_type,
|
||||
colors: card.colors,
|
||||
oracle_text: card.oracle_text,
|
||||
power: card.power,
|
||||
toughness: card.toughness,
|
||||
image_url: card.image_url,
|
||||
stock_image_url: card.stock_image_url,
|
||||
current_price: parseFloat(card.current_price) || 0,
|
||||
market_price: parseFloat(card.market_price) || 0,
|
||||
quantity: parseInt(card.quantity) || 1,
|
||||
added_at: card.added_at
|
||||
}));
|
||||
|
||||
res.status(200).json({ cards });
|
||||
|
||||
} else if (req.method === 'POST') {
|
||||
// Add card to collection - only allow if user has write access
|
||||
const canWrite = collection.user_id === user.userId ||
|
||||
['owner', 'editor'].includes(collection.user_role);
|
||||
|
||||
if (!canWrite) {
|
||||
return res.status(403).json({ error: 'You do not have permission to add cards to this collection' });
|
||||
}
|
||||
|
||||
const { cardId, quantity = 1 } = req.body;
|
||||
|
||||
if (!cardId) {
|
||||
return res.status(400).json({ error: 'Card ID is required' });
|
||||
}
|
||||
|
||||
// Check if card exists
|
||||
const cardCheck = await sql`SELECT id FROM cards WHERE id = ${cardId}`;
|
||||
if (cardCheck.length === 0) {
|
||||
return res.status(404).json({ error: 'Card not found' });
|
||||
}
|
||||
|
||||
// Check if card already exists in collection
|
||||
const existingResult = await sql`
|
||||
SELECT * FROM collection_cards
|
||||
WHERE collection_id = ${collection.id} AND card_id = ${cardId}
|
||||
`;
|
||||
|
||||
if (existingResult.length > 0) {
|
||||
// Update quantity if card already exists
|
||||
const result = await sql`
|
||||
UPDATE collection_cards
|
||||
SET quantity = quantity + ${quantity}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE collection_id = ${collection.id} AND card_id = ${cardId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Card quantity updated in collection',
|
||||
card: result[0]
|
||||
});
|
||||
} else {
|
||||
// Add new card to collection
|
||||
const result = await sql`
|
||||
INSERT INTO collection_cards (collection_id, card_id, quantity)
|
||||
VALUES (${collection.id}, ${cardId}, ${quantity})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Card added to collection',
|
||||
card: result[0]
|
||||
});
|
||||
}
|
||||
|
||||
// Update collection's updated_at timestamp
|
||||
await sql`
|
||||
UPDATE collections
|
||||
SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ${collection.id}
|
||||
`;
|
||||
|
||||
} else if (req.method === 'PUT') {
|
||||
// Update card quantity in collection
|
||||
const canWrite = collection.user_id === user.userId ||
|
||||
['owner', 'editor'].includes(collection.user_role);
|
||||
|
||||
if (!canWrite) {
|
||||
return res.status(403).json({ error: 'You do not have permission to modify this collection' });
|
||||
}
|
||||
|
||||
const { cardId, quantity } = req.body;
|
||||
|
||||
if (!cardId || quantity === undefined) {
|
||||
return res.status(400).json({ error: 'Card ID and quantity are required' });
|
||||
}
|
||||
|
||||
if (quantity <= 0) {
|
||||
// Remove card from collection if quantity is 0 or negative
|
||||
await sql`
|
||||
DELETE FROM collection_cards
|
||||
WHERE collection_id = ${collection.id} AND card_id = ${cardId}
|
||||
`;
|
||||
|
||||
res.status(200).json({ message: 'Card removed from collection' });
|
||||
} else {
|
||||
// Update quantity
|
||||
const result = await sql`
|
||||
UPDATE collection_cards
|
||||
SET quantity = ${quantity}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE collection_id = ${collection.id} AND card_id = ${cardId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
if (result.length === 0) {
|
||||
return res.status(404).json({ error: 'Card not found in collection' });
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Card quantity updated',
|
||||
card: result[0]
|
||||
});
|
||||
}
|
||||
|
||||
// Update collection's updated_at timestamp
|
||||
await sql`
|
||||
UPDATE collections
|
||||
SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ${collection.id}
|
||||
`;
|
||||
|
||||
} else if (req.method === 'DELETE') {
|
||||
// Remove card from collection
|
||||
const canWrite = collection.user_id === user.userId ||
|
||||
['owner', 'editor'].includes(collection.user_role);
|
||||
|
||||
if (!canWrite) {
|
||||
return res.status(403).json({ error: 'You do not have permission to modify this collection' });
|
||||
}
|
||||
|
||||
const { cardId } = req.body;
|
||||
|
||||
if (!cardId) {
|
||||
return res.status(400).json({ error: 'Card ID is required' });
|
||||
}
|
||||
|
||||
const result = await sql`
|
||||
DELETE FROM collection_cards
|
||||
WHERE collection_id = ${collection.id} AND card_id = ${cardId}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
if (result.length === 0) {
|
||||
return res.status(404).json({ error: 'Card not found in collection' });
|
||||
}
|
||||
|
||||
// Update collection's updated_at timestamp
|
||||
await sql`
|
||||
UPDATE collections
|
||||
SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ${collection.id}
|
||||
`;
|
||||
|
||||
res.status(200).json({ message: 'Card removed from collection' });
|
||||
|
||||
} else {
|
||||
res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Collection cards API error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
210
pages/api/collections/[identifier]/permissions.js
Normal file
210
pages/api/collections/[identifier]/permissions.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { getUserFromRequest } from '../../../../lib/permission-middleware';
|
||||
import { isValidSlug } from '../../../../lib/slug-utils';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Set CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get authenticated user
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { identifier } = req.query;
|
||||
|
||||
if (!identifier) {
|
||||
return res.status(400).json({ error: 'Collection identifier is required' });
|
||||
}
|
||||
|
||||
// Determine if identifier is a slug or numeric ID
|
||||
const isSlug = isValidSlug(identifier) || isNaN(parseInt(identifier));
|
||||
|
||||
// Get collection and verify ownership
|
||||
let collectionResult;
|
||||
if (isSlug) {
|
||||
collectionResult = await sql`
|
||||
SELECT * FROM collections
|
||||
WHERE slug = ${identifier} AND user_id = ${user.userId}
|
||||
`;
|
||||
} else {
|
||||
const numericId = parseInt(identifier);
|
||||
collectionResult = await sql`
|
||||
SELECT * FROM collections
|
||||
WHERE id = ${numericId} AND user_id = ${user.userId}
|
||||
`;
|
||||
}
|
||||
|
||||
if (collectionResult.length === 0) {
|
||||
return res.status(404).json({ error: 'Collection not found or you do not have permission to manage permissions' });
|
||||
}
|
||||
|
||||
const collection = collectionResult[0];
|
||||
|
||||
if (req.method === 'GET') {
|
||||
// Get all permissions for this collection
|
||||
const permissionsResult = await sql`
|
||||
SELECT
|
||||
cp.*,
|
||||
u.email,
|
||||
u.first_name,
|
||||
u.last_name
|
||||
FROM collection_permissions cp
|
||||
JOIN users u ON cp.user_id = u.id
|
||||
WHERE cp.collection_id = ${collection.id}
|
||||
ORDER BY cp.created_at DESC
|
||||
`;
|
||||
|
||||
const permissions = permissionsResult.map(perm => ({
|
||||
id: perm.id,
|
||||
userId: perm.user_id,
|
||||
email: perm.email,
|
||||
firstName: perm.first_name,
|
||||
lastName: perm.last_name,
|
||||
role: perm.role,
|
||||
status: perm.status,
|
||||
createdAt: perm.created_at,
|
||||
updatedAt: perm.updated_at
|
||||
}));
|
||||
|
||||
res.status(200).json({ permissions });
|
||||
|
||||
} else if (req.method === 'POST') {
|
||||
// Add new permission
|
||||
const { email, role = 'viewer' } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' });
|
||||
}
|
||||
|
||||
if (!['viewer', 'editor', 'owner'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role. Must be viewer, editor, or owner' });
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const userResult = await sql`
|
||||
SELECT id FROM users WHERE email = ${email}
|
||||
`;
|
||||
|
||||
if (userResult.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const targetUserId = userResult[0].id;
|
||||
|
||||
// Check if permission already exists
|
||||
const existingResult = await sql`
|
||||
SELECT id FROM collection_permissions
|
||||
WHERE collection_id = ${collection.id} AND user_id = ${targetUserId}
|
||||
`;
|
||||
|
||||
if (existingResult.length > 0) {
|
||||
return res.status(400).json({ error: 'User already has permissions for this collection' });
|
||||
}
|
||||
|
||||
// Create new permission
|
||||
const result = await sql`
|
||||
INSERT INTO collection_permissions (collection_id, user_id, role, status)
|
||||
VALUES (${collection.id}, ${targetUserId}, ${role}, 'active')
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Permission added successfully',
|
||||
permission: result[0]
|
||||
});
|
||||
|
||||
} else if (req.method === 'PUT') {
|
||||
// Update existing permission
|
||||
const { permissionId, role, status } = req.body;
|
||||
|
||||
if (!permissionId) {
|
||||
return res.status(400).json({ error: 'Permission ID is required' });
|
||||
}
|
||||
|
||||
const updateFields = [];
|
||||
const updateValues = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (role !== undefined) {
|
||||
if (!['viewer', 'editor', 'owner'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
updateFields.push(`role = $${paramIndex}`);
|
||||
updateValues.push(role);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (status !== undefined) {
|
||||
if (!['active', 'pending', 'revoked'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status' });
|
||||
}
|
||||
updateFields.push(`status = $${paramIndex}`);
|
||||
updateValues.push(status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
}
|
||||
|
||||
updateFields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
updateValues.push(permissionId, collection.id);
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE collection_permissions
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = $${paramIndex} AND collection_id = $${paramIndex + 1}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await sql.query(updateQuery, updateValues);
|
||||
|
||||
if (result.length === 0) {
|
||||
return res.status(404).json({ error: 'Permission not found' });
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Permission updated successfully',
|
||||
permission: result[0]
|
||||
});
|
||||
|
||||
} else if (req.method === 'DELETE') {
|
||||
// Remove permission
|
||||
const { permissionId } = req.body;
|
||||
|
||||
if (!permissionId) {
|
||||
return res.status(400).json({ error: 'Permission ID is required' });
|
||||
}
|
||||
|
||||
const result = await sql`
|
||||
DELETE FROM collection_permissions
|
||||
WHERE id = ${permissionId} AND collection_id = ${collection.id}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
if (result.length === 0) {
|
||||
return res.status(404).json({ error: 'Permission not found' });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Permission removed successfully' });
|
||||
|
||||
} else {
|
||||
res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Collection permissions API error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ export default async function handler(req, res) {
|
|||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { id: identifier } = req.query;
|
||||
const { identifier } = req.query;
|
||||
|
||||
if (!identifier) {
|
||||
return res.status(400).json({ error: 'Collection identifier is required' });
|
||||
|
|
@ -63,11 +63,11 @@ export default async function handler(req, res) {
|
|||
`;
|
||||
}
|
||||
|
||||
if (collectionResult.rows.length === 0) {
|
||||
if (collectionResult.length === 0) {
|
||||
return res.status(404).json({ error: 'Collection not found or access denied' });
|
||||
}
|
||||
|
||||
const collection = collectionResult.rows[0];
|
||||
const collection = collectionResult[0];
|
||||
|
||||
// Get the top 5 rarest cards from the collection
|
||||
const thumbnailsResult = await sql`
|
||||
|
|
@ -102,7 +102,7 @@ export default async function handler(req, res) {
|
|||
LIMIT 5
|
||||
`;
|
||||
|
||||
const thumbnails = thumbnailsResult.rows.map(card => ({
|
||||
const thumbnails = thumbnailsResult.map(card => ({
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
rarity: card.rarity,
|
||||
|
|
@ -8,7 +8,7 @@ import Layout from '../../components/Layout';
|
|||
|
||||
export default function CollectionView() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const { identifier } = router.query;
|
||||
|
||||
// Get user from auth context - for now using admin user
|
||||
const user = {
|
||||
|
|
@ -21,9 +21,20 @@ export default function CollectionView() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [selectedTCG, setSelectedTCG] = useState('MTG');
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: false,
|
||||
image: '',
|
||||
tags: []
|
||||
});
|
||||
|
||||
// Filter states
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedRarity, setSelectedRarity] = useState('All Rarities');
|
||||
|
|
@ -37,18 +48,39 @@ export default function CollectionView() {
|
|||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
if (identifier) {
|
||||
fetchCollectionData();
|
||||
}
|
||||
}, [id]);
|
||||
}, [identifier]);
|
||||
|
||||
const fetchCollectionData = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${id}`);
|
||||
const response = await fetch(`/api/collections/${identifier}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCollection(data.collection);
|
||||
setCards(data.cards || []);
|
||||
|
||||
// Check if we accessed via numeric ID and need to redirect to slug
|
||||
if (data.slug && identifier !== data.slug && !isNaN(parseInt(identifier))) {
|
||||
// Redirect to slug URL
|
||||
router.replace(`/collection/${data.slug}`, undefined, { shallow: false });
|
||||
return;
|
||||
}
|
||||
|
||||
setCollection(data);
|
||||
setEditForm({
|
||||
name: data.name || '',
|
||||
description: data.description || '',
|
||||
isPublic: data.isPublic || false,
|
||||
image: data.image || '',
|
||||
tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : [])
|
||||
});
|
||||
|
||||
// Fetch collection cards
|
||||
const cardsResponse = await fetch(`/api/collections/${identifier}/cards`);
|
||||
if (cardsResponse.ok) {
|
||||
const cardsData = await cardsResponse.json();
|
||||
setCards(cardsData.cards || []);
|
||||
}
|
||||
|
||||
// Check if collection is favorited
|
||||
checkIfFavorited();
|
||||
|
|
@ -75,15 +107,73 @@ export default function CollectionView() {
|
|||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const isFav = data.favorites.some(fav => fav.item_id === parseInt(id));
|
||||
const isFav = data.favorites.some(fav => fav.item_id === collection?.id);
|
||||
setIsFavorited(isFav);
|
||||
} else {
|
||||
console.error('Failed to check favorites:', response.status);
|
||||
// Keep default false state
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking favorites:', error);
|
||||
// Keep default false state
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCollection = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${identifier}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: editForm.name,
|
||||
description: editForm.description,
|
||||
isPublic: editForm.isPublic,
|
||||
image: editForm.image,
|
||||
tags: editForm.tags
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedCollection = await response.json();
|
||||
|
||||
// If the name changed and we got a new slug, redirect
|
||||
if (updatedCollection.slug && updatedCollection.slug !== identifier) {
|
||||
router.push(`/collection/${updatedCollection.slug}`);
|
||||
} else {
|
||||
// Just refresh the data
|
||||
fetchCollectionData();
|
||||
}
|
||||
|
||||
setShowEditModal(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to update collection');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating collection:', error);
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCollection = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${identifier}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push('/collections');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to delete collection');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting collection:', error);
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -108,7 +198,7 @@ export default function CollectionView() {
|
|||
|
||||
const handleAddCard = async (card) => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${id}/cards`, {
|
||||
const response = await fetch(`/api/collections/${identifier}/cards`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -130,8 +220,6 @@ export default function CollectionView() {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
try {
|
||||
if (isFavorited) {
|
||||
|
|
@ -144,7 +232,7 @@ export default function CollectionView() {
|
|||
},
|
||||
body: JSON.stringify({
|
||||
itemType: 'collection',
|
||||
itemId: parseInt(id)
|
||||
itemId: collection.id
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -163,7 +251,7 @@ export default function CollectionView() {
|
|||
},
|
||||
body: JSON.stringify({
|
||||
itemType: 'collection',
|
||||
itemId: parseInt(id)
|
||||
itemId: collection.id
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -180,21 +268,21 @@ export default function CollectionView() {
|
|||
|
||||
const togglePublic = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${id}`, {
|
||||
const response = await fetch(`/api/collections/${identifier}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
is_public: !collection.is_public
|
||||
isPublic: !collection.isPublic
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setCollection(prev => ({
|
||||
...prev,
|
||||
is_public: !prev.is_public
|
||||
isPublic: !prev.isPublic
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -204,7 +292,7 @@ export default function CollectionView() {
|
|||
|
||||
const handleImageUpload = async (imageUrl) => {
|
||||
try {
|
||||
const response = await fetch(`/api/collections/${id}`, {
|
||||
const response = await fetch(`/api/collections/${identifier}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -299,9 +387,7 @@ export default function CollectionView() {
|
|||
return acc;
|
||||
}, {});
|
||||
|
||||
// Debug: Log removed - functionality working
|
||||
|
||||
// Get game display names and counts - make it more flexible
|
||||
// Get game display names and counts
|
||||
const gameStats = {};
|
||||
Object.keys(groupedCards).forEach(game => {
|
||||
if (game && game !== 'Other') {
|
||||
|
|
@ -313,7 +399,7 @@ export default function CollectionView() {
|
|||
return (
|
||||
<Layout user={user}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
@ -328,7 +414,7 @@ export default function CollectionView() {
|
|||
Collection not found
|
||||
</h2>
|
||||
<Link href="/collections">
|
||||
<button className="px-4 py-2 rounded-lg gradient-bg-purple text-white">
|
||||
<button className="px-4 py-2 rounded-lg text-white" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
||||
Back to Collections
|
||||
</button>
|
||||
</Link>
|
||||
|
|
@ -358,6 +444,32 @@ export default function CollectionView() {
|
|||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Edit and Delete buttons - only show for owner */}
|
||||
{collection.userRole === 'owner' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowEditModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 flex items-center space-x-2"
|
||||
style={{ 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium border rounded-lg hover:bg-red-50 hover:border-red-200 hover:text-red-600 flex items-center space-x-2"
|
||||
style={{ 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="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>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50"
|
||||
|
|
@ -396,11 +508,10 @@ export default function CollectionView() {
|
|||
|
||||
{/* Creator and Stats */}
|
||||
<div className="flex items-center space-x-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
|
||||
{collection.creator_email ? (
|
||||
{collection.creator ? (
|
||||
<CollaboratorFacepile
|
||||
collectionId={id}
|
||||
creatorEmail={collection.creator_email}
|
||||
collectionId={identifier}
|
||||
creatorEmail={collection.creator}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
|
|
@ -408,12 +519,12 @@ export default function CollectionView() {
|
|||
</span>
|
||||
)}
|
||||
<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.value || '0'}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span>Created {new Date(collection.created_at).toLocaleDateString()}</span>
|
||||
<span>Last updated {new Date(collection.updated_at).toLocaleDateString()}</span>
|
||||
<span>Created {new Date(collection.createdAt).toLocaleDateString()}</span>
|
||||
<span>Last updated {new Date(collection.lastViewed).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -561,7 +672,8 @@ export default function CollectionView() {
|
|||
|
||||
<button
|
||||
onClick={() => router.push('/cards')}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center space-x-2"
|
||||
className="px-4 py-2 text-white rounded-lg flex items-center space-x-2"
|
||||
style={{ backgroundColor: 'var(--accent-ember)' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
|
|
@ -625,7 +737,7 @@ export default function CollectionView() {
|
|||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
Add cards to get started with your collection
|
||||
</p>
|
||||
<button className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
<button className="px-6 py-3 text-white rounded-lg" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
||||
Browse Cards to Add
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -658,7 +770,132 @@ export default function CollectionView() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Collection Modal */}
|
||||
{showEditModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="card max-w-md w-full mx-4">
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Edit Collection
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Collection Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field w-full"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({...editForm, name: e.target.value})}
|
||||
placeholder="Enter collection name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="input-field w-full"
|
||||
rows="3"
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm({...editForm, description: e.target.value})}
|
||||
placeholder="Describe your collection"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Hero Image (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
className="input-field w-full"
|
||||
value={editForm.image}
|
||||
onChange={(e) => setEditForm({...editForm, image: e.target.value})}
|
||||
placeholder="Enter image URL"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border)' }}>
|
||||
<div>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Public Collection
|
||||
</label>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Make this collection discoverable by other users
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditForm({...editForm, isPublic: !editForm.isPublic})}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
editForm.isPublic ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
editForm.isPublic ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditCollection}
|
||||
disabled={!editForm.name.trim()}
|
||||
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="card max-w-md w-full mx-4">
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Delete Collection
|
||||
</h2>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
Are you sure you want to delete "{collection.name}"? This action cannot be undone and will permanently remove all cards and data associated with this collection.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteCollection}
|
||||
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Delete Collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Image Modal */}
|
||||
<UploadImageModal
|
||||
|
|
@ -672,8 +909,8 @@ export default function CollectionView() {
|
|||
<ShareModal
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
collectionId={id}
|
||||
isPublic={collection.is_public}
|
||||
collectionId={identifier}
|
||||
isPublic={collection.isPublic}
|
||||
onTogglePublic={togglePublic}
|
||||
onInviteUser={(email) => console.log('Invited:', email)}
|
||||
/>
|
||||
Loading…
Reference in a new issue