Update templates and publishing to use server-side auth + fix likes-related glitches

This commit is contained in:
akhisud3195 2025-09-16 00:35:02 +04:00
parent 041ab25b7b
commit 8a86b892d0
9 changed files with 317 additions and 340 deletions

View 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)
}));
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);