🔗 Implement Collection Slug URLs
🎯 Vanity URLs for Collections: - Added slug-based URLs like /collection/modern-masters-2021 - Backwards compatible with numeric IDs - SEO-friendly and memorable URLs 🛠️ Slug System: - Created lib/slug-utils.js with slug generation and validation - generateSlug() converts names to URL-friendly format - generateUniqueSlug() handles duplicates with numeric suffixes - isValidSlug() validates format (lowercase, hyphens, no special chars) 📊 Database Schema: - Added slug column to collections table with unique constraint - Migration script adds slugs to existing collections - Database constraints ensure slug format and uniqueness - Performance index on slug column 🔌 API Updates: - Updated collections API to generate slugs for new collections - New [identifier].js endpoint handles both slugs and IDs - Thumbnails API supports both slug and ID lookups - Smart identifier detection (slug vs numeric ID) 🎨 Frontend Integration: - Collections page uses slugs for navigation - Fallback to ID if slug not available (backwards compatibility) - Updated all collection links to use slugs - Sample collections created with proper slugs ✨ URL Examples: - /collection/modern-masters-2021 (new slug format) - /collection/123 (old ID format still works) - Automatic redirect potential for future The collection URLs are now beautiful and shareable! 🚀
This commit is contained in:
parent
b95d972e95
commit
50a3156b92
7 changed files with 572 additions and 32 deletions
66
lib/slug-utils.js
Normal file
66
lib/slug-utils.js
Normal file
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { sql } from '@vercel/postgres';
|
import { sql } from '@vercel/postgres';
|
||||||
import { getUserFromRequest, logCollectionActivity } from '../../lib/permission-middleware';
|
import { getUserFromRequest, logCollectionActivity } from '../../lib/permission-middleware';
|
||||||
|
import { generateUniqueSlug } from '../../lib/slug-utils';
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
// Set CORS headers
|
// Set CORS headers
|
||||||
|
|
@ -51,6 +52,7 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
const collections = result.rows.map(collection => ({
|
const collections = result.rows.map(collection => ({
|
||||||
id: collection.id,
|
id: collection.id,
|
||||||
|
slug: collection.slug,
|
||||||
name: collection.name,
|
name: collection.name,
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
tcg: collection.tcg || 'MTG',
|
tcg: collection.tcg || 'MTG',
|
||||||
|
|
@ -86,9 +88,14 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
const userId = user.userId;
|
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`
|
const result = await sql`
|
||||||
INSERT INTO collections (name, description, tcg, is_public, image, tags, user_id)
|
INSERT INTO collections (name, description, tcg, is_public, image, tags, user_id, slug)
|
||||||
VALUES (${name}, ${description}, ${tcg}, ${isPublic}, ${image}, ${tags.join(',')}, ${userId})
|
VALUES (${name}, ${description}, ${tcg}, ${isPublic}, ${image}, ${tags.join(',')}, ${userId}, ${uniqueSlug})
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { sql } from '@vercel/postgres';
|
import { sql } from '@vercel/postgres';
|
||||||
import { getUserFromRequest } from '../../../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../../../lib/permission-middleware';
|
||||||
|
import { isValidSlug } from '../../../../lib/slug-utils';
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
// Set CORS headers
|
// Set CORS headers
|
||||||
|
|
@ -24,40 +25,49 @@ 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: collectionId } = req.query;
|
const { id: identifier } = req.query;
|
||||||
|
|
||||||
if (!collectionId) {
|
if (!identifier) {
|
||||||
return res.status(400).json({ error: 'Collection ID is required' });
|
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
|
// Verify user has access to this collection
|
||||||
const collectionResult = await sql`
|
let collectionResult;
|
||||||
|
if (isSlug) {
|
||||||
|
collectionResult = await sql`
|
||||||
SELECT c.*, cp.role as user_role
|
SELECT c.*, cp.role as user_role
|
||||||
FROM collections c
|
FROM collections c
|
||||||
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active'
|
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}
|
WHERE c.slug = ${identifier}
|
||||||
AND (
|
AND (
|
||||||
c.user_id = ${user.userId} OR
|
c.user_id = ${user.userId} OR
|
||||||
cp.id IS NOT NULL OR
|
cp.id IS NOT NULL OR
|
||||||
c.is_public = true
|
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) {
|
if (collectionResult.rows.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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define rarity priority order (highest to lowest value)
|
const collection = collectionResult.rows[0];
|
||||||
const rarityOrder = {
|
|
||||||
'mythic': 8,
|
|
||||||
'legendary': 7,
|
|
||||||
'rare': 6,
|
|
||||||
'uncommon': 5,
|
|
||||||
'common': 4,
|
|
||||||
'special': 3,
|
|
||||||
'promo': 2,
|
|
||||||
'token': 1
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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`
|
||||||
|
|
@ -73,7 +83,7 @@ export default async function handler(req, res) {
|
||||||
cc.quantity
|
cc.quantity
|
||||||
FROM collection_cards cc
|
FROM collection_cards cc
|
||||||
JOIN cards ON cc.card_id = cards.id
|
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)
|
AND (cards.image_url IS NOT NULL OR cards.stock_image_url IS NOT NULL)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE cards.rarity
|
CASE cards.rarity
|
||||||
|
|
|
||||||
217
pages/api/collections/[identifier].js
Normal file
217
pages/api/collections/[identifier].js
Normal file
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,11 +38,12 @@ export default function Collections() {
|
||||||
const collectionsWithThumbnails = await Promise.all(
|
const collectionsWithThumbnails = await Promise.all(
|
||||||
data.map(async (collection) => {
|
data.map(async (collection) => {
|
||||||
try {
|
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() : [];
|
const thumbnails = thumbnailResponse.ok ? await thumbnailResponse.json() : [];
|
||||||
return { ...collection, thumbnails };
|
return { ...collection, thumbnails };
|
||||||
} catch (error) {
|
} 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: [] };
|
return { ...collection, thumbnails: [] };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -352,7 +353,7 @@ export default function Collections() {
|
||||||
<div
|
<div
|
||||||
key={collection.id}
|
key={collection.id}
|
||||||
className="card hover:shadow-xl transition-all duration-300 cursor-pointer group"
|
className="card hover:shadow-xl transition-all duration-300 cursor-pointer group"
|
||||||
onClick={() => router.push(`/collection/${collection.id}`)}
|
onClick={() => router.push(`/collection/${collection.slug || collection.id}`)}
|
||||||
>
|
>
|
||||||
{/* Thumbnail Section */}
|
{/* Thumbnail Section */}
|
||||||
<CollectionThumbnail collection={collection} />
|
<CollectionThumbnail collection={collection} />
|
||||||
|
|
@ -572,7 +573,7 @@ export default function Collections() {
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSuccessModal(false);
|
setShowSuccessModal(false);
|
||||||
router.push(`/collection/${createdCollection.id}`);
|
router.push(`/collection/${createdCollection.slug || createdCollection.id}`);
|
||||||
}}
|
}}
|
||||||
className="w-full py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
className="w-full py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
124
scripts/add-collection-slugs.js
Normal file
124
scripts/add-collection-slugs.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
import { generateUniqueSlug } from '../lib/slug-utils.js';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
|
||||||
|
async function addCollectionSlugs() {
|
||||||
|
const sql = neon(process.env.POSTGRES_URL);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔗 Adding slug support to collections...');
|
||||||
|
|
||||||
|
// Step 1: Add slug column to collections table
|
||||||
|
console.log('📝 Adding slug column...');
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE collections
|
||||||
|
ADD COLUMN IF NOT EXISTS slug VARCHAR(100) UNIQUE
|
||||||
|
`;
|
||||||
|
console.log('✅ Added slug column');
|
||||||
|
|
||||||
|
// Step 2: Get all existing collections
|
||||||
|
console.log('🔍 Fetching existing collections...');
|
||||||
|
const collections = await sql`
|
||||||
|
SELECT id, name, slug FROM collections
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
console.log(`📊 Found ${collections.length} collections`);
|
||||||
|
|
||||||
|
if (collections.length === 0) {
|
||||||
|
console.log('✅ No collections to migrate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Generate unique slugs for all collections
|
||||||
|
console.log('🏷️ Generating unique slugs...');
|
||||||
|
const existingSlugs = [];
|
||||||
|
const updates = [];
|
||||||
|
|
||||||
|
for (const collection of collections) {
|
||||||
|
// Skip if collection already has a slug
|
||||||
|
if (collection.slug) {
|
||||||
|
existingSlugs.push(collection.slug);
|
||||||
|
console.log(`⏭️ Skipping "${collection.name}" - already has slug: ${collection.slug}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique slug
|
||||||
|
const uniqueSlug = await generateUniqueSlug(collection.name, existingSlugs);
|
||||||
|
existingSlugs.push(uniqueSlug);
|
||||||
|
|
||||||
|
updates.push({
|
||||||
|
id: collection.id,
|
||||||
|
name: collection.name,
|
||||||
|
slug: uniqueSlug
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🎯 "${collection.name}" → "${uniqueSlug}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Update collections with their new slugs
|
||||||
|
if (updates.length > 0) {
|
||||||
|
console.log(`📝 Updating ${updates.length} collections with slugs...`);
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
await sql`
|
||||||
|
UPDATE collections
|
||||||
|
SET slug = ${update.slug}
|
||||||
|
WHERE id = ${update.id}
|
||||||
|
`;
|
||||||
|
console.log(`✅ Updated: ${update.name} → ${update.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Add index for performance
|
||||||
|
console.log('🚀 Adding slug index for performance...');
|
||||||
|
try {
|
||||||
|
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_collections_slug ON collections(slug)`;
|
||||||
|
console.log('✅ Added slug index');
|
||||||
|
} catch (error) {
|
||||||
|
if (!error.message.includes('already exists')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.log('✅ Slug index already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Add constraint to ensure slugs are not null for new collections
|
||||||
|
console.log('🔒 Adding slug constraints...');
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE collections ADD CONSTRAINT check_slug_format CHECK (slug ~ '^[a-z0-9]([a-z0-9-]*[a-z0-9])?$' AND length(slug) <= 50)`;
|
||||||
|
console.log('✅ Added slug format constraint');
|
||||||
|
} catch (error) {
|
||||||
|
if (!error.message.includes('already exists')) {
|
||||||
|
console.warn('⚠️ Could not add slug constraint:', error.message);
|
||||||
|
} else {
|
||||||
|
console.log('✅ Slug constraint already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Collection slug migration completed successfully!');
|
||||||
|
console.log('\n📋 Summary:');
|
||||||
|
console.log(` 📊 Total collections: ${collections.length}`);
|
||||||
|
console.log(` 🆕 New slugs created: ${updates.length}`);
|
||||||
|
console.log(` ⏭️ Already had slugs: ${collections.length - updates.length}`);
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
console.log('\n🔗 New URL structure:');
|
||||||
|
console.log(' Old: /collection/123');
|
||||||
|
console.log(' New: /collection/modern-masters-2021');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to add collection slugs:', error.message);
|
||||||
|
console.error('Full error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
addCollectionSlugs();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { addCollectionSlugs };
|
||||||
115
scripts/create-sample-collections.js
Normal file
115
scripts/create-sample-collections.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { neon } from '@neondatabase/serverless';
|
||||||
|
import { generateUniqueSlug } from '../lib/slug-utils.js';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
|
||||||
|
async function createSampleCollections() {
|
||||||
|
const sql = neon(process.env.POSTGRES_URL);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🎨 Creating sample collections with slugs...');
|
||||||
|
|
||||||
|
// First, check if we have a user to assign collections to
|
||||||
|
const users = await sql`SELECT id FROM users LIMIT 1`;
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
console.log('❌ No users found. Please create a user first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = users[0].id;
|
||||||
|
|
||||||
|
const sampleCollections = [
|
||||||
|
{
|
||||||
|
name: "Modern Masters 2021",
|
||||||
|
description: "Complete set of Modern Masters 2021 with all the best reprints",
|
||||||
|
tcg: "MTG",
|
||||||
|
isPublic: true,
|
||||||
|
tags: ["modern", "masters", "complete", "reprint"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pokemon Base Set",
|
||||||
|
description: "Original Pokemon base set collection from 1998",
|
||||||
|
tcg: "Pokemon",
|
||||||
|
isPublic: false,
|
||||||
|
tags: ["base", "original", "holographic", "vintage"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Commander Staples",
|
||||||
|
description: "Essential cards for Commander format gameplay",
|
||||||
|
tcg: "MTG",
|
||||||
|
isPublic: true,
|
||||||
|
tags: ["commander", "staples", "multiplayer", "edh"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Disney Lorcana First Chapter",
|
||||||
|
description: "Disney Lorcana First Chapter collection with rare enchanted cards",
|
||||||
|
tcg: "Lorcana",
|
||||||
|
isPublic: true,
|
||||||
|
tags: ["disney", "first-chapter", "enchanted", "new"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Vintage Pokemon Cards",
|
||||||
|
description: "Rare vintage Pokemon cards from the early sets",
|
||||||
|
tcg: "Pokemon",
|
||||||
|
isPublic: false,
|
||||||
|
tags: ["vintage", "rare", "holographic", "investment"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get existing slugs
|
||||||
|
const existingSlugsData = await sql`SELECT slug FROM collections WHERE slug IS NOT NULL`;
|
||||||
|
const existingSlugs = (existingSlugsData || []).map(row => row.slug);
|
||||||
|
|
||||||
|
console.log('📝 Creating collections...');
|
||||||
|
|
||||||
|
for (const collection of sampleCollections) {
|
||||||
|
// Generate unique slug
|
||||||
|
const uniqueSlug = await generateUniqueSlug(collection.name, existingSlugs);
|
||||||
|
existingSlugs.push(uniqueSlug);
|
||||||
|
|
||||||
|
// Insert collection
|
||||||
|
const result = await sql`
|
||||||
|
INSERT INTO collections (name, description, tcg, is_public, tags, user_id, slug)
|
||||||
|
VALUES (
|
||||||
|
${collection.name},
|
||||||
|
${collection.description},
|
||||||
|
${collection.tcg},
|
||||||
|
${collection.isPublic},
|
||||||
|
${collection.tags.join(',')},
|
||||||
|
${userId},
|
||||||
|
${uniqueSlug}
|
||||||
|
)
|
||||||
|
RETURNING id, name, slug
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create owner permission
|
||||||
|
await sql`
|
||||||
|
INSERT INTO collection_permissions (collection_id, user_id, role, status)
|
||||||
|
VALUES (${result[0].id}, ${userId}, 'owner', 'active')
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`✅ Created: "${collection.name}" → "${uniqueSlug}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Sample collections created successfully!');
|
||||||
|
console.log('\n🔗 You can now access collections via:');
|
||||||
|
console.log(' • /collection/modern-masters-2021');
|
||||||
|
console.log(' • /collection/pokemon-base-set');
|
||||||
|
console.log(' • /collection/commander-staples');
|
||||||
|
console.log(' • /collection/disney-lorcana-first-chapter');
|
||||||
|
console.log(' • /collection/vintage-pokemon-cards');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to create sample collections:', error.message);
|
||||||
|
console.error('Full error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
createSampleCollections();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createSampleCollections };
|
||||||
Loading…
Reference in a new issue