diff --git a/apps/rowboat/app/actions/assistant-templates.actions.ts b/apps/rowboat/app/actions/assistant-templates.actions.ts new file mode 100644 index 00000000..428738ac --- /dev/null +++ b/apps/rowboat/app/actions/assistant-templates.actions.ts @@ -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) { + 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) { + 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) + })); +} diff --git a/apps/rowboat/app/api/assistant-templates/[id]/like/route.ts b/apps/rowboat/app/api/assistant-templates/[id]/like/route.ts deleted file mode 100644 index 606f8dbc..00000000 --- a/apps/rowboat/app/api/assistant-templates/[id]/like/route.ts +++ /dev/null @@ -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 }); - } -} - - diff --git a/apps/rowboat/app/api/assistant-templates/[id]/route.ts b/apps/rowboat/app/api/assistant-templates/[id]/route.ts deleted file mode 100644 index d0f7217b..00000000 --- a/apps/rowboat/app/api/assistant-templates/[id]/route.ts +++ /dev/null @@ -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 }); - } -} - - diff --git a/apps/rowboat/app/api/assistant-templates/categories/route.ts b/apps/rowboat/app/api/assistant-templates/categories/route.ts deleted file mode 100644 index 58e5e120..00000000 --- a/apps/rowboat/app/api/assistant-templates/categories/route.ts +++ /dev/null @@ -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 }); - } -} - - diff --git a/apps/rowboat/app/api/assistant-templates/route.ts b/apps/rowboat/app/api/assistant-templates/route.ts deleted file mode 100644 index 03389fae..00000000 --- a/apps/rowboat/app/api/assistant-templates/route.ts +++ /dev/null @@ -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 }); - } -} - - diff --git a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx index 2f49ee8e..27f78083 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx @@ -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); diff --git a/apps/rowboat/app/projects/components/build-assistant-section.tsx b/apps/rowboat/app/projects/components/build-assistant-section.tsx index 555e1a63..bd3c20b6 100644 --- a/apps/rowboat/app/projects/components/build-assistant-section.tsx +++ b/apps/rowboat/app/projects/components/build-assistant-section.tsx @@ -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); diff --git a/apps/rowboat/components/common/UnifiedTemplatesSection.tsx b/apps/rowboat/components/common/UnifiedTemplatesSection.tsx index 6f30d52c..382922ca 100644 --- a/apps/rowboat/components/common/UnifiedTemplatesSection.tsx +++ b/apps/rowboat/components/common/UnifiedTemplatesSection.tsx @@ -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(1); const [rowsShown, setRowsShown] = useState(4); + + // Track if user has interacted with likes to prevent ALL re-sorting + const [hasUserInteractedWithLikes, setHasUserInteractedWithLikes] = useState(false); + const [originalOrder, setOriginalOrder] = useState>(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(); + 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); diff --git a/apps/rowboat/src/infrastructure/repositories/mongodb.assistant-templates.repository.ts b/apps/rowboat/src/infrastructure/repositories/mongodb.assistant-templates.repository.ts index 1c134e90..129648a0 100644 --- a/apps/rowboat/src/infrastructure/repositories/mongodb.assistant-templates.repository.ts +++ b/apps/rowboat/src/infrastructure/repositories/mongodb.assistant-templates.repository.ts @@ -73,6 +73,14 @@ export class MongoDBAssistantTemplatesRepository { return result?.likeCount || 0; } + async getLikedTemplates(templateIds: string[], userId: string): Promise { + const likes = await this.likesCollection.find({ + assistantId: { $in: templateIds }, + userId + }).toArray(); + return likes.map(like => like.assistantId); + } + async getCategories(): Promise { const categories = await this.collection.distinct('category', { isPublic: true }); return categories.filter(Boolean);