From 137de1c353934b3129b0b203d8231361ab9d03a8 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Fri, 2 Jan 2026 02:35:32 +1000 Subject: [PATCH] feat: add collections feature for organizing tools - Add Collection and CollectionTool models to Prisma schema - Create Zod validation schemas for collections - Implement full CRUD API routes for collections - Add tool management endpoints (add/remove tools) - Create UI components: CollectionCard, CollectionForm, CollectionList, AddToolSearch, CollectionToolList - Add /dashboard/collections pages for list and detail views - Add new icons to @tpmjs/ui: folder, plus, trash, edit, box, search, loader, arrowLeft, alertCircle, globe - Add xs size variant to Icon component - Add Collections link to dashboard page Features: - Full CRUD for named collections - Public/private visibility toggle - Tool search to add tools to collections - Ownership-based access control - Collection limit: 50 per user - Tool limit: 100 per collection --- .../web/src/app/api/collections/[id]/route.ts | 363 ++++++++++++++++++ .../collections/[id]/tools/[toolId]/route.ts | 128 ++++++ .../app/api/collections/[id]/tools/route.ts | 218 +++++++++++ apps/web/src/app/api/collections/route.ts | 230 +++++++++++ .../app/dashboard/collections/[id]/page.tsx | 358 +++++++++++++++++ .../src/app/dashboard/collections/page.tsx | 182 +++++++++ apps/web/src/app/dashboard/page.tsx | 22 ++ .../components/collections/AddToolSearch.tsx | 230 +++++++++++ .../components/collections/CollectionCard.tsx | 103 +++++ .../components/collections/CollectionForm.tsx | 135 +++++++ .../components/collections/CollectionList.tsx | 52 +++ .../collections/CollectionToolList.tsx | 103 +++++ packages/db/prisma/schema.prisma | 64 ++- packages/types/package.json | 4 + packages/types/src/collection.ts | 113 ++++++ packages/types/tsup.config.ts | 2 +- packages/ui/src/Icon/icons.ts | 40 ++ packages/ui/src/Icon/types.ts | 2 +- packages/ui/src/Icon/variants.ts | 1 + 19 files changed, 2346 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/api/collections/[id]/route.ts create mode 100644 apps/web/src/app/api/collections/[id]/tools/[toolId]/route.ts create mode 100644 apps/web/src/app/api/collections/[id]/tools/route.ts create mode 100644 apps/web/src/app/api/collections/route.ts create mode 100644 apps/web/src/app/dashboard/collections/[id]/page.tsx create mode 100644 apps/web/src/app/dashboard/collections/page.tsx create mode 100644 apps/web/src/components/collections/AddToolSearch.tsx create mode 100644 apps/web/src/components/collections/CollectionCard.tsx create mode 100644 apps/web/src/components/collections/CollectionForm.tsx create mode 100644 apps/web/src/components/collections/CollectionList.tsx create mode 100644 apps/web/src/components/collections/CollectionToolList.tsx create mode 100644 packages/types/src/collection.ts diff --git a/apps/web/src/app/api/collections/[id]/route.ts b/apps/web/src/app/api/collections/[id]/route.ts new file mode 100644 index 0000000..093a7ca --- /dev/null +++ b/apps/web/src/app/api/collections/[id]/route.ts @@ -0,0 +1,363 @@ +import { prisma } from '@tpmjs/db'; +import { UpdateCollectionSchema } from '@tpmjs/types/collection'; +import { headers } from 'next/headers'; +import { type NextRequest, NextResponse } from 'next/server'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +const API_VERSION = '1.0.0'; + +interface ApiResponse { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + meta: { + version: string; + timestamp: string; + requestId?: string; + }; +} + +interface RouteContext { + params: Promise<{ id: string }>; +} + +/** + * GET /api/collections/[id] + * Get a single collection with its tools + */ +export async function GET( + _request: NextRequest, + context: RouteContext +): Promise> { + const requestId = crypto.randomUUID(); + const { id } = await context.params; + + try { + // Check authentication + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json( + { + success: false, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 401 } + ); + } + + // Fetch collection with tools + const collection = await prisma.collection.findUnique({ + where: { id }, + include: { + tools: { + include: { + tool: { + include: { + package: { + select: { + id: true, + npmPackageName: true, + category: true, + }, + }, + }, + }, + }, + orderBy: { position: 'asc' }, + }, + _count: { select: { tools: true } }, + }, + }); + + if (!collection) { + return NextResponse.json( + { + success: false, + error: { code: 'NOT_FOUND', message: 'Collection not found' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 404 } + ); + } + + // Check ownership (unless collection is public) + if (collection.userId !== session.user.id && !collection.isPublic) { + return NextResponse.json( + { + success: false, + error: { code: 'FORBIDDEN', message: 'Access denied' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 403 } + ); + } + + return NextResponse.json({ + success: true, + data: { + id: collection.id, + name: collection.name, + description: collection.description, + isPublic: collection.isPublic, + toolCount: collection._count.tools, + createdAt: collection.createdAt, + updatedAt: collection.updatedAt, + isOwner: collection.userId === session.user.id, + tools: collection.tools.map((ct) => ({ + id: ct.id, + toolId: ct.toolId, + position: ct.position, + note: ct.note, + addedAt: ct.addedAt, + tool: { + id: ct.tool.id, + name: ct.tool.name, + description: ct.tool.description, + package: ct.tool.package, + }, + })), + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } catch (error) { + console.error('[API Error] GET /api/collections/[id]:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to fetch collection' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/collections/[id] + * Update a collection + */ +export async function PATCH( + request: NextRequest, + context: RouteContext +): Promise> { + const requestId = crypto.randomUUID(); + const { id } = await context.params; + + try { + // Check authentication + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json( + { + success: false, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 401 } + ); + } + + // Check collection exists and ownership + const existingCollection = await prisma.collection.findUnique({ + where: { id }, + }); + + if (!existingCollection) { + return NextResponse.json( + { + success: false, + error: { code: 'NOT_FOUND', message: 'Collection not found' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 404 } + ); + } + + if (existingCollection.userId !== session.user.id) { + return NextResponse.json( + { + success: false, + error: { code: 'FORBIDDEN', message: 'Access denied' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 403 } + ); + } + + // Parse and validate request body + const body = await request.json(); + const parseResult = UpdateCollectionSchema.safeParse(body); + + if (!parseResult.success) { + return NextResponse.json( + { + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid request body', + details: { errors: parseResult.error.flatten().fieldErrors }, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + + const { name, description, isPublic } = parseResult.data; + + // If name is being changed, check for duplicates + if (name && name !== existingCollection.name) { + const duplicateName = await prisma.collection.findFirst({ + where: { + userId: session.user.id, + name: { equals: name, mode: 'insensitive' }, + id: { not: id }, + }, + }); + + if (duplicateName) { + return NextResponse.json( + { + success: false, + error: { + code: 'DUPLICATE_NAME', + message: 'A collection with this name already exists', + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 409 } + ); + } + } + + // Update collection + const collection = await prisma.collection.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(description !== undefined && { description }), + ...(isPublic !== undefined && { isPublic }), + }, + include: { + _count: { select: { tools: true } }, + }, + }); + + return NextResponse.json({ + success: true, + data: { + id: collection.id, + name: collection.name, + description: collection.description, + isPublic: collection.isPublic, + toolCount: collection._count.tools, + createdAt: collection.createdAt, + updatedAt: collection.updatedAt, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } catch (error) { + console.error('[API Error] PATCH /api/collections/[id]:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to update collection' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/collections/[id] + * Delete a collection + */ +export async function DELETE( + _request: NextRequest, + context: RouteContext +): Promise> { + const requestId = crypto.randomUUID(); + const { id } = await context.params; + + try { + // Check authentication + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json( + { + success: false, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 401 } + ); + } + + // Check collection exists and ownership + const collection = await prisma.collection.findUnique({ + where: { id }, + }); + + if (!collection) { + return NextResponse.json( + { + success: false, + error: { code: 'NOT_FOUND', message: 'Collection not found' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 404 } + ); + } + + if (collection.userId !== session.user.id) { + return NextResponse.json( + { + success: false, + error: { code: 'FORBIDDEN', message: 'Access denied' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 403 } + ); + } + + // Delete collection (cascade will delete CollectionTools) + await prisma.collection.delete({ + where: { id }, + }); + + return NextResponse.json({ + success: true, + data: { deleted: true }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } catch (error) { + console.error('[API Error] DELETE /api/collections/[id]:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to delete collection' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/collections/[id]/tools/[toolId]/route.ts b/apps/web/src/app/api/collections/[id]/tools/[toolId]/route.ts new file mode 100644 index 0000000..7d59687 --- /dev/null +++ b/apps/web/src/app/api/collections/[id]/tools/[toolId]/route.ts @@ -0,0 +1,128 @@ +import { prisma } from '@tpmjs/db'; +import { headers } from 'next/headers'; +import { type NextRequest, NextResponse } from 'next/server'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +const API_VERSION = '1.0.0'; + +interface ApiResponse { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + meta: { + version: string; + timestamp: string; + requestId?: string; + }; +} + +interface RouteContext { + params: Promise<{ id: string; toolId: string }>; +} + +/** + * DELETE /api/collections/[id]/tools/[toolId] + * Remove a tool from a collection + */ +export async function DELETE( + _request: NextRequest, + context: RouteContext +): Promise> { + const requestId = crypto.randomUUID(); + const { id: collectionId, toolId } = await context.params; + + try { + // Check authentication + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json( + { + success: false, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 401 } + ); + } + + // Verify collection exists and user owns it + const collection = await prisma.collection.findUnique({ + where: { id: collectionId }, + }); + + if (!collection) { + return NextResponse.json( + { + success: false, + error: { code: 'NOT_FOUND', message: 'Collection not found' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 404 } + ); + } + + if (collection.userId !== session.user.id) { + return NextResponse.json( + { + success: false, + error: { code: 'FORBIDDEN', message: 'Access denied' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 403 } + ); + } + + // Find the collection-tool entry + const collectionTool = await prisma.collectionTool.findUnique({ + where: { + collectionId_toolId: { + collectionId, + toolId, + }, + }, + }); + + if (!collectionTool) { + return NextResponse.json( + { + success: false, + error: { code: 'NOT_FOUND', message: 'Tool not found in this collection' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 404 } + ); + } + + // Delete the entry + await prisma.collectionTool.delete({ + where: { id: collectionTool.id }, + }); + + return NextResponse.json({ + success: true, + data: { deleted: true }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } catch (error) { + console.error('[API Error] DELETE /api/collections/[id]/tools/[toolId]:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to remove tool from collection' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/collections/[id]/tools/route.ts b/apps/web/src/app/api/collections/[id]/tools/route.ts new file mode 100644 index 0000000..270a03c --- /dev/null +++ b/apps/web/src/app/api/collections/[id]/tools/route.ts @@ -0,0 +1,218 @@ +import { prisma } from '@tpmjs/db'; +import { AddToolToCollectionSchema, COLLECTION_LIMITS } from '@tpmjs/types/collection'; +import { headers } from 'next/headers'; +import { type NextRequest, NextResponse } from 'next/server'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +const API_VERSION = '1.0.0'; + +interface ApiResponse { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + meta: { + version: string; + timestamp: string; + requestId?: string; + }; +} + +interface RouteContext { + params: Promise<{ id: string }>; +} + +/** + * POST /api/collections/[id]/tools + * Add a tool to a collection + */ +export async function POST( + request: NextRequest, + context: RouteContext +): Promise> { + const requestId = crypto.randomUUID(); + const { id: collectionId } = await context.params; + + try { + // Check authentication + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json( + { + success: false, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 401 } + ); + } + + // Verify collection exists and user owns it + const collection = await prisma.collection.findUnique({ + where: { id: collectionId }, + include: { _count: { select: { tools: true } } }, + }); + + if (!collection) { + return NextResponse.json( + { + success: false, + error: { code: 'NOT_FOUND', message: 'Collection not found' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 404 } + ); + } + + if (collection.userId !== session.user.id) { + return NextResponse.json( + { + success: false, + error: { code: 'FORBIDDEN', message: 'Access denied' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 403 } + ); + } + + // Parse and validate request body + const body = await request.json(); + const parseResult = AddToolToCollectionSchema.safeParse(body); + + if (!parseResult.success) { + return NextResponse.json( + { + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid request body', + details: { errors: parseResult.error.flatten().fieldErrors }, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + + const { toolId, note, position } = parseResult.data; + + // Check tool limit + if (collection._count.tools >= COLLECTION_LIMITS.MAX_TOOLS_PER_COLLECTION) { + return NextResponse.json( + { + success: false, + error: { + code: 'LIMIT_EXCEEDED', + message: `Maximum ${COLLECTION_LIMITS.MAX_TOOLS_PER_COLLECTION} tools per collection`, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + + // Verify tool exists + const tool = await prisma.tool.findUnique({ + where: { id: toolId }, + include: { + package: { + select: { + id: true, + npmPackageName: true, + category: true, + }, + }, + }, + }); + + if (!tool) { + return NextResponse.json( + { + success: false, + error: { code: 'NOT_FOUND', message: 'Tool not found' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 404 } + ); + } + + // Check if tool is already in collection + const existingEntry = await prisma.collectionTool.findUnique({ + where: { + collectionId_toolId: { + collectionId, + toolId, + }, + }, + }); + + if (existingEntry) { + return NextResponse.json( + { + success: false, + error: { code: 'DUPLICATE_TOOL', message: 'Tool is already in this collection' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 409 } + ); + } + + // Get max position if not specified + const maxPosition = + position ?? + (await prisma.collectionTool.count({ + where: { collectionId }, + })); + + // Add tool to collection + const collectionTool = await prisma.collectionTool.create({ + data: { + collectionId, + toolId, + note: note || null, + position: maxPosition, + }, + }); + + return NextResponse.json( + { + success: true, + data: { + id: collectionTool.id, + toolId: collectionTool.toolId, + position: collectionTool.position, + note: collectionTool.note, + addedAt: collectionTool.addedAt, + tool: { + id: tool.id, + name: tool.name, + description: tool.description, + package: tool.package, + }, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 201 } + ); + } catch (error) { + console.error('[API Error] POST /api/collections/[id]/tools:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to add tool to collection' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/collections/route.ts b/apps/web/src/app/api/collections/route.ts new file mode 100644 index 0000000..be47cd8 --- /dev/null +++ b/apps/web/src/app/api/collections/route.ts @@ -0,0 +1,230 @@ +import { prisma } from '@tpmjs/db'; +import { COLLECTION_LIMITS, CreateCollectionSchema } from '@tpmjs/types/collection'; +import { headers } from 'next/headers'; +import { type NextRequest, NextResponse } from 'next/server'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +const API_VERSION = '1.0.0'; + +/** + * Standard API response structure + */ +interface ApiResponse { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + meta: { + version: string; + timestamp: string; + requestId?: string; + }; + pagination?: { + limit: number; + offset: number; + count: number; + hasMore: boolean; + }; +} + +/** + * GET /api/collections + * List all collections for the authenticated user + */ +export async function GET(request: NextRequest): Promise> { + const requestId = crypto.randomUUID(); + + try { + // Check authentication + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json( + { + success: false, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 401 } + ); + } + + // Parse pagination params + const searchParams = request.nextUrl.searchParams; + const limit = Math.min(Math.max(Number.parseInt(searchParams.get('limit') || '20', 10), 1), 50); + const offset = Math.max(Number.parseInt(searchParams.get('offset') || '0', 10), 0); + + // Fetch collections with tool count + const collections = await prisma.collection.findMany({ + where: { userId: session.user.id }, + include: { + _count: { select: { tools: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + skip: offset, + }); + + const hasMore = collections.length > limit; + const data = hasMore ? collections.slice(0, limit) : collections; + + return NextResponse.json({ + success: true, + data: data.map((c) => ({ + id: c.id, + name: c.name, + description: c.description, + isPublic: c.isPublic, + toolCount: c._count.tools, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + })), + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + pagination: { limit, offset, count: data.length, hasMore }, + }); + } catch (error) { + console.error('[API Error] GET /api/collections:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to fetch collections' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} + +/** + * POST /api/collections + * Create a new collection + */ +export async function POST(request: NextRequest): Promise> { + const requestId = crypto.randomUUID(); + + try { + // Check authentication + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json( + { + success: false, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 401 } + ); + } + + // Parse and validate request body + const body = await request.json(); + const parseResult = CreateCollectionSchema.safeParse(body); + + if (!parseResult.success) { + return NextResponse.json( + { + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid request body', + details: { errors: parseResult.error.flatten().fieldErrors }, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + + const { name, description, isPublic } = parseResult.data; + + // Check collection limit + const existingCount = await prisma.collection.count({ + where: { userId: session.user.id }, + }); + + if (existingCount >= COLLECTION_LIMITS.MAX_COLLECTIONS_PER_USER) { + return NextResponse.json( + { + success: false, + error: { + code: 'LIMIT_EXCEEDED', + message: `Maximum ${COLLECTION_LIMITS.MAX_COLLECTIONS_PER_USER} collections allowed`, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + + // Check for duplicate name (case-insensitive) + const existingCollection = await prisma.collection.findFirst({ + where: { + userId: session.user.id, + name: { equals: name, mode: 'insensitive' }, + }, + }); + + if (existingCollection) { + return NextResponse.json( + { + success: false, + error: { + code: 'DUPLICATE_NAME', + message: 'A collection with this name already exists', + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 409 } + ); + } + + // Create collection + const collection = await prisma.collection.create({ + data: { + userId: session.user.id, + name, + description: description || null, + isPublic, + }, + }); + + return NextResponse.json( + { + success: true, + data: { + id: collection.id, + name: collection.name, + description: collection.description, + isPublic: collection.isPublic, + toolCount: 0, + createdAt: collection.createdAt, + updatedAt: collection.updatedAt, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 201 } + ); + } catch (error) { + console.error('[API Error] POST /api/collections:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to create collection' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/dashboard/collections/[id]/page.tsx b/apps/web/src/app/dashboard/collections/[id]/page.tsx new file mode 100644 index 0000000..a158527 --- /dev/null +++ b/apps/web/src/app/dashboard/collections/[id]/page.tsx @@ -0,0 +1,358 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { AddToolSearch } from '~/components/collections/AddToolSearch'; +import { CollectionForm } from '~/components/collections/CollectionForm'; +import { CollectionToolList } from '~/components/collections/CollectionToolList'; + +interface CollectionTool { + id: string; + toolId: string; + position: number; + note: string | null; + addedAt: string; + tool: { + id: string; + name: string; + description: string; + package: { + id: string; + npmPackageName: string; + category: string; + }; + }; +} + +interface Collection { + id: string; + name: string; + description: string | null; + isPublic: boolean; + toolCount: number; + createdAt: string; + updatedAt: string; + isOwner: boolean; + tools: CollectionTool[]; +} + +export default function CollectionDetailPage(): React.ReactElement { + const params = useParams(); + const router = useRouter(); + const collectionId = params.id as string; + + const [collection, setCollection] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [removingToolId, setRemovingToolId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const fetchCollection = useCallback(async () => { + try { + const response = await fetch(`/api/collections/${collectionId}`); + const data = await response.json(); + + if (data.success) { + setCollection(data.data); + } else { + if (data.error?.code === 'UNAUTHORIZED') { + router.push('/sign-in'); + return; + } + if (data.error?.code === 'NOT_FOUND') { + router.push('/dashboard/collections'); + return; + } + setError(data.error?.message || 'Failed to fetch collection'); + } + } catch (err) { + console.error('Failed to fetch collection:', err); + setError('Failed to fetch collection'); + } finally { + setIsLoading(false); + } + }, [collectionId, router]); + + useEffect(() => { + fetchCollection(); + }, [fetchCollection]); + + const handleUpdate = async (data: { name: string; description?: string; isPublic: boolean }) => { + if (!collection) return; + setIsUpdating(true); + + try { + const response = await fetch(`/api/collections/${collectionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + + const result = await response.json(); + + if (result.success) { + setCollection((prev) => + prev + ? { + ...prev, + name: result.data.name, + description: result.data.description, + isPublic: result.data.isPublic, + updatedAt: result.data.updatedAt, + } + : null + ); + setIsEditing(false); + } else { + throw new Error(result.error?.message || 'Failed to update collection'); + } + } catch (err) { + console.error('Failed to update collection:', err); + throw err; + } finally { + setIsUpdating(false); + } + }; + + const handleDelete = async () => { + if ( + !confirm( + 'Are you sure you want to delete this collection? All tools will be removed and this action cannot be undone.' + ) + ) { + return; + } + + setIsDeleting(true); + + try { + const response = await fetch(`/api/collections/${collectionId}`, { + method: 'DELETE', + }); + + const result = await response.json(); + + if (result.success) { + router.push('/dashboard/collections'); + } else { + throw new Error(result.error?.message || 'Failed to delete collection'); + } + } catch (err) { + console.error('Failed to delete collection:', err); + alert('Failed to delete collection'); + setIsDeleting(false); + } + }; + + const handleToolAdded = (tool: { + id: string; + name: string; + description: string; + package: { npmPackageName: string; category: string }; + }) => { + if (!collection) return; + + const newTool: CollectionTool = { + id: crypto.randomUUID(), + toolId: tool.id, + position: collection.tools.length, + note: null, + addedAt: new Date().toISOString(), + tool: { + id: tool.id, + name: tool.name, + description: tool.description, + package: { + id: '', + npmPackageName: tool.package.npmPackageName, + category: tool.package.category, + }, + }, + }; + + setCollection((prev) => + prev + ? { + ...prev, + toolCount: prev.toolCount + 1, + tools: [...prev.tools, newTool], + } + : null + ); + }; + + const handleRemoveTool = async (toolId: string) => { + setRemovingToolId(toolId); + + try { + const response = await fetch(`/api/collections/${collectionId}/tools/${toolId}`, { + method: 'DELETE', + }); + + const result = await response.json(); + + if (result.success) { + setCollection((prev) => + prev + ? { + ...prev, + toolCount: prev.toolCount - 1, + tools: prev.tools.filter((t) => t.toolId !== toolId), + } + : null + ); + } else { + throw new Error(result.error?.message || 'Failed to remove tool'); + } + } catch (err) { + console.error('Failed to remove tool:', err); + alert('Failed to remove tool'); + } finally { + setRemovingToolId(null); + } + }; + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+ ); + } + + if (error || !collection) { + return ( +
+
+
+ +

Error

+

{error || 'Collection not found'}

+ + + +
+
+
+ ); + } + + const existingToolIds = collection.tools.map((t) => t.toolId); + + return ( +
+
+ {/* Header */} +
+ + + +
+ {isEditing ? ( +
+

Edit Collection

+ setIsEditing(false)} + isSubmitting={isUpdating} + submitLabel="Save Changes" + /> +
+ ) : ( + <> +
+

{collection.name}

+ {collection.isPublic && ( + + + Public + + )} +
+ {collection.description && ( +

{collection.description}

+ )} + + )} +
+ {collection.isOwner && !isEditing && ( +
+ + +
+ )} +
+ + {/* Stats */} +
+ + + {collection.toolCount} {collection.toolCount === 1 ? 'tool' : 'tools'} + + Updated {new Date(collection.updatedAt).toLocaleDateString()} +
+ + {/* Add Tool Search */} + {collection.isOwner && ( +
+

Add Tools

+ +
+ )} + + {/* Tools List */} +
+

Tools in this Collection

+ +
+
+
+ ); +} diff --git a/apps/web/src/app/dashboard/collections/page.tsx b/apps/web/src/app/dashboard/collections/page.tsx new file mode 100644 index 0000000..97725f5 --- /dev/null +++ b/apps/web/src/app/dashboard/collections/page.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { CollectionForm } from '~/components/collections/CollectionForm'; +import { CollectionList } from '~/components/collections/CollectionList'; + +interface Collection { + id: string; + name: string; + description: string | null; + toolCount: number; + isPublic: boolean; + updatedAt: string; +} + +export default function CollectionsPage(): React.ReactElement { + const router = useRouter(); + const [collections, setCollections] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreateForm, setShowCreateForm] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + const fetchCollections = useCallback(async () => { + try { + const response = await fetch('/api/collections'); + const data = await response.json(); + + if (data.success) { + setCollections(data.data); + } else { + if (data.error?.code === 'UNAUTHORIZED') { + router.push('/sign-in'); + return; + } + setError(data.error?.message || 'Failed to fetch collections'); + } + } catch (err) { + console.error('Failed to fetch collections:', err); + setError('Failed to fetch collections'); + } finally { + setIsLoading(false); + } + }, [router]); + + useEffect(() => { + fetchCollections(); + }, [fetchCollections]); + + const handleCreate = async (data: { name: string; description?: string; isPublic: boolean }) => { + setIsCreating(true); + + try { + const response = await fetch('/api/collections', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + + const result = await response.json(); + + if (result.success) { + setCollections((prev) => [result.data, ...prev]); + setShowCreateForm(false); + } else { + throw new Error(result.error?.message || 'Failed to create collection'); + } + } catch (err) { + console.error('Failed to create collection:', err); + throw err; + } finally { + setIsCreating(false); + } + }; + + const handleDelete = async (id: string) => { + if ( + !confirm('Are you sure you want to delete this collection? This action cannot be undone.') + ) { + return; + } + + setDeletingId(id); + + try { + const response = await fetch(`/api/collections/${id}`, { + method: 'DELETE', + }); + + const result = await response.json(); + + if (result.success) { + setCollections((prev) => prev.filter((c) => c.id !== id)); + } else { + throw new Error(result.error?.message || 'Failed to delete collection'); + } + } catch (err) { + console.error('Failed to delete collection:', err); + alert('Failed to delete collection'); + } finally { + setDeletingId(null); + } + }; + + if (isLoading) { + return ( +
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+ +

Error

+

{error}

+ +
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+ + + +

My Collections

+
+ {!showCreateForm && ( + + )} +
+ + {/* Create Form */} + {showCreateForm && ( +
+

Create New Collection

+ setShowCreateForm(false)} + isSubmitting={isCreating} + submitLabel="Create Collection" + /> +
+ )} + + {/* Collections List */} + +
+
+ ); +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index f415737..14a76f3 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,6 +1,8 @@ import { SignOutButton } from '@/components/auth/SignOutButton'; import { auth } from '@/lib/auth'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; import { headers } from 'next/headers'; +import Link from 'next/link'; import { redirect } from 'next/navigation'; export default async function DashboardPage() { @@ -20,6 +22,26 @@ export default async function DashboardPage() {
+ {/* Quick Actions */} +
+ +
+
+
+ +
+
+

My Collections

+

+ Organize and share your favorite tools +

+
+
+
+ +
+ + {/* Profile Section */}

Profile

diff --git a/apps/web/src/components/collections/AddToolSearch.tsx b/apps/web/src/components/collections/AddToolSearch.tsx new file mode 100644 index 0000000..9545404 --- /dev/null +++ b/apps/web/src/components/collections/AddToolSearch.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { Input } from '@tpmjs/ui/Input/Input'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface Tool { + id: string; + name: string; + description: string; + package: { + npmPackageName: string; + category: string; + }; +} + +interface AddToolSearchProps { + collectionId: string; + existingToolIds: string[]; + onToolAdded: (tool: Tool) => void; +} + +interface SearchResult { + id: string; + name: string; + description: string; + package: { + npmPackageName: string; + category: string; + }; +} + +export function AddToolSearch({ + collectionId, + existingToolIds, + onToolAdded, +}: AddToolSearchProps): React.ReactElement { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [addingId, setAddingId] = useState(null); + const [error, setError] = useState(null); + const searchTimeoutRef = useRef | null>(null); + const containerRef = useRef(null); + + // Debounced search + const search = useCallback(async (searchQuery: string) => { + if (!searchQuery.trim()) { + setResults([]); + setIsOpen(false); + return; + } + + setIsSearching(true); + setError(null); + + try { + const response = await fetch( + `/api/tools/search?q=${encodeURIComponent(searchQuery)}&limit=10` + ); + const data = await response.json(); + + if (data.success && data.results?.tools) { + setResults(data.results.tools); + setIsOpen(true); + } else { + setResults([]); + } + } catch (err) { + console.error('Search failed:', err); + setError('Search failed'); + setResults([]); + } finally { + setIsSearching(false); + } + }, []); + + // Handle query changes with debounce + useEffect(() => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + if (query.trim()) { + searchTimeoutRef.current = setTimeout(() => { + search(query); + }, 300); + } else { + setResults([]); + setIsOpen(false); + } + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [query, search]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleAddTool = async (tool: SearchResult) => { + setAddingId(tool.id); + setError(null); + + try { + const response = await fetch(`/api/collections/${collectionId}/tools`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolId: tool.id }), + }); + + const data = await response.json(); + + if (data.success) { + onToolAdded({ + id: tool.id, + name: tool.name, + description: tool.description, + package: tool.package, + }); + setQuery(''); + setResults([]); + setIsOpen(false); + } else { + setError(data.error?.message || 'Failed to add tool'); + } + } catch (err) { + console.error('Failed to add tool:', err); + setError('Failed to add tool'); + } finally { + setAddingId(null); + } + }; + + // Filter out already added tools + const filteredResults = results.filter((tool) => !existingToolIds.includes(tool.id)); + + return ( +
+
+ + setQuery(e.target.value)} + placeholder="Search for tools to add..." + className="pl-9" + onFocus={() => { + if (filteredResults.length > 0) { + setIsOpen(true); + } + }} + /> + {isSearching && ( + + )} +
+ + {error &&

{error}

} + + {isOpen && filteredResults.length > 0 && ( +
+ {filteredResults.map((tool) => ( +
+
+
+
+ {tool.name} + + {tool.package.category} + +
+

+ {tool.description} +

+

+ {tool.package.npmPackageName} +

+
+ +
+
+ ))} +
+ )} + + {isOpen && query.trim() && filteredResults.length === 0 && !isSearching && ( +
+

+ {results.length > 0 + ? 'All matching tools are already in this collection' + : 'No tools found matching your search'} +

+
+ )} +
+ ); +} diff --git a/apps/web/src/components/collections/CollectionCard.tsx b/apps/web/src/components/collections/CollectionCard.tsx new file mode 100644 index 0000000..1d28607 --- /dev/null +++ b/apps/web/src/components/collections/CollectionCard.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@tpmjs/ui/Card/Card'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; + +interface CollectionCardProps { + collection: { + id: string; + name: string; + description: string | null; + toolCount: number; + isPublic: boolean; + updatedAt: Date | string; + }; + onDelete?: (id: string) => void; + isDeleting?: boolean; +} + +export function CollectionCard({ + collection, + onDelete, + isDeleting, +}: CollectionCardProps): React.ReactElement { + const updatedDate = new Date(collection.updatedAt); + + return ( + + +
+
+ + + {collection.name} + + + {collection.description && ( + + {collection.description} + + )} +
+ {collection.isPublic && ( + + + Public + + )} +
+
+ + +
+ + + {collection.toolCount} {collection.toolCount === 1 ? 'tool' : 'tools'} + +
+
+ + + + Updated {updatedDate.toLocaleDateString()} + +
+ + + + {onDelete && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/collections/CollectionForm.tsx b/apps/web/src/components/collections/CollectionForm.tsx new file mode 100644 index 0000000..6b04a38 --- /dev/null +++ b/apps/web/src/components/collections/CollectionForm.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { Button } from '@tpmjs/ui/Button/Button'; +import { Checkbox } from '@tpmjs/ui/Checkbox/Checkbox'; +import { FormField } from '@tpmjs/ui/FormField/FormField'; +import { Input } from '@tpmjs/ui/Input/Input'; +import { Textarea } from '@tpmjs/ui/Textarea/Textarea'; +import { useState } from 'react'; + +interface CollectionFormProps { + initialData?: { + name: string; + description: string | null; + isPublic: boolean; + }; + onSubmit: (data: { name: string; description?: string; isPublic: boolean }) => Promise; + onCancel?: () => void; + isSubmitting?: boolean; + submitLabel?: string; +} + +interface FormErrors { + name?: string; + description?: string; +} + +export function CollectionForm({ + initialData, + onSubmit, + onCancel, + isSubmitting = false, + submitLabel = 'Create Collection', +}: CollectionFormProps): React.ReactElement { + const [name, setName] = useState(initialData?.name ?? ''); + const [description, setDescription] = useState(initialData?.description ?? ''); + const [isPublic, setIsPublic] = useState(initialData?.isPublic ?? false); + const [errors, setErrors] = useState({}); + + const validate = (): boolean => { + const newErrors: FormErrors = {}; + + if (!name.trim()) { + newErrors.name = 'Name is required'; + } else if (name.length > 100) { + newErrors.name = 'Name must be 100 characters or less'; + } else if (!/^[a-zA-Z0-9\s\-_]+$/.test(name)) { + newErrors.name = 'Name can only contain letters, numbers, spaces, hyphens, and underscores'; + } + + if (description && description.length > 500) { + newErrors.description = 'Description must be 500 characters or less'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validate()) return; + + await onSubmit({ + name: name.trim(), + description: description.trim() || undefined, + isPublic, + }); + }; + + return ( +
+ + setName(e.target.value)} + placeholder="My Collection" + state={errors.name ? 'error' : 'default'} + disabled={isSubmitting} + maxLength={100} + /> + + + +