mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-31 19:15:17 +02:00
Update templates and publishing to use server-side auth + fix likes-related glitches
This commit is contained in:
parent
041ab25b7b
commit
8a86b892d0
9 changed files with 317 additions and 340 deletions
204
apps/rowboat/app/actions/assistant-templates.actions.ts
Normal file
204
apps/rowboat/app/actions/assistant-templates.actions.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authCheck } from "./auth.actions";
|
||||||
|
import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository';
|
||||||
|
import { ensureLibraryTemplatesSeeded } from '@/app/lib/assistant_templates_seed';
|
||||||
|
import { auth0 } from '@/app/lib/auth0';
|
||||||
|
import { USE_AUTH } from '@/app/lib/feature_flags';
|
||||||
|
|
||||||
|
const repo = new MongoDBAssistantTemplatesRepository();
|
||||||
|
|
||||||
|
// Helper function to serialize MongoDB objects for client components
|
||||||
|
function serializeTemplate(template: any) {
|
||||||
|
return JSON.parse(JSON.stringify(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeTemplates(templates: any[]) {
|
||||||
|
return templates.map(serializeTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListTemplatesSchema = 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 CreateTemplateSchema = 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 listAssistantTemplates(request: z.infer<typeof ListTemplatesSchema>) {
|
||||||
|
const user = await authCheck();
|
||||||
|
|
||||||
|
// Ensure library JSONs are seeded into the unified collection (idempotent)
|
||||||
|
await ensureLibraryTemplatesSeeded();
|
||||||
|
|
||||||
|
const params = ListTemplatesSchema.parse(request);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Add isLiked status to each template
|
||||||
|
const itemsWithLikeStatus = await addLikeStatusToTemplates(result.items, user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
items: serializeTemplates(itemsWithLikeStatus)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add isLiked status to all templates
|
||||||
|
const allTemplates = [...lib.items, ...com.items];
|
||||||
|
const itemsWithLikeStatus = await addLikeStatusToTemplates(allTemplates, user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: serializeTemplates(itemsWithLikeStatus),
|
||||||
|
nextCursor: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAssistantTemplateCategories() {
|
||||||
|
const user = await authCheck();
|
||||||
|
|
||||||
|
const categories = await repo.getCategories();
|
||||||
|
return { items: categories };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAssistantTemplate(id: string) {
|
||||||
|
const user = await authCheck();
|
||||||
|
|
||||||
|
const item = await repo.fetch(id);
|
||||||
|
if (!item) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
return serializeTemplate(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAssistantTemplate(data: z.infer<typeof CreateTemplateSchema>) {
|
||||||
|
const user = await authCheck();
|
||||||
|
|
||||||
|
const validatedData = CreateTemplateSchema.parse(data);
|
||||||
|
|
||||||
|
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 (validatedData.isAnonymous) {
|
||||||
|
authorName = 'Anonymous';
|
||||||
|
authorEmail = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await repo.create({
|
||||||
|
name: validatedData.name,
|
||||||
|
description: validatedData.description,
|
||||||
|
category: validatedData.category,
|
||||||
|
authorId: user.id,
|
||||||
|
authorName,
|
||||||
|
authorEmail,
|
||||||
|
isAnonymous: validatedData.isAnonymous,
|
||||||
|
workflow: validatedData.workflow,
|
||||||
|
tags: validatedData.tags,
|
||||||
|
copilotPrompt: validatedData.copilotPrompt,
|
||||||
|
thumbnailUrl: validatedData.thumbnailUrl,
|
||||||
|
downloadCount: 0,
|
||||||
|
likeCount: 0,
|
||||||
|
featured: false,
|
||||||
|
isPublic: true,
|
||||||
|
likes: [],
|
||||||
|
source: 'community',
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeTemplate(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAssistantTemplate(id: string) {
|
||||||
|
const user = await authCheck();
|
||||||
|
|
||||||
|
const item = await repo.fetch(id);
|
||||||
|
if (!item) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow deleting library/prebuilt items
|
||||||
|
if ((item as any).source === 'library' || item.authorId === 'rowboat-system') {
|
||||||
|
throw new Error('Not allowed to delete this template');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.authorId !== user.id) {
|
||||||
|
// Do not reveal existence
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await repo.deleteByIdAndAuthor(id, user.id);
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleTemplateLike(id: string) {
|
||||||
|
const user = await authCheck();
|
||||||
|
|
||||||
|
// Use authenticated user ID instead of guest ID
|
||||||
|
const result = await repo.toggleLike(id, user.id);
|
||||||
|
return serializeTemplate(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser() {
|
||||||
|
const user = await authCheck();
|
||||||
|
return { id: user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to add isLiked status to templates
|
||||||
|
async function addLikeStatusToTemplates(templates: any[], userId: string) {
|
||||||
|
if (templates.length === 0) return templates;
|
||||||
|
|
||||||
|
// Get all template IDs
|
||||||
|
const templateIds = templates.map(t => t.id);
|
||||||
|
|
||||||
|
// Check which templates the user has liked
|
||||||
|
const likedTemplates = await repo.getLikedTemplates(templateIds, userId);
|
||||||
|
const likedSet = new Set(likedTemplates);
|
||||||
|
|
||||||
|
// Add isLiked property to each template
|
||||||
|
return templates.map(template => ({
|
||||||
|
...template,
|
||||||
|
isLiked: likedSet.has(template.id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { ToolConfig } from "../entities/tool_config";
|
||||||
import { App as ChatApp } from "../playground/app";
|
import { App as ChatApp } from "../playground/app";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createSharedWorkflowFromJson } from '@/app/actions/shared-workflow.actions';
|
import { createSharedWorkflowFromJson } from '@/app/actions/shared-workflow.actions';
|
||||||
|
import { createAssistantTemplate } from '@/app/actions/assistant-templates.actions';
|
||||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
|
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
|
||||||
import { PromptConfig } from "../entities/prompt_config";
|
import { PromptConfig } from "../entities/prompt_config";
|
||||||
import { DataSourceConfig } from "../entities/datasource_config";
|
import { DataSourceConfig } from "../entities/datasource_config";
|
||||||
|
|
@ -1645,19 +1646,11 @@ export function WorkflowEditor({
|
||||||
|
|
||||||
setCommunityPublishing(true);
|
setCommunityPublishing(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/assistant-templates', {
|
await createAssistantTemplate({
|
||||||
method: 'POST',
|
...communityData,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
workflow: state.present.workflow, // Use the current workflow
|
||||||
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);
|
setCommunityPublishSuccess(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCommunityPublishSuccess(false);
|
setCommunityPublishSuccess(false);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { 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';
|
||||||
|
|
@ -19,6 +19,13 @@ import { z } from "zod";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { AssistantSection } from '@/components/common/AssistantSection';
|
import { AssistantSection } from '@/components/common/AssistantSection';
|
||||||
import { UnifiedTemplatesSection } from '@/components/common/UnifiedTemplatesSection';
|
import { UnifiedTemplatesSection } from '@/components/common/UnifiedTemplatesSection';
|
||||||
|
import {
|
||||||
|
listAssistantTemplates,
|
||||||
|
getAssistantTemplateCategories,
|
||||||
|
toggleTemplateLike,
|
||||||
|
deleteAssistantTemplate,
|
||||||
|
getAssistantTemplate
|
||||||
|
} from '@/app/actions/assistant-templates.actions';
|
||||||
|
|
||||||
const SHOW_PREBUILT_CARDS = process.env.NEXT_PUBLIC_SHOW_PREBUILT_CARDS !== 'false';
|
const SHOW_PREBUILT_CARDS = process.env.NEXT_PUBLIC_SHOW_PREBUILT_CARDS !== 'false';
|
||||||
|
|
||||||
|
|
@ -99,15 +106,15 @@ 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 fetchLibraryTemplatesPage = async (cursor?: string | null, limit: number = 20) => {
|
const fetchLibraryTemplatesPage = useCallback(async (cursor?: string | null, limit: number = 20) => {
|
||||||
setTemplatesLoading(true);
|
setTemplatesLoading(true);
|
||||||
setTemplatesError(null);
|
setTemplatesError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ source: 'library', limit: String(limit) });
|
const data = await listAssistantTemplates({
|
||||||
if (cursor) params.set('cursor', cursor);
|
source: 'library',
|
||||||
const response = await fetch(`/api/assistant-templates?${params.toString()}`);
|
limit,
|
||||||
if (!response.ok) throw new Error('Failed to fetch library templates');
|
cursor: cursor || undefined
|
||||||
const data = await response.json();
|
});
|
||||||
setTemplates(prev => cursor ? [...prev, ...data.items] : data.items);
|
setTemplates(prev => cursor ? [...prev, ...data.items] : data.items);
|
||||||
setTemplatesCursor(data.nextCursor || null);
|
setTemplatesCursor(data.nextCursor || null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -116,17 +123,17 @@ export function BuildAssistantSection() {
|
||||||
} finally {
|
} finally {
|
||||||
setTemplatesLoading(false);
|
setTemplatesLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const fetchCommunityTemplatesPage = async (cursor?: string | null, limit: number = 20) => {
|
const fetchCommunityTemplatesPage = useCallback(async (cursor?: string | null, limit: number = 20) => {
|
||||||
setCommunityTemplatesLoading(true);
|
setCommunityTemplatesLoading(true);
|
||||||
setCommunityTemplatesError(null);
|
setCommunityTemplatesError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ source: 'community', limit: String(limit) });
|
const data = await listAssistantTemplates({
|
||||||
if (cursor) params.set('cursor', cursor);
|
source: 'community',
|
||||||
const response = await fetch(`/api/assistant-templates?${params.toString()}`);
|
limit,
|
||||||
if (!response.ok) throw new Error('Failed to fetch community templates');
|
cursor: cursor || undefined
|
||||||
const data = await response.json();
|
});
|
||||||
setCommunityTemplates(prev => cursor ? [...prev, ...data.items] : data.items);
|
setCommunityTemplates(prev => cursor ? [...prev, ...data.items] : data.items);
|
||||||
setCommunityCursor(data.nextCursor || null);
|
setCommunityCursor(data.nextCursor || null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -135,10 +142,10 @@ export function BuildAssistantSection() {
|
||||||
} finally {
|
} finally {
|
||||||
setCommunityTemplatesLoading(false);
|
setCommunityTemplatesLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Ensure we have at least `targetCount` items loaded for a given type
|
// Ensure we have at least `targetCount` items loaded for a given type
|
||||||
const ensureTemplatesLoaded = async (type: 'prebuilt' | 'community', targetCount: number) => {
|
const ensureTemplatesLoaded = useCallback(async (type: 'prebuilt' | 'community', targetCount: number) => {
|
||||||
const current = type === 'prebuilt' ? templates.length : communityTemplates.length;
|
const current = type === 'prebuilt' ? templates.length : communityTemplates.length;
|
||||||
const cursor = type === 'prebuilt' ? templatesCursor : communityCursor;
|
const cursor = type === 'prebuilt' ? templatesCursor : communityCursor;
|
||||||
if (current >= targetCount) return;
|
if (current >= targetCount) return;
|
||||||
|
|
@ -159,7 +166,7 @@ export function BuildAssistantSection() {
|
||||||
needed = targetCount - (type === 'prebuilt' ? templates.length : communityTemplates.length);
|
needed = targetCount - (type === 'prebuilt' ? templates.length : communityTemplates.length);
|
||||||
if (nextCursor === null) break;
|
if (nextCursor === null) break;
|
||||||
}
|
}
|
||||||
};
|
}, [templates.length, communityTemplates.length, templatesCursor, communityCursor, fetchLibraryTemplatesPage, fetchCommunityTemplatesPage]);
|
||||||
|
|
||||||
// Handle template selection
|
// Handle template selection
|
||||||
const handleTemplateSelect = async (template: any) => {
|
const handleTemplateSelect = async (template: any) => {
|
||||||
|
|
@ -167,10 +174,8 @@ export function BuildAssistantSection() {
|
||||||
setLoadingTemplateId(template.id);
|
setLoadingTemplateId(template.id);
|
||||||
try {
|
try {
|
||||||
if (template.type === 'prebuilt') {
|
if (template.type === 'prebuilt') {
|
||||||
// Fetch full workflow from unified API, then create from JSON
|
// Fetch full workflow from server action, then create from JSON
|
||||||
const res = await fetch(`/api/assistant-templates/${template.id}`);
|
const data = await getAssistantTemplate(template.id);
|
||||||
if (!res.ok) throw new Error('Failed to fetch template details');
|
|
||||||
const data = await res.json();
|
|
||||||
await createProjectFromJsonWithOptions({
|
await createProjectFromJsonWithOptions({
|
||||||
workflowJson: JSON.stringify(data.workflow),
|
workflowJson: JSON.stringify(data.workflow),
|
||||||
router,
|
router,
|
||||||
|
|
@ -181,9 +186,7 @@ export function BuildAssistantSection() {
|
||||||
});
|
});
|
||||||
} else if (template.type === 'community') {
|
} else if (template.type === 'community') {
|
||||||
// Fetch full workflow for community template, then create from JSON
|
// Fetch full workflow for community template, then create from JSON
|
||||||
const res = await fetch(`/api/assistant-templates/${template.id}`);
|
const data = await getAssistantTemplate(template.id);
|
||||||
if (!res.ok) throw new Error('Failed to fetch community template details');
|
|
||||||
const data = await res.json();
|
|
||||||
await createProjectFromJsonWithOptions({
|
await createProjectFromJsonWithOptions({
|
||||||
workflowJson: JSON.stringify(data.workflow),
|
workflowJson: JSON.stringify(data.workflow),
|
||||||
router,
|
router,
|
||||||
|
|
@ -202,44 +205,23 @@ export function BuildAssistantSection() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stable guest id for like toggles
|
// Handle template like (unified for library and community) - now uses proper authentication
|
||||||
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) => {
|
const handleTemplateLike = async (template: any) => {
|
||||||
try {
|
try {
|
||||||
const guestId = getGuestId();
|
const data = await toggleTemplateLike(template.id);
|
||||||
const response = await fetch(`/api/assistant-templates/${template.id}/like`, {
|
|
||||||
method: 'POST',
|
if (template.type === 'community') {
|
||||||
headers: { 'x-guest-id': guestId },
|
setCommunityTemplates(prev => prev.map(t =>
|
||||||
});
|
t.id === template.id
|
||||||
|
? { ...t, likeCount: data.likeCount, isLiked: data.liked }
|
||||||
if (response.ok) {
|
: t
|
||||||
const data = await response.json();
|
));
|
||||||
if (template.type === 'community') {
|
} else {
|
||||||
setCommunityTemplates(prev => prev.map(t =>
|
setTemplates(prev => prev.map(t =>
|
||||||
t.id === template.id
|
t.id === template.id
|
||||||
? { ...t, likeCount: data.likeCount, isLiked: data.liked }
|
? { ...t, likeCount: data.likeCount, isLiked: data.liked } as any
|
||||||
: t
|
: t
|
||||||
));
|
));
|
||||||
} else {
|
|
||||||
setTemplates(prev => prev.map(t =>
|
|
||||||
t.id === template.id
|
|
||||||
? { ...t, likeCount: data.likeCount, isLiked: data.liked } as any
|
|
||||||
: t
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error toggling like:', err);
|
console.error('Error toggling like:', err);
|
||||||
|
|
@ -250,9 +232,7 @@ export function BuildAssistantSection() {
|
||||||
const handleTemplateShare = async (template: any) => {
|
const handleTemplateShare = async (template: any) => {
|
||||||
try {
|
try {
|
||||||
// Fetch workflow for the template and create a shared snapshot
|
// Fetch workflow for the template and create a shared snapshot
|
||||||
const res = await fetch(`/api/assistant-templates/${template.id}`);
|
const data = await getAssistantTemplate(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', {
|
const shareResp = await fetch('/api/shared-workflow', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -294,7 +274,7 @@ export function BuildAssistantSection() {
|
||||||
// Load initial library templates to fill 4 rows x up to 3 columns ≈ 12
|
// Load initial library templates to fill 4 rows x up to 3 columns ≈ 12
|
||||||
fetchProjects();
|
fetchProjects();
|
||||||
ensureTemplatesLoaded('prebuilt', 12);
|
ensureTemplatesLoaded('prebuilt', 12);
|
||||||
}, []);
|
}, [ensureTemplatesLoaded]);
|
||||||
|
|
||||||
// Handle URL parameters for auto-creation and direct redirect to build view
|
// Handle URL parameters for auto-creation and direct redirect to build view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -328,9 +308,7 @@ export function BuildAssistantSection() {
|
||||||
const isMongoId = !!urlTemplate && /^[a-f0-9]{24}$/i.test(urlTemplate);
|
const isMongoId = !!urlTemplate && /^[a-f0-9]{24}$/i.test(urlTemplate);
|
||||||
if (urlTemplate && isMongoId) {
|
if (urlTemplate && isMongoId) {
|
||||||
// New-style share: template is an assistant-templates id
|
// New-style share: template is an assistant-templates id
|
||||||
const res = await fetch(`/api/assistant-templates/${urlTemplate}`);
|
const data = await getAssistantTemplate(urlTemplate);
|
||||||
if (!res.ok) throw new Error('Failed to fetch shared template');
|
|
||||||
const data = await res.json();
|
|
||||||
await createProjectFromJsonWithOptions({
|
await createProjectFromJsonWithOptions({
|
||||||
workflowJson: JSON.stringify(data.workflow),
|
workflowJson: JSON.stringify(data.workflow),
|
||||||
router,
|
router,
|
||||||
|
|
@ -663,10 +641,7 @@ export function BuildAssistantSection() {
|
||||||
onShare={handleTemplateShare}
|
onShare={handleTemplateShare}
|
||||||
onDelete={async (item) => {
|
onDelete={async (item) => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/assistant-templates/${item.id}`, { method: 'DELETE' });
|
await deleteAssistantTemplate(item.id);
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error('Failed to delete template');
|
|
||||||
}
|
|
||||||
setCommunityTemplates(prev => prev.filter(t => t.id !== item.id));
|
setCommunityTemplates(prev => prev.filter(t => t.id !== item.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Input } from "@heroui/react";
|
||||||
import { Search, Filter } from 'lucide-react';
|
import { Search, Filter } from 'lucide-react';
|
||||||
import { AssistantCard } from './AssistantCard';
|
import { AssistantCard } from './AssistantCard';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getCurrentUser } from '@/app/actions/assistant-templates.actions';
|
||||||
|
|
||||||
interface TemplateItem {
|
interface TemplateItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -69,14 +70,31 @@ export function UnifiedTemplatesSection({
|
||||||
// Row-based pagination state
|
// Row-based pagination state
|
||||||
const [columns, setColumns] = useState<number>(1);
|
const [columns, setColumns] = useState<number>(1);
|
||||||
const [rowsShown, setRowsShown] = useState<number>(4);
|
const [rowsShown, setRowsShown] = useState<number>(4);
|
||||||
|
|
||||||
|
// Track if user has interacted with likes to prevent ALL re-sorting
|
||||||
|
const [hasUserInteractedWithLikes, setHasUserInteractedWithLikes] = useState(false);
|
||||||
|
const [originalOrder, setOriginalOrder] = useState<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
// Handle like interaction - capture current order and disable further sorting
|
||||||
|
const handleLike = (item: TemplateItem) => {
|
||||||
|
if (!hasUserInteractedWithLikes) {
|
||||||
|
// Capture the current sorted order when user first interacts with likes
|
||||||
|
const currentOrder = new Map<string, number>();
|
||||||
|
filteredTemplates.forEach((template, index) => {
|
||||||
|
currentOrder.set(template.id, index);
|
||||||
|
});
|
||||||
|
setOriginalOrder(currentOrder);
|
||||||
|
}
|
||||||
|
setHasUserInteractedWithLikes(true);
|
||||||
|
onLike?.(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/me', { cache: 'no-store' });
|
const data = await getCurrentUser();
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
if (isMounted) setCurrentUserId(data.id || null);
|
if (isMounted) setCurrentUserId(data.id || null);
|
||||||
} catch (_e) {}
|
} catch (_e) {}
|
||||||
})();
|
})();
|
||||||
|
|
@ -98,6 +116,7 @@ export function UnifiedTemplatesSection({
|
||||||
return Array.from(categories).sort();
|
return Array.from(categories).sort();
|
||||||
}, [allTemplates]);
|
}, [allTemplates]);
|
||||||
|
|
||||||
|
|
||||||
// Filter and sort templates
|
// Filter and sort templates
|
||||||
const filteredTemplates = useMemo(() => {
|
const filteredTemplates = useMemo(() => {
|
||||||
let filtered = [...allTemplates];
|
let filtered = [...allTemplates];
|
||||||
|
|
@ -120,33 +139,41 @@ export function UnifiedTemplatesSection({
|
||||||
filtered = filtered.filter(item => selectedCategories.has(item.category));
|
filtered = filtered.filter(item => selectedCategories.has(item.category));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply sorting
|
// Apply sorting ONLY if user hasn't interacted with likes
|
||||||
filtered.sort((a, b) => {
|
if (!hasUserInteractedWithLikes) {
|
||||||
switch (sortBy) {
|
// Normal sorting
|
||||||
case 'newest':
|
filtered.sort((a, b) => {
|
||||||
if (a.createdAt && b.createdAt) {
|
switch (sortBy) {
|
||||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
case 'newest':
|
||||||
}
|
if (a.createdAt && b.createdAt) {
|
||||||
return 0;
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
case 'alphabetical':
|
}
|
||||||
return a.name.localeCompare(b.name);
|
return 0;
|
||||||
case 'popular':
|
case 'alphabetical':
|
||||||
default:
|
return a.name.localeCompare(b.name);
|
||||||
// Sort across both types by like count desc; tie-break by createdAt desc, then name
|
case 'popular':
|
||||||
{
|
default:
|
||||||
const aLikes = a.likeCount || 0;
|
// Normal sorting by like count when no user interaction
|
||||||
const bLikes = b.likeCount || 0;
|
const aLikes = Number(a.likeCount) || 0;
|
||||||
|
const bLikes = Number(b.likeCount) || 0;
|
||||||
if (bLikes !== aLikes) return bLikes - aLikes;
|
if (bLikes !== aLikes) return bLikes - aLikes;
|
||||||
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
if (bTime !== aTime) return bTime - aTime;
|
if (bTime !== aTime) return bTime - aTime;
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
} else {
|
||||||
|
// User has interacted - use original order to prevent jumping
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aOrder = originalOrder.get(a.id) ?? 0;
|
||||||
|
const bOrder = originalOrder.get(b.id) ?? 0;
|
||||||
|
return aOrder - bOrder;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [allTemplates, searchQuery, selectedType, selectedCategories, sortBy]);
|
}, [allTemplates, searchQuery, selectedType, selectedCategories, sortBy, hasUserInteractedWithLikes, originalOrder]);
|
||||||
|
|
||||||
// Determine columns based on Tailwind breakpoints used by the grid
|
// Determine columns based on Tailwind breakpoints used by the grid
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -163,9 +190,12 @@ export function UnifiedTemplatesSection({
|
||||||
return () => window.removeEventListener('resize', update);
|
return () => window.removeEventListener('resize', update);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset rowsShown when filters/sort change
|
// Reset rowsShown and allow re-sorting when filters/sort change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRowsShown(4);
|
setRowsShown(4);
|
||||||
|
// Reset the like interaction flag so sorting can work again
|
||||||
|
setHasUserInteractedWithLikes(false);
|
||||||
|
setOriginalOrder(new Map());
|
||||||
}, [searchQuery, selectedType, selectedCategories, sortBy]);
|
}, [searchQuery, selectedType, selectedCategories, sortBy]);
|
||||||
|
|
||||||
const itemsPerRow = Math.max(columns, 1);
|
const itemsPerRow = Math.max(columns, 1);
|
||||||
|
|
@ -401,7 +431,7 @@ export function UnifiedTemplatesSection({
|
||||||
onClick={() => onTemplateClick?.(item)}
|
onClick={() => onTemplateClick?.(item)}
|
||||||
loading={loadingItemId === item.id}
|
loading={loadingItemId === item.id}
|
||||||
getUniqueTools={getUniqueTools}
|
getUniqueTools={getUniqueTools}
|
||||||
onLike={() => onLike?.(item)}
|
onLike={() => handleLike(item)}
|
||||||
onShare={() => onShare?.(item)}
|
onShare={() => onShare?.(item)}
|
||||||
onDelete={onDelete && currentUserId && item.type === 'community' && item.authorId === currentUserId ? () => {
|
onDelete={onDelete && currentUserId && item.type === 'community' && item.authorId === currentUserId ? () => {
|
||||||
setPendingDeleteItem(item);
|
setPendingDeleteItem(item);
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,14 @@ export class MongoDBAssistantTemplatesRepository {
|
||||||
return result?.likeCount || 0;
|
return result?.likeCount || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLikedTemplates(templateIds: string[], userId: string): Promise<string[]> {
|
||||||
|
const likes = await this.likesCollection.find({
|
||||||
|
assistantId: { $in: templateIds },
|
||||||
|
userId
|
||||||
|
}).toArray();
|
||||||
|
return likes.map(like => like.assistantId);
|
||||||
|
}
|
||||||
|
|
||||||
async getCategories(): Promise<string[]> {
|
async getCategories(): Promise<string[]> {
|
||||||
const categories = await this.collection.distinct('category', { isPublic: true });
|
const categories = await this.collection.distinct('category', { isPublic: true });
|
||||||
return categories.filter(Boolean);
|
return categories.filter(Boolean);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue