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
This commit is contained in:
parent
f8740fb7ff
commit
137de1c353
19 changed files with 2346 additions and 4 deletions
363
apps/web/src/app/api/collections/[id]/route.ts
Normal file
363
apps/web/src/app/api/collections/[id]/route.ts
Normal file
|
|
@ -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<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
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<NextResponse<ApiResponse>> {
|
||||
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<NextResponse<ApiResponse>> {
|
||||
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<NextResponse<ApiResponse>> {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
128
apps/web/src/app/api/collections/[id]/tools/[toolId]/route.ts
Normal file
128
apps/web/src/app/api/collections/[id]/tools/[toolId]/route.ts
Normal file
|
|
@ -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<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
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<NextResponse<ApiResponse>> {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
218
apps/web/src/app/api/collections/[id]/tools/route.ts
Normal file
218
apps/web/src/app/api/collections/[id]/tools/route.ts
Normal file
|
|
@ -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<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
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<NextResponse<ApiResponse>> {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
230
apps/web/src/app/api/collections/route.ts
Normal file
230
apps/web/src/app/api/collections/route.ts
Normal file
|
|
@ -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<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
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<NextResponse<ApiResponse>> {
|
||||
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<NextResponse<ApiResponse>> {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
358
apps/web/src/app/dashboard/collections/[id]/page.tsx
Normal file
358
apps/web/src/app/dashboard/collections/[id]/page.tsx
Normal file
|
|
@ -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<Collection | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [removingToolId, setRemovingToolId] = useState<string | null>(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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-4xl mx-auto py-12 px-4">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-surface-secondary rounded w-48 mb-4" />
|
||||
<div className="h-4 bg-surface-secondary rounded w-96 mb-8" />
|
||||
<div className="h-12 bg-surface-secondary rounded mb-6" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-24 bg-surface-secondary rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !collection) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-4xl mx-auto py-12 px-4">
|
||||
<div className="text-center py-16">
|
||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
|
||||
<p className="text-foreground-secondary mb-4">{error || 'Collection not found'}</p>
|
||||
<Link href="/dashboard/collections">
|
||||
<Button>Back to Collections</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const existingToolIds = collection.tools.map((t) => t.toolId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-4xl mx-auto py-12 px-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/collections"
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon icon="arrowLeft" size="sm" />
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<div className="bg-background border border-border rounded-lg p-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">Edit Collection</h2>
|
||||
<CollectionForm
|
||||
initialData={{
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
isPublic: collection.isPublic,
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
isSubmitting={isUpdating}
|
||||
submitLabel="Save Changes"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">{collection.name}</h1>
|
||||
{collection.isPublic && (
|
||||
<Badge variant="secondary" size="sm">
|
||||
<Icon icon="globe" size="sm" className="mr-1" />
|
||||
Public
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{collection.description && (
|
||||
<p className="text-foreground-secondary">{collection.description}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{collection.isOwner && !isEditing && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsEditing(true)}>
|
||||
<Icon icon="edit" size="sm" className="mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
loading={isDeleting}
|
||||
disabled={isDeleting}
|
||||
className="text-error hover:text-error hover:bg-error/10"
|
||||
>
|
||||
<Icon icon="trash" size="sm" className="mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm text-foreground-secondary mb-8">
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="box" size="sm" />
|
||||
{collection.toolCount} {collection.toolCount === 1 ? 'tool' : 'tools'}
|
||||
</span>
|
||||
<span>Updated {new Date(collection.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
{/* Add Tool Search */}
|
||||
{collection.isOwner && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-medium text-foreground mb-2">Add Tools</h2>
|
||||
<AddToolSearch
|
||||
collectionId={collection.id}
|
||||
existingToolIds={existingToolIds}
|
||||
onToolAdded={handleToolAdded}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools List */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground mb-3">Tools in this Collection</h2>
|
||||
<CollectionToolList
|
||||
tools={collection.tools}
|
||||
onRemove={collection.isOwner ? handleRemoveTool : undefined}
|
||||
removingId={removingToolId}
|
||||
isOwner={collection.isOwner}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
apps/web/src/app/dashboard/collections/page.tsx
Normal file
182
apps/web/src/app/dashboard/collections/page.tsx
Normal file
|
|
@ -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<Collection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-6xl mx-auto py-12 px-4">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-surface-secondary rounded w-48 mb-8" />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-48 bg-surface-secondary rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-6xl mx-auto py-12 px-4">
|
||||
<div className="text-center py-16">
|
||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
|
||||
<p className="text-foreground-secondary mb-4">{error}</p>
|
||||
<Button onClick={fetchCollections}>Try Again</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-6xl mx-auto py-12 px-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon icon="arrowLeft" size="sm" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-foreground">My Collections</h1>
|
||||
</div>
|
||||
{!showCreateForm && (
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<Icon icon="plus" size="sm" className="mr-2" />
|
||||
New Collection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-background border border-border rounded-lg p-6 mb-8">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">Create New Collection</h2>
|
||||
<CollectionForm
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
isSubmitting={isCreating}
|
||||
submitLabel="Create Collection"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collections List */}
|
||||
<CollectionList collections={collections} onDelete={handleDelete} deletingId={deletingId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<SignOutButton />
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 mb-8">
|
||||
<Link href="/dashboard/collections" className="block">
|
||||
<div className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<Icon icon="folder" size="md" className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-foreground">My Collections</h2>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Organize and share your favorite tools
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Profile Section */}
|
||||
<div className="bg-background border border-border rounded-lg p-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">Profile</h2>
|
||||
|
||||
|
|
|
|||
230
apps/web/src/components/collections/AddToolSearch.tsx
Normal file
230
apps/web/src/components/collections/AddToolSearch.tsx
Normal file
|
|
@ -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<SearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [addingId, setAddingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="relative">
|
||||
<Icon
|
||||
icon="search"
|
||||
size="sm"
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
|
||||
/>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search for tools to add..."
|
||||
className="pl-9"
|
||||
onFocus={() => {
|
||||
if (filteredResults.length > 0) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isSearching && (
|
||||
<Icon
|
||||
icon="loader"
|
||||
size="sm"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-tertiary animate-spin"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-error mt-2">{error}</p>}
|
||||
|
||||
{isOpen && filteredResults.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-background border border-border rounded-lg shadow-lg max-h-80 overflow-y-auto">
|
||||
{filteredResults.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="p-3 hover:bg-surface-secondary transition-colors border-b border-border last:border-b-0"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-foreground truncate">{tool.name}</span>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{tool.package.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary line-clamp-2">
|
||||
{tool.description}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-tertiary mt-1">
|
||||
{tool.package.npmPackageName}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAddTool(tool)}
|
||||
loading={addingId === tool.id}
|
||||
disabled={addingId !== null}
|
||||
>
|
||||
<Icon icon="plus" size="sm" className="mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && query.trim() && filteredResults.length === 0 && !isSearching && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-background border border-border rounded-lg shadow-lg p-4 text-center">
|
||||
<p className="text-foreground-secondary">
|
||||
{results.length > 0
|
||||
? 'All matching tools are already in this collection'
|
||||
: 'No tools found matching your search'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/web/src/components/collections/CollectionCard.tsx
Normal file
103
apps/web/src/components/collections/CollectionCard.tsx
Normal file
|
|
@ -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 (
|
||||
<Card variant="default" className="hover:border-foreground/20 transition-colors group">
|
||||
<CardHeader padding="md">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle as="h3" className="truncate">
|
||||
<Link
|
||||
href={`/dashboard/collections/${collection.id}`}
|
||||
className="hover:underline focus:underline focus:outline-none"
|
||||
>
|
||||
{collection.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
{collection.description && (
|
||||
<CardDescription className="line-clamp-2 mt-1">
|
||||
{collection.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{collection.isPublic && (
|
||||
<Badge variant="secondary" size="sm" className="flex-shrink-0">
|
||||
<Icon icon="globe" size="sm" className="mr-1" />
|
||||
Public
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent padding="md" className="pt-0">
|
||||
<div className="flex items-center gap-2 text-sm text-foreground-secondary">
|
||||
<Icon icon="box" size="sm" />
|
||||
<span>
|
||||
{collection.toolCount} {collection.toolCount === 1 ? 'tool' : 'tools'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter
|
||||
padding="md"
|
||||
className="flex justify-between items-center border-t border-border pt-3"
|
||||
>
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
Updated {updatedDate.toLocaleDateString()}
|
||||
</span>
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Link href={`/dashboard/collections/${collection.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Icon icon="edit" size="sm" className="mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(collection.id)}
|
||||
disabled={isDeleting}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Icon icon="trash" size="sm" className="mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
135
apps/web/src/components/collections/CollectionForm.tsx
Normal file
135
apps/web/src/components/collections/CollectionForm.tsx
Normal file
|
|
@ -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<void>;
|
||||
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<FormErrors>({});
|
||||
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<FormField
|
||||
label="Name"
|
||||
htmlFor="collection-name"
|
||||
required
|
||||
error={errors.name}
|
||||
state={errors.name ? 'error' : 'default'}
|
||||
>
|
||||
<Input
|
||||
id="collection-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Collection"
|
||||
state={errors.name ? 'error' : 'default'}
|
||||
disabled={isSubmitting}
|
||||
maxLength={100}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Description"
|
||||
htmlFor="collection-description"
|
||||
error={errors.description}
|
||||
state={errors.description ? 'error' : 'default'}
|
||||
helperText="Optional. Describe what this collection is for."
|
||||
>
|
||||
<Textarea
|
||||
id="collection-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="A collection of tools for..."
|
||||
state={errors.description ? 'error' : 'default'}
|
||||
disabled={isSubmitting}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="pt-2">
|
||||
<Checkbox
|
||||
id="collection-public"
|
||||
label="Make this collection public"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-foreground-tertiary mt-1 ml-7">
|
||||
Public collections can be viewed by anyone with the link
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" loading={isSubmitting} disabled={isSubmitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button type="button" variant="ghost" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/components/collections/CollectionList.tsx
Normal file
52
apps/web/src/components/collections/CollectionList.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
'use client';
|
||||
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { CollectionCard } from './CollectionCard';
|
||||
|
||||
interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
toolCount: number;
|
||||
isPublic: boolean;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
interface CollectionListProps {
|
||||
collections: Collection[];
|
||||
onDelete?: (id: string) => void;
|
||||
deletingId?: string | null;
|
||||
}
|
||||
|
||||
export function CollectionList({
|
||||
collections,
|
||||
onDelete,
|
||||
deletingId,
|
||||
}: CollectionListProps): React.ReactElement {
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-surface-secondary flex items-center justify-center mb-4">
|
||||
<Icon icon="folder" size="lg" className="text-foreground-tertiary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">No collections yet</h3>
|
||||
<p className="text-foreground-secondary max-w-sm">
|
||||
Create your first collection to organize and share your favorite tools.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{collections.map((collection) => (
|
||||
<CollectionCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
onDelete={onDelete}
|
||||
isDeleting={deletingId === collection.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/web/src/components/collections/CollectionToolList.tsx
Normal file
103
apps/web/src/components/collections/CollectionToolList.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
'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';
|
||||
|
||||
interface CollectionTool {
|
||||
id: string;
|
||||
toolId: string;
|
||||
position: number;
|
||||
note: string | null;
|
||||
addedAt: Date | string;
|
||||
tool: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
package: {
|
||||
npmPackageName: string;
|
||||
category: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface CollectionToolListProps {
|
||||
tools: CollectionTool[];
|
||||
onRemove?: (toolId: string) => void;
|
||||
removingId?: string | null;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
export function CollectionToolList({
|
||||
tools,
|
||||
onRemove,
|
||||
removingId,
|
||||
isOwner = true,
|
||||
}: CollectionToolListProps): React.ReactElement {
|
||||
if (tools.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center border border-dashed border-border rounded-lg">
|
||||
<div className="w-12 h-12 rounded-full bg-surface-secondary flex items-center justify-center mb-3">
|
||||
<Icon icon="box" size="md" className="text-foreground-tertiary" />
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">No tools added yet</h4>
|
||||
<p className="text-sm text-foreground-secondary max-w-xs">
|
||||
{isOwner
|
||||
? 'Use the search above to find and add tools to this collection.'
|
||||
: 'This collection is empty.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border border border-border rounded-lg overflow-hidden">
|
||||
{tools.map((collectionTool) => (
|
||||
<div
|
||||
key={collectionTool.id}
|
||||
className="p-4 bg-background hover:bg-surface-secondary/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link
|
||||
href={`/tool/${collectionTool.tool.package.npmPackageName}/${collectionTool.tool.name}`}
|
||||
className="font-medium text-foreground hover:underline focus:underline focus:outline-none"
|
||||
>
|
||||
{collectionTool.tool.name}
|
||||
</Link>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{collectionTool.tool.package.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary line-clamp-2 mb-2">
|
||||
{collectionTool.tool.description}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-tertiary">
|
||||
{collectionTool.tool.package.npmPackageName}
|
||||
</p>
|
||||
{collectionTool.note && (
|
||||
<p className="text-sm text-foreground-secondary mt-2 italic">
|
||||
“{collectionTool.note}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isOwner && onRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(collectionTool.toolId)}
|
||||
disabled={removingId !== null}
|
||||
loading={removingId === collectionTool.toolId}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-foreground-secondary hover:text-error"
|
||||
>
|
||||
<Icon icon="trash" size="sm" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -95,6 +95,7 @@ model Tool {
|
|||
// Relations
|
||||
simulations Simulation[]
|
||||
healthChecks HealthCheck[]
|
||||
collections CollectionTool[]
|
||||
|
||||
@@unique([packageId, name])
|
||||
@@index([qualityScore])
|
||||
|
|
@ -329,8 +330,9 @@ model User {
|
|||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
collections Collection[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
|
@ -386,3 +388,61 @@ model Verification {
|
|||
|
||||
@@map("verifications")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Collection Models
|
||||
// ============================================================================
|
||||
|
||||
/// Collection - user-created groups of tools
|
||||
model Collection {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Owner relationship
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Collection metadata
|
||||
name String @db.VarChar(100)
|
||||
description String? @db.VarChar(500)
|
||||
isPublic Boolean @default(false) @map("is_public")
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
tools CollectionTool[]
|
||||
|
||||
// Unique constraint: user can't have duplicate collection names
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
@@index([isPublic])
|
||||
@@index([createdAt])
|
||||
@@map("collections")
|
||||
}
|
||||
|
||||
/// CollectionTool - junction table for many-to-many relationship between collections and tools
|
||||
model CollectionTool {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Relationships
|
||||
collectionId String @map("collection_id")
|
||||
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
|
||||
toolId String @map("tool_id")
|
||||
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Ordering - allows users to reorder tools within a collection
|
||||
position Int @default(0)
|
||||
|
||||
// Optional user notes about why this tool is in the collection
|
||||
note String? @db.VarChar(500)
|
||||
|
||||
// Timestamps
|
||||
addedAt DateTime @default(now()) @map("added_at")
|
||||
|
||||
// Unique constraint: tool can only be in a collection once
|
||||
@@unique([collectionId, toolId])
|
||||
@@index([collectionId])
|
||||
@@index([toolId])
|
||||
@@map("collection_tools")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
"./tpmjs": {
|
||||
"types": "./dist/tpmjs.d.ts",
|
||||
"default": "./dist/tpmjs.js"
|
||||
},
|
||||
"./collection": {
|
||||
"types": "./dist/collection.d.ts",
|
||||
"default": "./dist/collection.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
|
|
|
|||
113
packages/types/src/collection.ts
Normal file
113
packages/types/src/collection.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Regex for valid collection names: letters, numbers, spaces, hyphens, underscores
|
||||
const NAME_REGEX = /^[a-zA-Z0-9\s\-_]+$/;
|
||||
|
||||
// ============================================================================
|
||||
// Collection Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const CreateCollectionSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(100, 'Name must be 100 characters or less')
|
||||
.regex(NAME_REGEX, 'Name can only contain letters, numbers, spaces, hyphens, and underscores'),
|
||||
description: z.string().max(500, 'Description must be 500 characters or less').optional(),
|
||||
isPublic: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const UpdateCollectionSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(100, 'Name must be 100 characters or less')
|
||||
.regex(NAME_REGEX, 'Name can only contain letters, numbers, spaces, hyphens, and underscores')
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.max(500, 'Description must be 500 characters or less')
|
||||
.nullable()
|
||||
.optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Collection Tool Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const AddToolToCollectionSchema = z.object({
|
||||
toolId: z.string().min(1, 'Tool ID is required'),
|
||||
note: z.string().max(500, 'Note must be 500 characters or less').optional(),
|
||||
position: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export const UpdateCollectionToolSchema = z.object({
|
||||
note: z.string().max(500, 'Note must be 500 characters or less').nullable().optional(),
|
||||
position: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export const ReorderToolsSchema = z.object({
|
||||
toolIds: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Response Types (for API responses)
|
||||
// ============================================================================
|
||||
|
||||
export const CollectionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
isPublic: z.boolean(),
|
||||
toolCount: z.number(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export const CollectionToolSchema = z.object({
|
||||
id: z.string(),
|
||||
toolId: z.string(),
|
||||
position: z.number(),
|
||||
note: z.string().nullable(),
|
||||
addedAt: z.date(),
|
||||
tool: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
package: z.object({
|
||||
id: z.string(),
|
||||
npmPackageName: z.string(),
|
||||
category: z.string(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CollectionWithToolsSchema = CollectionSchema.extend({
|
||||
tools: z.array(CollectionToolSchema),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Type Exports
|
||||
// ============================================================================
|
||||
|
||||
export type CreateCollectionInput = z.infer<typeof CreateCollectionSchema>;
|
||||
export type UpdateCollectionInput = z.infer<typeof UpdateCollectionSchema>;
|
||||
export type AddToolToCollectionInput = z.infer<typeof AddToolToCollectionSchema>;
|
||||
export type UpdateCollectionToolInput = z.infer<typeof UpdateCollectionToolSchema>;
|
||||
export type ReorderToolsInput = z.infer<typeof ReorderToolsSchema>;
|
||||
export type Collection = z.infer<typeof CollectionSchema>;
|
||||
export type CollectionTool = z.infer<typeof CollectionToolSchema>;
|
||||
export type CollectionWithTools = z.infer<typeof CollectionWithToolsSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
export const COLLECTION_LIMITS = {
|
||||
MAX_COLLECTIONS_PER_USER: 50,
|
||||
MAX_TOOLS_PER_COLLECTION: 100,
|
||||
MAX_NAME_LENGTH: 100,
|
||||
MAX_DESCRIPTION_LENGTH: 500,
|
||||
MAX_NOTE_LENGTH: 500,
|
||||
} as const;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/tool.ts', 'src/registry.ts', 'src/tpmjs.ts'],
|
||||
entry: ['src/tool.ts', 'src/registry.ts', 'src/tpmjs.ts', 'src/collection.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,46 @@ export const icons = {
|
|||
viewBox: '0 0 24 24',
|
||||
path: 'M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z',
|
||||
},
|
||||
folder: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z',
|
||||
},
|
||||
plus: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
|
||||
},
|
||||
trash: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z',
|
||||
},
|
||||
edit: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z',
|
||||
},
|
||||
box: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M20 3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H4V5h16v14z',
|
||||
},
|
||||
search: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z',
|
||||
},
|
||||
loader: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 4V2C6.48 2 2 6.48 2 12h2c0-4.41 3.59-8 8-8zm0 16c-4.41 0-8-3.59-8-8H2c0 5.52 4.48 10 10 10v-2zm8-8c0-4.41-3.59-8-8-8V2c5.52 0 10 4.48 10 10h-2zm-2 0c0 4.41-3.59 8-8 8v2c5.52 0 10-4.48 10-10h-2z',
|
||||
},
|
||||
arrowLeft: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
|
||||
},
|
||||
alertCircle: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z',
|
||||
},
|
||||
globe: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export interface IconProps extends Omit<SVGAttributes<SVGSVGElement>, 'children'
|
|||
* Size of the icon
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const iconVariants = createVariants({
|
|||
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'w-3 h-3', // 12px
|
||||
sm: 'w-4 h-4', // 16px
|
||||
md: 'w-5 h-5', // 20px
|
||||
lg: 'w-6 h-6', // 24px
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue