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
|
||||
|
||||
**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
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
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 };
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue