🔗 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 { 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 *
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
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.id = ${collectionId}
|
||||
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
|
||||
|
|
|
|||
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(
|
||||
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() {
|
|||
<div
|
||||
key={collection.id}
|
||||
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 */}
|
||||
<CollectionThumbnail collection={collection} />
|
||||
|
|
@ -572,7 +573,7 @@ export default function Collections() {
|
|||
<button
|
||||
onClick={() => {
|
||||
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"
|
||||
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