🔄 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' });
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id: identifier } = req.query;
|
const { identifier } = req.query;
|
||||||
|
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
return res.status(400).json({ error: 'Collection identifier is required' });
|
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' });
|
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
|
// Get the top 5 rarest cards from the collection
|
||||||
const thumbnailsResult = await sql`
|
const thumbnailsResult = await sql`
|
||||||
|
|
@ -102,7 +102,7 @@ export default async function handler(req, res) {
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const thumbnails = thumbnailsResult.rows.map(card => ({
|
const thumbnails = thumbnailsResult.map(card => ({
|
||||||
id: card.id,
|
id: card.id,
|
||||||
name: card.name,
|
name: card.name,
|
||||||
rarity: card.rarity,
|
rarity: card.rarity,
|
||||||
|
|
@ -8,7 +8,7 @@ import Layout from '../../components/Layout';
|
||||||
|
|
||||||
export default function CollectionView() {
|
export default function CollectionView() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { id } = router.query;
|
const { identifier } = router.query;
|
||||||
|
|
||||||
// Get user from auth context - for now using admin user
|
// Get user from auth context - for now using admin user
|
||||||
const user = {
|
const user = {
|
||||||
|
|
@ -21,9 +21,20 @@ export default function CollectionView() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isFavorited, setIsFavorited] = useState(false);
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
const [showShareModal, setShowShareModal] = useState(false);
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [copySuccess, setCopySuccess] = useState(false);
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
const [selectedTCG, setSelectedTCG] = useState('MTG');
|
const [selectedTCG, setSelectedTCG] = useState('MTG');
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
isPublic: false,
|
||||||
|
image: '',
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedRarity, setSelectedRarity] = useState('All Rarities');
|
const [selectedRarity, setSelectedRarity] = useState('All Rarities');
|
||||||
|
|
@ -37,18 +48,39 @@ export default function CollectionView() {
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (identifier) {
|
||||||
fetchCollectionData();
|
fetchCollectionData();
|
||||||
}
|
}
|
||||||
}, [id]);
|
}, [identifier]);
|
||||||
|
|
||||||
const fetchCollectionData = async () => {
|
const fetchCollectionData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/collections/${id}`);
|
const response = await fetch(`/api/collections/${identifier}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
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
|
// Check if collection is favorited
|
||||||
checkIfFavorited();
|
checkIfFavorited();
|
||||||
|
|
@ -75,15 +107,73 @@ export default function CollectionView() {
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
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);
|
setIsFavorited(isFav);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to check favorites:', response.status);
|
console.error('Failed to check favorites:', response.status);
|
||||||
// Keep default false state
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking favorites:', 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) => {
|
const handleAddCard = async (card) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/collections/${id}/cards`, {
|
const response = await fetch(`/api/collections/${identifier}/cards`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -130,8 +220,6 @@ export default function CollectionView() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const toggleFavorite = async () => {
|
const toggleFavorite = async () => {
|
||||||
try {
|
try {
|
||||||
if (isFavorited) {
|
if (isFavorited) {
|
||||||
|
|
@ -144,7 +232,7 @@ export default function CollectionView() {
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
itemType: 'collection',
|
itemType: 'collection',
|
||||||
itemId: parseInt(id)
|
itemId: collection.id
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -163,7 +251,7 @@ export default function CollectionView() {
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
itemType: 'collection',
|
itemType: 'collection',
|
||||||
itemId: parseInt(id)
|
itemId: collection.id
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -180,21 +268,21 @@ export default function CollectionView() {
|
||||||
|
|
||||||
const togglePublic = async () => {
|
const togglePublic = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/collections/${id}`, {
|
const response = await fetch(`/api/collections/${identifier}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
is_public: !collection.is_public
|
isPublic: !collection.isPublic
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setCollection(prev => ({
|
setCollection(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
is_public: !prev.is_public
|
isPublic: !prev.isPublic
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -204,7 +292,7 @@ export default function CollectionView() {
|
||||||
|
|
||||||
const handleImageUpload = async (imageUrl) => {
|
const handleImageUpload = async (imageUrl) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/collections/${id}`, {
|
const response = await fetch(`/api/collections/${identifier}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -299,9 +387,7 @@ export default function CollectionView() {
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// Debug: Log removed - functionality working
|
// Get game display names and counts
|
||||||
|
|
||||||
// Get game display names and counts - make it more flexible
|
|
||||||
const gameStats = {};
|
const gameStats = {};
|
||||||
Object.keys(groupedCards).forEach(game => {
|
Object.keys(groupedCards).forEach(game => {
|
||||||
if (game && game !== 'Other') {
|
if (game && game !== 'Other') {
|
||||||
|
|
@ -313,7 +399,7 @@ export default function CollectionView() {
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<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>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
@ -328,7 +414,7 @@ export default function CollectionView() {
|
||||||
Collection not found
|
Collection not found
|
||||||
</h2>
|
</h2>
|
||||||
<Link href="/collections">
|
<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
|
Back to Collections
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -358,6 +444,32 @@ export default function CollectionView() {
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center space-x-3">
|
<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
|
<button
|
||||||
onClick={() => setShowUploadModal(true)}
|
onClick={() => setShowUploadModal(true)}
|
||||||
className="px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50"
|
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 */}
|
{/* Creator and Stats */}
|
||||||
<div className="flex items-center space-x-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<div className="flex items-center space-x-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{collection.creator ? (
|
||||||
{collection.creator_email ? (
|
|
||||||
<CollaboratorFacepile
|
<CollaboratorFacepile
|
||||||
collectionId={id}
|
collectionId={identifier}
|
||||||
creatorEmail={collection.creator_email}
|
creatorEmail={collection.creator}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
|
@ -408,12 +519,12 @@ export default function CollectionView() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div>Cards: <span className="font-medium">{cards.length}</span></div>
|
<div>Cards: <span className="font-medium">{cards.length}</span></div>
|
||||||
<div>Cost: <span className="font-medium">${collection.totalValue || '0'}</span></div>
|
<div>Cost: <span className="font-medium">${collection.value || '0'}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<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>Created {new Date(collection.createdAt).toLocaleDateString()}</span>
|
||||||
<span>Last updated {new Date(collection.updated_at).toLocaleDateString()}</span>
|
<span>Last updated {new Date(collection.lastViewed).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -561,7 +672,8 @@ export default function CollectionView() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/cards')}
|
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">
|
<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" />
|
<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)' }}>
|
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Add cards to get started with your collection
|
Add cards to get started with your collection
|
||||||
</p>
|
</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
|
Browse Cards to Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -658,7 +770,132 @@ export default function CollectionView() {
|
||||||
</div>
|
</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 */}
|
{/* Upload Image Modal */}
|
||||||
<UploadImageModal
|
<UploadImageModal
|
||||||
|
|
@ -672,8 +909,8 @@ export default function CollectionView() {
|
||||||
<ShareModal
|
<ShareModal
|
||||||
isOpen={showShareModal}
|
isOpen={showShareModal}
|
||||||
onClose={() => setShowShareModal(false)}
|
onClose={() => setShowShareModal(false)}
|
||||||
collectionId={id}
|
collectionId={identifier}
|
||||||
isPublic={collection.is_public}
|
isPublic={collection.isPublic}
|
||||||
onTogglePublic={togglePublic}
|
onTogglePublic={togglePublic}
|
||||||
onInviteUser={(email) => console.log('Invited:', email)}
|
onInviteUser={(email) => console.log('Invited:', email)}
|
||||||
/>
|
/>
|
||||||
Loading…
Reference in a new issue