mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-30 10:56:29 +02:00
Community cards and prebuilt cards (#258)
* Add community sharing feature and merge with pre-built templates * Add warning before publishing * [Untested] Add delete flow for community cards * Fix bug with sorting by likes count and update design of cards * Fix community assistant parsing errors * Remove all as a type filter * Remove default assistant name for publishing to community * Update DB calls to be standardized paginated
This commit is contained in:
parent
62c1230cff
commit
be4e17b5a5
20 changed files with 2144 additions and 264 deletions
|
|
@ -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, useEffect } from "react";
|
||||
|
||||
interface TopBarProps {
|
||||
localProjectName: string;
|
||||
|
|
@ -42,6 +44,20 @@ 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;
|
||||
};
|
||||
setCommunityData: (data: any) => void;
|
||||
onCommunityPublish: () => void;
|
||||
communityPublishing: boolean;
|
||||
communityPublishSuccess: boolean;
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
|
|
@ -80,6 +96,13 @@ export function TopBar({
|
|||
onShareWorkflow,
|
||||
shareUrl,
|
||||
onCopyShareUrl,
|
||||
shareMode,
|
||||
setShareMode,
|
||||
communityData,
|
||||
setCommunityData,
|
||||
onCommunityPublish,
|
||||
communityPublishing,
|
||||
communityPublishSuccess,
|
||||
}: TopBarProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
|
@ -87,11 +110,39 @@ export function TopBar({
|
|||
|
||||
// Share modal state
|
||||
const { isOpen: isShareModalOpen, onOpen: onShareModalOpen, onClose: onShareModalClose } = useDisclosure();
|
||||
const { isOpen: isConfirmOpen, onOpen: onConfirmOpen, onClose: onConfirmClose } = useDisclosure();
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
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
|
||||
};
|
||||
|
||||
// After successful community publish, briefly show success and then close modal
|
||||
useEffect(() => {
|
||||
if (communityPublishSuccess) {
|
||||
const timer = setTimeout(() => {
|
||||
onShareModalClose();
|
||||
}, 1200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [communityPublishSuccess, onShareModalClose]);
|
||||
|
||||
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 +647,261 @@ 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 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={communityPublishSuccess ? "success" : "primary"}
|
||||
onPress={() => {
|
||||
// Open confirmation first
|
||||
onConfirmOpen();
|
||||
}}
|
||||
isLoading={communityPublishing}
|
||||
isDisabled={communityPublishSuccess || !communityData.name.trim() || !communityData.description.trim() || !communityData.category}
|
||||
className={`${communityPublishSuccess ? 'bg-green-600 hover:bg-green-700' : 'bg-blue-600 hover:bg-blue-700'} px-6 py-2 text-white font-medium`}
|
||||
>
|
||||
{communityPublishSuccess ? 'Published' : (communityPublishing ? 'Publishing...' : 'Publish to Community')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Confirmation Modal for Community Publish */}
|
||||
<Modal
|
||||
isOpen={isConfirmOpen}
|
||||
onClose={() => { setAcknowledged(false); onConfirmClose(); }}
|
||||
size="md"
|
||||
classNames={{
|
||||
base: "bg-white dark:bg-gray-900",
|
||||
header: "border-b border-gray-200 dark:border-gray-700 pb-3",
|
||||
body: "py-5",
|
||||
footer: "border-t border-gray-200 dark:border-gray-700 pt-3"
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Confirm publish to community</h3>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<p>Publishing to community will make this assistant and its description publicly visible to other users.</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Your assistant may appear in the community templates library.</li>
|
||||
<li>Others can import and use this assistant in their own projects.</li>
|
||||
<li>Do not include secrets or private data in the description or workflow.</li>
|
||||
</ul>
|
||||
<div className="mt-3 flex items-start gap-2">
|
||||
<input
|
||||
id="ack-publish"
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
className="mt-1 h-4 w-4"
|
||||
/>
|
||||
<label htmlFor="ack-publish" className="text-sm">I understand this will be publicly available.</label>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onShareModalClose}>
|
||||
Close
|
||||
<Button variant="light" onPress={() => { setAcknowledged(false); onConfirmClose(); }}>Cancel</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={!acknowledged}
|
||||
onPress={() => {
|
||||
onConfirmClose();
|
||||
setAcknowledged(false);
|
||||
onCommunityPublish();
|
||||
}}
|
||||
>
|
||||
Confirm & Publish
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
|
|
|||
|
|
@ -1633,6 +1633,51 @@ export function WorkflowEditor({
|
|||
setTimeout(() => setShowCopySuccess(false), 2000);
|
||||
}
|
||||
|
||||
// Community publishing functions
|
||||
const [shareMode, setShareMode] = useState<'url' | 'community'>('url');
|
||||
const [communityData, setCommunityData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
tags: [] as string[],
|
||||
isAnonymous: false,
|
||||
copilotPrompt: '',
|
||||
});
|
||||
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/assistant-templates', {
|
||||
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 +1994,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}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { listTemplates, listProjects } from "@/app/actions/project.actions";
|
||||
import { listProjects } from "@/app/actions/project.actions";
|
||||
import { createProjectWithOptions, createProjectFromJsonWithOptions, createProjectFromTemplate } from "../lib/project-creation-utils";
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import clsx from 'clsx';
|
||||
|
|
@ -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 { AssistantSection } from '@/components/common/AssistantSection';
|
||||
import { UnifiedTemplatesSection } from '@/components/common/UnifiedTemplatesSection';
|
||||
|
||||
const SHOW_PREBUILT_CARDS = process.env.NEXT_PUBLIC_SHOW_PREBUILT_CARDS !== 'false';
|
||||
|
||||
|
|
@ -48,9 +50,17 @@ export function BuildAssistantSection() {
|
|||
const [promptError, setPromptError] = useState<string | null>(null);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
// Library templates (paginated)
|
||||
const [templates, setTemplates] = useState<any[]>([]);
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false);
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null);
|
||||
const [templatesCursor, setTemplatesCursor] = useState<string | null>(null);
|
||||
|
||||
// Community templates (paginated)
|
||||
const [communityTemplates, setCommunityTemplates] = useState<any[]>([]);
|
||||
const [communityTemplatesLoading, setCommunityTemplatesLoading] = useState(false);
|
||||
const [communityTemplatesError, setCommunityTemplatesError] = useState<string | null>(null);
|
||||
const [communityCursor, setCommunityCursor] = useState<string | null>(null);
|
||||
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
|
||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
|
@ -88,41 +98,176 @@ export function BuildAssistantSection() {
|
|||
return Array.from(uniqueToolsMap.values()).filter(tool => tool.logo); // Only show tools with logos like ToolkitCard
|
||||
};
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
const fetchLibraryTemplatesPage = async (cursor?: string | null, limit: number = 20) => {
|
||||
setTemplatesLoading(true);
|
||||
setTemplatesError(null);
|
||||
try {
|
||||
const templatesArray = await listTemplates();
|
||||
setTemplates(templatesArray);
|
||||
const params = new URLSearchParams({ source: 'library', limit: String(limit) });
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const response = await fetch(`/api/assistant-templates?${params.toString()}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch library templates');
|
||||
const data = await response.json();
|
||||
setTemplates(prev => cursor ? [...prev, ...data.items] : data.items);
|
||||
setTemplatesCursor(data.nextCursor || null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching templates:', error);
|
||||
console.error('Error fetching library templates:', error);
|
||||
setTemplatesError(error instanceof Error ? error.message : 'Failed to load templates');
|
||||
} finally {
|
||||
setTemplatesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCommunityTemplatesPage = async (cursor?: string | null, limit: number = 20) => {
|
||||
setCommunityTemplatesLoading(true);
|
||||
setCommunityTemplatesError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ source: 'community', limit: String(limit) });
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const response = await fetch(`/api/assistant-templates?${params.toString()}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch community templates');
|
||||
const data = await response.json();
|
||||
setCommunityTemplates(prev => cursor ? [...prev, ...data.items] : data.items);
|
||||
setCommunityCursor(data.nextCursor || null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching community templates:', error);
|
||||
setCommunityTemplatesError(error instanceof Error ? error.message : 'Failed to load community templates');
|
||||
} finally {
|
||||
setCommunityTemplatesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure we have at least `targetCount` items loaded for a given type
|
||||
const ensureTemplatesLoaded = async (type: 'prebuilt' | 'community', targetCount: number) => {
|
||||
const current = type === 'prebuilt' ? templates.length : communityTemplates.length;
|
||||
const cursor = type === 'prebuilt' ? templatesCursor : communityCursor;
|
||||
if (current >= targetCount) return;
|
||||
// Fetch pages until we meet or exceed target or run out of pages
|
||||
// Use page size equal to remaining needed but capped reasonably
|
||||
let needed = targetCount - current;
|
||||
let nextCursor = cursor;
|
||||
while (needed > 0 && (nextCursor !== null || current === 0)) {
|
||||
const pageSize = Math.min(Math.max(needed, 12), 30);
|
||||
if (type === 'prebuilt') {
|
||||
await fetchLibraryTemplatesPage(nextCursor, pageSize);
|
||||
nextCursor = templatesCursor; // will be updated by set state; slight lag acceptable
|
||||
} else {
|
||||
await fetchCommunityTemplatesPage(nextCursor, pageSize);
|
||||
nextCursor = communityCursor;
|
||||
}
|
||||
// Update needed based on latest lengths
|
||||
needed = targetCount - (type === 'prebuilt' ? templates.length : communityTemplates.length);
|
||||
if (nextCursor === null) break;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle template selection
|
||||
const handleTemplateSelect = async (template: any) => {
|
||||
// Show a small non-blocking spinner on the clicked card
|
||||
setLoadingTemplateId(template.id);
|
||||
try {
|
||||
await createProjectWithOptions({
|
||||
template: template.id,
|
||||
// Prefer a card-specific copilot prompt if present on the template JSON
|
||||
prompt: template.copilotPrompt || 'Explain this workflow',
|
||||
router,
|
||||
onError: () => {
|
||||
// Clear loading state if creation fails
|
||||
setLoadingTemplateId(null);
|
||||
},
|
||||
});
|
||||
if (template.type === 'prebuilt') {
|
||||
// Fetch full workflow from unified API, then create from JSON
|
||||
const res = await fetch(`/api/assistant-templates/${template.id}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch template details');
|
||||
const data = await res.json();
|
||||
await createProjectFromJsonWithOptions({
|
||||
workflowJson: JSON.stringify(data.workflow),
|
||||
router,
|
||||
onSuccess: (_projectId) => {},
|
||||
onError: () => {
|
||||
setLoadingTemplateId(null);
|
||||
}
|
||||
});
|
||||
} else if (template.type === 'community') {
|
||||
// Fetch full workflow for community template, then create from JSON
|
||||
const res = await fetch(`/api/assistant-templates/${template.id}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch community template details');
|
||||
const data = await res.json();
|
||||
await createProjectFromJsonWithOptions({
|
||||
workflowJson: JSON.stringify(data.workflow),
|
||||
router,
|
||||
onSuccess: (projectId) => {
|
||||
router.push(`/projects/${projectId}/workflow`);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error creating project from community template:', error);
|
||||
setLoadingTemplateId(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
// In case of unexpected error, clear loading state
|
||||
setLoadingTemplateId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Stable guest id for like toggles
|
||||
const getGuestId = () => {
|
||||
try {
|
||||
let guestId = sessionStorage.getItem('guestId');
|
||||
if (!guestId) {
|
||||
guestId = `guest-${crypto.randomUUID()}`;
|
||||
sessionStorage.setItem('guestId', guestId);
|
||||
}
|
||||
return guestId;
|
||||
} catch (_e) {
|
||||
return `guest-${crypto.randomUUID()}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle template like (unified for library and community)
|
||||
const handleTemplateLike = async (template: any) => {
|
||||
try {
|
||||
const guestId = getGuestId();
|
||||
const response = await fetch(`/api/assistant-templates/${template.id}/like`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-guest-id': guestId },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (template.type === 'community') {
|
||||
setCommunityTemplates(prev => prev.map(t =>
|
||||
t.id === template.id
|
||||
? { ...t, likeCount: data.likeCount, isLiked: data.liked }
|
||||
: t
|
||||
));
|
||||
} else {
|
||||
setTemplates(prev => prev.map(t =>
|
||||
t.id === template.id
|
||||
? { ...t, likeCount: data.likeCount, isLiked: data.liked } as any
|
||||
: t
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling like:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle template share (for both library and community)
|
||||
const handleTemplateShare = async (template: any) => {
|
||||
try {
|
||||
// Fetch workflow for the template and create a shared snapshot
|
||||
const res = await fetch(`/api/assistant-templates/${template.id}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch template for sharing');
|
||||
const data = await res.json();
|
||||
|
||||
const shareResp = await fetch('/api/shared-workflow', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ workflow: data.workflow }),
|
||||
});
|
||||
if (!shareResp.ok) throw new Error('Failed to create shared workflow');
|
||||
const shareData = await shareResp.json();
|
||||
const url = `${window.location.origin}/projects?shared=${shareData.id}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
console.log('URL copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy shared URL:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle prompt card selection
|
||||
const handlePromptSelect = (promptText: string) => {
|
||||
setUserPrompt(promptText);
|
||||
|
|
@ -145,8 +290,9 @@ export function BuildAssistantSection() {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
// Load initial library templates to fill 4 rows x up to 3 columns ≈ 12
|
||||
fetchProjects();
|
||||
ensureTemplatesLoaded('prebuilt', 12);
|
||||
}, []);
|
||||
|
||||
// Handle URL parameters for auto-creation and direct redirect to build view
|
||||
|
|
@ -184,19 +330,40 @@ export function BuildAssistantSection() {
|
|||
|
||||
if (urlPrompt || urlTemplate) {
|
||||
setAutoCreateLoading(true);
|
||||
createProjectWithOptions({
|
||||
template: urlTemplate || undefined,
|
||||
prompt: urlPrompt || undefined,
|
||||
router,
|
||||
onError: (error) => {
|
||||
console.error('Error auto-creating project:', error);
|
||||
setAutoCreateLoading(false);
|
||||
// Fall back to showing the form with the prompt pre-filled
|
||||
if (urlPrompt) {
|
||||
setUserPrompt(urlPrompt);
|
||||
}
|
||||
try {
|
||||
const isMongoId = !!urlTemplate && /^[a-f0-9]{24}$/i.test(urlTemplate);
|
||||
if (urlTemplate && isMongoId) {
|
||||
// New-style share: template is an assistant-templates id
|
||||
const res = await fetch(`/api/assistant-templates/${urlTemplate}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch shared template');
|
||||
const data = await res.json();
|
||||
await createProjectFromJsonWithOptions({
|
||||
workflowJson: JSON.stringify(data.workflow),
|
||||
router,
|
||||
onError: (error) => {
|
||||
console.error('Error auto-creating project from template id:', error);
|
||||
setAutoCreateLoading(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Legacy share using static key
|
||||
await createProjectWithOptions({
|
||||
template: urlTemplate || undefined,
|
||||
prompt: urlPrompt || undefined,
|
||||
router,
|
||||
onError: (error) => {
|
||||
console.error('Error auto-creating project:', error);
|
||||
setAutoCreateLoading(false);
|
||||
if (urlPrompt) {
|
||||
setUserPrompt(urlPrompt);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error handling template auto-create:', err);
|
||||
setAutoCreateLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -291,7 +458,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">
|
||||
|
|
@ -460,151 +629,65 @@ export function BuildAssistantSection() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pre-built Assistants Section - Only show for New Assistant tab */}
|
||||
{/* Unified Templates 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() === '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">
|
||||
Support
|
||||
</span>
|
||||
</div>
|
||||
{renderGrid(customerSupportTemplates)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<UnifiedTemplatesSection
|
||||
prebuiltTemplates={templates.map(template => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category || 'Other',
|
||||
tools: template.tools,
|
||||
type: 'prebuilt' as const,
|
||||
likeCount: (template as any).likeCount || 0,
|
||||
isLiked: (template as any).isLiked || false,
|
||||
}))}
|
||||
communityTemplates={communityTemplates.map(template => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
authorId: template.authorId,
|
||||
source: template.source,
|
||||
authorName: template.authorName,
|
||||
isAnonymous: template.isAnonymous,
|
||||
likeCount: template.likeCount,
|
||||
createdAt: template.publishedAt,
|
||||
isLiked: template.isLiked,
|
||||
type: 'community' as const,
|
||||
}))}
|
||||
loading={templatesLoading || communityTemplatesLoading}
|
||||
error={templatesError || communityTemplatesError}
|
||||
onTemplateClick={handleTemplateSelect}
|
||||
onRetry={() => {
|
||||
fetchLibraryTemplatesPage(undefined, 12);
|
||||
fetchCommunityTemplatesPage(undefined, 12);
|
||||
}}
|
||||
loadingItemId={loadingTemplateId}
|
||||
onLike={handleTemplateLike}
|
||||
onShare={handleTemplateShare}
|
||||
onDelete={async (item) => {
|
||||
try {
|
||||
const resp = await fetch(`/api/assistant-templates/${item.id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to delete template');
|
||||
}
|
||||
setCommunityTemplates(prev => prev.filter(t => t.id !== item.id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// Optional: surface non-blocking feedback; keeping console error for now
|
||||
}
|
||||
}}
|
||||
getUniqueTools={getUniqueTools}
|
||||
onLoadMore={async (type, target) => {
|
||||
await ensureTemplatesLoaded(type, target);
|
||||
}}
|
||||
onTypeChange={async (type, target) => {
|
||||
await ensureTemplatesLoaded(type, target);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue