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:
Ajax Davis 2026-01-02 02:35:32 +10:00
parent f8740fb7ff
commit 137de1c353
19 changed files with 2346 additions and 4 deletions

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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>
);
}

View 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>
);
}

View file

@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">
&ldquo;{collectionTool.note}&rdquo;
</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>
);
}

View file

@ -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")
}

View file

@ -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"],

View 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;

View file

@ -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,

View file

@ -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;

View file

@ -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';
}
/**

View file

@ -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