mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
Add community sharing feature v1
This commit is contained in:
parent
33088fce56
commit
21f39000c0
14 changed files with 1647 additions and 176 deletions
117
apps/rowboat/app/api/community-assistants/[id]/like/route.ts
Normal file
117
apps/rowboat/app/api/community-assistants/[id]/like/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
93
apps/rowboat/app/api/community-assistants/[id]/route.ts
Normal file
93
apps/rowboat/app/api/community-assistants/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
135
apps/rowboat/app/api/community-assistants/route.ts
Normal file
135
apps/rowboat/app/api/community-assistants/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
234
apps/rowboat/components/common/AssistantCard.tsx
Normal file
234
apps/rowboat/components/common/AssistantCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
apps/rowboat/components/common/AssistantSection.tsx
Normal file
241
apps/rowboat/components/common/AssistantSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
apps/rowboat/components/community/CommunitySection.tsx
Normal file
226
apps/rowboat/components/community/CommunitySection.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
apps/rowboat/src/entities/models/community-assistant.ts
Normal file
39
apps/rowboat/src/entities/models/community-assistant.ts
Normal 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>;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue