feat(clusters): add cluster navigation UI and cluster view page with media tabs

- Add "Clusters" collapsible section in sidebar with color-coded icons
- Implement client cluster fetching and navigation links in sidebar
- Create cluster view page with header, stats cards, and tabbed media interface
- Integrate media player modals for video, photo, and text viewing
- Enhance InfiniteVirtualGrid to support custom API endpoints and response formats
- Refactor cluster API routes query params variable naming for consistency
- Add search and loading/error handling within cluster media pages
- Implement bookmark and rating functionality within cluster media grid
- Ensure responsive design and active state highlighting in sidebar and cluster pages
- Update library cluster progress documentation to reflect Phase 3 completion status
This commit is contained in:
tigeren 2025-10-12 06:14:01 +00:00
parent 4306d4ace8
commit 0d6d2730bc
7 changed files with 677 additions and 24 deletions

View File

@ -2,8 +2,8 @@
## 📊 Implementation Status
**Last Updated**: 2025-10-11
**Overall Progress**: 50% Complete (Phase 1 & 2 of 4)
**Last Updated**: 2025-10-12
**Overall Progress**: 75% Complete (Phase 1, 2 & 3 of 4)
---
@ -135,7 +135,76 @@
---
## 📋 Next Tasks (Phase 3)
### Phase 3: Navigation & Viewing (COMPLETE) ✅
**Status**: ✅ COMPLETE
**Time Spent**: ~2 hours
**Completion Date**: 2025-10-12
#### Task 3.1: Update Sidebar ✅
- [x] Add "Clusters" section to sidebar
- [x] List all clusters with color-coded icons
- [x] Collapsible section with expand/collapse
- [x] Click to navigate to cluster view
- [x] Color-coded indicators matching cluster colors
- [x] Icon components for each cluster type
**Files Modified**:
- `/src/components/sidebar.tsx` (95 lines added)
**Features**:
- ✓ Collapsible "Clusters" section
- ✓ Fetches clusters from API on load
- ✓ Color-coded circular icons with cluster colors
- ✓ Icon mapping for all 10 cluster icons
- ✓ Click navigation to `/clusters/[id]`
- ✓ Visual active state when on cluster page
- ✓ Responsive collapsed/expanded states
#### Task 3.2: Create Cluster View Page ✅
- [x] Create `/app/clusters/[id]/page.tsx`
- [x] Cluster header with name, icon, color, description
- [x] Statistics cards (Videos, Photos, Texts, Storage)
- [x] Tabbed interface (Videos/Photos/Texts/Stats)
- [x] Reuse InfiniteVirtualGrid with custom API endpoint
- [x] Search functionality
- [x] Media player integration (video/photo/text viewers)
- [x] Error handling and loading states
**Files Created**:
- `/src/app/clusters/[id]/page.tsx` (468 lines)
**Files Modified**:
- `/src/components/infinite-virtual-grid.tsx` (added apiEndpoint prop)
**Features**:
- ✓ Beautiful cluster header with color theming
- ✓ Real-time statistics cards
- ✓ Tabbed interface with media counts
- ✓ Unified media grid with custom API endpoints
- ✓ Video player modal integration
- ✓ Photo viewer modal integration
- ✓ Text viewer modal integration
- ✓ Bookmark and rating functionality
- ✓ Back navigation
- ✓ Loading and error states
- ✓ Libraries breakdown in Stats tab
- ✓ Responsive design
#### Task 3.3: Enhanced Media Grid Component ✅
- [x] Added apiEndpoint prop to InfiniteVirtualGrid
- [x] Support for custom cluster API endpoints
- [x] Backward compatibility with existing usage
- [x] Proper response format handling
**Features**:
- ✓ Custom API endpoint support
- ✓ Handles different response formats (cluster vs default APIs)
- ✓ Maintains existing functionality
- ✓ Named export for flexible imports
---
## 📋 Next Tasks (Phase 4)
### Phase 3: Navigation & Viewing (PENDING)
**Estimated Time**: 5 hours

View File

@ -37,17 +37,17 @@ export async function GET(
WHERE lcm.cluster_id = ? AND m.type = 'photo'
`;
const params: any[] = [clusterId];
const queryParams: any[] = [clusterId];
if (search) {
query += ' AND (m.title LIKE ? OR m.path LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
queryParams.push(`%${search}%`, `%${search}%`);
}
query += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
queryParams.push(limit, offset);
const photos = db.prepare(query).all(...params);
const photos = db.prepare(query).all(...queryParams);
// Get total count
let countQuery = `

View File

@ -37,17 +37,17 @@ export async function GET(
WHERE lcm.cluster_id = ? AND m.type = 'text'
`;
const params: any[] = [clusterId];
const queryParams: any[] = [clusterId];
if (search) {
query += ' AND (m.title LIKE ? OR m.path LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
queryParams.push(`%${search}%`, `%${search}%`);
}
query += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
queryParams.push(limit, offset);
const texts = db.prepare(query).all(...params);
const texts = db.prepare(query).all(...queryParams);
// Get total count
let countQuery = `

View File

@ -37,17 +37,17 @@ export async function GET(
WHERE lcm.cluster_id = ? AND m.type = 'video'
`;
const params: any[] = [clusterId];
const queryParams: any[] = [clusterId];
if (search) {
query += ' AND (m.title LIKE ? OR m.path LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
queryParams.push(`%${search}%`, `%${search}%`);
}
query += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
queryParams.push(limit, offset);
const videos = db.prepare(query).all(...params);
const videos = db.prepare(query).all(...queryParams);
// Get total count
let countQuery = `

View File

@ -0,0 +1,470 @@
'use client';
import { useState, useEffect, use } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Film, Image as ImageIcon, FileText, TrendingUp, HardDrive, Database, Folder } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import InfiniteVirtualGrid from '@/components/infinite-virtual-grid';
import UnifiedVideoPlayer from '@/components/unified-video-player';
import PhotoViewer from '@/components/photo-viewer';
import TextViewer from '@/components/text-viewer';
import type { Cluster } from '@/db';
interface ClusterPageProps {
params: Promise<{ id: string }>;
}
type MediaType = 'videos' | 'photos' | 'texts' | 'stats';
export default function ClusterPage({ params }: ClusterPageProps) {
const resolvedParams = use(params);
const router = useRouter();
const clusterId = parseInt(resolvedParams.id);
const [cluster, setCluster] = useState<Cluster | null>(null);
const [libraries, setLibraries] = useState<any[]>([]);
const [stats, setStats] = useState<any>(null);
const [activeTab, setActiveTab] = useState<MediaType>('videos');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Video player state
const [selectedVideo, setSelectedVideo] = useState<any | null>(null);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
// Photo viewer state
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
const [photoIndex, setPhotoIndex] = useState(0);
const [isPhotoViewerOpen, setIsPhotoViewerOpen] = useState(false);
// Text viewer state
const [selectedText, setSelectedText] = useState<any | null>(null);
const [isTextViewerOpen, setIsTextViewerOpen] = useState(false);
useEffect(() => {
if (!isNaN(clusterId)) {
fetchClusterData();
}
}, [clusterId]);
const fetchClusterData = async () => {
try {
setLoading(true);
setError(null);
// Fetch cluster details
const clusterRes = await fetch(`/api/clusters/${clusterId}`);
if (!clusterRes.ok) {
throw new Error('Cluster not found');
}
const clusterData = await clusterRes.json();
setCluster(clusterData.cluster);
setLibraries(clusterData.libraries);
// Fetch statistics
const statsRes = await fetch(`/api/clusters/${clusterId}/stats`);
if (statsRes.ok) {
const statsData = await statsRes.json();
setStats(statsData);
}
} catch (error: any) {
setError(error.message);
} finally {
setLoading(false);
}
};
const handleVideoClick = (video: any) => {
setSelectedVideo(video);
setIsPlayerOpen(true);
};
const handlePhotoClick = (photo: any, index: number) => {
setSelectedPhoto(photo);
setPhotoIndex(index);
setIsPhotoViewerOpen(true);
};
const handleTextClick = (text: any) => {
setSelectedText(text);
setIsTextViewerOpen(true);
};
const handleBookmark = async (id: number) => {
try {
const res = await fetch('/api/bookmarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaId: id })
});
if (res.ok) {
// Refresh data
fetchClusterData();
}
} catch (error) {
console.error('Error bookmarking:', error);
}
};
const handleUnbookmark = async (id: number) => {
try {
const res = await fetch(`/api/bookmarks/${id}`, {
method: 'DELETE'
});
if (res.ok) {
// Refresh data
fetchClusterData();
}
} catch (error) {
console.error('Error unbookmarking:', error);
}
};
const handleRate = async (id: number, rating: number) => {
try {
const res = await fetch('/api/stars', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaId: id, rating })
});
if (res.ok) {
// Refresh data
fetchClusterData();
}
} catch (error) {
console.error('Error rating:', error);
}
};
const getIconComponent = (iconName: string) => {
const icons: Record<string, any> = {
folder: Folder,
film: Film,
tv: Film,
image: ImageIcon,
book: FileText,
database: Database,
archive: HardDrive,
star: TrendingUp,
heart: TrendingUp,
flag: TrendingUp,
};
return icons[iconName] || Folder;
};
const formatSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent mx-auto mb-4"></div>
<p className="text-zinc-400">Loading cluster...</p>
</div>
</div>
);
}
if (error || !cluster) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="text-center">
<Database className="h-16 w-16 text-zinc-600 mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">Cluster Not Found</h2>
<p className="text-zinc-400 mb-4">{error || 'The requested cluster does not exist.'}</p>
<Button onClick={() => router.push('/settings')}>
Go to Settings
</Button>
</div>
</div>
);
}
const IconComponent = getIconComponent(cluster.icon);
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center gap-4 mb-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="text-zinc-400 hover:text-white"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
</div>
<div className="flex items-start gap-4">
<div
className="w-16 h-16 rounded-xl flex items-center justify-center shadow-lg flex-shrink-0"
style={{ backgroundColor: `${cluster.color}20` }}
>
<IconComponent className="h-8 w-8" style={{ color: cluster.color }} />
</div>
<div className="flex-1">
<h1 className="text-3xl font-bold text-white mb-2">{cluster.name}</h1>
{cluster.description && (
<p className="text-zinc-400 text-lg">{cluster.description}</p>
)}
<div className="flex gap-3 mt-3 text-sm text-zinc-500">
<span>{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'}</span>
{stats && (
<>
<span></span>
<span>{stats.total_media} items</span>
<span></span>
<span>{formatSize(stats.total_size)}</span>
</>
)}
</div>
</div>
</div>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-zinc-900 border-zinc-800 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-600/20 rounded-lg flex items-center justify-center">
<Film className="h-5 w-5 text-red-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{stats.video_count || 0}</p>
<p className="text-sm text-zinc-400">Videos</p>
</div>
</div>
</Card>
<Card className="bg-zinc-900 border-zinc-800 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-600/20 rounded-lg flex items-center justify-center">
<ImageIcon className="h-5 w-5 text-green-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{stats.photo_count || 0}</p>
<p className="text-sm text-zinc-400">Photos</p>
</div>
</div>
</Card>
<Card className="bg-zinc-900 border-zinc-800 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{stats.text_count || 0}</p>
<p className="text-sm text-zinc-400">Texts</p>
</div>
</div>
</Card>
<Card className="bg-zinc-900 border-zinc-800 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
<HardDrive className="h-5 w-5 text-purple-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{formatSize(stats.total_size || 0)}</p>
<p className="text-sm text-zinc-400">Storage</p>
</div>
</div>
</Card>
</div>
</div>
)}
{/* Tabs */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="border-b border-zinc-800">
<nav className="flex gap-4">
<button
onClick={() => setActiveTab('videos')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'videos'
? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300'
}`}
>
<Film className="h-4 w-4 inline mr-2" />
Videos ({stats?.video_count || 0})
</button>
<button
onClick={() => setActiveTab('photos')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'photos'
? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300'
}`}
>
<ImageIcon className="h-4 w-4 inline mr-2" />
Photos ({stats?.photo_count || 0})
</button>
<button
onClick={() => setActiveTab('texts')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'texts'
? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300'
}`}
>
<FileText className="h-4 w-4 inline mr-2" />
Texts ({stats?.text_count || 0})
</button>
<button
onClick={() => setActiveTab('stats')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'stats'
? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300'
}`}
>
<TrendingUp className="h-4 w-4 inline mr-2" />
Stats
</button>
</nav>
</div>
</div>
{/* Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{activeTab === 'videos' && (
<InfiniteVirtualGrid
type="video"
onItemClick={handleVideoClick}
onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark}
onRate={handleRate}
apiEndpoint={`/api/clusters/${clusterId}/videos`}
/>
)}
{activeTab === 'photos' && (
<InfiniteVirtualGrid
type="photo"
onItemClick={(item, index) => handlePhotoClick(item, index || 0)}
onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark}
onRate={handleRate}
apiEndpoint={`/api/clusters/${clusterId}/photos`}
/>
)}
{activeTab === 'texts' && (
<InfiniteVirtualGrid
type="text"
onItemClick={handleTextClick}
onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark}
onRate={handleRate}
apiEndpoint={`/api/clusters/${clusterId}/texts`}
/>
)}
{activeTab === 'stats' && stats && (
<div className="space-y-6">
{/* Libraries Breakdown */}
<Card className="bg-zinc-900 border-zinc-800 p-6">
<h3 className="text-lg font-semibold text-white mb-4">Libraries in this Cluster</h3>
<div className="space-y-3">
{libraries.map((library: any) => (
<div
key={library.id}
className="flex items-center justify-between p-3 bg-zinc-800 rounded-lg"
>
<div className="flex items-center gap-3">
<Folder className="h-4 w-4 text-zinc-400" />
<span className="text-sm font-mono text-zinc-200">{library.path}</span>
</div>
</div>
))}
</div>
</Card>
{/* Media Statistics */}
<Card className="bg-zinc-900 border-zinc-800 p-6">
<h3 className="text-lg font-semibold text-white mb-4">Media Statistics</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-zinc-400">Total Items</p>
<p className="text-2xl font-bold text-white">{stats.total_media}</p>
</div>
<div>
<p className="text-sm text-zinc-400">Videos</p>
<p className="text-2xl font-bold text-white">{stats.video_count}</p>
</div>
<div>
<p className="text-sm text-zinc-400">Photos</p>
<p className="text-2xl font-bold text-white">{stats.photo_count}</p>
</div>
<div>
<p className="text-sm text-zinc-400">Texts</p>
<p className="text-2xl font-bold text-white">{stats.text_count}</p>
</div>
<div>
<p className="text-sm text-zinc-400">Total Size</p>
<p className="text-2xl font-bold text-white">{formatSize(stats.total_size)}</p>
</div>
<div>
<p className="text-sm text-zinc-400">Bookmarked</p>
<p className="text-2xl font-bold text-white">{stats.bookmarked_count}</p>
</div>
</div>
</Card>
</div>
)}
</div>
{/* Video Player Modal */}
{isPlayerOpen && selectedVideo && (
<UnifiedVideoPlayer
video={selectedVideo}
isOpen={isPlayerOpen}
onClose={() => {
setIsPlayerOpen(false);
setSelectedVideo(null);
}}
/>
)}
{/* Photo Viewer Modal */}
{isPhotoViewerOpen && selectedPhoto && (
<PhotoViewer
photo={selectedPhoto}
isOpen={isPhotoViewerOpen}
onClose={() => {
setIsPhotoViewerOpen(false);
setSelectedPhoto(null);
}}
/>
)}
{/* Text Viewer Modal */}
{isTextViewerOpen && selectedText && (
<TextViewer
text={selectedText}
isOpen={isTextViewerOpen}
onClose={() => {
setIsTextViewerOpen(false);
setSelectedText(null);
}}
/>
)}
</div>
);
}

