Add community sharing feature v1

This commit is contained in:
akhisud3195 2025-09-14 23:13:39 +04:00
parent 33088fce56
commit 21f39000c0
14 changed files with 1647 additions and 176 deletions

View file

@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository';
import { authCheck } from '@/app/actions/auth.actions';
import { auth0 } from '@/app/lib/auth0';
import { USE_AUTH } from '@/app/lib/feature_flags';
const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository();
const ToggleLikeSchema = z.object({
liked: z.boolean(),
});
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Try to parse body, but don't require it for toggle functionality
let body = {};
try {
const text = await req.text();
if (text) {
body = JSON.parse(text);
}
} catch (error) {
// If no body or invalid JSON, continue with empty body
}
// Get user ID (works for both authenticated and guest users)
let userId: string;
let userEmail: string | undefined;
if (USE_AUTH) {
try {
const user = await authCheck();
userId = user.id;
userEmail = user.email;
} catch (error) {
// If not authenticated, use a consistent guest ID from headers or generate one
const guestId = req.headers.get('x-guest-id') || `guest-${crypto.randomUUID()}`;
userId = guestId;
}
} else {
// For development/testing without auth, use a consistent guest ID
const guestId = req.headers.get('x-guest-id') || `guest-${crypto.randomUUID()}`;
userId = guestId;
}
// Verify the assistant exists and is public
const assistant = await communityAssistantsRepo.fetch(id);
if (!assistant || !assistant.isPublic) {
return NextResponse.json(
{ error: 'Community assistant not found' },
{ status: 404 }
);
}
// Toggle the like
const result = await communityAssistantsRepo.toggleLike(id, userId, userEmail);
return NextResponse.json({
liked: result.liked,
likeCount: result.likeCount,
});
} catch (error) {
console.error('Error toggling like:', error);
return NextResponse.json(
{ error: 'Failed to toggle like' },
{ status: 500 }
);
}
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Get user ID (works for both authenticated and guest users)
let userId: string;
if (USE_AUTH) {
try {
const user = await authCheck();
userId = user.id;
} catch (error) {
// If not authenticated, generate a guest ID
userId = `guest-${crypto.randomUUID()}`;
}
} else {
// For development/testing without auth
userId = `guest-${crypto.randomUUID()}`;
}
// Get like count and user's like status
const [likeCount, userLiked] = await Promise.all([
communityAssistantsRepo.getLikeCount(id),
communityAssistantsRepo.getUserLikes(id, userId),
]);
return NextResponse.json({
likeCount,
liked: userLiked,
});
} catch (error) {
console.error('Error getting like status:', error);
return NextResponse.json(
{ error: 'Failed to get like status' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository';
import { authCheck } from '@/app/actions/auth.actions';
import { USE_AUTH } from '@/app/lib/feature_flags';
const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository();
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const assistant = await communityAssistantsRepo.fetch(id);
if (!assistant) {
return NextResponse.json(
{ error: 'Community assistant not found' },
{ status: 404 }
);
}
// Only return public assistants
if (!assistant.isPublic) {
return NextResponse.json(
{ error: 'Community assistant not found' },
{ status: 404 }
);
}
return NextResponse.json(assistant);
} catch (error) {
console.error('Error fetching community assistant:', error);
return NextResponse.json(
{ error: 'Failed to fetch community assistant' },
{ status: 500 }
);
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Get authenticated user
let user;
if (USE_AUTH) {
user = await authCheck();
} else {
// For development/testing without auth
user = { id: 'guest', email: 'guest@example.com' };
}
const { id } = await params;
const body = await req.json();
const { action } = body;
if (action === 'import') {
// Import the community assistant as a new project
const assistant = await communityAssistantsRepo.fetch(id);
if (!assistant || !assistant.isPublic) {
return NextResponse.json(
{ error: 'Community assistant not found' },
{ status: 404 }
);
}
// Increment download count
await communityAssistantsRepo.incrementDownloadCount(id);
// Return the workflow data for project creation
return NextResponse.json({
workflow: assistant.workflow,
name: assistant.name,
description: assistant.description,
});
}
return NextResponse.json(
{ error: 'Invalid action' },
{ status: 400 }
);
} catch (error) {
console.error('Error processing community assistant action:', error);
return NextResponse.json(
{ error: 'Failed to process action' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository';
const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository();
export async function GET(req: NextRequest) {
try {
const categories = await communityAssistantsRepo.getCategories();
return NextResponse.json({ categories });
} catch (error) {
console.error('Error fetching categories:', error);
return NextResponse.json(
{ error: 'Failed to fetch categories' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository';
import { CommunityAssistant } from '@/src/entities/models/community-assistant';
import { authCheck } from '@/app/actions/auth.actions';
import { auth0 } from '@/app/lib/auth0';
import { USE_AUTH } from '@/app/lib/feature_flags';
const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository();
// Schema for creating a community assistant
const CreateCommunityAssistantSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().min(1).max(500),
category: z.string().min(1),
tags: z.array(z.string()).max(10),
isAnonymous: z.boolean().default(false),
workflow: z.any(), // Will be validated against Workflow schema
copilotPrompt: z.string().optional(),
thumbnailUrl: z.string().url().optional(),
estimatedComplexity: z.enum(['beginner', 'intermediate', 'advanced']).default('beginner'),
});
// Schema for listing community assistants
const ListCommunityAssistantsSchema = z.object({
category: z.string().optional(),
search: z.string().optional(),
featured: z.boolean().optional(),
cursor: z.string().optional(),
limit: z.number().min(1).max(50).default(20),
});
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const params = ListCommunityAssistantsSchema.parse({
category: searchParams.get('category') || undefined,
search: searchParams.get('search') || undefined,
featured: searchParams.get('featured') ? searchParams.get('featured') === 'true' : undefined,
cursor: searchParams.get('cursor') || undefined,
limit: searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : 20,
});
const result = await communityAssistantsRepo.list({
category: params.category,
search: params.search,
featured: params.featured,
isPublic: true, // Only show public assistants
}, params.cursor, params.limit);
return NextResponse.json(result);
} catch (error) {
console.error('Error listing community assistants:', error);
return NextResponse.json(
{ error: 'Failed to list community assistants' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
// Get authenticated user
let user;
if (USE_AUTH) {
user = await authCheck();
} else {
// For development/testing without auth
user = { id: 'guest', email: 'guest@example.com' };
}
const body = await req.json();
const data = CreateCommunityAssistantSchema.parse(body);
// Get user display name from Auth0 session
let authorName = 'Anonymous';
let authorEmail: string | undefined;
if (USE_AUTH) {
try {
const { user: auth0User } = await auth0.getSession() || {};
if (auth0User) {
authorName = auth0User.name ?? auth0User.email ?? 'Anonymous';
authorEmail = auth0User.email;
}
} catch (error) {
console.warn('Could not get Auth0 user info:', error);
}
}
// Override with user choice
if (!data.isAnonymous) {
authorName = data.isAnonymous ? 'Anonymous' : authorName;
} else {
authorName = 'Anonymous';
authorEmail = undefined;
}
const communityAssistant = await communityAssistantsRepo.create({
name: data.name,
description: data.description,
category: data.category,
authorId: user.id,
authorName,
authorEmail,
isAnonymous: data.isAnonymous,
workflow: data.workflow,
tags: data.tags,
copilotPrompt: data.copilotPrompt,
thumbnailUrl: data.thumbnailUrl,
estimatedComplexity: data.estimatedComplexity,
publishedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
downloadCount: 0,
likeCount: 0,
featured: false,
isPublic: true,
likes: [],
});
return NextResponse.json(communityAssistant);
} catch (error) {
console.error('Error creating community assistant:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Failed to create community assistant' },
{ status: 500 }
);
}
}

View file

@ -1,10 +1,12 @@
"use client";
"use client";
import React from "react";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input, ButtonGroup, Checkbox, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input, ButtonGroup, Checkbox, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure, Textarea, Select, SelectItem, Chip, Radio, RadioGroup } from "@heroui/react";
import { Button as CustomButton } from "@/components/ui/button";
import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon, Clock, Plug, MessageCircleIcon, ShareIcon } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { ProgressBar, ProgressStep } from "@/components/ui/progress-bar";
import { useUser } from '@auth0/nextjs-auth0';
import { useState } from "react";
interface TopBarProps {
localProjectName: string;
@ -42,6 +44,21 @@ interface TopBarProps {
onShareWorkflow: () => void;
shareUrl: string | null;
onCopyShareUrl: () => void;
shareMode: 'url' | 'community';
setShareMode: (mode: 'url' | 'community') => void;
communityData: {
name: string;
description: string;
category: string;
tags: string[];
isAnonymous: boolean;
copilotPrompt: string;
estimatedComplexity: 'beginner' | 'intermediate' | 'advanced';
};
setCommunityData: (data: any) => void;
onCommunityPublish: () => void;
communityPublishing: boolean;
communityPublishSuccess: boolean;
}
export function TopBar({
@ -80,6 +97,13 @@ export function TopBar({
onShareWorkflow,
shareUrl,
onCopyShareUrl,
shareMode,
setShareMode,
communityData,
setCommunityData,
onCommunityPublish,
communityPublishing,
communityPublishSuccess,
}: TopBarProps) {
const router = useRouter();
const params = useParams();
@ -87,11 +111,27 @@ export function TopBar({
// Share modal state
const { isOpen: isShareModalOpen, onOpen: onShareModalOpen, onClose: onShareModalClose } = useDisclosure();
const [copyButtonText, setCopyButtonText] = useState('Copy');
const handleShareClick = () => {
onShareWorkflow(); // Call the original share function to generate URL
onShareModalOpen(); // Open the modal
};
const handleCopyUrl = () => {
onCopyShareUrl(); // Call the original copy function
setCopyButtonText('Copied!');
setTimeout(() => {
setCopyButtonText('Copy');
}, 2000); // Reset after 2 seconds
};
const { user } = useUser();
const getUserDisplayName = () => {
if (!user) return 'Anonymous';
return user.name ?? user.email ?? 'Anonymous';
};
// Progress bar steps with completion logic and current step detection
const step1Complete = hasAgentInstructionChanges;
@ -596,46 +636,205 @@ export function TopBar({
</div>
{/* Share Modal */}
<Modal isOpen={isShareModalOpen} onClose={onShareModalClose} size="lg">
<Modal
isOpen={isShareModalOpen}
onClose={onShareModalClose}
size="2xl"
scrollBehavior="inside"
classNames={{
base: "bg-white dark:bg-gray-900 max-h-[90vh]",
header: "border-b border-gray-200 dark:border-gray-700 pb-4 flex-shrink-0",
body: "py-6 overflow-y-auto flex-1",
footer: "border-t border-gray-200 dark:border-gray-700 pt-4 flex-shrink-0"
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
Share Assistant
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Share Assistant</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 font-normal">Choose how you&apos;d like to share your assistant</p>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Share this assistant with others using the URL below:
</p>
{shareUrl ? (
<div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<input
type="text"
value={shareUrl || ''}
readOnly
className="flex-1 bg-transparent text-sm text-gray-700 dark:text-gray-300 outline-none"
/>
<Button
size="sm"
variant="solid"
onPress={onCopyShareUrl}
className="bg-indigo-100 hover:bg-indigo-200 text-indigo-800"
>
Copy
</Button>
<div className="space-y-8">
{/* Quick Share Section */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<ShareIcon size={16} className="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">Quick Share</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Share with a direct link</p>
</div>
</div>
) : (
<div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<Spinner size="sm" />
<span className="text-sm text-gray-600 dark:text-gray-400">
Generating share URL...
</span>
{shareUrl ? (
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
<div className="flex-1 min-w-0">
<input
type="text"
value={shareUrl || ''}
readOnly
className="w-full bg-transparent text-sm text-gray-700 dark:text-gray-300 outline-none font-mono focus:outline-none !focus:ring-0 !focus:ring-offset-0 !ring-0 !ring-offset-0"
/>
</div>
<Button
size="sm"
variant="solid"
onPress={handleCopyUrl}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium"
>
{copyButtonText}
</Button>
</div>
) : (
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
<Spinner size="sm" />
<span className="text-sm text-gray-500 dark:text-gray-400">
Generating share URL...
</span>
</div>
)}
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200 dark:border-gray-700"></div>
</div>
)}
<div className="relative flex justify-center">
<span className="px-4 bg-white dark:bg-gray-900 text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">or</span>
</div>
</div>
{/* Community Publishing Section */}
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<MessageCircleIcon size={16} className="text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">Publish to Community</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Make it discoverable by others</p>
</div>
</div>
<div className="space-y-5">
{/* Assistant Name */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Assistant Name <span className="text-red-500">*</span>
</label>
<Input
placeholder="Enter assistant name"
value={communityData.name}
onChange={(e) => setCommunityData({ ...communityData, name: e.target.value })}
classNames={{
input: "text-sm focus:outline-none !focus:ring-0 !focus:ring-offset-0 !ring-0 !ring-offset-0",
inputWrapper: "border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 focus-within:border-gray-300 dark:focus-within:border-gray-500 !focus-within:ring-0 !focus-within:ring-offset-0 !ring-0 !ring-offset-0"
}}
/>
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Description <span className="text-red-500">*</span>
</label>
<Textarea
placeholder="Describe what this assistant does..."
value={communityData.description}
onChange={(e) => setCommunityData({ ...communityData, description: e.target.value })}
minRows={3}
classNames={{
input: "text-sm focus:outline-none !focus:ring-0 !focus:ring-offset-0 !ring-0 !ring-offset-0",
inputWrapper: "border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 focus-within:border-gray-300 dark:focus-within:border-gray-500 !focus-within:ring-0 !focus-within:ring-offset-0 !ring-0 !ring-offset-0"
}}
/>
</div>
{/* Category */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Category <span className="text-red-500">*</span>
</label>
<Select
placeholder="Select a category"
selectedKeys={communityData.category ? [communityData.category] : []}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as string;
setCommunityData({ ...communityData, category: selected });
}}
classNames={{
trigger: "border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 focus:outline-none !focus:ring-0 !focus:ring-offset-0 !ring-0 !ring-offset-0 focus-within:border-gray-300 dark:focus-within:border-gray-500 !focus-within:ring-0 !focus-within:ring-offset-0",
value: "text-sm"
}}
>
<SelectItem key="Work Productivity">Work Productivity</SelectItem>
<SelectItem key="Developer Productivity">Developer Productivity</SelectItem>
<SelectItem key="News & Social">News & Social</SelectItem>
<SelectItem key="Customer Support">Customer Support</SelectItem>
<SelectItem key="Education">Education</SelectItem>
<SelectItem key="Entertainment">Entertainment</SelectItem>
<SelectItem key="Other">Other</SelectItem>
</Select>
</div>
{/* Privacy Toggle */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800/30 rounded-xl border border-gray-200 dark:border-gray-700">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{communityData.isAnonymous ? 'Publish anonymously' : `Publish as ${getUserDisplayName()}`}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{communityData.isAnonymous ? 'Your name will be hidden from the community' : 'Your name will be visible to the community'}
</div>
</div>
<button
type="button"
onClick={() => setCommunityData({ ...communityData, isAnonymous: !communityData.isAnonymous })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
communityData.isAnonymous ? 'bg-gray-300 dark:bg-gray-600' : 'bg-blue-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
communityData.isAnonymous ? 'translate-x-1' : 'translate-x-6'
}`}
/>
</button>
</div>
{/* Success Message */}
{communityPublishSuccess && (
<div className="flex items-center gap-3 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl">
<div className="w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/40 flex items-center justify-center">
<span className="text-green-600 dark:text-green-400 text-xs"></span>
</div>
<p className="text-green-700 dark:text-green-300 text-sm font-medium">
Successfully published to community!
</p>
</div>
)}
</div>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onShareModalClose}>
Close
<ModalFooter className="gap-3">
<Button
variant="light"
onPress={onShareModalClose}
className="px-6 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Cancel
</Button>
<Button
color="primary"
onPress={onCommunityPublish}
isLoading={communityPublishing}
isDisabled={!communityData.name.trim() || !communityData.description.trim() || !communityData.category}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
>
{communityPublishing ? 'Publishing...' : 'Publish to Community'}
</Button>
</ModalFooter>
</ModalContent>

View file

@ -1633,6 +1633,52 @@ export function WorkflowEditor({
setTimeout(() => setShowCopySuccess(false), 2000);
}
// Community publishing functions
const [shareMode, setShareMode] = useState<'url' | 'community'>('url');
const [communityData, setCommunityData] = useState({
name: projectConfig.name || '',
description: '',
category: '',
tags: [] as string[],
isAnonymous: false,
copilotPrompt: '',
estimatedComplexity: 'beginner' as 'beginner' | 'intermediate' | 'advanced',
});
const [communityPublishing, setCommunityPublishing] = useState(false);
const [communityPublishSuccess, setCommunityPublishSuccess] = useState(false);
const handleCommunityPublish = async () => {
if (!communityData.name.trim() || !communityData.description.trim() || !communityData.category) {
return;
}
setCommunityPublishing(true);
try {
const response = await fetch('/api/community-assistants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...communityData,
workflow: state.present.workflow, // Use the current workflow
}),
});
if (!response.ok) {
throw new Error('Failed to publish to community');
}
setCommunityPublishSuccess(true);
setTimeout(() => {
setCommunityPublishSuccess(false);
// Close modal or reset
}, 2000);
} catch (error) {
console.error('Error publishing to community:', error);
} finally {
setCommunityPublishing(false);
}
};
// Cleanup blob URL on unmount
// No-op cleanup; shareUrl is a normal URL now
@ -1949,6 +1995,13 @@ export function WorkflowEditor({
onShareWorkflow={handleShareWorkflow}
shareUrl={shareUrl}
onCopyShareUrl={handleCopyShareUrl}
shareMode={shareMode}
setShareMode={setShareMode}
communityData={communityData}
setCommunityData={setCommunityData}
onCommunityPublish={handleCommunityPublish}
communityPublishing={communityPublishing}
communityPublishSuccess={communityPublishSuccess}
onPublishWorkflow={handlePublishWorkflow}
onChangeMode={onChangeMode}
onRevertToLive={handleRevertToLive}

View file

@ -16,6 +16,8 @@ import { Tabs, Tab } from "@/components/ui/tabs";
import { Project } from "@/src/entities/models/project";
import { z } from "zod";
import Link from 'next/link';
import { CommunitySection } from '@/components/community/CommunitySection';
import { AssistantSection } from '@/components/common/AssistantSection';
const SHOW_PREBUILT_CARDS = process.env.NEXT_PUBLIC_SHOW_PREBUILT_CARDS !== 'false';
@ -291,7 +293,9 @@ export function BuildAssistantSection() {
{/* Tabs Section */}
<div className="max-w-5xl mx-auto">
<div className="p-6 pb-0">
<Tabs defaultSelectedKey="new" selectedKey={selectedTab} onSelectionChange={(key) => setSelectedTab(key as string)} className="w-full">
<Tabs defaultSelectedKey="new" selectedKey={selectedTab} onSelectionChange={(key) => {
setSelectedTab(key as string);
}} className="w-full">
<Tab key="new" title="New Assistant">
<div className="pt-4">
<div className="flex items-center gap-12">
@ -463,149 +467,31 @@ export function BuildAssistantSection() {
{/* Pre-built Assistants Section - Only show for New Assistant tab */}
{selectedTab === 'new' && SHOW_PREBUILT_CARDS && (
<div className="max-w-5xl mx-auto mt-16">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
<div className="text-left mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Prebuilt Assistants
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Start quickly and let Skipper adapt it to your needs.
</p>
</div>
{templatesLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-500 dark:text-gray-400">
Loading pre-built assistants...
</div>
) : templatesError ? (
<div className="flex items-center justify-center py-12 text-sm text-red-500 dark:text-red-400">
Error: {templatesError}
</div>
) : templates.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-500 dark:text-gray-400">
No pre-built assistants available
</div>
) : (
(() => {
const workTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'work productivity');
const devTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'developer productivity');
const newsTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'news & social');
const customerSupportTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'customer support');
const renderGrid = (items: any[]) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((template) => (
<button
key={template.id}
onClick={() => handleTemplateSelect(template)}
disabled={loadingTemplateId === template.id}
className={clsx(
"relative block p-4 border border-gray-200 dark:border-gray-700 rounded-xl transition-all group text-left",
"hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-md",
loadingTemplateId === template.id && "opacity-90 cursor-not-allowed"
)}
>
<div className="space-y-2">
<div className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-1">
{template.name}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{template.description}
</div>
{(() => {
const tools = getUniqueTools(template);
return tools.length > 0 && (
<div className="flex items-center gap-2 mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500">
Tools:
</div>
<div className="flex items-center gap-1">
{tools.slice(0, 4).map((tool) => (
tool.logo && (
<PictureImg
key={tool.name}
src={tool.logo}
alt={`${tool.name} logo`}
className="w-4 h-4 rounded-sm object-cover flex-shrink-0"
title={tool.name}
/>
)
))}
{tools.length > 4 && (
<span className="text-xs text-gray-400 dark:text-gray-500">
+{tools.length - 4}
</span>
)}
</div>
</div>
);
})()}
<div className="flex items-center justify-between mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500"></div>
{loadingTemplateId === template.id ? (
<div className="text-blue-600 dark:text-blue-400">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
</div>
) : (
<div className="w-2 h-2 rounded-full bg-blue-500 opacity-75"></div>
)}
</div>
</div>
</button>
))}
</div>
);
return (
<div className="space-y-8">
{workTemplates.length > 0 && (
<div>
<div className="mb-3">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/30">
Work Productivity
</span>
</div>
{renderGrid(workTemplates)}
</div>
)}
{devTemplates.length > 0 && (
<div>
<div className="mb-3">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-400/10 dark:text-indigo-300 dark:ring-indigo-400/30">
Developer Productivity
</span>
</div>
{renderGrid(devTemplates)}
</div>
)}
{newsTemplates.length > 0 && (
<div>
<div className="mb-3">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-50 text-green-700 ring-1 ring-green-200 dark:bg-green-400/10 dark:text-green-300 dark:ring-green-400/30">
News & Social
</span>
</div>
{renderGrid(newsTemplates)}
</div>
)}
{customerSupportTemplates.length > 0 && (
<div>
<div className="mb-3">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-red-50 text-red-700 ring-1 ring-red-200 dark:bg-red-400/10 dark:text-red-300 dark:ring-red-400/30">
Customer Support
</span>
</div>
{renderGrid(customerSupportTemplates)}
</div>
)}
</div>
);
})()
)}
<AssistantSection
title="Prebuilt Assistants"
description="Start quickly and let Skipper adapt it to your needs."
items={templates.map(template => ({
id: template.id,
name: template.name,
description: template.description,
category: template.category || 'Other',
tools: template.tools,
estimatedComplexity: template.estimatedComplexity
}))}
loading={templatesLoading}
error={templatesError}
onItemClick={handleTemplateSelect}
loadingItemId={loadingTemplateId}
emptyMessage="No pre-built assistants available"
getUniqueTools={getUniqueTools}
/>
</div>
</div>
)}
{/* Community Assistants Section */}
<div className="max-w-5xl mx-auto mt-16">
<CommunitySection />
</div>
</div>
</div>
)}

View file

@ -0,0 +1,234 @@
'use client';
import React from 'react';
import { clsx } from 'clsx';
import { PictureImg } from '@/components/ui/picture-img';
import { Heart, Share2, Calendar } from 'lucide-react';
// Helper function to get relative time
const getRelativeTime = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return 'just now';
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `${diffInMinutes} minute${diffInMinutes === 1 ? '' : 's'} ago`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `${diffInHours} hour${diffInHours === 1 ? '' : 's'} ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) {
return `${diffInDays} day${diffInDays === 1 ? '' : 's'} ago`;
}
const diffInWeeks = Math.floor(diffInDays / 7);
if (diffInWeeks < 4) {
return `${diffInWeeks} week${diffInWeeks === 1 ? '' : 's'} ago`;
}
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) {
return `${diffInMonths} month${diffInMonths === 1 ? '' : 's'} ago`;
}
const diffInYears = Math.floor(diffInDays / 365);
return `${diffInYears} year${diffInYears === 1 ? '' : 's'} ago`;
};
interface AssistantCardProps {
id: string;
name: string;
description: string;
category: string;
tools?: Array<{
name: string;
logo?: string;
}>;
// Community-specific props
authorName?: string;
isAnonymous?: boolean;
likeCount?: number;
createdAt?: string;
onLike?: () => void;
onShare?: () => void;
isLiked?: boolean;
// Pre-built specific props
estimatedComplexity?: string;
// Common props
onClick?: () => void;
loading?: boolean;
disabled?: boolean;
getUniqueTools?: (item: any) => Array<{ name: string; logo?: string }>;
}
export function AssistantCard({
id,
name,
description,
category,
tools = [],
authorName,
isAnonymous = false,
likeCount = 0,
createdAt,
onLike,
onShare,
isLiked = false,
estimatedComplexity,
onClick,
loading = false,
disabled = false,
getUniqueTools
}: AssistantCardProps) {
const displayTools = getUniqueTools ? getUniqueTools({ tools }) : tools;
const isCommunity = authorName !== undefined;
const getCategoryColor = (category: string) => {
const lowerCategory = category.toLowerCase();
if (lowerCategory.includes('work productivity')) {
return 'bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/30';
} else if (lowerCategory.includes('developer productivity')) {
return 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-400/10 dark:text-indigo-300 dark:ring-indigo-400/30';
} else if (lowerCategory.includes('news') || lowerCategory.includes('social')) {
return 'bg-green-50 text-green-700 ring-1 ring-green-200 dark:bg-green-400/10 dark:text-green-300 dark:ring-green-400/30';
} else if (lowerCategory.includes('customer support')) {
return 'bg-red-50 text-red-700 ring-1 ring-red-200 dark:bg-red-400/10 dark:text-red-300 dark:ring-red-400/30';
} else if (lowerCategory.includes('education')) {
return 'bg-blue-50 text-blue-700 ring-1 ring-blue-200 dark:bg-blue-400/10 dark:text-blue-300 dark:ring-blue-400/30';
} else if (lowerCategory.includes('entertainment')) {
return 'bg-purple-50 text-purple-700 ring-1 ring-purple-200 dark:bg-purple-400/10 dark:text-purple-300 dark:ring-purple-400/30';
} else {
return 'bg-gray-50 text-gray-700 ring-1 ring-gray-200 dark:bg-gray-400/10 dark:text-gray-300 dark:ring-gray-400/30';
}
};
return (
<div
onClick={onClick}
className={clsx(
"relative block p-4 border border-gray-200 dark:border-gray-700 rounded-xl transition-all group text-left cursor-pointer",
"hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-md",
loading && "opacity-90 cursor-not-allowed",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<div className="space-y-3">
{/* Title and Description */}
<div>
<div className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-1">
{name}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1">
{description}
</div>
</div>
{/* Category Badge */}
<div className="flex items-center justify-between">
<span className={clsx(
"inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold",
getCategoryColor(category)
)}>
{category}
</span>
{loading && (
<div className="text-blue-600 dark:text-blue-400">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
</div>
)}
</div>
{/* Community-specific info */}
{isCommunity && (
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-2">
<span>{isAnonymous ? 'Anonymous' : (authorName || 'Unknown')}</span>
{createdAt && (
<div className="flex items-center gap-1">
<Calendar size={12} />
<span>{getRelativeTime(createdAt)}</span>
</div>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onLike?.();
}}
className={clsx(
"flex items-center gap-1 hover:text-red-500 transition-colors",
isLiked && "text-red-500"
)}
>
<Heart size={14} className={isLiked ? "fill-current" : ""} />
<span>{likeCount}</span>
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onShare?.();
}}
className="flex items-center gap-1 hover:text-blue-500 transition-colors"
>
<Share2 size={14} />
</button>
</div>
</div>
)}
{/* Tools */}
{displayTools.length > 0 && (
<div className="flex items-center gap-2">
<div className="text-xs text-gray-400 dark:text-gray-500">
Tools:
</div>
<div className="flex items-center gap-1">
{displayTools.slice(0, 4).map((tool) => (
tool.logo && (
<PictureImg
key={tool.name}
src={tool.logo}
alt={`${tool.name} logo`}
className="w-4 h-4 rounded-sm object-cover flex-shrink-0"
title={tool.name}
/>
)
))}
{displayTools.length > 4 && (
<span className="text-xs text-gray-400 dark:text-gray-500">
+{displayTools.length - 4}
</span>
)}
</div>
</div>
)}
{/* Complexity for pre-built templates */}
{estimatedComplexity && (
<div className="mt-2">
<span className={clsx(
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
estimatedComplexity === 'beginner' && "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400",
estimatedComplexity === 'intermediate' && "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400",
estimatedComplexity === 'advanced' && "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400"
)}>
{estimatedComplexity}
</span>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,241 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Input } from "@heroui/react";
import { Search } from 'lucide-react';
import { AssistantCard } from './AssistantCard';
interface AssistantItem {
id: string;
name: string;
description: string;
category: string;
tools?: Array<{
name: string;
logo?: string;
}>;
// Community-specific
authorName?: string;
isAnonymous?: boolean;
likeCount?: number;
createdAt?: string;
isLiked?: boolean;
// Pre-built specific
estimatedComplexity?: string;
}
interface AssistantSectionProps {
title: string;
description: string;
items: AssistantItem[];
loading?: boolean;
error?: string | null;
onItemClick?: (item: AssistantItem) => void;
onRetry?: () => void;
loadingItemId?: string | null;
emptyMessage?: string;
// Community-specific callbacks
onLike?: (item: AssistantItem) => void;
onShare?: (item: AssistantItem) => void;
// Pre-built specific
getUniqueTools?: (item: AssistantItem) => Array<{ name: string; logo?: string }>;
// Filter state
initialSearchQuery?: string;
initialSelectedCategory?: string;
onFiltersChange?: (filters: {
searchQuery: string;
selectedCategory: string;
}) => void;
}
export function AssistantSection({
title,
description,
items,
loading = false,
error = null,
onItemClick,
onRetry,
loadingItemId = null,
emptyMessage = "No assistants available",
onLike,
onShare,
getUniqueTools,
initialSearchQuery = '',
initialSelectedCategory = '',
onFiltersChange
}: AssistantSectionProps) {
const [searchQuery, setSearchQuery] = useState(initialSearchQuery);
const [selectedCategory, setSelectedCategory] = useState(initialSelectedCategory);
// Notify parent of filter changes if callback provided
useEffect(() => {
if (onFiltersChange) {
onFiltersChange({
searchQuery,
selectedCategory
});
}
}, [searchQuery, selectedCategory, onFiltersChange]);
// Get available categories from items
const availableCategories = React.useMemo(() => {
const categories = new Set(items.map(item => item.category));
return Array.from(categories).sort();
}, [items]);
// Filter items
const filteredItems = React.useMemo(() => {
let filtered = [...items];
// Apply search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(item =>
item.name.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query) ||
item.category.toLowerCase().includes(query)
);
}
// Apply category filter
if (selectedCategory) {
filtered = filtered.filter(item => item.category === selectedCategory);
}
return filtered;
}, [items, searchQuery, selectedCategory]);
const isCommunity = items.length > 0 && items[0].authorName !== undefined;
if (loading) {
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
<div className="text-left mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
{title}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
</div>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-gray-500 dark:text-gray-400 mt-2">Loading assistants...</p>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
<div className="text-left mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
{title}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
</div>
<div className="text-center py-12">
<p className="text-red-500 dark:text-red-400">{error}</p>
{onRetry && (
<button
onClick={onRetry}
className="mt-4 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Try Again
</button>
)}
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
<div className="text-left mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
{title}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<Input
placeholder="Search assistants..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
startContent={<Search size={16} className="text-gray-400" />}
className="max-w-md"
classNames={{
input: "focus:outline-none focus:ring-0 focus:border-gray-300 dark:focus:border-gray-600",
inputWrapper: "focus-within:ring-0 focus-within:ring-offset-0 focus-within:border-gray-300 dark:focus-within:border-gray-600"
}}
/>
</div>
<div className="flex gap-2">
<div className="relative">
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-48 px-3 py-2 pr-10 border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 appearance-none text-sm"
>
<option value="">All Categories</option>
{availableCategories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
</div>
{/* Grid */}
{filteredItems.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">{emptyMessage}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredItems.map((item) => (
<AssistantCard
key={item.id}
id={item.id}
name={item.name}
description={item.description}
category={item.category}
tools={item.tools}
authorName={item.authorName}
isAnonymous={item.isAnonymous}
likeCount={item.likeCount}
createdAt={item.createdAt}
estimatedComplexity={item.estimatedComplexity}
onClick={() => onItemClick?.(item)}
loading={loadingItemId === item.id}
getUniqueTools={getUniqueTools}
onLike={onLike ? () => onLike(item) : undefined}
onShare={onShare ? () => onShare(item) : undefined}
isLiked={item.isLiked}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,226 @@
'use client';
import React, { useState, useEffect } from 'react';
import { AssistantSection } from '@/components/common/AssistantSection';
import { useRouter } from 'next/navigation';
import { createProjectFromJsonWithOptions } from '@/app/projects/lib/project-creation-utils';
interface CommunityAssistant {
id: string;
name: string;
description: string;
category: string;
authorId: string;
authorName: string;
authorEmail?: string | null;
isAnonymous: boolean;
workflow: any;
tags: string[];
publishedAt: string;
lastUpdatedAt: string;
downloadCount: number;
likeCount: number;
featured: boolean;
isPublic: boolean;
likes: string[];
copilotPrompt?: string;
thumbnailUrl?: string | null;
estimatedComplexity: 'beginner' | 'intermediate' | 'advanced';
}
interface CommunitySectionProps {
onImport?: (assistant: CommunityAssistant) => void;
}
export function CommunitySection({ onImport }: CommunitySectionProps) {
const [assistants, setAssistants] = useState<CommunityAssistant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [importingIds, setImportingIds] = useState<Set<string>>(new Set());
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
const router = useRouter();
// Fetch community assistants
const fetchAssistants = async (filters?: { searchQuery: string; selectedCategory: string }) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (filters?.searchQuery) params.append('search', filters.searchQuery);
if (filters?.selectedCategory) params.append('category', filters.selectedCategory);
const url = `/api/community-assistants?${params}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch assistants');
const data = await response.json();
setAssistants(data.items || []);
} catch (err) {
console.error('Error fetching assistants:', err);
setError(err instanceof Error ? err.message : 'Failed to load assistants');
} finally {
setLoading(false);
}
};
// Load guest likes from session storage
const loadGuestLikes = () => {
try {
const stored = sessionStorage.getItem('guestLikes');
if (stored) {
const likes = JSON.parse(stored);
setLikedIds(new Set(likes));
}
} catch (err) {
console.error('Error loading guest likes:', err);
}
};
// Save guest likes to session storage
const saveGuestLikes = (likes: Set<string>) => {
try {
sessionStorage.setItem('guestLikes', JSON.stringify(Array.from(likes)));
} catch (err) {
console.error('Error saving guest likes:', err);
}
};
// Get or create consistent guest ID
const getGuestId = () => {
try {
let guestId = sessionStorage.getItem('guestId');
if (!guestId) {
guestId = `guest-${crypto.randomUUID()}`;
sessionStorage.setItem('guestId', guestId);
}
return guestId;
} catch (err) {
// Fallback if sessionStorage is not available
return `guest-${crypto.randomUUID()}`;
}
};
// Handle like toggle
const handleLike = async (item: any) => {
const assistant = assistants.find(a => a.id === item.id);
if (!assistant) return;
try {
const guestId = getGuestId();
const response = await fetch(`/api/community-assistants/${assistant.id}/like`, {
method: 'POST',
headers: {
'x-guest-id': guestId,
},
});
if (response.ok) {
const data = await response.json();
setLikedIds(prev => {
const newSet = new Set(prev);
if (data.liked) {
newSet.add(assistant.id);
} else {
newSet.delete(assistant.id);
}
saveGuestLikes(newSet);
return newSet;
});
// Update the assistant's like count
setAssistants(prev => prev.map(a =>
a.id === assistant.id
? { ...a, likeCount: data.likeCount }
: a
));
}
} catch (err) {
console.error('Error toggling like:', err);
}
};
// Handle share
const handleShare = (item: any) => {
const assistant = assistants.find(a => a.id === item.id);
if (!assistant) return;
const url = `${window.location.origin}/community-assistants/${assistant.id}`;
navigator.clipboard.writeText(url).then(() => {
// You could add a toast notification here
console.log('URL copied to clipboard');
}).catch(err => {
console.error('Failed to copy URL:', err);
});
};
// Handle import
const handleImport = async (item: any) => {
const assistant = assistants.find(a => a.id === item.id);
if (!assistant) return;
if (onImport) {
onImport(assistant);
return;
}
setImportingIds(prev => new Set(prev).add(assistant.id));
try {
const response = await fetch(`/api/community-assistants/${assistant.id}`);
if (!response.ok) throw new Error('Failed to fetch assistant details');
const data = await response.json();
await createProjectFromJsonWithOptions({
workflowJson: JSON.stringify(data.workflow),
router,
onSuccess: (projectId) => {
router.push(`/projects/${projectId}/workflow`);
},
onError: (error) => {
console.error('Error creating project:', error);
}
});
} catch (err) {
console.error('Error importing assistant:', err);
// You could add error handling here
} finally {
setImportingIds(prev => {
const newSet = new Set(prev);
newSet.delete(assistant.id);
return newSet;
});
}
};
// Load data on mount
useEffect(() => {
fetchAssistants();
loadGuestLikes();
}, []);
return (
<AssistantSection
title="Community Assistants"
description="Discover and use assistants created by the community."
items={assistants.map(assistant => ({
id: assistant.id,
name: assistant.name,
description: assistant.description,
category: assistant.category,
authorName: assistant.authorName,
isAnonymous: assistant.isAnonymous,
likeCount: assistant.likeCount,
createdAt: assistant.publishedAt,
isLiked: likedIds.has(assistant.id)
}))}
loading={loading}
error={error}
onItemClick={handleImport}
onRetry={() => fetchAssistants()}
loadingItemId={Array.from(importingIds)[0] || null}
emptyMessage="No community assistants available"
onLike={handleLike}
onShare={handleShare}
/>
);
}

View file

@ -0,0 +1,39 @@
import { z } from "zod";
import { Workflow } from "../../../app/lib/types/workflow_types";
export const CommunityAssistant = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
category: z.string(),
authorId: z.string(),
authorName: z.string(),
authorEmail: z.string().optional(),
isAnonymous: z.boolean(),
workflow: Workflow,
tags: z.array(z.string()),
publishedAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
downloadCount: z.number().default(0),
likeCount: z.number().default(0),
featured: z.boolean().default(false),
isPublic: z.boolean().default(true),
// Social features
likes: z.array(z.string()).default([]), // Array of user IDs who liked it
// Template-like metadata
copilotPrompt: z.string().optional(),
thumbnailUrl: z.string().optional(),
estimatedComplexity: z.enum(['beginner', 'intermediate', 'advanced']).default('beginner'),
});
export type CommunityAssistant = z.infer<typeof CommunityAssistant>;
export const CommunityAssistantLike = z.object({
id: z.string(),
assistantId: z.string(),
userId: z.string(), // Can be guest ID for anonymous users
userEmail: z.string().optional(), // For logged-in users
createdAt: z.string().datetime(),
});
export type CommunityAssistantLike = z.infer<typeof CommunityAssistantLike>;

View file

@ -11,6 +11,7 @@ import { SCHEDULED_JOB_RULES_COLLECTION, SCHEDULED_JOB_RULES_INDEXES } from "../
import { COMPOSIO_TRIGGER_DEPLOYMENTS_COLLECTION, COMPOSIO_TRIGGER_DEPLOYMENTS_INDEXES } from "../repositories/mongodb.composio-trigger-deployments.indexes";
import { USERS_COLLECTION, USERS_INDEXES } from "../repositories/mongodb.users.indexes";
import { SHARED_WORKFLOWS_COLLECTION, SHARED_WORKFLOWS_INDEXES } from "../repositories/mongodb.shared-workflows.indexes";
import { COMMUNITY_ASSISTANTS_COLLECTION, COMMUNITY_ASSISTANTS_INDEXES, COMMUNITY_ASSISTANT_LIKES_COLLECTION, COMMUNITY_ASSISTANT_LIKES_INDEXES } from "../repositories/mongodb.community-assistants.indexes";
export async function ensureAllIndexes(database: Db): Promise<void> {
await database.collection(API_KEYS_COLLECTION).createIndexes(API_KEYS_INDEXES);
@ -25,4 +26,6 @@ export async function ensureAllIndexes(database: Db): Promise<void> {
await database.collection(COMPOSIO_TRIGGER_DEPLOYMENTS_COLLECTION).createIndexes(COMPOSIO_TRIGGER_DEPLOYMENTS_INDEXES);
await database.collection(USERS_COLLECTION).createIndexes(USERS_INDEXES);
await database.collection(SHARED_WORKFLOWS_COLLECTION).createIndexes(SHARED_WORKFLOWS_INDEXES);
await database.collection(COMMUNITY_ASSISTANTS_COLLECTION).createIndexes(COMMUNITY_ASSISTANTS_INDEXES);
await database.collection(COMMUNITY_ASSISTANT_LIKES_COLLECTION).createIndexes(COMMUNITY_ASSISTANT_LIKES_INDEXES);
}

View file

@ -0,0 +1,22 @@
import { IndexDescription } from "mongodb";
export const COMMUNITY_ASSISTANTS_COLLECTION = "community_assistants";
export const COMMUNITY_ASSISTANT_LIKES_COLLECTION = "community_assistant_likes";
export const COMMUNITY_ASSISTANTS_INDEXES: IndexDescription[] = [
{ key: { category: 1, publishedAt: -1 }, name: "category_publishedAt" },
{ key: { tags: 1 }, name: "tags" },
{ key: { authorId: 1 }, name: "authorId" },
{ key: { isPublic: 1, featured: 1, publishedAt: -1 }, name: "isPublic_featured_publishedAt" },
{ key: { name: "text", description: "text", tags: "text" }, name: "text_search" },
{ key: { publishedAt: -1 }, name: "publishedAt_desc" },
{ key: { likeCount: -1 }, name: "likeCount_desc" },
{ key: { downloadCount: -1 }, name: "downloadCount_desc" },
];
export const COMMUNITY_ASSISTANT_LIKES_INDEXES: IndexDescription[] = [
{ key: { assistantId: 1, userId: 1 }, name: "assistantId_userId", unique: true },
{ key: { assistantId: 1 }, name: "assistantId" },
{ key: { userId: 1 }, name: "userId" },
{ key: { createdAt: -1 }, name: "createdAt_desc" },
];

View file

@ -0,0 +1,205 @@
import { z } from "zod";
import { Filter, ObjectId } from "mongodb";
import { db } from "@/app/lib/mongodb";
import { CommunityAssistant, CommunityAssistantLike } from "@/src/entities/models/community-assistant";
import { PaginatedList } from "@/src/entities/common/paginated-list";
import { NotFoundError } from "@/src/entities/errors/common";
/**
* MongoDB document schema for CommunityAssistant.
* Excludes the 'id' field as it's represented by MongoDB's '_id'.
*/
const DocSchema = CommunityAssistant.omit({ id: true });
/**
* MongoDB document schema for CommunityAssistantLike.
*/
const LikeDocSchema = CommunityAssistantLike.omit({ id: true });
/**
* MongoDB implementation of the CommunityAssistants repository.
*/
export class MongoDBCommunityAssistantsRepository {
private readonly collection = db.collection<z.infer<typeof DocSchema>>("community_assistants");
private readonly likesCollection = db.collection<z.infer<typeof LikeDocSchema>>("community_assistant_likes");
async create(data: Omit<z.infer<typeof CommunityAssistant>, 'id'>): Promise<z.infer<typeof CommunityAssistant>> {
const now = new Date().toISOString();
const _id = new ObjectId();
const doc: z.infer<typeof DocSchema> = {
...data,
publishedAt: now,
lastUpdatedAt: now,
};
await this.collection.insertOne({
...doc,
_id,
});
return {
...doc,
id: _id.toString(),
};
}
async fetch(id: string): Promise<z.infer<typeof CommunityAssistant> | null> {
const result = await this.collection.findOne({ _id: new ObjectId(id) });
if (!result) return null;
return {
...result,
id: result._id.toString(),
};
}
async list(filters: {
category?: string;
search?: string;
featured?: boolean;
isPublic?: boolean;
authorId?: string;
} = {}, cursor?: string, limit: number = 20): Promise<z.infer<ReturnType<typeof PaginatedList<typeof CommunityAssistant>>>> {
const query: Filter<z.infer<typeof DocSchema>> = {};
if (filters.category) {
query.category = filters.category;
}
if (filters.featured !== undefined) {
query.featured = filters.featured;
}
if (filters.isPublic !== undefined) {
query.isPublic = filters.isPublic;
}
if (filters.authorId) {
query.authorId = filters.authorId;
}
if (filters.search) {
query.$or = [
{ name: { $regex: filters.search, $options: 'i' } },
{ description: { $regex: filters.search, $options: 'i' } },
{ tags: { $in: [new RegExp(filters.search, 'i')] } },
];
}
const skip = cursor ? parseInt(cursor) : 0;
const results = await this.collection
.find(query)
.sort({ publishedAt: -1 })
.skip(skip)
.limit(limit)
.toArray();
const items = results.map(result => ({
...result,
id: result._id.toString(),
}));
const nextCursor = results.length === limit ? (skip + limit).toString() : null;
return {
items,
nextCursor,
};
}
async update(id: string, data: Partial<Omit<z.infer<typeof CommunityAssistant>, 'id' | 'publishedAt'>>): Promise<z.infer<typeof CommunityAssistant> | null> {
const now = new Date().toISOString();
const updateData = {
...data,
lastUpdatedAt: now,
};
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(id) },
{ $set: updateData },
{ returnDocument: 'after' }
);
if (!result) return null;
return {
...result,
id: result._id.toString(),
};
}
async delete(id: string): Promise<boolean> {
const result = await this.collection.deleteOne({ _id: new ObjectId(id) });
return result.deletedCount > 0;
}
async incrementDownloadCount(id: string): Promise<void> {
await this.collection.updateOne(
{ _id: new ObjectId(id) },
{ $inc: { downloadCount: 1 } }
);
}
async toggleLike(assistantId: string, userId: string, userEmail?: string): Promise<{ liked: boolean; likeCount: number }> {
const likeId = new ObjectId();
const now = new Date().toISOString();
// Check if user already liked this assistant
const existingLike = await this.likesCollection.findOne({
assistantId: assistantId,
userId,
});
if (existingLike) {
// Unlike: remove the like
await this.likesCollection.deleteOne({ _id: existingLike._id });
await this.collection.updateOne(
{ _id: new ObjectId(assistantId) },
{
$inc: { likeCount: -1 },
$pull: { likes: userId }
}
);
return { liked: false, likeCount: await this.getLikeCount(assistantId) };
} else {
// Like: add the like
await this.likesCollection.insertOne({
_id: likeId,
assistantId: assistantId,
userId,
userEmail,
createdAt: now,
});
await this.collection.updateOne(
{ _id: new ObjectId(assistantId) },
{
$inc: { likeCount: 1 },
$addToSet: { likes: userId }
}
);
return { liked: true, likeCount: await this.getLikeCount(assistantId) };
}
}
async getLikeCount(assistantId: string): Promise<number> {
const result = await this.collection.findOne(
{ _id: new ObjectId(assistantId) },
{ projection: { likeCount: 1 } }
);
return result?.likeCount || 0;
}
async getUserLikes(assistantId: string, userId: string): Promise<boolean> {
const like = await this.likesCollection.findOne({
assistantId: assistantId,
userId,
});
return !!like;
}
async getCategories(): Promise<string[]> {
const categories = await this.collection.distinct('category', { isPublic: true });
return categories.filter(Boolean);
}
}