🔄 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:
Randall Stillwell 2025-07-26 22:16:52 -05:00
parent 50a3156b92
commit 374ad421f6
9 changed files with 833 additions and 638 deletions

View file

@ -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);
};

View file

@ -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);

View file

@ -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);

View file

@ -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' });
}
}

View 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' });
}
}

View 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' });
}
}

View 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' });
}
}

View file

@ -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,

View file

@ -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)}
/> />