View File

@ -29,6 +29,7 @@ interface InfiniteVirtualGridProps {
onUnbookmark: (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => Promise<void>;
onRate: (id: number, rating: number) => Promise<void>;
onDataUpdate?: (items: MediaItem[]) => void;
apiEndpoint?: string; // Optional custom API endpoint
}
const ITEM_HEIGHT = 220;
@ -41,7 +42,8 @@ export default function InfiniteVirtualGrid({
onBookmark,
onUnbookmark,
onRate,
onDataUpdate
onDataUpdate,
apiEndpoint
}: InfiniteVirtualGridProps) {
const [totalItems, setTotalItems] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
@ -131,8 +133,10 @@ export default function InfiniteVirtualGrid({
...(searchTerm && { search: searchTerm })
});
const endpoint = type === 'bookmark' ? 'bookmarks' : `${type}s`;
const response = await fetch(`/api/${endpoint}?${params}`);
// Use custom endpoint if provided, otherwise use default
const endpoint = apiEndpoint || (type === 'bookmark' ? 'bookmarks' : `${type}s`);
const url = apiEndpoint ? `${endpoint}?${params}` : `/api/${endpoint}?${params}`;
const response = await fetch(url);
const data = await response.json();
let items: MediaItem[] = [];
@ -155,7 +159,7 @@ export default function InfiniteVirtualGrid({
const fetchTotalCount = useCallback(async () => {
try {
const endpoint = type === 'bookmark' ? 'bookmarks' : `${type}s`;
const endpoint = apiEndpoint || (type === 'bookmark' ? 'bookmarks' : `${type}s`);
const params = new URLSearchParams({
limit: '50',
@ -163,17 +167,31 @@ export default function InfiniteVirtualGrid({
...(searchTerm && { search: searchTerm })
});
const response = await fetch(`/api/${endpoint}?${params}`);
const url = apiEndpoint ? `${endpoint}?${params}` : `/api/${endpoint}?${params}`;
const response = await fetch(url);
const data = await response.json();
const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
const items = data[itemsKey] || [];
// Handle different response formats
let items: MediaItem[] = [];
let total = 0;
if (apiEndpoint) {
// Custom endpoint (cluster APIs)
const itemsKey = `${type}s`;
items = data[itemsKey] || [];
total = data.total || 0;
} else {
// Default endpoints
const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
items = data[itemsKey] || [];
total = data.pagination?.total || 0;
}
if (items.length > 0) {
dataCacheRef.current.set(0, items);
}
setTotalItems(data.pagination.total || 0);
setTotalItems(total);
setIsLoadingInitial(false);
} catch (error) {
console.error(`Error fetching total count for ${type}:`, error);
@ -188,7 +206,7 @@ export default function InfiniteVirtualGrid({
setTotalItems(0);
setIsLoadingInitial(true);
fetchTotalCount();
}, [type, searchTerm, fetchTotalCount]);
}, [type, searchTerm, apiEndpoint, fetchTotalCount]);
useEffect(() => {
const updateWidth = () => {
@ -662,4 +680,5 @@ export default function InfiniteVirtualGrid({
);
}
export { InfiniteVirtualGrid };
export type { MediaItem };

View File

@ -16,7 +16,9 @@ import {
Play,
FolderOpen,
Bookmark,
Database,
} from "lucide-react";
import type { Cluster } from '@/db';
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils";
@ -25,11 +27,14 @@ import { Suspense } from "react";
const SidebarContent = () => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
const [showClusters, setShowClusters] = useState(true);
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
fetchLibraries();
fetchClusters();
}, []);
const fetchLibraries = async () => {
@ -42,6 +47,32 @@ const SidebarContent = () => {
}
};
const fetchClusters = async () => {
try {
const res = await fetch('/api/clusters');
const data = await res.json();
setClusters(data);
} catch (error) {
console.error('Error fetching clusters:', error);
}
};
const getIconComponent = (iconName: string) => {
const icons: Record<string, any> = {
folder: Folder,
film: Film,
tv: Video,
image: ImageIcon,
book: Bookmark,
database: Database,
archive: FolderOpen,
star: Play,
heart: Play,
flag: Play,
};
return icons[iconName] || Folder;
};
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
};
@ -103,6 +134,70 @@ const SidebarContent = () => {
))}
</div>
{/* Clusters Section */}
{clusters.length > 0 && (
<div className="pt-6 flex-shrink-0">
<button
onClick={() => setShowClusters(!showClusters)}
className={cn(
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
isCollapsed && "justify-center"
)}
>
<Database className="h-4 w-4 text-muted-foreground" />
{!isCollapsed && (
<>
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex-1 text-left">
Clusters
</h2>
<ChevronRight className={cn(
"h-3 w-3 text-muted-foreground transition-transform",
showClusters && "rotate-90"
)} />
</>
)}
</button>
{showClusters && (
<div className="space-y-1">
{clusters.map((cluster) => {
const IconComponent = getIconComponent(cluster.icon);
const isActive = pathname === `/clusters/${cluster.id}`;
return (
<Link
href={`/clusters/${cluster.id}`}
key={cluster.id}
passHref
>
<Button
variant="ghost"
className={cn(
"w-full justify-start h-9 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group text-sm",
isActive && "bg-primary/10 text-primary border border-primary/20 shadow-sm"
)}
>
<div
className="w-3.5 h-3.5 rounded flex items-center justify-center"
style={{ backgroundColor: `${cluster.color}30` }}
>
<IconComponent
className="h-2.5 w-2.5"
style={{ color: cluster.color }}
/>
</div>
{!isCollapsed && (
<span className="ml-3 truncate text-xs">
{cluster.name}
</span>
)}
</Button>
</Link>
);
})}
</div>
)}
</div>
)}
{/* Libraries Section */}
{libraries.length > 0 && (
<div className="pt-6 flex-1 flex flex-col overflow-hidden">