From bd232a34070b9297ee0ab0c875be58e0ecfb200b Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Sat, 7 Feb 2026 02:36:47 +1000 Subject: [PATCH] fix(web): restore public collections listing page The /collections page was incorrectly replaced with a redirect to / in a previous commit. This restores the full public collections listing with search, sorting, virtualized table, likes, and copy. --- apps/web/src/app/collections/page.tsx | 304 +++++++++++++++++++++++++- 1 file changed, 296 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/collections/page.tsx b/apps/web/src/app/collections/page.tsx index cd26b00..1a4e359 100644 --- a/apps/web/src/app/collections/page.tsx +++ b/apps/web/src/app/collections/page.tsx @@ -1,10 +1,298 @@ -import { redirect } from 'next/navigation'; +'use client'; -/** - * DEPRECATED: The /collections list page is deprecated. - * Users should browse collections through user profiles. - * All requests are 301 redirected to the homepage. - */ -export default function CollectionsListRedirectPage() { - redirect('/'); +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState'; +import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { Input } from '@tpmjs/ui/Input/Input'; +import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState'; +import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader'; +import { Select } from '@tpmjs/ui/Select/Select'; +import Link from 'next/link'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; +import { AppHeader } from '~/components/AppHeader'; +import { CopyDropdown, getCollectionCopyOptions } from '~/components/CopyDropdown'; +import { LikeButton } from '~/components/LikeButton'; + +interface PublicCollection { + id: string; + slug: string; + name: string; + description: string | null; + likeCount: number; + toolCount: number; + createdAt: string; + createdBy: { + id: string; + name: string; + image: string | null; + username: string | null; + }; +} + +type SortOption = 'likes' | 'recent' | 'tools'; + +function sortCollections(collections: PublicCollection[], sortBy: SortOption): PublicCollection[] { + return [...collections].sort((a, b) => { + switch (sortBy) { + case 'likes': + return b.likeCount - a.likeCount; + case 'recent': + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + case 'tools': + return b.toolCount - a.toolCount; + default: + return 0; + } + }); +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trim()}...`; +} + +export default function PublicCollectionsPage(): React.ReactElement { + const [collections, setCollections] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [search, setSearch] = useState(''); + const [sort, setSort] = useState('likes'); + const loadingMore = useRef(false); + + const fetchCollections = useCallback( + async (offset: number, resetList = false) => { + try { + if (loadingMore.current && !resetList) return; + loadingMore.current = true; + + const params = new URLSearchParams({ + limit: '100', + offset: String(offset), + sort, + }); + + const response = await fetch(`/api/public/collections?${params}`); + const data = await response.json(); + + if (data.success) { + if (resetList || offset === 0) { + setCollections(data.data); + } else { + setCollections((prev) => [...prev, ...data.data]); + } + setHasMore(data.pagination.hasMore); + } else { + setError(data.error?.message || 'Failed to fetch collections'); + } + } catch (err) { + console.error('Failed to fetch collections:', err); + setError('Failed to fetch collections'); + } finally { + setIsLoading(false); + loadingMore.current = false; + } + }, + [sort] + ); + + useEffect(() => { + setIsLoading(true); + fetchCollections(0, true); + }, [fetchCollections]); + + const loadMore = useCallback(() => { + if (!hasMore || loadingMore.current) return; + fetchCollections(collections.length); + }, [hasMore, collections.length, fetchCollections]); + + // Filter and sort collections + const filteredCollections = useMemo(() => { + let result = collections; + + if (search) { + const query = search.toLowerCase(); + result = result.filter( + (c) => c.name.toLowerCase().includes(query) || c.description?.toLowerCase().includes(query) + ); + } + + return sortCollections(result, sort); + }, [collections, search, sort]); + + const TableHeader = useCallback( + () => ( + + Name + Description + Tools + Likes + Creator + Copy + + ), + [] + ); + + const TableRow = useCallback((_index: number, collection: PublicCollection) => { + return ( + <> + + + {collection.name} + + + + {collection.description ? truncateText(collection.description, 60) : '—'} + + + + {collection.toolCount} + + + + + + +
+ {collection.createdBy.image ? ( + {collection.createdBy.name} + ) : ( +
+ +
+ )} + + {collection.createdBy.name} + +
+ + + {collection.createdBy.username && ( + + )} + + + ); + }, []); + + return ( +
+ + +
+ + + {/* Filters */} +
+
+ setSearch(e.target.value)} + placeholder="Search collections..." + /> +
+ +
+ Sort: +