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:
parent
4306d4ace8
commit
0d6d2730bc
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
## 📊 Implementation Status
|
## 📊 Implementation Status
|
||||||
|
|
||||||
**Last Updated**: 2025-10-11
|
**Last Updated**: 2025-10-12
|
||||||
**Overall Progress**: 50% Complete (Phase 1 & 2 of 4)
|
**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)
|
### Phase 3: Navigation & Viewing (PENDING)
|
||||||
**Estimated Time**: 5 hours
|
**Estimated Time**: 5 hours
|
||||||
|
|
|
||||||
|
|
@ -37,17 +37,17 @@ export async function GET(
|
||||||
WHERE lcm.cluster_id = ? AND m.type = 'photo'
|
WHERE lcm.cluster_id = ? AND m.type = 'photo'
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: any[] = [clusterId];
|
const queryParams: any[] = [clusterId];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
query += ' AND (m.title LIKE ? OR m.path LIKE ?)';
|
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 ?';
|
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
|
// Get total count
|
||||||
let countQuery = `
|
let countQuery = `
|
||||||
|
|
|
||||||
|
|
@ -37,17 +37,17 @@ export async function GET(
|
||||||
WHERE lcm.cluster_id = ? AND m.type = 'text'
|
WHERE lcm.cluster_id = ? AND m.type = 'text'
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: any[] = [clusterId];
|
const queryParams: any[] = [clusterId];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
query += ' AND (m.title LIKE ? OR m.path LIKE ?)';
|
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 ?';
|
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
|
// Get total count
|
||||||
let countQuery = `
|
let countQuery = `
|
||||||
|
|
|
||||||
|
|
@ -37,17 +37,17 @@ export async function GET(
|
||||||
WHERE lcm.cluster_id = ? AND m.type = 'video'
|
WHERE lcm.cluster_id = ? AND m.type = 'video'
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: any[] = [clusterId];
|
const queryParams: any[] = [clusterId];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
query += ' AND (m.title LIKE ? OR m.path LIKE ?)';
|
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 ?';
|
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
|
// Get total count
|
||||||
let countQuery = `
|
let countQuery = `
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ interface InfiniteVirtualGridProps {
|
||||||
onUnbookmark: (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => Promise<void>;
|
onUnbookmark: (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => Promise<void>;
|
||||||
onRate: (id: number, rating: number) => Promise<void>;
|
onRate: (id: number, rating: number) => Promise<void>;
|
||||||
onDataUpdate?: (items: MediaItem[]) => void;
|
onDataUpdate?: (items: MediaItem[]) => void;
|
||||||
|
apiEndpoint?: string; // Optional custom API endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEM_HEIGHT = 220;
|
const ITEM_HEIGHT = 220;
|
||||||
|
|
@ -41,7 +42,8 @@ export default function InfiniteVirtualGrid({
|
||||||
onBookmark,
|
onBookmark,
|
||||||
onUnbookmark,
|
onUnbookmark,
|
||||||
onRate,
|
onRate,
|
||||||
onDataUpdate
|
onDataUpdate,
|
||||||
|
apiEndpoint
|
||||||
}: InfiniteVirtualGridProps) {
|
}: InfiniteVirtualGridProps) {
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
@ -131,8 +133,10 @@ export default function InfiniteVirtualGrid({
|
||||||
...(searchTerm && { search: searchTerm })
|
...(searchTerm && { search: searchTerm })
|
||||||
});
|
});
|
||||||
|
|
||||||
const endpoint = type === 'bookmark' ? 'bookmarks' : `${type}s`;
|
// Use custom endpoint if provided, otherwise use default
|
||||||
const response = await fetch(`/api/${endpoint}?${params}`);
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
let items: MediaItem[] = [];
|
let items: MediaItem[] = [];
|
||||||
|
|
@ -155,7 +159,7 @@ export default function InfiniteVirtualGrid({
|
||||||
|
|
||||||
const fetchTotalCount = useCallback(async () => {
|
const fetchTotalCount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const endpoint = type === 'bookmark' ? 'bookmarks' : `${type}s`;
|
const endpoint = apiEndpoint || (type === 'bookmark' ? 'bookmarks' : `${type}s`);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: '50',
|
limit: '50',
|
||||||
|
|
@ -163,17 +167,31 @@ export default function InfiniteVirtualGrid({
|
||||||
...(searchTerm && { search: searchTerm })
|
...(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 data = await response.json();
|
||||||
|
|
||||||
const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
|
// Handle different response formats
|
||||||
const items = data[itemsKey] || [];
|
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) {
|
if (items.length > 0) {
|
||||||
dataCacheRef.current.set(0, items);
|
dataCacheRef.current.set(0, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTotalItems(data.pagination.total || 0);
|
setTotalItems(total);
|
||||||
setIsLoadingInitial(false);
|
setIsLoadingInitial(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching total count for ${type}:`, error);
|
console.error(`Error fetching total count for ${type}:`, error);
|
||||||
|
|
@ -188,7 +206,7 @@ export default function InfiniteVirtualGrid({
|
||||||
setTotalItems(0);
|
setTotalItems(0);
|
||||||
setIsLoadingInitial(true);
|
setIsLoadingInitial(true);
|
||||||
fetchTotalCount();
|
fetchTotalCount();
|
||||||
}, [type, searchTerm, fetchTotalCount]);
|
}, [type, searchTerm, apiEndpoint, fetchTotalCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateWidth = () => {
|
const updateWidth = () => {
|
||||||
|
|
@ -662,4 +680,5 @@ export default function InfiniteVirtualGrid({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { InfiniteVirtualGrid };
|
||||||
export type { MediaItem };
|
export type { MediaItem };
|
||||||
|
|
@ -16,7 +16,9 @@ import {
|
||||||
Play,
|
Play,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
Database,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type { Cluster } from '@/db';
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -25,11 +27,14 @@ import { Suspense } from "react";
|
||||||
const SidebarContent = () => {
|
const SidebarContent = () => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
|
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
|
||||||
|
const [clusters, setClusters] = useState<Cluster[]>([]);
|
||||||
|
const [showClusters, setShowClusters] = useState(true);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLibraries();
|
fetchLibraries();
|
||||||
|
fetchClusters();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchLibraries = async () => {
|
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 = () => {
|
const toggleSidebar = () => {
|
||||||
setIsCollapsed(!isCollapsed);
|
setIsCollapsed(!isCollapsed);
|
||||||
};
|
};
|
||||||
|
|
@ -103,6 +134,70 @@ const SidebarContent = () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 Section */}
|
||||||
{libraries.length > 0 && (
|
{libraries.length > 0 && (
|
||||||
<div className="pt-6 flex-1 flex flex-col overflow-hidden">
|
<div className="pt-6 flex-1 flex flex-col overflow-hidden">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue