Skip to content

Commit 52ea2be

Browse files
feat: implement core UI components including Magnetic, Spotlight, and PageTransition wrappers with layout integration
1 parent b493340 commit 52ea2be

13 files changed

Lines changed: 771 additions & 407 deletions

File tree

web/src/app/apilab/ApiLabClient.tsx

Lines changed: 91 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,43 @@
11
'use client';
22

3-
import { useState, useEffect, useRef, useMemo } from 'react';
4-
import { useSearchParams } from 'next/navigation';
5-
import { gsap } from '@lib/gsap';
6-
import { Search, Layers, Database, Sparkles } from 'lucide-react';
7-
import {
8-
getFlattenedApis,
9-
getApiCategories,
10-
filterApis
11-
} from '@/lib/api-service';
3+
import { useState, useMemo } from 'react';
4+
import { motion, AnimatePresence } from 'framer-motion';
5+
import { PublicApi } from '@/lib/api-service';
126
import ApiCard from '@/components/modules/apilab/ApiCard';
13-
import EmptyState from '@/components/shared/EmptyState';
14-
import TouchScale from '@/components/shared/TouchScale';
7+
import { Search, Sparkles, SlidersHorizontal, ArrowUpDown } from 'lucide-react';
158
import styles from './page.module.css';
169

17-
const ITEMS_PER_PAGE = 24;
18-
19-
export default function ApiLabClient() {
20-
const searchParams = useSearchParams();
21-
const [activeCategory, setActiveCategory] = useState('All');
22-
const [searchTerm, setSearchTerm] = useState('');
23-
const [visibleCount, setVisibleCount] = useState(ITEMS_PER_PAGE);
24-
const [prevId, setPrevId] = useState<string | null>(null);
25-
const containerRef = useRef<HTMLDivElement>(null);
26-
const loadMoreRef = useRef<HTMLDivElement>(null);
27-
28-
const currentId = searchParams.get('id');
29-
const allApis = useMemo(() => getFlattenedApis(), []);
30-
const categories = useMemo(() => getApiCategories(), []);
31-
32-
// Sync with URL ID
33-
if (currentId !== prevId) {
34-
setPrevId(currentId);
35-
if (currentId) {
36-
const api = allApis.find(a => a.id === currentId);
37-
if (api) {
38-
setSearchTerm(api.name);
39-
setActiveCategory(api.category);
40-
}
41-
}
42-
}
10+
interface ApiLabClientProps {
11+
initialApis: PublicApi[];
12+
}
4313

14+
/**
15+
* Industrial-grade API Discovery Dashboard.
16+
* Optimized for high-density data and tactile feedback.
17+
*/
18+
export default function ApiLabClient({ initialApis }: ApiLabClientProps) {
19+
const [searchQuery, setSearchQuery] = useState('');
20+
const [selectedCategory, setSelectedCategory] = useState('All');
21+
22+
// Categorization Logic
23+
const categories = useMemo(() => {
24+
const cats = Array.from(new Set(initialApis.map(api => api.category)));
25+
return ['All', ...cats.sort()];
26+
}, [initialApis]);
27+
28+
// Filtering Logic
4429
const filteredApis = useMemo(() => {
45-
return filterApis(allApis, searchTerm, activeCategory);
46-
}, [allApis, searchTerm, activeCategory]);
47-
48-
const visibleApis = filteredApis.slice(0, visibleCount);
49-
50-
// GSAP Entrance
51-
useEffect(() => {
52-
const ctx = gsap.context(() => {
53-
gsap.from(".api-card-wrapper", {
54-
y: 40,
55-
opacity: 0,
56-
stagger: 0.05,
57-
duration: 0.8,
58-
ease: "expo.out",
59-
clearProps: "all"
60-
});
61-
}, containerRef);
62-
return () => ctx.revert();
63-
}, [activeCategory, searchTerm, visibleCount]);
64-
65-
// Infinite Scroll Observer
66-
useEffect(() => {
67-
const observer = new IntersectionObserver((entries) => {
68-
if (entries[0].isIntersecting && visibleCount < filteredApis.length) {
69-
setVisibleCount(prev => prev + ITEMS_PER_PAGE);
70-
}
71-
}, { threshold: 0.1 });
72-
73-
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
74-
return () => observer.disconnect();
75-
}, [filteredApis.length, visibleCount]);
30+
return initialApis.filter(api => {
31+
const matchesSearch = api.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
32+
api.description.toLowerCase().includes(searchQuery.toLowerCase());
33+
const matchesCategory = selectedCategory === 'All' || api.category === selectedCategory;
34+
return matchesSearch && matchesCategory;
35+
});
36+
}, [initialApis, searchQuery, selectedCategory]);
7637

7738
return (
78-
<div className={styles.dashboardContainer} ref={containerRef} style={{ paddingTop: '8rem' }}>
79-
{/* Sidebar Navigation */}
39+
<div className={styles.dashboardContainer}>
40+
{/* STICKY SIDEBAR */}
8041
<aside className={styles.sidebar}>
8142
<div className={styles.sidebarHeader}>
8243
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-accent/10 border border-accent/20 text-accent text-[10px] font-bold uppercase tracking-widest mb-4">
@@ -87,77 +48,78 @@ export default function ApiLabClient() {
8748
<p className={styles.miniSubtitle}>Discovery & Integration</p>
8849
</div>
8950

90-
<div className={styles.searchBox}>
91-
<TouchScale scale={0.98}>
92-
<div className="relative group">
93-
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 group-focus-within:text-accent transition-colors" />
51+
<div className={styles.sidebarSection}>
52+
<label className={styles.sidebarLabel}>Global Discovery</label>
53+
<div className={styles.searchWrapper}>
54+
<Search className={styles.searchIcon} />
9455
<input
95-
type="text"
96-
placeholder="Search streams..."
97-
className={styles.sidebarSearch}
98-
value={searchTerm}
99-
onChange={(e) => setSearchTerm(e.target.value)}
56+
type="text"
57+
placeholder="Search Lab..."
58+
className={styles.searchInput}
59+
value={searchQuery}
60+
onChange={(e) => setSearchQuery(e.target.value)}
10061
/>
101-
</div>
102-
</TouchScale>
62+
</div>
10363
</div>
10464

105-
<div className="mt-4">
106-
<h3 className={styles.sidebarTitle}>Categories</h3>
107-
<nav className={styles.categoryList}>
108-
<TouchScale scale={0.98} className="w-full">
109-
<button
110-
onClick={() => setActiveCategory('All')}
111-
className={`${styles.filterBtn} ${activeCategory === 'All' ? styles.active : ''} w-full flex items-center gap-2`}
112-
>
113-
<Layers className="w-4 h-4" />
114-
All Streams
115-
</button>
116-
</TouchScale>
117-
{categories.map((cat) => (
118-
<TouchScale key={cat.name} scale={0.98} className="w-full">
119-
<button
120-
onClick={() => setActiveCategory(cat.name)}
121-
className={`${styles.filterBtn} ${activeCategory === cat.name ? styles.active : ''} w-full flex items-center justify-between`}
122-
>
123-
<span>{cat.name}</span>
124-
<span className="text-[10px] opacity-50">{cat.count}</span>
125-
</button>
126-
</TouchScale>
127-
))}
128-
</nav>
65+
<div className={styles.sidebarSection}>
66+
<div className="flex items-center justify-between mb-4">
67+
<label className={styles.sidebarLabel}>Categories</label>
68+
<SlidersHorizontal className="w-3 h-3 text-gray-500" />
69+
</div>
70+
<div className={styles.categoryList}>
71+
{categories.map(cat => (
72+
<button
73+
key={cat}
74+
onClick={() => setSelectedCategory(cat)}
75+
className={`${styles.categoryBtn} ${selectedCategory === cat ? styles.active : ''}`}
76+
>
77+
{cat}
78+
{selectedCategory === cat && <motion.div layoutId="sidebar-active" className={styles.activeIndicator} />}
79+
</button>
80+
))}
81+
</div>
12982
</div>
13083
</aside>
13184

132-
{/* Main Results Area */}
133-
<main className={styles.mainContent}>
134-
<div className={styles.resultMeta}>
85+
{/* MAIN DISCOVERY GRID */}
86+
<main className={styles.discoveryContent}>
87+
<div className={styles.discoveryHeader}>
13588
<div className="flex items-center gap-4">
136-
<Database className="w-4 h-4" />
137-
<span>Showing {filteredApis.length} artifacts across {activeCategory}</span>
89+
<div className={styles.statBox}>
90+
<span className={styles.statValue}>{filteredApis.length}</span>
91+
<span className={styles.statLabel}>Resources Found</span>
92+
</div>
13893
</div>
94+
<button className={styles.sortBtn}>
95+
<ArrowUpDown className="w-4 h-4" />
96+
Latest Release
97+
</button>
13998
</div>
14099

141-
{visibleApis.length > 0 ? (
142-
<div className={styles.grid}>
143-
{visibleApis.map((api) => (
144-
<ApiCard key={api.id} api={api} />
100+
{/* Liquid Grid with Staggered Reveals */}
101+
<div className={styles.gridContainer}>
102+
<AnimatePresence mode="popLayout">
103+
{filteredApis.slice(0, 40).map((api, index) => (
104+
<motion.div
105+
key={api.name}
106+
layout
107+
initial={{ opacity: 0, y: 30, scale: 0.95 }}
108+
animate={{ opacity: 1, y: 0, scale: 1 }}
109+
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
110+
transition={{
111+
type: "spring",
112+
stiffness: 100,
113+
damping: 20,
114+
delay: index * 0.05,
115+
restDelta: 0.001
116+
}}
117+
>
118+
<ApiCard api={api} />
119+
</motion.div>
145120
))}
146-
</div>
147-
) : (
148-
<EmptyState
149-
title="Data Stream Empty"
150-
message={`Zero results detected for "${searchTerm}" in the current matrix.`}
151-
icon="filter"
152-
/>
153-
)}
154-
155-
{/* Load More Indicator */}
156-
{visibleCount < filteredApis.length && (
157-
<div ref={loadMoreRef} className={styles.loader}>
158-
<div className={styles.spinner} />
159-
</div>
160-
)}
121+
</AnimatePresence>
122+
</div>
161123
</main>
162124
</div>
163125
);

0 commit comments

Comments
 (0)