diff --git a/apps/rowboat/app/api/assistant-templates/[id]/route.ts b/apps/rowboat/app/api/assistant-templates/[id]/route.ts index 81616fda..d0f7217b 100644 --- a/apps/rowboat/app/api/assistant-templates/[id]/route.ts +++ b/apps/rowboat/app/api/assistant-templates/[id]/route.ts @@ -1,5 +1,7 @@ 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(); @@ -15,4 +17,41 @@ export async function GET(_req: NextRequest, context: { params: Promise<{ id: st } } +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/me/route.ts b/apps/rowboat/app/api/me/route.ts new file mode 100644 index 00000000..b1591439 --- /dev/null +++ b/apps/rowboat/app/api/me/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { authCheck } from '@/app/actions/auth.actions'; +import { USE_AUTH } from '@/app/lib/feature_flags'; + +export async function GET(_req: NextRequest) { + try { + let user; + if (USE_AUTH) { + user = await authCheck(); + } else { + user = { id: 'guest_user' } as any; + } + return NextResponse.json({ id: user.id }); + } catch (error) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} + + diff --git a/apps/rowboat/app/projects/components/build-assistant-section.tsx b/apps/rowboat/app/projects/components/build-assistant-section.tsx index de313ac1..5bcbb5ba 100644 --- a/apps/rowboat/app/projects/components/build-assistant-section.tsx +++ b/apps/rowboat/app/projects/components/build-assistant-section.tsx @@ -608,6 +608,8 @@ export function BuildAssistantSection() { name: template.name, description: template.description, category: template.category, + authorId: template.authorId, + source: template.source, authorName: template.authorName, isAnonymous: template.isAnonymous, likeCount: template.likeCount, @@ -625,6 +627,19 @@ export function BuildAssistantSection() { loadingItemId={loadingTemplateId} onLike={handleTemplateLike} onShare={handleTemplateShare} + onDelete={async (item) => { + if (!confirm('Delete this template? This action cannot be undone.')) return; + try { + const resp = await fetch(`/api/assistant-templates/${item.id}`, { method: 'DELETE' }); + if (!resp.ok) { + throw new Error('Failed to delete template'); + } + setCommunityTemplates(prev => prev.filter(t => t.id !== item.id)); + } catch (e) { + console.error(e); + alert('Failed to delete template'); + } + }} getUniqueTools={getUniqueTools} /> diff --git a/apps/rowboat/components/common/AssistantCard.tsx b/apps/rowboat/components/common/AssistantCard.tsx index 67f7b1e5..7873c011 100644 --- a/apps/rowboat/components/common/AssistantCard.tsx +++ b/apps/rowboat/components/common/AssistantCard.tsx @@ -60,6 +60,7 @@ interface AssistantCardProps { createdAt?: string; onLike?: () => void; onShare?: () => void; + onDelete?: () => void; isLiked?: boolean; // Template type indicator templateType?: 'prebuilt' | 'community'; @@ -83,6 +84,7 @@ export function AssistantCard({ onLike, onShare, isLiked = false, + onDelete, templateType, onClick, loading = false, @@ -268,6 +270,20 @@ export function AssistantCard({ {copied && Copied} + {onDelete && ( + { + e.preventDefault(); + e.stopPropagation(); + onDelete(); + }} + className="flex items-center gap-1 hover:text-red-600 transition-colors" + aria-label="Delete template" + > + {/* small x icon */} + + + )} diff --git a/apps/rowboat/components/common/UnifiedTemplatesSection.tsx b/apps/rowboat/components/common/UnifiedTemplatesSection.tsx index 3adf3613..edb24290 100644 --- a/apps/rowboat/components/common/UnifiedTemplatesSection.tsx +++ b/apps/rowboat/components/common/UnifiedTemplatesSection.tsx @@ -11,6 +11,8 @@ interface TemplateItem { name: string; description: string; category: string; + authorId?: string; + source?: 'library' | 'community'; tools?: Array<{ name: string; logo?: string; @@ -35,6 +37,7 @@ interface UnifiedTemplatesSectionProps { loadingItemId?: string | null; onLike?: (item: TemplateItem) => void; onShare?: (item: TemplateItem) => void; + onDelete?: (item: TemplateItem) => void; getUniqueTools?: (item: TemplateItem) => Array<{ name: string; logo?: string }>; } @@ -48,12 +51,29 @@ export function UnifiedTemplatesSection({ loadingItemId = null, onLike, onShare, + onDelete, getUniqueTools }: UnifiedTemplatesSectionProps) { const [searchQuery, setSearchQuery] = useState(''); const [selectedType, setSelectedType] = useState<'all' | 'prebuilt' | 'community'>('all'); const [selectedCategories, setSelectedCategories] = useState>(new Set()); const [sortBy, setSortBy] = useState<'popular' | 'newest' | 'alphabetical'>('popular'); + const [currentUserId, setCurrentUserId] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [pendingDeleteItem, setPendingDeleteItem] = useState(null); + + useEffect(() => { + let isMounted = true; + (async () => { + try { + const resp = await fetch('/api/me', { cache: 'no-store' }); + if (!resp.ok) return; + const data = await resp.json(); + if (isMounted) setCurrentUserId(data.id || null); + } catch (_e) {} + })(); + return () => { isMounted = false; }; + }, []); // Combine all templates const allTemplates = useMemo(() => { @@ -345,6 +365,10 @@ export function UnifiedTemplatesSection({ getUniqueTools={getUniqueTools} onLike={() => onLike?.(item)} onShare={() => onShare?.(item)} + onDelete={onDelete && currentUserId && item.type === 'community' && item.authorId === currentUserId ? () => { + setPendingDeleteItem(item); + setConfirmOpen(true); + } : undefined} isLiked={item.isLiked} templateType={item.type} /> @@ -353,6 +377,38 @@ export function UnifiedTemplatesSection({ > )} + + {/* Delete confirmation modal */} + {confirmOpen && ( + + + Delete template? + + This will permanently remove "{pendingDeleteItem?.name}" from the community templates. This action cannot be undone. + + + { setConfirmOpen(false); setPendingDeleteItem(null); }} + className="px-4 py-2 text-sm rounded-md bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300" + > + Cancel + + { + if (pendingDeleteItem && onDelete) { + await onDelete(pendingDeleteItem); + } + setConfirmOpen(false); + setPendingDeleteItem(null); + }} + className="px-4 py-2 text-sm rounded-md bg-red-600 hover:bg-red-700 text-white" + > + Delete + + + + + )} ); } 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 896473cf..1c134e90 100644 --- a/apps/rowboat/src/infrastructure/repositories/mongodb.assistant-templates.repository.ts +++ b/apps/rowboat/src/infrastructure/repositories/mongodb.assistant-templates.repository.ts @@ -77,6 +77,16 @@ export class MongoDBAssistantTemplatesRepository { const categories = await this.collection.distinct('category', { isPublic: true }); return categories.filter(Boolean); } + + async deleteByIdAndAuthor(id: string, authorId: string): Promise { + const result = await this.collection.deleteOne({ _id: new ObjectId(id), authorId } as any); + if (result.deletedCount && result.deletedCount > 0) { + // Clean up likes associated with this assistant template + await this.likesCollection.deleteMany({ assistantId: id } as any); + return true; + } + return false; + } }