mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +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
|
|
@ -2,7 +2,8 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { container } from "@/di/container";
|
import { container } from "@/di/container";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { templates } from "../lib/project_templates";
|
// Fetch library templates from the unified assistant templates repository
|
||||||
|
import { MongoDBAssistantTemplatesRepository } from "@/src/infrastructure/repositories/mongodb.assistant-templates.repository";
|
||||||
import { authCheck } from "./auth.actions";
|
import { authCheck } from "./auth.actions";
|
||||||
import { ApiKey } from "@/src/entities/models/api-key";
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
import { Project } from "@/src/entities/models/project";
|
import { Project } from "@/src/entities/models/project";
|
||||||
|
|
@ -40,14 +41,17 @@ const updateLiveWorkflowController = container.resolve<IUpdateLiveWorkflowContro
|
||||||
const revertToLiveWorkflowController = container.resolve<IRevertToLiveWorkflowController>('revertToLiveWorkflowController');
|
const revertToLiveWorkflowController = container.resolve<IRevertToLiveWorkflowController>('revertToLiveWorkflowController');
|
||||||
|
|
||||||
export async function listTemplates() {
|
export async function listTemplates() {
|
||||||
const templatesArray = Object.entries(templates)
|
const repo = new MongoDBAssistantTemplatesRepository();
|
||||||
.filter(([key]) => key !== 'default') // Exclude the default template
|
const result = await repo.list({ source: 'library', isPublic: true }, undefined, 100);
|
||||||
.map(([key, template]) => ({
|
// Map to the shape expected by callers (tools at top-level)
|
||||||
id: key,
|
return result.items.map((item) => ({
|
||||||
...template
|
id: item.id,
|
||||||
}));
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
return templatesArray;
|
category: item.category,
|
||||||
|
tools: (item as any).workflow?.tools || [],
|
||||||
|
copilotPrompt: item.copilotPrompt,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function projectAuthCheck(projectId: string) {
|
export async function projectAuthCheck(projectId: string) {
|
||||||
|
|
|
||||||
30
apps/rowboat/app/api/assistant-templates/[id]/like/route.ts
Normal file
30
apps/rowboat/app/api/assistant-templates/[id]/like/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository';
|
||||||
|
|
||||||
|
const repo = new MongoDBAssistantTemplatesRepository();
|
||||||
|
|
||||||
|
const ToggleLikeSchema = z.object({
|
||||||
|
guestId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
// Prefer header like existing community route
|
||||||
|
const guestId = req.headers.get('x-guest-id') || undefined;
|
||||||
|
const body = !guestId ? await req.json().catch(() => ({})) : {};
|
||||||
|
const parsed = ToggleLikeSchema.safeParse({ guestId: guestId || body.guestId });
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: 'Missing guestId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const result = await repo.toggleLike(id, parsed.data.guestId);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling like:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to toggle like' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
57
apps/rowboat/app/api/assistant-templates/[id]/route.ts
Normal file
57
apps/rowboat/app/api/assistant-templates/[id]/route.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository';
|
||||||
|
import { authCheck } from '@/app/actions/auth.actions';
|
||||||
|
import { USE_AUTH } from '@/app/lib/feature_flags';
|
||||||
|
|
||||||
|
const repo = new MongoDBAssistantTemplatesRepository();
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const item = await repo.fetch(id);
|
||||||
|
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
return NextResponse.json(item);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching assistant template:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch assistant template' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const item = await repo.fetch(id);
|
||||||
|
if (!item) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow deleting library/prebuilt items
|
||||||
|
if ((item as any).source === 'library' || item.authorId === 'rowboat-system') {
|
||||||
|
return NextResponse.json({ error: 'Not allowed' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let user;
|
||||||
|
if (USE_AUTH) {
|
||||||
|
user = await authCheck();
|
||||||
|
} else {
|
||||||
|
user = { id: 'guest_user' } as any; // guest mode acts as a single user
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.authorId !== user.id) {
|
||||||
|
// Do not reveal existence
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await repo.deleteByIdAndAuthor(id, user.id);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting assistant template:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete assistant template' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
16
apps/rowboat/app/api/assistant-templates/categories/route.ts
Normal file
16
apps/rowboat/app/api/assistant-templates/categories/route.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository';
|
||||||
|
|
||||||
|
const repo = new MongoDBAssistantTemplatesRepository();
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const categories = await repo.getCategories();
|
||||||
|
return NextResponse.json({ items: categories });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching categories:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch categories' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
130
apps/rowboat/app/api/assistant-templates/route.ts
Normal file
130
apps/rowboat/app/api/assistant-templates/route.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository';
|
||||||
|
import { ensureLibraryTemplatesSeeded } from '@/app/lib/assistant_templates_seed';
|
||||||
|
import { authCheck } from '@/app/actions/auth.actions';
|
||||||
|
import { auth0 } from '@/app/lib/auth0';
|
||||||
|
import { USE_AUTH } from '@/app/lib/feature_flags';
|
||||||
|
|
||||||
|
const repo = new MongoDBAssistantTemplatesRepository();
|
||||||
|
|
||||||
|
const ListSchema = z.object({
|
||||||
|
category: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
featured: z.boolean().optional(),
|
||||||
|
source: z.enum(['library','community']).optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(50).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateSchema = 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(),
|
||||||
|
copilotPrompt: z.string().optional(),
|
||||||
|
thumbnailUrl: z.string().url().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Ensure library JSONs are seeded into the unified collection (idempotent)
|
||||||
|
await ensureLibraryTemplatesSeeded();
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const params = ListSchema.parse({
|
||||||
|
category: searchParams.get('category') || undefined,
|
||||||
|
search: searchParams.get('search') || undefined,
|
||||||
|
featured: searchParams.get('featured') ? searchParams.get('featured') === 'true' : undefined,
|
||||||
|
source: (searchParams.get('source') as 'library' | 'community') || undefined,
|
||||||
|
cursor: searchParams.get('cursor') || undefined,
|
||||||
|
limit: searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If source specified, query that subset; otherwise return combined from the unified collection
|
||||||
|
if (params.source === 'library' || params.source === 'community') {
|
||||||
|
const result = await repo.list({
|
||||||
|
category: params.category,
|
||||||
|
search: params.search,
|
||||||
|
featured: params.featured,
|
||||||
|
isPublic: true,
|
||||||
|
source: params.source,
|
||||||
|
}, params.cursor, params.limit);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No source: combine both subsets from the unified collection
|
||||||
|
const [lib, com] = await Promise.all([
|
||||||
|
repo.list({ category: params.category, search: params.search, featured: params.featured, isPublic: true, source: 'library' }, undefined, params.limit),
|
||||||
|
repo.list({ category: params.category, search: params.search, featured: params.featured, isPublic: true, source: 'community' }, undefined, params.limit),
|
||||||
|
]);
|
||||||
|
return NextResponse.json({ items: [...lib.items, ...com.items], nextCursor: null });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing assistant templates:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to list assistant templates' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
let user;
|
||||||
|
if (USE_AUTH) {
|
||||||
|
user = await authCheck();
|
||||||
|
} else {
|
||||||
|
user = { id: 'guest', email: 'guest@example.com' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const data = CreateSchema.parse(body);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.isAnonymous) {
|
||||||
|
authorName = 'Anonymous';
|
||||||
|
authorEmail = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await repo.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,
|
||||||
|
downloadCount: 0,
|
||||||
|
likeCount: 0,
|
||||||
|
featured: false,
|
||||||
|
isPublic: true,
|
||||||
|
likes: [],
|
||||||
|
source: 'community',
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(created);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating assistant template:', 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 assistant template' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
19
apps/rowboat/app/api/me/route.ts
Normal file
19
apps/rowboat/app/api/me/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { authCheck } from '@/app/actions/auth.actions';
|
||||||
|
import { USE_AUTH } from '@/app/lib/feature_flags';
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest) {
|
||||||
|
try {
|
||||||
|
let user;
|
||||||
|
if (USE_AUTH) {
|
||||||
|
user = await authCheck();
|
||||||
|
} else {
|
||||||
|
user = { id: 'guest_user' } as any;
|
||||||
|
}
|
||||||
|
return NextResponse.json({ id: user.id });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { templates } from '@/app/lib/project_templates';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
// The templates are now dynamically loaded from JSON files in the templates folder
|
|
||||||
return NextResponse.json(templates);
|
|
||||||
}
|
|
||||||
52
apps/rowboat/app/lib/assistant_templates_seed.ts
Normal file
52
apps/rowboat/app/lib/assistant_templates_seed.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { db } from "@/app/lib/mongodb";
|
||||||
|
import { prebuiltTemplates } from "@/app/lib/prebuilt-cards";
|
||||||
|
|
||||||
|
// idempotent seed: creates library (prebuilt) templates in DB if missing
|
||||||
|
// Uses name+authorName match to avoid duplicates; tags include a stable prebuilt key
|
||||||
|
export async function ensureLibraryTemplatesSeeded(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const collection = db.collection("assistant_templates");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const entries = Object.entries(prebuiltTemplates);
|
||||||
|
for (const [prebuiltKey, tpl] of entries) {
|
||||||
|
// minimal guard; only ingest valid workflow-like objects
|
||||||
|
if (!(tpl as any)?.agents || !Array.isArray((tpl as any).agents)) continue;
|
||||||
|
|
||||||
|
const name = (tpl as any).name || prebuiltKey;
|
||||||
|
|
||||||
|
// check if already present (by name + authorName Rowboat and special tag)
|
||||||
|
const existing = await collection.findOne({ name, authorName: "Rowboat", tags: { $in: [ `prebuilt:${prebuiltKey}`, "__library__" ] } });
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
name,
|
||||||
|
description: (tpl as any).description || "",
|
||||||
|
category: (tpl as any).category || "Other",
|
||||||
|
authorId: "rowboat-system",
|
||||||
|
authorName: "Rowboat",
|
||||||
|
authorEmail: undefined,
|
||||||
|
isAnonymous: false,
|
||||||
|
workflow: tpl as any,
|
||||||
|
tags: ["__library__", `prebuilt:${prebuiltKey}`].filter(Boolean),
|
||||||
|
publishedAt: now,
|
||||||
|
lastUpdatedAt: now,
|
||||||
|
downloadCount: 0,
|
||||||
|
likeCount: 0,
|
||||||
|
featured: false,
|
||||||
|
isPublic: true,
|
||||||
|
likes: [] as string[],
|
||||||
|
copilotPrompt: (tpl as any).copilotPrompt || undefined,
|
||||||
|
thumbnailUrl: undefined,
|
||||||
|
source: 'library' as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await collection.insertOne(doc as any);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// best-effort seed; do not throw to avoid breaking requests
|
||||||
|
console.error("ensureLibraryTemplatesSeeded error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,50 +1,33 @@
|
||||||
import { WorkflowTemplate } from "./types/workflow_types";
|
import { WorkflowTemplate } from "./types/workflow_types";
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { prebuiltTemplates } from './prebuilt-cards';
|
|
||||||
|
|
||||||
const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1";
|
// Provide a minimal default template to satisfy legacy code paths that
|
||||||
|
// still reference `templates.default`. Real templates are DB-backed.
|
||||||
// Build templates object using static imports so Vercel bundles them
|
const defaultTemplate: z.infer<typeof WorkflowTemplate> = {
|
||||||
function buildTemplates(): { [key: string]: z.infer<typeof WorkflowTemplate> } {
|
name: 'Blank Template',
|
||||||
const templates: { [key: string]: z.infer<typeof WorkflowTemplate> } = {};
|
description: 'A blank canvas to build your assistant.',
|
||||||
|
startAgent: "",
|
||||||
// Add default template
|
agents: [],
|
||||||
templates['default'] = {
|
prompts: [],
|
||||||
name: 'Blank Template',
|
tools: [
|
||||||
description: 'A blank canvas to build your agents.',
|
{
|
||||||
startAgent: "",
|
name: "Generate Image",
|
||||||
agents: [],
|
description: "Generate an image using Google Gemini given a text prompt. Returns base64-encoded image data and any text parts.",
|
||||||
prompts: [],
|
isGeminiImage: true,
|
||||||
tools: [
|
parameters: {
|
||||||
{
|
type: 'object',
|
||||||
name: "Generate Image",
|
properties: {
|
||||||
description: "Generate an image using Google Gemini given a text prompt. Returns base64-encoded image data and any text parts.",
|
prompt: { type: 'string', description: 'Text prompt describing the image to generate' },
|
||||||
isGeminiImage: true,
|
modelName: { type: 'string', description: 'Optional Gemini model override' },
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
prompt: { type: 'string', description: 'Text prompt describing the image to generate' },
|
|
||||||
modelName: { type: 'string', description: 'Optional Gemini model override' },
|
|
||||||
},
|
|
||||||
required: ['prompt'],
|
|
||||||
additionalProperties: true,
|
|
||||||
},
|
},
|
||||||
|
required: ['prompt'],
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
};
|
],
|
||||||
|
pipelines: [],
|
||||||
|
};
|
||||||
|
|
||||||
// Merge static prebuilt templates
|
export const templates: Record<string, z.infer<typeof WorkflowTemplate>> = {
|
||||||
Object.entries(prebuiltTemplates).forEach(([key, tpl]) => {
|
default: defaultTemplate,
|
||||||
// Basic guard to avoid bad entries
|
};
|
||||||
if ((tpl as any)?.agents && Array.isArray((tpl as any).agents)) {
|
|
||||||
templates[key] = tpl as z.infer<typeof WorkflowTemplate>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return templates;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const templates: { [key: string]: z.infer<typeof WorkflowTemplate> } = buildTemplates();
|
|
||||||
|
|
||||||
// Note: Prebuilt cards are now loaded from app/lib/prebuilt-cards/ directory
|
|
||||||
// starting_copilot_prompts has been removed as it was unused
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
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 { 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 { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon, Clock, Plug, MessageCircleIcon, ShareIcon } from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { ProgressBar, ProgressStep } from "@/components/ui/progress-bar";
|
import { ProgressBar, ProgressStep } from "@/components/ui/progress-bar";
|
||||||
|
import { useUser } from '@auth0/nextjs-auth0';
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
localProjectName: string;
|
localProjectName: string;
|
||||||
|
|
@ -42,6 +44,20 @@ interface TopBarProps {
|
||||||
onShareWorkflow: () => void;
|
onShareWorkflow: () => void;
|
||||||
shareUrl: string | null;
|
shareUrl: string | null;
|
||||||
onCopyShareUrl: () => void;
|
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({
|
export function TopBar({
|
||||||
|
|
@ -80,6 +96,13 @@ export function TopBar({
|
||||||
onShareWorkflow,
|
onShareWorkflow,
|
||||||
shareUrl,
|
shareUrl,
|
||||||
onCopyShareUrl,
|
onCopyShareUrl,
|
||||||
|
shareMode,
|
||||||
|
setShareMode,
|
||||||
|
communityData,
|
||||||
|
setCommunityData,
|
||||||
|
onCommunityPublish,
|
||||||
|
communityPublishing,
|
||||||
|
communityPublishSuccess,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -87,12 +110,40 @@ export function TopBar({
|
||||||
|
|
||||||
// Share modal state
|
// Share modal state
|
||||||
const { isOpen: isShareModalOpen, onOpen: onShareModalOpen, onClose: onShareModalClose } = useDisclosure();
|
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 = () => {
|
const handleShareClick = () => {
|
||||||
onShareWorkflow(); // Call the original share function to generate URL
|
onShareWorkflow(); // Call the original share function to generate URL
|
||||||
onShareModalOpen(); // Open the modal
|
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
|
// Progress bar steps with completion logic and current step detection
|
||||||
const step1Complete = hasAgentInstructionChanges;
|
const step1Complete = hasAgentInstructionChanges;
|
||||||
const step2Complete = hasPlaygroundTested && hasAgentInstructionChanges;
|
const step2Complete = hasPlaygroundTested && hasAgentInstructionChanges;
|
||||||
|
|
@ -596,46 +647,261 @@ export function TopBar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Share Modal */}
|
{/* 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>
|
<ModalContent>
|
||||||
<ModalHeader className="flex flex-col gap-1">
|
<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>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className="space-y-4">
|
<div className="space-y-8">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
{/* Quick Share Section */}
|
||||||
Share this assistant with others using the URL below:
|
<div className="space-y-4">
|
||||||
</p>
|
<div className="flex items-center gap-3">
|
||||||
{shareUrl ? (
|
<div className="w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<ShareIcon size={16} className="text-blue-600 dark:text-blue-400" />
|
||||||
<input
|
</div>
|
||||||
type="text"
|
<div>
|
||||||
value={shareUrl || ''}
|
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">Quick Share</h3>
|
||||||
readOnly
|
<p className="text-sm text-gray-500 dark:text-gray-400">Share with a direct link</p>
|
||||||
className="flex-1 bg-transparent text-sm text-gray-700 dark:text-gray-300 outline-none"
|
</div>
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="solid"
|
|
||||||
onPress={onCopyShareUrl}
|
|
||||||
className="bg-indigo-100 hover:bg-indigo-200 text-indigo-800"
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
{shareUrl ? (
|
||||||
<Spinner size="sm" />
|
<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">
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex-1 min-w-0">
|
||||||
Generating share URL...
|
<input
|
||||||
</span>
|
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>
|
||||||
)}
|
<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>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="light" onPress={onShareModalClose}>
|
<Button variant="light" onPress={() => { setAcknowledged(false); onConfirmClose(); }}>Cancel</Button>
|
||||||
Close
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isDisabled={!acknowledged}
|
||||||
|
onPress={() => {
|
||||||
|
onConfirmClose();
|
||||||
|
setAcknowledged(false);
|
||||||
|
onCommunityPublish();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm & Publish
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|
|
||||||
|
|
@ -1633,6 +1633,51 @@ export function WorkflowEditor({
|
||||||
setTimeout(() => setShowCopySuccess(false), 2000);
|
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
|
// Cleanup blob URL on unmount
|
||||||
// No-op cleanup; shareUrl is a normal URL now
|
// No-op cleanup; shareUrl is a normal URL now
|
||||||
|
|
||||||
|
|
@ -1949,6 +1994,13 @@ export function WorkflowEditor({
|
||||||
onShareWorkflow={handleShareWorkflow}
|
onShareWorkflow={handleShareWorkflow}
|
||||||
shareUrl={shareUrl}
|
shareUrl={shareUrl}
|
||||||
onCopyShareUrl={handleCopyShareUrl}
|
onCopyShareUrl={handleCopyShareUrl}
|
||||||
|
shareMode={shareMode}
|
||||||
|
setShareMode={setShareMode}
|
||||||
|
communityData={communityData}
|
||||||
|
setCommunityData={setCommunityData}
|
||||||
|
onCommunityPublish={handleCommunityPublish}
|
||||||
|
communityPublishing={communityPublishing}
|
||||||
|
communityPublishSuccess={communityPublishSuccess}
|
||||||
onPublishWorkflow={handlePublishWorkflow}
|
onPublishWorkflow={handlePublishWorkflow}
|
||||||
onChangeMode={onChangeMode}
|
onChangeMode={onChangeMode}
|
||||||
onRevertToLive={handleRevertToLive}
|
onRevertToLive={handleRevertToLive}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
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 { createProjectWithOptions, createProjectFromJsonWithOptions, createProjectFromTemplate } from "../lib/project-creation-utils";
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
@ -16,6 +16,8 @@ import { Tabs, Tab } from "@/components/ui/tabs";
|
||||||
import { Project } from "@/src/entities/models/project";
|
import { Project } from "@/src/entities/models/project";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import Link from 'next/link';
|
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';
|
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 [promptError, setPromptError] = useState<string | null>(null);
|
||||||
const [importLoading, setImportLoading] = useState(false);
|
const [importLoading, setImportLoading] = useState(false);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
// Library templates (paginated)
|
||||||
const [templates, setTemplates] = useState<any[]>([]);
|
const [templates, setTemplates] = useState<any[]>([]);
|
||||||
const [templatesLoading, setTemplatesLoading] = useState(false);
|
const [templatesLoading, setTemplatesLoading] = useState(false);
|
||||||
const [templatesError, setTemplatesError] = useState<string | null>(null);
|
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 [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
|
||||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
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
|
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);
|
setTemplatesLoading(true);
|
||||||
setTemplatesError(null);
|
setTemplatesError(null);
|
||||||
try {
|
try {
|
||||||
const templatesArray = await listTemplates();
|
const params = new URLSearchParams({ source: 'library', limit: String(limit) });
|
||||||
setTemplates(templatesArray);
|
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) {
|
} 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');
|
setTemplatesError(error instanceof Error ? error.message : 'Failed to load templates');
|
||||||
} finally {
|
} finally {
|
||||||
setTemplatesLoading(false);
|
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
|
// Handle template selection
|
||||||
const handleTemplateSelect = async (template: any) => {
|
const handleTemplateSelect = async (template: any) => {
|
||||||
// Show a small non-blocking spinner on the clicked card
|
// Show a small non-blocking spinner on the clicked card
|
||||||
setLoadingTemplateId(template.id);
|
setLoadingTemplateId(template.id);
|
||||||
try {
|
try {
|
||||||
await createProjectWithOptions({
|
if (template.type === 'prebuilt') {
|
||||||
template: template.id,
|
// Fetch full workflow from unified API, then create from JSON
|
||||||
// Prefer a card-specific copilot prompt if present on the template JSON
|
const res = await fetch(`/api/assistant-templates/${template.id}`);
|
||||||
prompt: template.copilotPrompt || 'Explain this workflow',
|
if (!res.ok) throw new Error('Failed to fetch template details');
|
||||||
router,
|
const data = await res.json();
|
||||||
onError: () => {
|
await createProjectFromJsonWithOptions({
|
||||||
// Clear loading state if creation fails
|
workflowJson: JSON.stringify(data.workflow),
|
||||||
setLoadingTemplateId(null);
|
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) {
|
} catch (_err) {
|
||||||
// In case of unexpected error, clear loading state
|
// In case of unexpected error, clear loading state
|
||||||
setLoadingTemplateId(null);
|
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
|
// Handle prompt card selection
|
||||||
const handlePromptSelect = (promptText: string) => {
|
const handlePromptSelect = (promptText: string) => {
|
||||||
setUserPrompt(promptText);
|
setUserPrompt(promptText);
|
||||||
|
|
@ -145,8 +290,9 @@ export function BuildAssistantSection() {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTemplates();
|
// Load initial library templates to fill 4 rows x up to 3 columns ≈ 12
|
||||||
fetchProjects();
|
fetchProjects();
|
||||||
|
ensureTemplatesLoaded('prebuilt', 12);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle URL parameters for auto-creation and direct redirect to build view
|
// Handle URL parameters for auto-creation and direct redirect to build view
|
||||||
|
|
@ -184,19 +330,40 @@ export function BuildAssistantSection() {
|
||||||
|
|
||||||
if (urlPrompt || urlTemplate) {
|
if (urlPrompt || urlTemplate) {
|
||||||
setAutoCreateLoading(true);
|
setAutoCreateLoading(true);
|
||||||
createProjectWithOptions({
|
try {
|
||||||
template: urlTemplate || undefined,
|
const isMongoId = !!urlTemplate && /^[a-f0-9]{24}$/i.test(urlTemplate);
|
||||||
prompt: urlPrompt || undefined,
|
if (urlTemplate && isMongoId) {
|
||||||
router,
|
// New-style share: template is an assistant-templates id
|
||||||
onError: (error) => {
|
const res = await fetch(`/api/assistant-templates/${urlTemplate}`);
|
||||||
console.error('Error auto-creating project:', error);
|
if (!res.ok) throw new Error('Failed to fetch shared template');
|
||||||
setAutoCreateLoading(false);
|
const data = await res.json();
|
||||||
// Fall back to showing the form with the prompt pre-filled
|
await createProjectFromJsonWithOptions({
|
||||||
if (urlPrompt) {
|
workflowJson: JSON.stringify(data.workflow),
|
||||||
setUserPrompt(urlPrompt);
|
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 */}
|
{/* Tabs Section */}
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="p-6 pb-0">
|
<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">
|
<Tab key="new" title="New Assistant">
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<div className="flex items-center gap-12">
|
<div className="flex items-center gap-12">
|
||||||
|
|
@ -460,151 +629,65 @@ export function BuildAssistantSection() {
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{selectedTab === 'new' && SHOW_PREBUILT_CARDS && (
|
||||||
<div className="max-w-5xl mx-auto mt-16">
|
<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">
|
<UnifiedTemplatesSection
|
||||||
<div className="text-left mb-6">
|
prebuiltTemplates={templates.map(template => ({
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
id: template.id,
|
||||||
Prebuilt Assistants
|
name: template.name,
|
||||||
</h2>
|
description: template.description,
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
category: template.category || 'Other',
|
||||||
Start quickly and let Skipper adapt it to your needs.
|
tools: template.tools,
|
||||||
</p>
|
type: 'prebuilt' as const,
|
||||||
</div>
|
likeCount: (template as any).likeCount || 0,
|
||||||
{templatesLoading ? (
|
isLiked: (template as any).isLiked || false,
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-gray-500 dark:text-gray-400">
|
}))}
|
||||||
Loading pre-built assistants...
|
communityTemplates={communityTemplates.map(template => ({
|
||||||
</div>
|
id: template.id,
|
||||||
) : templatesError ? (
|
name: template.name,
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-red-500 dark:text-red-400">
|
description: template.description,
|
||||||
Error: {templatesError}
|
category: template.category,
|
||||||
</div>
|
authorId: template.authorId,
|
||||||
) : templates.length === 0 ? (
|
source: template.source,
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-gray-500 dark:text-gray-400">
|
authorName: template.authorName,
|
||||||
No pre-built assistants available
|
isAnonymous: template.isAnonymous,
|
||||||
</div>
|
likeCount: template.likeCount,
|
||||||
) : (
|
createdAt: template.publishedAt,
|
||||||
(() => {
|
isLiked: template.isLiked,
|
||||||
const workTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'work productivity');
|
type: 'community' as const,
|
||||||
const devTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'developer productivity');
|
}))}
|
||||||
const newsTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'news & social');
|
loading={templatesLoading || communityTemplatesLoading}
|
||||||
const customerSupportTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'support');
|
error={templatesError || communityTemplatesError}
|
||||||
|
onTemplateClick={handleTemplateSelect}
|
||||||
const renderGrid = (items: any[]) => (
|
onRetry={() => {
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
fetchLibraryTemplatesPage(undefined, 12);
|
||||||
{items.map((template) => (
|
fetchCommunityTemplatesPage(undefined, 12);
|
||||||
<button
|
}}
|
||||||
key={template.id}
|
loadingItemId={loadingTemplateId}
|
||||||
onClick={() => handleTemplateSelect(template)}
|
onLike={handleTemplateLike}
|
||||||
disabled={loadingTemplateId === template.id}
|
onShare={handleTemplateShare}
|
||||||
className={clsx(
|
onDelete={async (item) => {
|
||||||
"relative block p-4 border border-gray-200 dark:border-gray-700 rounded-xl transition-all group text-left",
|
try {
|
||||||
"hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-md",
|
const resp = await fetch(`/api/assistant-templates/${item.id}`, { method: 'DELETE' });
|
||||||
loadingTemplateId === template.id && "opacity-90 cursor-not-allowed"
|
if (!resp.ok) {
|
||||||
)}
|
throw new Error('Failed to delete template');
|
||||||
>
|
}
|
||||||
<div className="space-y-2">
|
setCommunityTemplates(prev => prev.filter(t => t.id !== item.id));
|
||||||
<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">
|
} catch (e) {
|
||||||
{template.name}
|
console.error(e);
|
||||||
</div>
|
// Optional: surface non-blocking feedback; keeping console error for now
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
}
|
||||||
{template.description}
|
}}
|
||||||
</div>
|
getUniqueTools={getUniqueTools}
|
||||||
|
onLoadMore={async (type, target) => {
|
||||||
{(() => {
|
await ensureTemplatesLoaded(type, target);
|
||||||
const tools = getUniqueTools(template);
|
}}
|
||||||
return tools.length > 0 && (
|
onTypeChange={async (type, target) => {
|
||||||
<div className="flex items-center gap-2 mt-2">
|
await ensureTemplatesLoaded(type, target);
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
307
apps/rowboat/components/common/AssistantCard.tsx
Normal file
307
apps/rowboat/components/common/AssistantCard.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
'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;
|
||||||
|
onDelete?: () => void;
|
||||||
|
isLiked?: boolean;
|
||||||
|
// Template type indicator
|
||||||
|
templateType?: 'prebuilt' | 'community';
|
||||||
|
// 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,
|
||||||
|
onDelete,
|
||||||
|
templateType,
|
||||||
|
onClick,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
getUniqueTools
|
||||||
|
}: AssistantCardProps) {
|
||||||
|
const displayTools = getUniqueTools ? getUniqueTools({ tools }) : tools;
|
||||||
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = React.useState(false);
|
||||||
|
const [showDescriptionToggle, setShowDescriptionToggle] = React.useState(false);
|
||||||
|
const descriptionRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
let t: any;
|
||||||
|
if (copied) {
|
||||||
|
t = setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
return () => t && clearTimeout(t);
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = descriptionRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
// Measure if truncated (only when collapsed)
|
||||||
|
if (!isDescriptionExpanded) {
|
||||||
|
setShowDescriptionToggle(el.scrollHeight > el.clientHeight + 1);
|
||||||
|
} else {
|
||||||
|
setShowDescriptionToggle(true);
|
||||||
|
}
|
||||||
|
}, [description, isDescriptionExpanded]);
|
||||||
|
|
||||||
|
const getCategoryColor = (category: string) => {
|
||||||
|
const lowerCategory = category.toLowerCase();
|
||||||
|
if (lowerCategory.includes('work productivity')) {
|
||||||
|
return 'bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-300';
|
||||||
|
} else if (lowerCategory.includes('developer productivity')) {
|
||||||
|
return 'bg-indigo-50 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-300';
|
||||||
|
} else if (lowerCategory.includes('news') || lowerCategory.includes('social')) {
|
||||||
|
return 'bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-300';
|
||||||
|
} else if (lowerCategory.includes('customer support')) {
|
||||||
|
return 'bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-300';
|
||||||
|
} else if (lowerCategory.includes('education')) {
|
||||||
|
return 'bg-blue-50 text-blue-700 dark:bg-blue-400/10 dark:text-blue-300';
|
||||||
|
} else if (lowerCategory.includes('entertainment')) {
|
||||||
|
return 'bg-purple-50 text-purple-700 dark:bg-purple-400/10 dark:text-purple-300';
|
||||||
|
} else {
|
||||||
|
return 'bg-gray-50 text-gray-700 dark:bg-gray-400/10 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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="flex items-start justify-between gap-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 flex-1">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
{/* Template Type Badge */}
|
||||||
|
{templateType && (
|
||||||
|
<span className={clsx(
|
||||||
|
"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium flex-shrink-0",
|
||||||
|
templateType === 'prebuilt'
|
||||||
|
? "bg-blue-50 text-blue-700 dark:bg-blue-400/10 dark:text-blue-300"
|
||||||
|
: "bg-rose-50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-300"
|
||||||
|
)}>
|
||||||
|
{templateType === 'prebuilt' ? 'Library' : 'Community'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<div
|
||||||
|
ref={descriptionRef}
|
||||||
|
className={clsx(
|
||||||
|
"text-sm leading-5 text-gray-600 dark:text-gray-400 relative min-h-[2.5rem]",
|
||||||
|
(!isDescriptionExpanded && showDescriptionToggle) && "pr-20",
|
||||||
|
!isDescriptionExpanded && "line-clamp-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
{showDescriptionToggle && (
|
||||||
|
!isDescriptionExpanded ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="absolute bottom-0 right-0 h-5 w-24 pl-2 flex items-center justify-end bg-gradient-to-l from-white dark:from-gray-800/95 to-transparent">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setIsDescriptionExpanded(true); }}
|
||||||
|
className="pointer-events-auto text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 px-1"
|
||||||
|
aria-label="Read more"
|
||||||
|
>
|
||||||
|
Read more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setIsDescriptionExpanded(false); }}
|
||||||
|
className="mt-1 text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
aria-label="Show less"
|
||||||
|
>
|
||||||
|
Show less
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tools (reserve row height even when absent to align cards) */}
|
||||||
|
<div className="flex items-center gap-2 min-h-[20px] -mt-1">
|
||||||
|
{displayTools.length > 0 && (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Category Badge */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={clsx(
|
||||||
|
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Author and interaction info */}
|
||||||
|
<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 ? (authorName.split(' ')[0] || 'Rowboat') : 'Rowboat')}
|
||||||
|
</span>
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
className="ml-1 inline-flex items-center justify-center text-gray-400 hover:text-red-600 transition-colors"
|
||||||
|
aria-label="Delete template"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{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 || 0}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCopied(true);
|
||||||
|
onShare?.();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 hover:text-blue-500 transition-colors"
|
||||||
|
aria-label="Copy share URL"
|
||||||
|
>
|
||||||
|
<Share2 size={14} className={copied ? "text-blue-600" : undefined} />
|
||||||
|
{copied && <span className="text-[10px] text-blue-600">Copied</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
apps/rowboat/components/common/AssistantSection.tsx
Normal file
238
apps/rowboat/components/common/AssistantSection.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
onClick={() => onItemClick?.(item)}
|
||||||
|
loading={loadingItemId === item.id}
|
||||||
|
getUniqueTools={getUniqueTools}
|
||||||
|
onLike={onLike ? () => onLike(item) : undefined}
|
||||||
|
onShare={onShare ? () => onShare(item) : undefined}
|
||||||
|
isLiked={item.isLiked}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
468
apps/rowboat/components/common/UnifiedTemplatesSection.tsx
Normal file
468
apps/rowboat/components/common/UnifiedTemplatesSection.tsx
Normal file
|
|
@ -0,0 +1,468 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Input } from "@heroui/react";
|
||||||
|
import { Search, Filter } from 'lucide-react';
|
||||||
|
import { AssistantCard } from './AssistantCard';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface TemplateItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
authorId?: string;
|
||||||
|
source?: 'library' | 'community';
|
||||||
|
tools?: Array<{
|
||||||
|
name: string;
|
||||||
|
logo?: string;
|
||||||
|
}>;
|
||||||
|
// Community-specific
|
||||||
|
authorName?: string;
|
||||||
|
isAnonymous?: boolean;
|
||||||
|
likeCount?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
isLiked?: boolean;
|
||||||
|
// Template type indicator
|
||||||
|
type: 'prebuilt' | 'community';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedTemplatesSectionProps {
|
||||||
|
prebuiltTemplates: TemplateItem[];
|
||||||
|
communityTemplates: TemplateItem[];
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onTemplateClick?: (item: TemplateItem) => void;
|
||||||
|
onRetry?: () => void;
|
||||||
|
loadingItemId?: string | null;
|
||||||
|
onLike?: (item: TemplateItem) => void;
|
||||||
|
onShare?: (item: TemplateItem) => void;
|
||||||
|
onDelete?: (item: TemplateItem) => void;
|
||||||
|
getUniqueTools?: (item: TemplateItem) => Array<{ name: string; logo?: string }>;
|
||||||
|
onLoadMore?: (type: 'prebuilt' | 'community', targetCount: number) => Promise<void> | void;
|
||||||
|
onTypeChange?: (type: 'prebuilt' | 'community', targetCount: number) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnifiedTemplatesSection({
|
||||||
|
prebuiltTemplates,
|
||||||
|
communityTemplates,
|
||||||
|
loading = false,
|
||||||
|
error = null,
|
||||||
|
onTemplateClick,
|
||||||
|
onRetry,
|
||||||
|
loadingItemId = null,
|
||||||
|
onLike,
|
||||||
|
onShare,
|
||||||
|
onDelete,
|
||||||
|
getUniqueTools,
|
||||||
|
onLoadMore,
|
||||||
|
onTypeChange,
|
||||||
|
}: UnifiedTemplatesSectionProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedType, setSelectedType] = useState<'prebuilt' | 'community'>('prebuilt');
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
|
||||||
|
const [sortBy, setSortBy] = useState<'popular' | 'newest' | 'alphabetical'>('popular');
|
||||||
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [pendingDeleteItem, setPendingDeleteItem] = useState<TemplateItem | null>(null);
|
||||||
|
|
||||||
|
// Row-based pagination state
|
||||||
|
const [columns, setColumns] = useState<number>(1);
|
||||||
|
const [rowsShown, setRowsShown] = useState<number>(4);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/me', { cache: 'no-store' });
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (isMounted) setCurrentUserId(data.id || null);
|
||||||
|
} catch (_e) {}
|
||||||
|
})();
|
||||||
|
return () => { isMounted = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Combine all templates
|
||||||
|
const allTemplates = useMemo(() => {
|
||||||
|
const combined = [
|
||||||
|
...prebuiltTemplates.map(t => ({ ...t, type: 'prebuilt' as const })),
|
||||||
|
...communityTemplates.map(t => ({ ...t, type: 'community' as const }))
|
||||||
|
];
|
||||||
|
return combined;
|
||||||
|
}, [prebuiltTemplates, communityTemplates]);
|
||||||
|
|
||||||
|
// Get available categories
|
||||||
|
const availableCategories = useMemo(() => {
|
||||||
|
const categories = new Set(allTemplates.map(item => item.category));
|
||||||
|
return Array.from(categories).sort();
|
||||||
|
}, [allTemplates]);
|
||||||
|
|
||||||
|
// Filter and sort templates
|
||||||
|
const filteredTemplates = useMemo(() => {
|
||||||
|
let filtered = [...allTemplates];
|
||||||
|
|
||||||
|
// 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 type filter (default to 'prebuilt' / Library)
|
||||||
|
filtered = filtered.filter(item => item.type === selectedType);
|
||||||
|
|
||||||
|
// Apply category filter
|
||||||
|
if (selectedCategories.size > 0) {
|
||||||
|
filtered = filtered.filter(item => selectedCategories.has(item.category));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'newest':
|
||||||
|
if (a.createdAt && b.createdAt) {
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
case 'alphabetical':
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
case 'popular':
|
||||||
|
default:
|
||||||
|
// Sort across both types by like count desc; tie-break by createdAt desc, then name
|
||||||
|
{
|
||||||
|
const aLikes = a.likeCount || 0;
|
||||||
|
const bLikes = b.likeCount || 0;
|
||||||
|
if (bLikes !== aLikes) return bLikes - aLikes;
|
||||||
|
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
|
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
|
if (bTime !== aTime) return bTime - aTime;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [allTemplates, searchQuery, selectedType, selectedCategories, sortBy]);
|
||||||
|
|
||||||
|
// Determine columns based on Tailwind breakpoints used by the grid
|
||||||
|
useEffect(() => {
|
||||||
|
const computeColumns = () => {
|
||||||
|
if (typeof window === 'undefined') return 1;
|
||||||
|
// Tailwind: grid-cols-1 sm:grid-cols-2 lg:grid-cols-3
|
||||||
|
const isLg = window.matchMedia('(min-width: 1024px)').matches;
|
||||||
|
const isSm = window.matchMedia('(min-width: 640px)').matches;
|
||||||
|
return isLg ? 3 : (isSm ? 2 : 1);
|
||||||
|
};
|
||||||
|
const update = () => setColumns(computeColumns());
|
||||||
|
update();
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
return () => window.removeEventListener('resize', update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset rowsShown when filters/sort change
|
||||||
|
useEffect(() => {
|
||||||
|
setRowsShown(4);
|
||||||
|
}, [searchQuery, selectedType, selectedCategories, sortBy]);
|
||||||
|
|
||||||
|
const itemsPerRow = Math.max(columns, 1);
|
||||||
|
const visibleCount = rowsShown * itemsPerRow;
|
||||||
|
const hasMore = filteredTemplates.length > visibleCount;
|
||||||
|
const remainingItems = Math.max(filteredTemplates.length - visibleCount, 0);
|
||||||
|
const remainingRows = Math.ceil(remainingItems / itemsPerRow);
|
||||||
|
|
||||||
|
const visibleTemplates = filteredTemplates.slice(0, visibleCount);
|
||||||
|
|
||||||
|
// Handle category toggle
|
||||||
|
const toggleCategory = (category: string) => {
|
||||||
|
setSelectedCategories(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(category)) {
|
||||||
|
newSet.delete(category);
|
||||||
|
} else {
|
||||||
|
newSet.add(category);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear all filters (default type back to 'prebuilt' / Library)
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedType('prebuilt');
|
||||||
|
setSelectedCategories(new Set());
|
||||||
|
setSortBy('popular');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if any filters are active
|
||||||
|
const hasActiveFilters = useMemo(() => {
|
||||||
|
return !!searchQuery || selectedType !== 'prebuilt' || selectedCategories.size > 0;
|
||||||
|
}, [searchQuery, selectedType, selectedCategories]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
Templates
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Discover and use pre-built and community templates.
|
||||||
|
</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 templates...</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">
|
||||||
|
Templates
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Discover and use pre-built and community templates.
|
||||||
|
</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">
|
||||||
|
Templates
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Discover and use pre-built and community templates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
{/* Search and Type Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Search templates..."
|
||||||
|
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">
|
||||||
|
{/* Type Filter Segmented Control (Library | Community) */}
|
||||||
|
<div className="flex gap-0.5 items-center h-8 rounded-full border border-gray-200 dark:border-gray-700 p-0 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
|
||||||
|
{[
|
||||||
|
{ key: 'prebuilt', label: 'Library' },
|
||||||
|
{ key: 'community', label: 'Community' }
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={async () => {
|
||||||
|
const newType = key as 'prebuilt' | 'community';
|
||||||
|
const target = rowsShown * itemsPerRow;
|
||||||
|
if (onTypeChange) {
|
||||||
|
await onTypeChange(newType, target);
|
||||||
|
}
|
||||||
|
setSelectedType(newType);
|
||||||
|
}}
|
||||||
|
aria-pressed={selectedType === key}
|
||||||
|
className={`inline-flex items-center h-8 px-2.5 rounded-full text-[13px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 ${
|
||||||
|
selectedType === key
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: 'bg-transparent text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as any)}
|
||||||
|
className="w-44 h-8 px-4 pr-10 border border-gray-300 dark:border-gray-700 rounded-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 appearance-none text-sm hover:bg-gray-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||||
|
>
|
||||||
|
<option value="popular">Most Popular</option>
|
||||||
|
<option value="newest">Newest First</option>
|
||||||
|
<option value="alphabetical">A-Z</option>
|
||||||
|
</select>
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 right-3 flex items-center">
|
||||||
|
<svg className="w-4 h-4 text-gray-400 -translate-y-[2px]" 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>
|
||||||
|
|
||||||
|
{/* Category Filters */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<Filter size={14} />
|
||||||
|
<span>Categories:</span>
|
||||||
|
</div>
|
||||||
|
{availableCategories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => toggleCategory(category)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors border shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 ${
|
||||||
|
selectedCategories.has(category)
|
||||||
|
? 'bg-blue-50 text-blue-700 border-blue-300 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-700'
|
||||||
|
: 'bg-gray-50 text-gray-700 border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters Button */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Clear all filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredTemplates.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{searchQuery || selectedType !== 'prebuilt' || selectedCategories.size > 0
|
||||||
|
? 'No templates found matching your filters'
|
||||||
|
: 'No templates available'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{(searchQuery || selectedType !== 'prebuilt' || selectedCategories.size > 0) && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Showing {Math.min(visibleCount, filteredTemplates.length)} of {filteredTemplates.length} template{filteredTemplates.length !== 1 ? 's' : ''} ({rowsShown} row{rowsShown !== 1 ? 's' : ''})
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{visibleTemplates.map((item) => (
|
||||||
|
<AssistantCard
|
||||||
|
key={`${item.type}-${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}
|
||||||
|
onClick={() => onTemplateClick?.(item)}
|
||||||
|
loading={loadingItemId === item.id}
|
||||||
|
getUniqueTools={getUniqueTools}
|
||||||
|
onLike={() => onLike?.(item)}
|
||||||
|
onShare={() => onShare?.(item)}
|
||||||
|
onDelete={onDelete && currentUserId && item.type === 'community' && item.authorId === currentUserId ? () => {
|
||||||
|
setPendingDeleteItem(item);
|
||||||
|
setConfirmOpen(true);
|
||||||
|
} : undefined}
|
||||||
|
isLiked={item.isLiked}
|
||||||
|
templateType={item.type}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex items-center justify-center pt-2">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const target = (rowsShown + 4) * itemsPerRow;
|
||||||
|
if (onLoadMore) {
|
||||||
|
await onLoadMore(selectedType, target);
|
||||||
|
}
|
||||||
|
setRowsShown(prev => prev + 4);
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
View more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation modal */}
|
||||||
|
{confirmOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 max-w-sm w-full p-5">
|
||||||
|
<div className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Delete template?</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
This will permanently remove "{pendingDeleteItem?.name}" from the community templates. This action cannot be undone.
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setConfirmOpen(false); setPendingDeleteItem(null); }}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (pendingDeleteItem && onDelete) {
|
||||||
|
await onDelete(pendingDeleteItem);
|
||||||
|
}
|
||||||
|
setConfirmOpen(false);
|
||||||
|
setPendingDeleteItem(null);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -95,6 +95,29 @@ export class CreateProjectUseCase implements ICreateProjectUseCase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the Gemini Image tool is always available by default
|
||||||
|
const hasImageTool = (workflow.tools || []).some(t => t.isGeminiImage || t.name === 'Generate Image');
|
||||||
|
if (!hasImageTool) {
|
||||||
|
const imageTool = {
|
||||||
|
name: 'Generate Image',
|
||||||
|
description: 'Generate an image using Google Gemini given a text prompt. Returns base64-encoded image data and any text parts.',
|
||||||
|
isGeminiImage: true,
|
||||||
|
parameters: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
prompt: { type: 'string', description: 'Text prompt describing the image to generate' },
|
||||||
|
modelName: { type: 'string', description: 'Optional Gemini model override' },
|
||||||
|
},
|
||||||
|
required: ['prompt'],
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
workflow = {
|
||||||
|
...workflow,
|
||||||
|
tools: [...(workflow.tools || []), imageTool] as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// create project secret
|
// create project secret
|
||||||
const secret = crypto.randomBytes(32).toString('hex');
|
const secret = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
|
|
||||||
42
apps/rowboat/src/entities/models/assistant-template.ts
Normal file
42
apps/rowboat/src/entities/models/assistant-template.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Workflow } from "../../../app/lib/types/workflow_types";
|
||||||
|
|
||||||
|
export const AssistantTemplate = 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([]),
|
||||||
|
// Template-like metadata
|
||||||
|
copilotPrompt: z.string().optional(),
|
||||||
|
thumbnailUrl: z.string().optional(),
|
||||||
|
// New field to indicate source of template
|
||||||
|
source: z.enum(["library", "community"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AssistantTemplate = z.infer<typeof AssistantTemplate>;
|
||||||
|
|
||||||
|
export const AssistantTemplateLike = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
assistantId: z.string(),
|
||||||
|
userId: z.string(),
|
||||||
|
userEmail: z.string().optional(),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AssistantTemplateLike = z.infer<typeof AssistantTemplateLike>;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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 { 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 { USERS_COLLECTION, USERS_INDEXES } from "../repositories/mongodb.users.indexes";
|
||||||
import { SHARED_WORKFLOWS_COLLECTION, SHARED_WORKFLOWS_INDEXES } from "../repositories/mongodb.shared-workflows.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> {
|
export async function ensureAllIndexes(database: Db): Promise<void> {
|
||||||
await database.collection(API_KEYS_COLLECTION).createIndexes(API_KEYS_INDEXES);
|
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(COMPOSIO_TRIGGER_DEPLOYMENTS_COLLECTION).createIndexes(COMPOSIO_TRIGGER_DEPLOYMENTS_INDEXES);
|
||||||
await database.collection(USERS_COLLECTION).createIndexes(USERS_INDEXES);
|
await database.collection(USERS_COLLECTION).createIndexes(USERS_INDEXES);
|
||||||
await database.collection(SHARED_WORKFLOWS_COLLECTION).createIndexes(SHARED_WORKFLOWS_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,92 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Filter, ObjectId } from "mongodb";
|
||||||
|
import { db } from "@/app/lib/mongodb";
|
||||||
|
import { AssistantTemplate, AssistantTemplateLike } from "@/src/entities/models/assistant-template";
|
||||||
|
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||||
|
|
||||||
|
const DocSchema = AssistantTemplate.omit({ id: true });
|
||||||
|
const LikeDocSchema = AssistantTemplateLike.omit({ id: true });
|
||||||
|
|
||||||
|
export class MongoDBAssistantTemplatesRepository {
|
||||||
|
private readonly collection = db.collection<z.infer<typeof DocSchema>>("assistant_templates");
|
||||||
|
private readonly likesCollection = db.collection<z.infer<typeof LikeDocSchema>>("assistant_template_likes");
|
||||||
|
|
||||||
|
async create(data: Omit<z.infer<typeof AssistantTemplate>, 'id' | 'publishedAt' | 'lastUpdatedAt'>): Promise<z.infer<typeof AssistantTemplate>> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const _id = new ObjectId();
|
||||||
|
const doc: z.infer<typeof DocSchema> = { ...data, publishedAt: now, lastUpdatedAt: now } as any;
|
||||||
|
await this.collection.insertOne({ ...doc, _id });
|
||||||
|
return { ...doc, id: _id.toString() } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(id: string): Promise<z.infer<typeof AssistantTemplate> | null> {
|
||||||
|
const result = await this.collection.findOne({ _id: new ObjectId(id) });
|
||||||
|
if (!result) return null;
|
||||||
|
return { ...result, id: result._id.toString() } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(filters: {
|
||||||
|
category?: string;
|
||||||
|
search?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
isPublic?: boolean;
|
||||||
|
authorId?: string;
|
||||||
|
source?: 'library' | 'community';
|
||||||
|
} = {}, cursor?: string, limit: number = 20): Promise<z.infer<ReturnType<typeof PaginatedList<typeof AssistantTemplate>>>> {
|
||||||
|
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.source) query.source = filters.source;
|
||||||
|
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(r => ({ ...r, id: r._id.toString() }));
|
||||||
|
const nextCursor = results.length === limit ? (skip + limit).toString() : null;
|
||||||
|
return { items, nextCursor } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleLike(assistantId: string, userId: string, userEmail?: string): Promise<{ liked: boolean; likeCount: number }> {
|
||||||
|
const existingLike = await this.likesCollection.findOne({ assistantId, userId });
|
||||||
|
if (existingLike) {
|
||||||
|
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 {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await this.likesCollection.insertOne({ assistantId, userId, userEmail, createdAt: now } as any);
|
||||||
|
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 getCategories(): Promise<string[]> {
|
||||||
|
const categories = await this.collection.distinct('category', { isPublic: true });
|
||||||
|
return categories.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByIdAndAuthor(id: string, authorId: string): Promise<boolean> {
|
||||||
|
const result = await this.collection.deleteOne({ _id: new ObjectId(id), authorId } as any);
|
||||||
|
if (result.deletedCount && result.deletedCount > 0) {
|
||||||
|
// Clean up likes associated with this assistant template
|
||||||
|
await this.likesCollection.deleteMany({ assistantId: id } as any);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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" },
|
||||||
|
];
|
||||||
Loading…
Add table
Add a link
Reference in a new issue