Files
2026-02-02 18:19:00 +00:00

436 lines
15 KiB
TypeScript

'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Search, TreePine, Network, ChevronRight, ChevronDown, Folder, FolderOpen, Tag, Loader2 } from 'lucide-react';
import dynamic from 'next/dynamic';
import {
Category,
CategoryTreeNode,
buildCategoryTree,
toD3Tree,
getLevelName,
getLevelColor,
searchCategories,
getCategoryBreadcrumb,
} from '@/lib/categories';
// Dynamic import for react-d3-tree (SSR issues)
const Tree = dynamic(() => import('react-d3-tree').then((mod) => mod.default), {
ssr: false,
loading: () => <div className="flex items-center justify-center h-full"><Loader2 className="animate-spin" /></div>,
});
// API base URL
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<'explorer' | 'diagram'>('explorer');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [stats, setStats] = useState({ total: 0, sectors: 0, types: 0, subs: 0, leaves: 0 });
// Fetch categories from API
useEffect(() => {
async function fetchCategories() {
try {
setLoading(true);
const response = await fetch(`${API_BASE}/categories`);
if (!response.ok) {
throw new Error('Failed to fetch categories');
}
const data = await response.json();
setCategories(data.categories || []);
setStats({
total: data.total || 0,
sectors: data.categories?.filter((c: Category) => c.level === 1).length || 0,
types: data.categories?.filter((c: Category) => c.level === 2).length || 0,
subs: data.categories?.filter((c: Category) => c.level === 3).length || 0,
leaves: data.categories?.filter((c: Category) => c.level === 4).length || 0,
});
// Expand level 1 by default
const level1Paths = new Set<string>(
data.categories
?.filter((c: Category) => c.level === 1)
.map((c: Category) => c.path) || []
);
setExpandedPaths(level1Paths);
} catch (err) {
console.error('Error fetching categories:', err);
setError(err instanceof Error ? err.message : 'Failed to load categories');
} finally {
setLoading(false);
}
}
fetchCategories();
}, []);
// Filter categories based on search
const filteredCategories = useMemo(() => {
if (!searchQuery.trim()) return categories;
return searchCategories(categories, searchQuery);
}, [categories, searchQuery]);
// Build tree structure
const tree = useMemo(() => buildCategoryTree(filteredCategories), [filteredCategories]);
// D3 tree data
const d3TreeData = useMemo(() => {
if (tree.length === 0) return null;
return {
name: 'GBP Categories',
children: toD3Tree(tree),
};
}, [tree]);
// Toggle expand/collapse
const toggleExpand = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
// Expand all ancestors when searching
useEffect(() => {
if (searchQuery.trim()) {
const pathsToExpand = new Set<string>();
for (const cat of filteredCategories) {
const parts = cat.path.split('.');
for (let i = 1; i < parts.length; i++) {
pathsToExpand.add(parts.slice(0, i).join('.'));
}
}
setExpandedPaths(pathsToExpand);
}
}, [searchQuery, filteredCategories]);
// Get breadcrumb for selected category
const breadcrumb = useMemo(() => {
if (!selectedCategory) return [];
return getCategoryBreadcrumb(selectedCategory.path, categories);
}, [selectedCategory, categories]);
// Render tree node (recursive)
const renderTreeNode = (node: CategoryTreeNode, depth: number = 0) => {
const isExpanded = expandedPaths.has(node.id);
const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedCategory?.path === node.id;
const level = node.data?.level || 1;
return (
<div key={node.id} className="select-none">
<div
className={`flex items-center gap-2 py-1.5 px-2 rounded cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 ${
isSelected ? 'bg-blue-50 dark:bg-blue-900/30 border-l-2 border-blue-500' : ''
}`}
style={{ paddingLeft: `${depth * 20 + 8}px` }}
onClick={() => {
setSelectedCategory(node.data || null);
if (hasChildren) {
toggleExpand(node.id);
}
}}
>
{/* Expand/Collapse Icon */}
<span className="w-4 h-4 flex items-center justify-center">
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)
) : (
<span className="w-4" />
)}
</span>
{/* Folder/Tag Icon */}
{hasChildren ? (
isExpanded ? (
<FolderOpen className="w-4 h-4 text-yellow-500" />
) : (
<Folder className="w-4 h-4 text-yellow-600" />
)
) : (
<Tag className="w-4 h-4 text-purple-500" />
)}
{/* Name */}
<span className="flex-1 truncate text-sm">{node.name}</span>
{/* Level Badge */}
<span
className={`text-xs px-1.5 py-0.5 rounded ${getLevelColor(level)} text-white`}
>
L{level}
</span>
{/* Count */}
{node.data && node.data.category_count > 0 && (
<span className="text-xs text-gray-400">
({node.data.category_count})
</span>
)}
</div>
{/* Children */}
{hasChildren && isExpanded && (
<div>
{node.children!.map((child) => renderTreeNode(child, depth + 1))}
</div>
)}
</div>
);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
<span className="ml-2">Loading categories...</span>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Retry
</button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
GBP Category Explorer
</h1>
<p className="text-sm text-gray-500 mt-1">
Browse {stats.total.toLocaleString()} Google Business Profile categories
</p>
</div>
{/* Stats */}
<div className="flex gap-4 text-sm">
<div className="text-center">
<div className="font-bold text-blue-600">{stats.sectors}</div>
<div className="text-gray-500">Sectors</div>
</div>
<div className="text-center">
<div className="font-bold text-green-600">{stats.types}</div>
<div className="text-gray-500">Types</div>
</div>
<div className="text-center">
<div className="font-bold text-yellow-600">{stats.subs}</div>
<div className="text-gray-500">Sub-cats</div>
</div>
<div className="text-center">
<div className="font-bold text-purple-600">{stats.leaves}</div>
<div className="text-gray-500">Categories</div>
</div>
</div>
</div>
</div>
</header>
{/* Toolbar */}
<div className="bg-white dark:bg-gray-800 border-b px-4 py-3">
<div className="max-w-7xl mx-auto flex items-center gap-4">
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search categories..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
/>
</div>
{/* View Toggle */}
<div className="flex border rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('explorer')}
className={`px-4 py-2 flex items-center gap-2 ${
viewMode === 'explorer'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-700 hover:bg-gray-50'
}`}
>
<TreePine className="w-4 h-4" />
Explorer
</button>
<button
onClick={() => setViewMode('diagram')}
className={`px-4 py-2 flex items-center gap-2 ${
viewMode === 'diagram'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-700 hover:bg-gray-50'
}`}
>
<Network className="w-4 h-4" />
Diagram
</button>
</div>
{/* Results count */}
{searchQuery && (
<span className="text-sm text-gray-500">
{filteredCategories.length} results
</span>
)}
</div>
</div>
{/* Main Content */}
<div className="px-4 py-6">
{viewMode === 'explorer' ? (
/* Explorer View - Full Width */
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
{tree.length > 0 ? (
tree.map((node) => renderTreeNode(node))
) : (
<div className="text-center text-gray-500 py-8">
No categories found
</div>
)}
</div>
) : (
/* Diagram View with Detail Panel */
<div className="flex gap-6 h-[calc(100vh-180px)]">
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="h-full w-full">
{d3TreeData ? (
<Tree
data={d3TreeData}
orientation="vertical"
pathFunc="step"
translate={{ x: 400, y: 50 }}
separation={{ siblings: 1, nonSiblings: 2 }}
nodeSize={{ x: 200, y: 80 }}
renderCustomNodeElement={({ nodeDatum, toggleNode }) => (
<g onClick={toggleNode}>
<circle r={15} fill="#3b82f6" />
<text
fill="#1f2937"
strokeWidth="0"
x={20}
dy=".35em"
fontSize={12}
fontFamily="sans-serif"
>
{nodeDatum.name.length > 25
? nodeDatum.name.slice(0, 25) + '...'
: nodeDatum.name}
</text>
</g>
)}
/>
) : (
<div className="flex items-center justify-center h-full text-gray-500">
No data to display
</div>
)}
</div>
</div>
{/* Detail Panel - Only in Diagram Mode */}
{selectedCategory && (
<div className="w-80 bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h3 className="font-bold text-lg mb-4">{selectedCategory.name}</h3>
{/* Breadcrumb */}
<div className="mb-4">
<span className="text-xs text-gray-500 uppercase">Path</span>
<div className="flex flex-wrap gap-1 mt-1">
{breadcrumb.map((cat, i) => (
<span key={cat.path} className="flex items-center">
<span
className={`text-xs px-2 py-1 rounded ${getLevelColor(cat.level)} text-white cursor-pointer hover:opacity-80`}
onClick={() => setSelectedCategory(cat)}
>
{cat.name}
</span>
{i < breadcrumb.length - 1 && (
<ChevronRight className="w-3 h-3 text-gray-400 mx-1" />
)}
</span>
))}
</div>
</div>
{/* Details */}
<div className="space-y-3 text-sm">
<div>
<span className="text-gray-500">Level:</span>
<span className="ml-2 font-medium">
{getLevelName(selectedCategory.level)} (L{selectedCategory.level})
</span>
</div>
<div>
<span className="text-gray-500">Path:</span>
<code className="ml-2 text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{selectedCategory.path}
</code>
</div>
<div>
<span className="text-gray-500">Children:</span>
<span className="ml-2 font-medium">
{selectedCategory.category_count}
</span>
</div>
<div>
<span className="text-gray-500">Slug:</span>
<code className="ml-2 text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{selectedCategory.slug}
</code>
</div>
</div>
{/* Use in search */}
<div className="mt-6 pt-4 border-t">
<button
className="w-full py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
onClick={() => {
// Copy ltree path for use in queries
navigator.clipboard.writeText(selectedCategory.path);
alert('Path copied to clipboard!');
}}
>
Copy Path for Query
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}