436 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|