diff --git a/lib/slug-utils.js b/lib/slug-utils.js new file mode 100644 index 0000000..de72591 --- /dev/null +++ b/lib/slug-utils.js @@ -0,0 +1,66 @@ +/** + * Generate a URL-friendly slug from a collection name + */ +export function generateSlug(name) { + return name + .toLowerCase() + .trim() + // Replace spaces and special characters with hyphens + .replace(/[^a-z0-9]+/g, '-') + // Remove leading/trailing hyphens + .replace(/^-+|-+$/g, '') + // Limit length to 50 characters + .substring(0, 50) + // Remove trailing hyphen if truncation created one + .replace(/-+$/, ''); +} + +/** + * Generate a unique slug by checking against existing slugs + */ +export async function generateUniqueSlug(name, existingSlugs = []) { + const baseSlug = generateSlug(name); + + // If base slug is unique, use it + if (!existingSlugs.includes(baseSlug)) { + return baseSlug; + } + + // Find the next available number suffix + let counter = 2; + let uniqueSlug = `${baseSlug}-${counter}`; + + while (existingSlugs.includes(uniqueSlug)) { + counter++; + uniqueSlug = `${baseSlug}-${counter}`; + } + + return uniqueSlug; +} + +/** + * Validate a slug format + */ +export function isValidSlug(slug) { + if (!slug || typeof slug !== 'string') { + return false; + } + + // Must be 1-50 characters, lowercase letters, numbers, and hyphens only + // Cannot start or end with hyphen + const slugRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + return slugRegex.test(slug) && slug.length <= 50; +} + +/** + * Convert existing collection names to suggested slugs for migration + */ +export function suggestSlugForCollection(collection) { + const baseSlug = generateSlug(collection.name); + return { + id: collection.id, + name: collection.name, + currentSlug: collection.slug || null, + suggestedSlug: baseSlug + }; +} \ No newline at end of file diff --git a/pages/api/collections.js b/pages/api/collections.js index 66d5332..c062e11 100644 --- a/pages/api/collections.js +++ b/pages/api/collections.js @@ -1,5 +1,6 @@ import { sql } from '@vercel/postgres'; import { getUserFromRequest, logCollectionActivity } from '../../lib/permission-middleware'; +import { generateUniqueSlug } from '../../lib/slug-utils'; export default async function handler(req, res) { // Set CORS headers @@ -51,6 +52,7 @@ export default async function handler(req, res) { const collections = result.rows.map(collection => ({ id: collection.id, + slug: collection.slug, name: collection.name, description: collection.description, tcg: collection.tcg || 'MTG', @@ -86,9 +88,14 @@ export default async function handler(req, res) { const userId = user.userId; + // Generate unique slug for the collection + const existingSlugsResult = await sql`SELECT slug FROM collections WHERE slug IS NOT NULL`; + const existingSlugs = existingSlugsResult.rows.map(row => row.slug); + const uniqueSlug = await generateUniqueSlug(name, existingSlugs); + const result = await sql` - INSERT INTO collections (name, description, tcg, is_public, image, tags, user_id) - VALUES (${name}, ${description}, ${tcg}, ${isPublic}, ${image}, ${tags.join(',')}, ${userId}) + INSERT INTO collections (name, description, tcg, is_public, image, tags, user_id, slug) + VALUES (${name}, ${description}, ${tcg}, ${isPublic}, ${image}, ${tags.join(',')}, ${userId}, ${uniqueSlug}) RETURNING * `; diff --git a/pages/api/collections/[id]/thumbnails.js b/pages/api/collections/[id]/thumbnails.js index 37280e3..04f1c86 100644 --- a/pages/api/collections/[id]/thumbnails.js +++ b/pages/api/collections/[id]/thumbnails.js @@ -1,5 +1,6 @@ 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 @@ -24,40 +25,49 @@ export default async function handler(req, res) { return res.status(401).json({ error: 'Authentication required' }); } - const { id: collectionId } = req.query; + const { id: identifier } = req.query; - if (!collectionId) { - return res.status(400).json({ error: 'Collection ID is required' }); + 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 - const 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 = ${collectionId} - AND ( - c.user_id = ${user.userId} OR - cp.id IS NOT NULL OR - c.is_public = true - ) - `; + 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.rows.length === 0) { return res.status(404).json({ error: 'Collection not found or access denied' }); } - // Define rarity priority order (highest to lowest value) - const rarityOrder = { - 'mythic': 8, - 'legendary': 7, - 'rare': 6, - 'uncommon': 5, - 'common': 4, - 'special': 3, - 'promo': 2, - 'token': 1 - }; + const collection = collectionResult.rows[0]; // Get the top 5 rarest cards from the collection const thumbnailsResult = await sql` @@ -73,7 +83,7 @@ export default async function handler(req, res) { cc.quantity FROM collection_cards cc JOIN cards ON cc.card_id = cards.id - WHERE cc.collection_id = ${collectionId} + WHERE cc.collection_id = ${collection.id} AND (cards.image_url IS NOT NULL OR cards.stock_image_url IS NOT NULL) ORDER BY CASE cards.rarity diff --git a/pages/api/collections/[identifier].js b/pages/api/collections/[identifier].js new file mode 100644 index 0000000..df59031 --- /dev/null +++ b/pages/api/collections/[identifier].js @@ -0,0 +1,217 @@ +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, 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)); + + // Build the query based on identifier type + let collectionQuery; + if (isSlug) { + collectionQuery = sql` + SELECT DISTINCT + c.*, + u.email as creator_email, + COUNT(cc.card_id) as card_count, + COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value, + cp.role as user_role, + CASE + WHEN c.user_id = ${user.userId} THEN 'owner' + WHEN cp.role IS NOT NULL THEN cp.role + ELSE NULL + END as effective_role + FROM collections c + LEFT JOIN users u ON c.user_id = u.id + LEFT JOIN collection_cards cc ON c.id = cc.collection_id + LEFT JOIN cards ON cc.card_id = cards.id + 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 + ) + GROUP BY c.id, u.email, cp.role + `; + } else { + // Numeric ID lookup + const numericId = parseInt(identifier); + collectionQuery = sql` + SELECT DISTINCT + c.*, + u.email as creator_email, + COUNT(cc.card_id) as card_count, + COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value, + cp.role as user_role, + CASE + WHEN c.user_id = ${user.userId} THEN 'owner' + WHEN cp.role IS NOT NULL THEN cp.role + ELSE NULL + END as effective_role + FROM collections c + LEFT JOIN users u ON c.user_id = u.id + LEFT JOIN collection_cards cc ON c.id = cc.collection_id + LEFT JOIN cards ON cc.card_id = cards.id + 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 + ) + GROUP BY c.id, u.email, cp.role + `; + } + + const result = await collectionQuery; + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Collection not found or access denied' }); + } + + const collection = result.rows[0]; + + if (req.method === 'GET') { + const formattedCollection = { + id: collection.id, + slug: collection.slug, + name: collection.name, + description: collection.description, + tcg: collection.tcg || 'MTG', + cardCount: parseInt(collection.card_count) || 0, + value: parseFloat(collection.total_value) || 0, + lastViewed: collection.updated_at, + createdAt: collection.created_at, + isPublic: collection.is_public || false, + image: collection.image, + tags: collection.tags ? collection.tags.split(',') : [], + creator: collection.creator_email, + userRole: collection.effective_role + }; + + res.status(200).json(formattedCollection); + + } else if (req.method === 'PUT') { + // Only allow updates by owner + if (collection.user_id !== user.userId) { + return res.status(403).json({ error: 'Only collection owners can edit collections' }); + } + + const { name, description, isPublic, image, tags } = req.body; + + // If name is being changed, generate new slug + let updateFields = []; + let updateValues = []; + let paramIndex = 1; + + if (name !== undefined && name !== collection.name) { + // Generate new unique slug if name changed + const existingSlugsResult = await sql`SELECT slug FROM collections WHERE slug IS NOT NULL AND id != ${collection.id}`; + const existingSlugs = existingSlugsResult.rows.map(row => row.slug); + const { generateUniqueSlug } = await import('../../../lib/slug-utils'); + const newSlug = await generateUniqueSlug(name, existingSlugs); + + updateFields.push(`name = $${paramIndex}`, `slug = $${paramIndex + 1}`); + updateValues.push(name, newSlug); + paramIndex += 2; + } + + if (description !== undefined) { + updateFields.push(`description = $${paramIndex}`); + updateValues.push(description); + paramIndex++; + } + + if (isPublic !== undefined) { + updateFields.push(`is_public = $${paramIndex}`); + updateValues.push(isPublic); + paramIndex++; + } + + if (image !== undefined) { + updateFields.push(`image = $${paramIndex}`); + updateValues.push(image); + paramIndex++; + } + + if (tags !== undefined) { + updateFields.push(`tags = $${paramIndex}`); + updateValues.push(Array.isArray(tags) ? tags.join(',') : tags); + paramIndex++; + } + + if (updateFields.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + updateFields.push('updated_at = CURRENT_TIMESTAMP'); + updateValues.push(collection.id); + + const updateQuery = ` + UPDATE collections + SET ${updateFields.join(', ')} + WHERE id = $${paramIndex} + RETURNING * + `; + + const updateResult = await sql.query(updateQuery, updateValues); + + const updatedCollection = { + id: updateResult.rows[0].id, + slug: updateResult.rows[0].slug, + name: updateResult.rows[0].name, + description: updateResult.rows[0].description, + isPublic: updateResult.rows[0].is_public, + image: updateResult.rows[0].image, + tags: updateResult.rows[0].tags ? updateResult.rows[0].tags.split(',') : [] + }; + + res.status(200).json(updatedCollection); + + } else if (req.method === 'DELETE') { + // Only allow deletion by owner + if (collection.user_id !== user.userId) { + return res.status(403).json({ error: 'Only collection owners can delete collections' }); + } + + // Delete collection and all related data + await sql`DELETE FROM collection_cards WHERE collection_id = ${collection.id}`; + await sql`DELETE FROM collection_permissions WHERE collection_id = ${collection.id}`; + await sql`DELETE FROM collections WHERE id = ${collection.id}`; + + res.status(200).json({ message: 'Collection deleted successfully' }); + + } else { + res.status(405).json({ error: 'Method not allowed' }); + } + + } catch (error) { + console.error('Collection API error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/pages/collections.js b/pages/collections.js index 42917f6..693a000 100644 --- a/pages/collections.js +++ b/pages/collections.js @@ -38,11 +38,12 @@ export default function Collections() { const collectionsWithThumbnails = await Promise.all( data.map(async (collection) => { try { - const thumbnailResponse = await fetch(`/api/collections/${collection.id}/thumbnails`); + const identifier = collection.slug || collection.id; + const thumbnailResponse = await fetch(`/api/collections/${identifier}/thumbnails`); const thumbnails = thumbnailResponse.ok ? await thumbnailResponse.json() : []; return { ...collection, thumbnails }; } catch (error) { - console.error(`Error fetching thumbnails for collection ${collection.id}:`, error); + console.error(`Error fetching thumbnails for collection ${collection.slug || collection.id}:`, error); return { ...collection, thumbnails: [] }; } }) @@ -352,7 +353,7 @@ export default function Collections() {