mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-01 03:16:29 +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 { z } from "zod";
|
||||
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 { PromptConfig } from "../entities/prompt_config";
|
||||
import { DataSourceConfig } from "../entities/datasource_config";
|
||||
|
|
@ -1645,19 +1646,11 @@ export function WorkflowEditor({
|
|||
|
||||
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
|
||||
}),
|
||||
await createAssistantTemplate({
|
||||
...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);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { listProjects } from "@/app/actions/project.actions";
|
||||
import { createProjectWithOptions, createProjectFromJsonWithOptions, createProjectFromTemplate } from "../lib/project-creation-utils";
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
|
@ -19,6 +19,13 @@ import { z } from "zod";
|
|||
import Link from 'next/link';
|
||||
import { AssistantSection } from '@/components/common/AssistantSection';
|
||||
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';
|
||||
|
||||
|
|
@ -99,15 +106,15 @@ export function BuildAssistantSection() {
|
|||
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);
|
||||
setTemplatesError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ source: 'library', limit: String(limit) });
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const response = await fetch(`/api/assistant-templates?${params.toString()}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch library templates');
|
||||
const data = await response.json();
|
||||
const data = await listAssistantTemplates({
|
||||
source: 'library',
|
||||
limit,
|
||||
cursor: cursor || undefined
|
||||
});
|
||||
setTemplates(prev => cursor ? [...prev, ...data.items] : data.items);
|
||||
setTemplatesCursor(data.nextCursor || null);
|
||||
} catch (error) {
|
||||
|
|
@ -116,17 +123,17 @@ export function BuildAssistantSection() {
|
|||
} finally {
|
||||
setTemplatesLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchCommunityTemplatesPage = async (cursor?: string | null, limit: number = 20) => {
|
||||
const fetchCommunityTemplatesPage = useCallback(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();
|
||||
const data = await listAssistantTemplates({
|
||||
source: 'community',
|
||||
limit,
|
||||
cursor: cursor || undefined
|
||||
});
|
||||
setCommunityTemplates(prev => cursor ? [...prev, ...data.items] : data.items);
|
||||
setCommunityCursor(data.nextCursor || null);
|
||||
} catch (error) {
|
||||
|
|
@ -135,10 +142,10 @@ export function BuildAssistantSection() {
|
|||
} finally {
|
||||
setCommunityTemplatesLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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 cursor = type === 'prebuilt' ? templatesCursor : communityCursor;
|
||||
if (current >= targetCount) return;
|
||||
|
|
@ -159,7 +166,7 @@ export function BuildAssistantSection() {
|
|||
needed = targetCount - (type === 'prebuilt' ? templates.length : communityTemplates.length);
|
||||
if (nextCursor === null) break;
|
||||
}
|
||||
};
|
||||
}, [templates.length, communityTemplates.length, templatesCursor, communityCursor, fetchLibraryTemplatesPage, fetchCommunityTemplatesPage]);
|
||||
|
||||
// Handle template selection
|
||||
const handleTemplateSelect = async (template: any) => {
|
||||
|
|
@ -167,10 +174,8 @@ export function BuildAssistantSection() {
|
|||
setLoadingTemplateId(template.id);
|
||||
try {
|
||||
if (template.type === 'prebuilt') {
|
||||
// Fetch full workflow from unified API, then create from JSON
|
||||
const res = await fetch(`/api/assistant-templates/${template.id}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch template details');
|
||||
const data = await res.json();
|
||||
// Fetch full workflow from server action, then create from JSON
|
||||
const data = await getAssistantTemplate(template.id);
|
||||
await createProjectFromJsonWithOptions({
|
||||
workflowJson: JSON.stringify(data.workflow),
|
||||
router,
|
||||
|
|
@ -181,9 +186,7 @@ export function BuildAssistantSection() {
|
|||
});
|
||||
} 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();
|
||||
const data = await getAssistantTemplate(template.id);
|
||||
await createProjectFromJsonWithOptions({
|
||||
workflowJson: JSON.stringify(data.workflow),
|
||||
router,
|
||||
|
|
@ -202,44 +205,23 @@ export function BuildAssistantSection() {
|
|||
}
|
||||
};
|
||||
|
||||
// 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)
|
||||
// Handle template like (unified for library and community) - now uses proper authentication
|
||||
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
|
||||
));
|
||||
}
|
||||
const data = await toggleTemplateLike(template.id);
|
||||
|
||||
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);
|
||||
|
|
@ -250,9 +232,7 @@ export function BuildAssistantSection() {
|
|||
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 data = await getAssistantTemplate(template.id);
|
||||
|
||||
const shareResp = await fetch('/api/shared-workflow', {
|
||||
method: 'POST',
|
||||
|
|
@ -294,7 +274,7 @@ export function BuildAssistantSection() {
|
|||
// Load initial library templates to fill 4 rows x up to 3 columns ≈ 12
|
||||
fetchProjects();
|
||||
ensureTemplatesLoaded('prebuilt', 12);
|
||||
}, []);
|
||||
}, [ensureTemplatesLoaded]);
|
||||
|
||||
// Handle URL parameters for auto-creation and direct redirect to build view
|
||||
useEffect(() => {
|
||||
|
|
@ -328,9 +308,7 @@ export function BuildAssistantSection() {
|
|||
const isMongoId = !!urlTemplate && /^[a-f0-9]{24}$/i.test(urlTemplate);
|
||||
if (urlTemplate && isMongoId) {
|
||||
// New-style share: template is an assistant-templates id
|
||||
const res = await fetch(`/api/assistant-templates/${urlTemplate}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch shared template');
|
||||
const data = await res.json();
|
||||
const data = await getAssistantTemplate(urlTemplate);
|
||||
await createProjectFromJsonWithOptions({
|
||||
workflowJson: JSON.stringify(data.workflow),
|
||||
router,
|
||||
|
|
@ -663,10 +641,7 @@ export function BuildAssistantSection() {
|
|||
onShare={handleTemplateShare}
|
||||
onDelete={async (item) => {
|
||||
try {
|
||||
const resp = await fetch(`/api/assistant-templates/${item.id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to delete template');
|
||||
}
|
||||
await deleteAssistantTemplate(item.id);
|
||||
setCommunityTemplates(prev => prev.filter(t => t.id !== item.id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Input } from "@heroui/react";
|
|||
import { Search, Filter } from 'lucide-react';
|
||||
import { AssistantCard } from './AssistantCard';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getCurrentUser } from '@/app/actions/assistant-templates.actions';
|
||||
|
||||
interface TemplateItem {
|
||||
id: string;
|
||||
|
|
@ -69,14 +70,31 @@ export function UnifiedTemplatesSection({
|
|||
// Row-based pagination state
|
||||
const [columns, setColumns] = useState<number>(1);
|
||||
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(() => {
|
||||
let isMounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/me', { cache: 'no-store' });
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await getCurrentUser();
|
||||
if (isMounted) setCurrentUserId(data.id || null);
|
||||
} catch (_e) {}
|
||||
})();
|
||||
|
|
@ -98,6 +116,7 @@ export function UnifiedTemplatesSection({
|
|||
return Array.from(categories).sort();
|
||||
}, [allTemplates]);
|
||||
|
||||
|
||||
// Filter and sort templates
|
||||
const filteredTemplates = useMemo(() => {
|
||||
let filtered = [...allTemplates];
|
||||
|
|
@ -120,33 +139,41 @@ export function UnifiedTemplatesSection({
|
|||
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;
|
||||
// Apply sorting ONLY if user hasn't interacted with likes
|
||||
if (!hasUserInteractedWithLikes) {
|
||||
// Normal 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:
|
||||
// Normal sorting by like count when no user interaction
|
||||
const aLikes = Number(a.likeCount) || 0;
|
||||
const bLikes = Number(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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} 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;
|
||||
}, [allTemplates, searchQuery, selectedType, selectedCategories, sortBy]);
|
||||
}, [allTemplates, searchQuery, selectedType, selectedCategories, sortBy, hasUserInteractedWithLikes, originalOrder]);
|
||||
|
||||
// Determine columns based on Tailwind breakpoints used by the grid
|
||||
useEffect(() => {
|
||||
|
|
@ -163,9 +190,12 @@ export function UnifiedTemplatesSection({
|
|||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
// Reset rowsShown when filters/sort change
|
||||
// Reset rowsShown and allow re-sorting when filters/sort change
|
||||
useEffect(() => {
|
||||
setRowsShown(4);
|
||||
// Reset the like interaction flag so sorting can work again
|
||||
setHasUserInteractedWithLikes(false);
|
||||
setOriginalOrder(new Map());
|
||||
}, [searchQuery, selectedType, selectedCategories, sortBy]);
|
||||
|
||||
const itemsPerRow = Math.max(columns, 1);
|
||||
|
|
@ -401,7 +431,7 @@ export function UnifiedTemplatesSection({
|
|||
onClick={() => onTemplateClick?.(item)}
|
||||
loading={loadingItemId === item.id}
|
||||
getUniqueTools={getUniqueTools}
|
||||
onLike={() => onLike?.(item)}
|
||||
onLike={() => handleLike(item)}
|
||||
onShare={() => onShare?.(item)}
|
||||
onDelete={onDelete && currentUserId && item.type === 'community' && item.authorId === currentUserId ? () => {
|
||||
setPendingDeleteItem(item);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,14 @@ export class MongoDBAssistantTemplatesRepository {
|
|||
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[]> {
|
||||
const categories = await this.collection.distinct('category', { isPublic: true });
|
||||
return categories.filter(Boolean);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue