From 21f39000c0edfb4c4044211c6ba22f6011983344 Mon Sep 17 00:00:00 2001 From: akhisud3195 Date: Sun, 14 Sep 2025 23:13:39 +0400 Subject: [PATCH] Add community sharing feature v1 --- .../community-assistants/[id]/like/route.ts | 117 ++++++++ .../api/community-assistants/[id]/route.ts | 93 ++++++ .../community-assistants/categories/route.ts | 18 ++ .../app/api/community-assistants/route.ts | 135 +++++++++ .../workflow/components/TopBar.tsx | 267 +++++++++++++++--- .../[projectId]/workflow/workflow_editor.tsx | 53 ++++ .../components/build-assistant-section.tsx | 170 ++--------- .../components/common/AssistantCard.tsx | 234 +++++++++++++++ .../components/common/AssistantSection.tsx | 241 ++++++++++++++++ .../components/community/CommunitySection.tsx | 226 +++++++++++++++ .../entities/models/community-assistant.ts | 39 +++ .../infrastructure/mongodb/ensure-indexes.ts | 3 + .../mongodb.community-assistants.indexes.ts | 22 ++ ...mongodb.community-assistants.repository.ts | 205 ++++++++++++++ 14 files changed, 1647 insertions(+), 176 deletions(-) create mode 100644 apps/rowboat/app/api/community-assistants/[id]/like/route.ts create mode 100644 apps/rowboat/app/api/community-assistants/[id]/route.ts create mode 100644 apps/rowboat/app/api/community-assistants/categories/route.ts create mode 100644 apps/rowboat/app/api/community-assistants/route.ts create mode 100644 apps/rowboat/components/common/AssistantCard.tsx create mode 100644 apps/rowboat/components/common/AssistantSection.tsx create mode 100644 apps/rowboat/components/community/CommunitySection.tsx create mode 100644 apps/rowboat/src/entities/models/community-assistant.ts create mode 100644 apps/rowboat/src/infrastructure/repositories/mongodb.community-assistants.indexes.ts create mode 100644 apps/rowboat/src/infrastructure/repositories/mongodb.community-assistants.repository.ts diff --git a/apps/rowboat/app/api/community-assistants/[id]/like/route.ts b/apps/rowboat/app/api/community-assistants/[id]/like/route.ts new file mode 100644 index 00000000..22294f47 --- /dev/null +++ b/apps/rowboat/app/api/community-assistants/[id]/like/route.ts @@ -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 } + ); + } +} diff --git a/apps/rowboat/app/api/community-assistants/[id]/route.ts b/apps/rowboat/app/api/community-assistants/[id]/route.ts new file mode 100644 index 00000000..af69797c --- /dev/null +++ b/apps/rowboat/app/api/community-assistants/[id]/route.ts @@ -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 } + ); + } +} diff --git a/apps/rowboat/app/api/community-assistants/categories/route.ts b/apps/rowboat/app/api/community-assistants/categories/route.ts new file mode 100644 index 00000000..617771af --- /dev/null +++ b/apps/rowboat/app/api/community-assistants/categories/route.ts @@ -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 } + ); + } +} diff --git a/apps/rowboat/app/api/community-assistants/route.ts b/apps/rowboat/app/api/community-assistants/route.ts new file mode 100644 index 00000000..4a72ae9e --- /dev/null +++ b/apps/rowboat/app/api/community-assistants/route.ts @@ -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 } + ); + } +} diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx index 312dc7b4..62090df2 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx @@ -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({ {/* Share Modal */} - + - Share Assistant +

Share Assistant

+

Choose how you'd like to share your assistant

-
-

- Share this assistant with others using the URL below: -

- {shareUrl ? ( -
- - +
+ {/* Quick Share Section */} +
+
+
+ +
+
+

Quick Share

+

Share with a direct link

+
- ) : ( -
- - - Generating share URL... - + + {shareUrl ? ( +
+
+ +
+ +
+ ) : ( +
+ + + Generating share URL... + +
+ )} +
+ + {/* Divider */} +
+
+
- )} +
+ or +
+
+ + {/* Community Publishing Section */} +
+
+
+ +
+
+

Publish to Community

+

Make it discoverable by others

+
+
+ +
+ {/* Assistant Name */} +
+ + 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" + }} + /> +
+ + {/* Description */} +
+ +