Compare commits

..

No commits in common. "44aedcbee61f8cd03245ad33ead665a933f460fa" and "2442d0dde79ecaf6c6c4d5c0ab78e3b3dc3c9cc0" have entirely different histories.

17 changed files with 662 additions and 1502 deletions

View File

@ -28,6 +28,4 @@ UI:
8. the video card can be clicked, once clicked, a poped up video player will be displayed. it can be closed, fast forward, expand to full screen, etc. 8. the video card can be clicked, once clicked, a poped up video player will be displayed. it can be closed, fast forward, expand to full screen, etc.
9. can bookmark/un-bookmark the video, can star the video 9. can bookmark/un-bookmark the video, can star the video
Development Rules:
1. Everytime after making all the changes, run 'pnpm build' to verify the changes are compiling correct.
2. Once added debug logs, don't delete it until told so.

View File

@ -28,60 +28,4 @@ UI:
8. the video card can be clicked, once clicked, a poped up video player will be displayed. it can be closed, fast forward, expand to full screen, etc. 8. the video card can be clicked, once clicked, a poped up video player will be displayed. it can be closed, fast forward, expand to full screen, etc.
9. can bookmark/un-bookmark the video, can star the video 9. can bookmark/un-bookmark the video, can star the video
Performance Optimization Plan
Phase 1: Critical API Pagination (Immediate Fix)
Implement database pagination across all list APIs
Add limit/offset parameters to /api/videos, /api/photos, /api/bookmarks
Add server-side filtering and sorting
Implement cursor-based pagination for better performance
Add database indexes for pagination queries
Create compound indexes for (type, created_at) queries
Add path-based indexes for folder-viewer queries
Optimize bookmark/star count queries
Phase 2: Frontend Memory Optimization
Implement virtual scrolling for large lists
Use react-window or react-virtualized for video/photo grids
Add infinite scroll with intersection observer
Implement client-side caching with LRU eviction
Add progressive loading strategies
Lazy load thumbnails as they come into view
Implement skeleton loaders during data fetching
Add debounced search with server-side filtering
Phase 3: File System Scanning Optimization
Parallel processing implementation
Use worker threads for thumbnail generation
Implement batch processing for database inserts
Add progress reporting with WebSocket/SSE
Smart scanning strategies
Implement incremental scanning (only new/changed files)
Add file watching for real-time updates
Use streaming file discovery instead of loading all paths
Phase 4: Database Performance
Connection pooling and optimization
Implement better-sqlite3 connection pooling
Add prepared statement caching
Implement batch operations for inserts/updates
Advanced indexing strategy
Full-text search indexes for title/path searching
Composite indexes for common query patterns
Materialized views for aggregated data (ratings, bookmarks)
Phase 5: Caching & CDN Strategy
Multi-level caching
Redis for API response caching
Browser caching with ETags
CDN for thumbnail delivery
Thumbnail optimization
WebP format with fallbacks
Multiple thumbnail sizes for different viewports
Lazy generation with background processing
Implementation Priority
P0 (Critical) : API pagination + frontend virtual scrolling
P1 (High) : Database indexes + connection optimization
P2 (Medium) : File scanning improvements
P3 (Low) : Advanced caching + CDN
This plan addresses all identified bottlenecks systematically, starting with the most critical issues that would cause immediate system failure with large datasets.

56
PRD.md
View File

@ -28,60 +28,4 @@ UI:
8. the video card can be clicked, once clicked, a poped up video player will be displayed. it can be closed, fast forward, expand to full screen, etc. 8. the video card can be clicked, once clicked, a poped up video player will be displayed. it can be closed, fast forward, expand to full screen, etc.
9. can bookmark/un-bookmark the video, can star the video 9. can bookmark/un-bookmark the video, can star the video
Performance Optimization Plan
Phase 1: Critical API Pagination (Immediate Fix)
Implement database pagination across all list APIs
Add limit/offset parameters to /api/videos, /api/photos, /api/bookmarks
Add server-side filtering and sorting
Implement cursor-based pagination for better performance
Add database indexes for pagination queries
Create compound indexes for (type, created_at) queries
Add path-based indexes for folder-viewer queries
Optimize bookmark/star count queries
Phase 2: Frontend Memory Optimization
Implement virtual scrolling for large lists
Use react-window or react-virtualized for video/photo grids
Add infinite scroll with intersection observer
Implement client-side caching with LRU eviction
Add progressive loading strategies
Lazy load thumbnails as they come into view
Implement skeleton loaders during data fetching
Add debounced search with server-side filtering
Phase 3: File System Scanning Optimization
Parallel processing implementation
Use worker threads for thumbnail generation
Implement batch processing for database inserts
Add progress reporting with WebSocket/SSE
Smart scanning strategies
Implement incremental scanning (only new/changed files)
Add file watching for real-time updates
Use streaming file discovery instead of loading all paths
Phase 4: Database Performance
Connection pooling and optimization
Implement better-sqlite3 connection pooling
Add prepared statement caching
Implement batch operations for inserts/updates
Advanced indexing strategy
Full-text search indexes for title/path searching
Composite indexes for common query patterns
Materialized views for aggregated data (ratings, bookmarks)
Phase 5: Caching & CDN Strategy
Multi-level caching
Redis for API response caching
Browser caching with ETags
CDN for thumbnail delivery
Thumbnail optimization
WebP format with fallbacks
Multiple thumbnail sizes for different viewports
Lazy generation with background processing
Implementation Priority
P0 (Critical) : API pagination + frontend virtual scrolling
P1 (High) : Database indexes + connection optimization
P2 (Medium) : File scanning improvements
P3 (Low) : Advanced caching + CDN
This plan addresses all identified bottlenecks systematically, starting with the most critical issues that would cause immediate system failure with large datasets.

BIN
media.db

Binary file not shown.

58
package-lock.json generated
View File

@ -20,8 +20,6 @@
"next": "15.5.0", "next": "15.5.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-window": "^1.8.11",
"react-window-infinite-loader": "^1.0.10",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
@ -30,7 +28,6 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/react-window": "^1.8.8",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
@ -49,15 +46,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz",
"integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
@ -844,16 +832,6 @@
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
}, },
"node_modules/@types/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
@ -1729,12 +1707,6 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -2261,36 +2233,6 @@
"react": "^19.1.0" "react": "^19.1.0"
} }
}, },
"node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-window-infinite-loader": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.10.tgz",
"integrity": "sha512-NO/csdHlxjWqA2RJZfzQgagAjGHspbO2ik9GtWZb0BY1Nnapq0auG8ErI+OhGCzpjYJsCYerqUlK6hkq9dfAAA==",
"license": "MIT",
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View File

@ -20,8 +20,6 @@
"next": "15.5.0", "next": "15.5.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-window": "^1.8.11",
"react-window-infinite-loader": "^1.0.10",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
@ -30,7 +28,6 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/react-window": "^1.8.8",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",

View File

@ -2,60 +2,16 @@ import { NextResponse } from 'next/server';
import db from '@/db'; import db from '@/db';
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100);
const offset = parseInt(searchParams.get('offset') || '0');
const search = searchParams.get('search') || '';
const sortBy = searchParams.get('sortBy') || 'updated_at';
const sortOrder = searchParams.get('sortOrder') || 'DESC';
// Validate sort parameters to prevent SQL injection
const allowedSortColumns = ['updated_at', 'created_at', 'title', 'size', 'type'];
const allowedSortOrders = ['ASC', 'DESC'];
const sortColumn = allowedSortColumns.includes(sortBy) ? sortBy : 'updated_at';
const sortDirection = allowedSortOrders.includes(sortOrder.toUpperCase()) ? sortOrder.toUpperCase() : 'DESC';
let whereClause = '';
let params: any[] = [];
if (search) {
whereClause = "WHERE (m.title LIKE ? OR m.path LIKE ?)";
params.push(`%${search}%`, `%${search}%`);
}
try { try {
// Get total count for pagination
const countQuery = `
SELECT COUNT(*) as total
FROM bookmarks b
JOIN media m ON b.media_id = m.id
${whereClause}
`;
const totalResult = db.prepare(countQuery).get(...params) as { total: number };
const total = totalResult.total;
// Get paginated results
const bookmarks = db.prepare(` const bookmarks = db.prepare(`
SELECT m.*, l.path as library_path SELECT m.*, l.path as library_path
FROM bookmarks b FROM bookmarks b
JOIN media m ON b.media_id = m.id JOIN media m ON b.media_id = m.id
JOIN libraries l ON m.library_id = l.id JOIN libraries l ON m.library_id = l.id
${whereClause} ORDER BY b.updated_at DESC
ORDER BY b.${sortColumn} ${sortDirection} `).all();
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
return NextResponse.json({ return NextResponse.json(bookmarks);
bookmarks,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total
}
});
} catch (error: any) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ error: error.message }, { status: 500 });
} }

View File

@ -1,41 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import db from '@/db'; import db from '@/db';
export async function GET(request: Request) { export async function GET() {
const { searchParams } = new URL(request.url); const photos = db.prepare(`
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100);
const offset = parseInt(searchParams.get('offset') || '0');
const search = searchParams.get('search') || '';
const sortBy = searchParams.get('sortBy') || 'created_at';
const sortOrder = searchParams.get('sortOrder') || 'DESC';
// Validate sort parameters to prevent SQL injection
const allowedSortColumns = ['created_at', 'title', 'size', 'bookmark_count', 'avg_rating'];
const allowedSortOrders = ['ASC', 'DESC'];
const sortColumn = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
const sortDirection = allowedSortOrders.includes(sortOrder.toUpperCase()) ? sortOrder.toUpperCase() : 'DESC';
let whereClause = "WHERE m.type = 'photo'";
let params: any[] = [];
if (search) {
whereClause += " AND (m.title LIKE ? OR m.path LIKE ?)";
params.push(`%${search}%`, `%${search}%`);
}
// Get total count for pagination
const countQuery = `
SELECT COUNT(*) as total
FROM media m
${whereClause}
`;
const totalResult = db.prepare(countQuery).get(...params) as { total: number };
const total = totalResult.total;
// Get paginated results
const query = `
SELECT SELECT
m.*, m.*,
COALESCE(b.bookmark_count, 0) as bookmark_count, COALESCE(b.bookmark_count, 0) as bookmark_count,
@ -55,20 +22,7 @@ export async function GET(request: Request) {
FROM stars FROM stars
GROUP BY media_id GROUP BY media_id
) s ON m.id = s.media_id ) s ON m.id = s.media_id
${whereClause} WHERE m.type = ?
ORDER BY m.${sortColumn} ${sortDirection} `).all('photo');
LIMIT ? OFFSET ? return NextResponse.json(photos);
`;
const photos = db.prepare(query).all(...params, limit, offset);
return NextResponse.json({
photos,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total
}
});
} }

View File

@ -2,74 +2,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import db from "@/db"; import db from "@/db";
export async function GET(request: Request) { export async function GET() {
const { searchParams } = new URL(request.url); const videos = db.prepare("SELECT * FROM media WHERE type = 'video'").all();
return NextResponse.json(videos);
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100);
const offset = parseInt(searchParams.get('offset') || '0');
const search = searchParams.get('search') || '';
const sortBy = searchParams.get('sortBy') || 'created_at';
const sortOrder = searchParams.get('sortOrder') || 'DESC';
// Validate sort parameters to prevent SQL injection
const allowedSortColumns = ['created_at', 'title', 'size', 'bookmark_count', 'avg_rating'];
const allowedSortOrders = ['ASC', 'DESC'];
const sortColumn = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
const sortDirection = allowedSortOrders.includes(sortOrder.toUpperCase()) ? sortOrder.toUpperCase() : 'DESC';
let whereClause = "WHERE m.type = 'video'";
let params: any[] = [];
if (search) {
whereClause += " AND (m.title LIKE ? OR m.path LIKE ?)";
params.push(`%${search}%`, `%${search}%`);
}
// Get total count for pagination
const countQuery = `
SELECT COUNT(*) as total
FROM media m
${whereClause}
`;
const totalResult = db.prepare(countQuery).get(...params) as { total: number };
const total = totalResult.total;
// Get paginated results
const query = `
SELECT
m.*,
COALESCE(b.bookmark_count, 0) as bookmark_count,
COALESCE(s.avg_rating, 0) as avg_rating,
COALESCE(s.star_count, 0) as star_count
FROM media m
LEFT JOIN (
SELECT media_id, COUNT(*) as bookmark_count
FROM bookmarks
GROUP BY media_id
) b ON m.id = b.media_id
LEFT JOIN (
SELECT
media_id,
AVG(rating) as avg_rating,
COUNT(*) as star_count
FROM stars
GROUP BY media_id
) s ON m.id = s.media_id
${whereClause}
ORDER BY m.${sortColumn} ${sortDirection}
LIMIT ? OFFSET ?
`;
const videos = db.prepare(query).all(...params, limit, offset);
return NextResponse.json({
videos,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total
}
});
} }

View File

@ -1,9 +1,9 @@
'use client'; "use client";
import { useState } from 'react'; import { useState, useEffect } from 'react';
import InfiniteVirtualGrid from '@/components/infinite-virtual-grid'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import VideoViewer from '@/components/video-viewer'; import InlineVideoPlayer from "@/components/inline-video-player";
import PhotoViewer from '@/components/photo-viewer'; import { Bookmark, Heart, Star, Film, Image as ImageIcon } from 'lucide-react';
interface MediaItem { interface MediaItem {
id: number; id: number;
@ -15,98 +15,177 @@ interface MediaItem {
bookmark_count: number; bookmark_count: number;
star_count: number; star_count: number;
avg_rating: number; avg_rating: number;
library_path: string;
} }
export default function BookmarksPage() { export default function BookmarksPage() {
const [items, setItems] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null); const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false); const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const [isVideoPlayerOpen, setIsVideoPlayerOpen] = useState(false); const [scrollPosition, setScrollPosition] = useState(0);
useEffect(() => {
fetchBookmarkedItems();
}, []);
const fetchBookmarkedItems = async () => {
try {
const response = await fetch('/api/bookmarks');
const data = await response.json();
setItems(data);
} catch (error) {
console.error('Error fetching bookmarked items:', error);
} finally {
setLoading(false);
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleItemClick = (item: MediaItem) => { const handleItemClick = (item: MediaItem) => {
if (item.type === 'video') { setScrollPosition(window.scrollY);
setSelectedItem(item); setSelectedItem(item);
setIsVideoPlayerOpen(true); setIsPlayerOpen(true);
} else { window.scrollTo({ top: 0, behavior: 'smooth' });
setSelectedItem(item);
setIsViewerOpen(true);
}
}; };
const handleCloseVideoPlayer = () => { const handleClosePlayer = () => {
setIsVideoPlayerOpen(false); setIsPlayerOpen(false);
setSelectedItem(null); setSelectedItem(null);
// Restore scroll position
setTimeout(() => {
window.scrollTo({ top: scrollPosition, behavior: 'smooth' });
}, 100);
}; };
const handleClosePhotoViewer = () => { if (loading) {
setIsViewerOpen(false); return (
setSelectedItem(null); <div className="flex items-center justify-center min-h-screen">
}; <div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
const handleBookmark = async (id: number) => { <p className="text-muted-foreground">Loading bookmarked items...</p>
try { </div>
await fetch(`/api/bookmarks/${id}`, { method: 'POST' }); </div>
} catch (error) { );
console.error('Error bookmarking item:', error); }
}
};
const handleUnbookmark = async (id: number) => {
try {
await fetch(`/api/bookmarks/${id}`, { method: 'DELETE' });
} catch (error) {
console.error('Error unbookmarking item:', error);
}
};
const handleRate = async (id: number, rating: number) => {
try {
await fetch(`/api/stars/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating })
});
} catch (error) {
console.error('Error rating item:', error);
}
};
return ( return (
<> <div className="min-h-screen bg-zinc-950">
<InfiniteVirtualGrid <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
type="bookmark" <div className="mb-8">
onItemClick={handleItemClick} <div className="flex items-center gap-3 mb-4">
onBookmark={handleBookmark} <div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
onUnbookmark={handleUnbookmark} <Bookmark className="h-6 w-6 text-white" />
onRate={handleRate} </div>
/> <div>
<h1 className="text-3xl font-bold text-white">Bookmarked Items</h1>
<p className="text-zinc-400 mt-1">
{items.length} {items.length === 1 ? 'item' : 'items'} bookmarked
</p>
</div>
</div>
</div>
{/* Video Player */} {items.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<div className="text-center max-w-md">
<div className="w-20 h-20 bg-zinc-900 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
<Bookmark className="h-10 w-10 text-zinc-400" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">No Bookmarks Yet</h2>
<p className="text-zinc-400">
Start bookmarking videos and photos by clicking the bookmark icon.
</p>
</div>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
{items.map((item) => (
<Card
key={item.id}
className="group bg-white dark:bg-slate-800 border-0 shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden cursor-pointer"
onClick={() => handleItemClick(item)}
>
<CardHeader className="p-0">
<div className={`relative overflow-hidden ${item.type === 'photo' ? 'aspect-square' : 'aspect-video'} bg-slate-200 dark:bg-slate-700`}>
{item.thumbnail ? (
<img
src={item.thumbnail}
alt={item.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-700 dark:to-slate-800">
{item.type === 'photo' ? (
<ImageIcon className="w-12 h-12 text-slate-400" />
) : (
<Film className="w-12 h-12 text-slate-400" />
)}
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
</CardHeader>
<CardContent className="p-3">
<CardTitle className="text-sm font-semibold text-slate-900 dark:text-slate-100 truncate mb-1">
{item.title || item.path.split('/').pop()}
</CardTitle>
<CardDescription className="text-xs text-slate-600 dark:text-slate-400">
{formatFileSize(item.size)}
</CardDescription>
{/* Stats */}
<div className="flex items-center gap-3 mt-2 text-xs text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-1">
<Heart className="h-3 w-3" />
<span>{item.bookmark_count || 0}</span>
</div>
<div className="flex items-center gap-1">
<Star className="h-3 w-3" />
<span>{item.avg_rating?.toFixed(1) || '0.0'}</span>
</div>
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate">
{item.type === 'photo' ? (
<span className="flex items-center gap-1">
<ImageIcon className="h-3 w-3" /> {item.library_path?.split('/').pop()}
</span>
) : (
<span className="flex items-center gap-1">
<Film className="h-3 w-3" /> {item.library_path?.split('/').pop()}
</span>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Inline Video Player */}
{selectedItem && selectedItem.type === 'video' && ( {selectedItem && selectedItem.type === 'video' && (
<VideoViewer <InlineVideoPlayer
video={selectedItem} video={{
isOpen={isVideoPlayerOpen} id: selectedItem.id,
onClose={handleCloseVideoPlayer} title: selectedItem.title || selectedItem.path.split('/').pop() || 'Untitled',
showBookmarks={true} path: selectedItem.path,
showRatings={true} size: selectedItem.size,
onBookmark={handleBookmark} thumbnail: selectedItem.thumbnail || '',
onUnbookmark={handleUnbookmark} }}
onRate={handleRate} isOpen={isPlayerOpen}
onClose={handleClosePlayer}
scrollPosition={scrollPosition}
/> />
)} )}
</div>
{/* Photo Viewer */}
{selectedItem && selectedItem.type === 'photo' && (
<PhotoViewer
photo={selectedItem}
isOpen={isViewerOpen}
onClose={handleClosePhotoViewer}
showBookmarks={true}
showRatings={true}
onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark}
onRate={handleRate}
/>
)}
</>
); );
} }

View File

@ -82,75 +82,15 @@
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; @apply bg-muted/50;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3); @apply bg-muted-foreground/30 rounded-full;
border-radius: 4px;
transition: background 0.2s ease;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5); @apply bg-muted-foreground/50;
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* Custom scrollbar for react-window grids */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg,
hsl(var(--primary) / 0.2) 0%,
hsl(var(--primary) / 0.4) 50%,
hsl(var(--primary) / 0.2) 100%);
border-radius: 3px;
transition: all 0.3s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg,
hsl(var(--primary) / 0.4) 0%,
hsl(var(--primary) / 0.6) 50%,
hsl(var(--primary) / 0.4) 100%);
transform: scaleX(1.2);
}
.custom-scrollbar::-webkit-scrollbar-thumb:active {
background: linear-gradient(180deg,
hsl(var(--primary) / 0.6) 0%,
hsl(var(--primary) / 0.8) 50%,
hsl(var(--primary) / 0.6) 100%);
}
/* Hide scrollbar when not needed */
.custom-scrollbar::-webkit-scrollbar-thumb:vertical {
min-height: 40px;
}
/* Fancy scroll indicator animation */
@keyframes scrollPulse {
0%, 100% {
opacity: 0.3;
transform: scaleY(0.8);
}
50% {
opacity: 0.7;
transform: scaleY(1);
}
}
.scroll-indicator {
animation: scrollPulse 2s ease-in-out infinite;
} }
/* Smooth transitions */ /* Smooth transitions */

View File

@ -25,7 +25,7 @@ export default function RootLayout({
> >
<div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20"> <div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20">
<Sidebar /> <Sidebar />
<main className="flex-1 bg-background/50 backdrop-blur-sm"> <main className="flex-1 overflow-y-auto bg-background/50 backdrop-blur-sm">
{children} {children}
</main> </main>
</div> </div>

View File

@ -1,7 +1,11 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import InfiniteVirtualGrid from '@/components/infinite-virtual-grid'; import Link from 'next/link';
import { Image as ImageIcon, Search, Filter, Star, Bookmark, HardDrive } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import PhotoViewer from '@/components/photo-viewer'; import PhotoViewer from '@/components/photo-viewer';
interface Photo { interface Photo {
@ -17,11 +21,78 @@ interface Photo {
} }
export default function PhotosPage() { export default function PhotosPage() {
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null); const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [isViewerOpen, setIsViewerOpen] = useState(false); const [isViewerOpen, setIsViewerOpen] = useState(false);
const handlePhotoClick = (photo: Photo) => { useEffect(() => {
fetchPhotos();
}, []);
const fetchPhotos = async () => {
try {
const response = await fetch('/api/photos');
const data = await response.json();
setPhotos(data);
} catch (error) {
console.error('Error fetching photos:', error);
} finally {
setLoading(false);
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatFilePath = (path: string) => {
if (!path) return '';
// Split path into directory and filename
const lastSlashIndex = path.lastIndexOf('/');
const lastBackslashIndex = path.lastIndexOf('\\');
const lastSeparatorIndex = Math.max(lastSlashIndex, lastBackslashIndex);
if (lastSeparatorIndex === -1) {
// No directory separator found, return as is
return path;
}
const directory = path.substring(0, lastSeparatorIndex);
const filename = path.substring(lastSeparatorIndex + 1);
// If directory is short enough, show it all
if (directory.length <= 30) {
return `${directory}/${filename}`;
}
// Truncate directory with ellipsis in the middle
const maxDirLength = 25;
const startLength = Math.floor(maxDirLength / 2);
const endLength = maxDirLength - startLength - 3; // -3 for "..."
const truncatedDir = directory.length > maxDirLength
? `${directory.substring(0, startLength)}...${directory.substring(directory.length - endLength)}`
: directory;
return `${truncatedDir}/${filename}`;
};
const filteredPhotos = photos.filter(photo =>
photo.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
photo.path.toLowerCase().includes(searchTerm.toLowerCase())
);
const handlePhotoClick = (photo: Photo, index: number) => {
setSelectedPhoto(photo); setSelectedPhoto(photo);
setCurrentPhotoIndex(index);
setIsViewerOpen(true); setIsViewerOpen(true);
}; };
@ -30,9 +101,26 @@ export default function PhotosPage() {
setSelectedPhoto(null); setSelectedPhoto(null);
}; };
const handleNextPhoto = () => {
if (filteredPhotos.length > 0) {
const nextIndex = (currentPhotoIndex + 1) % filteredPhotos.length;
setCurrentPhotoIndex(nextIndex);
setSelectedPhoto(filteredPhotos[nextIndex]);
}
};
const handlePrevPhoto = () => {
if (filteredPhotos.length > 0) {
const prevIndex = (currentPhotoIndex - 1 + filteredPhotos.length) % filteredPhotos.length;
setCurrentPhotoIndex(prevIndex);
setSelectedPhoto(filteredPhotos[prevIndex]);
}
};
const handleBookmark = async (photoId: number) => { const handleBookmark = async (photoId: number) => {
try { try {
await fetch(`/api/bookmarks/${photoId}`, { method: 'POST' }); await fetch(`/api/bookmarks/${photoId}`, { method: 'POST' });
fetchPhotos();
} catch (error) { } catch (error) {
console.error('Error bookmarking photo:', error); console.error('Error bookmarking photo:', error);
} }
@ -41,6 +129,7 @@ export default function PhotosPage() {
const handleUnbookmark = async (photoId: number) => { const handleUnbookmark = async (photoId: number) => {
try { try {
await fetch(`/api/bookmarks/${photoId}`, { method: 'DELETE' }); await fetch(`/api/bookmarks/${photoId}`, { method: 'DELETE' });
fetchPhotos();
} catch (error) { } catch (error) {
console.error('Error unbookmarking photo:', error); console.error('Error unbookmarking photo:', error);
} }
@ -53,28 +142,184 @@ export default function PhotosPage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating }) body: JSON.stringify({ rating })
}); });
fetchPhotos();
} catch (error) { } catch (error) {
console.error('Error rating photo:', error); console.error('Error rating photo:', error);
} }
}; };
if (loading) {
return (
<div className="min-h-screen p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-br from-primary to-primary/80 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg">
<ImageIcon className="h-8 w-8 text-primary-foreground" />
</div>
<p className="text-muted-foreground font-medium">Loading photos...</p>
</div>
</div>
</div>
</div>
);
}
return ( return (
<> <>
<InfiniteVirtualGrid <div className="min-h-screen p-6">
type="photo" <div className="max-w-7xl mx-auto">
onItemClick={handlePhotoClick} {/* Header */}
onBookmark={handleBookmark} <div className="mb-8">
onUnbookmark={handleUnbookmark} <div className="flex items-center gap-4 mb-4">
onRate={handleRate} <div className="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg">
/> <ImageIcon className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">
Photos
</h1>
<p className="text-muted-foreground">
{photos.length} {photos.length === 1 ? 'photo' : 'photos'} in your library
</p>
</div>
</div>
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search photos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-background border-border"
/>
</div>
<Button variant="outline" className="shrink-0">
<Filter className="h-4 w-4 mr-2" />
Filter
</Button>
</div>
</div>
{/* Photos Grid */}
{filteredPhotos.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-6">
{filteredPhotos.map((photo, index) => (
<Card
key={photo.id}
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden"
onClick={() => handlePhotoClick(photo, index)}
>
<div className="aspect-square relative overflow-hidden bg-muted">
<img
src={photo.thumbnail || "/placeholder-photo.svg"}
alt={photo.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-photo.svg';
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{/* Photo Type Badge */}
<div className="absolute top-2 right-2">
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
<ImageIcon className="h-3 w-3 text-white" />
</div>
</div>
{/* Bookmark and Rating Overlay */}
<div className="absolute top-2 left-2 flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
photo.bookmark_count > 0 ? handleUnbookmark(photo.id) : handleBookmark(photo.id);
}}
className="bg-black/70 backdrop-blur-sm rounded-full p-1.5 hover:bg-black/90 transition-colors"
>
<Bookmark className={`h-3 w-3 ${photo.bookmark_count > 0 ? 'fill-yellow-400 text-yellow-400' : 'text-white'}`} />
</button>
</div>
{/* Rating Stars */}
{photo.avg_rating > 0 && (
<div className="absolute bottom-2 right-2 flex gap-0.5 bg-black/70 backdrop-blur-sm rounded-full px-1.5 py-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`h-2.5 w-2.5 ${i < Math.round(photo.avg_rating) ? 'fill-yellow-400 text-yellow-400' : 'text-gray-400'}`} />
))}
</div>
)}
</div>
<CardContent className="p-3">
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-1 group-hover:text-primary transition-colors">
{photo.title}
</h3>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span>{formatFileSize(photo.size)}</span>
</div>
</div>
<p
className="text-xs text-muted-foreground mt-1 line-clamp-1 cursor-help"
title={photo.path}
>
{formatFilePath(photo.path)}
</p>
</CardContent>
</Card>
))}
</div>
) : searchTerm ? (
<div className="text-center py-20">
<div className="max-w-sm mx-auto">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">No photos found</h3>
<p className="text-muted-foreground mb-4">Try adjusting your search terms</p>
<Button
variant="outline"
onClick={() => setSearchTerm('')}
>
Clear search
</Button>
</div>
</div>
) : (
<div className="text-center py-20">
<div className="max-w-sm mx-auto">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">No Photos Found</h3>
<p className="text-muted-foreground mb-6">Add media libraries and scan for photos to get started</p>
<Link href="/settings">
<Button>
<ImageIcon className="h-4 w-4 mr-2" />
Add Library
</Button>
</Link>
</div>
</div>
)}
</div>
</div>
{/* Photo Viewer */} {/* Photo Viewer */}
<PhotoViewer <PhotoViewer
photo={selectedPhoto!} photo={selectedPhoto!}
isOpen={isViewerOpen} isOpen={isViewerOpen}
onClose={handleCloseViewer} onClose={handleCloseViewer}
onNext={handleNextPhoto}
onPrev={handlePrevPhoto}
showNavigation={filteredPhotos.length > 1}
showBookmarks={true} showBookmarks={true}
showRatings={true} showRatings={true}
formatFileSize={formatFileSize}
onBookmark={handleBookmark} onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark} onUnbookmark={handleUnbookmark}
onRate={handleRate} onRate={handleRate}

View File

@ -1,8 +1,13 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import InfiniteVirtualGrid from "@/components/infinite-virtual-grid"; import Link from "next/link";
import { Film, Play, Clock, HardDrive, Search, Filter, Star } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import VideoViewer from "@/components/video-viewer"; import VideoViewer from "@/components/video-viewer";
import { StarRating } from "@/components/star-rating";
interface Video { interface Video {
id: number; id: number;
@ -17,9 +22,75 @@ interface Video {
} }
const VideosPage = () => { const VideosPage = () => {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null); const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [scrollPosition, setScrollPosition] = useState(0);
const [isPlayerOpen, setIsPlayerOpen] = useState(false); const [isPlayerOpen, setIsPlayerOpen] = useState(false);
useEffect(() => {
fetchVideos();
}, []);
const fetchVideos = async () => {
try {
const res = await fetch("/api/videos");
const data = await res.json();
setVideos(data);
} catch (error) {
console.error('Error fetching videos:', error);
} finally {
setLoading(false);
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatFilePath = (path: string) => {
if (!path) return '';
// Split path into directory and filename
const lastSlashIndex = path.lastIndexOf('/');
const lastBackslashIndex = path.lastIndexOf('\\');
const lastSeparatorIndex = Math.max(lastSlashIndex, lastBackslashIndex);
if (lastSeparatorIndex === -1) {
// No directory separator found, return as is
return path;
}
const directory = path.substring(0, lastSeparatorIndex);
const filename = path.substring(lastSeparatorIndex + 1);
// If directory is short enough, show it all
if (directory.length <= 30) {
return `${directory}/${filename}`;
}
// Truncate directory with ellipsis in the middle
const maxDirLength = 25;
const startLength = Math.floor(maxDirLength / 2);
const endLength = maxDirLength - startLength - 3; // -3 for "..."
const truncatedDir = directory.length > maxDirLength
? `${directory.substring(0, startLength)}...${directory.substring(directory.length - endLength)}`
: directory;
return `${truncatedDir}/${filename}`;
};
const filteredVideos = videos.filter(video =>
video.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
video.path.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleVideoClick = (video: Video) => { const handleVideoClick = (video: Video) => {
setSelectedVideo(video); setSelectedVideo(video);
setIsPlayerOpen(true); setIsPlayerOpen(true);
@ -33,6 +104,7 @@ const VideosPage = () => {
const handleBookmark = async (videoId: number) => { const handleBookmark = async (videoId: number) => {
try { try {
await fetch(`/api/bookmarks/${videoId}`, { method: 'POST' }); await fetch(`/api/bookmarks/${videoId}`, { method: 'POST' });
fetchVideos();
} catch (error) { } catch (error) {
console.error('Error bookmarking video:', error); console.error('Error bookmarking video:', error);
} }
@ -41,6 +113,7 @@ const VideosPage = () => {
const handleUnbookmark = async (videoId: number) => { const handleUnbookmark = async (videoId: number) => {
try { try {
await fetch(`/api/bookmarks/${videoId}`, { method: 'DELETE' }); await fetch(`/api/bookmarks/${videoId}`, { method: 'DELETE' });
fetchVideos();
} catch (error) { } catch (error) {
console.error('Error unbookmarking video:', error); console.error('Error unbookmarking video:', error);
} }
@ -53,28 +126,170 @@ const VideosPage = () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating }) body: JSON.stringify({ rating })
}); });
fetchVideos();
} catch (error) { } catch (error) {
console.error('Error rating video:', error); console.error('Error rating video:', error);
} }
}; };
const formatFileSize = (bytes: number) => { if (loading) {
if (bytes === 0) return '0 Bytes'; return (
const k = 1024; <div className="min-h-screen p-6">
const sizes = ['Bytes', 'KB', 'MB', 'GB']; <div className="max-w-7xl mx-auto">
const i = Math.floor(Math.log(bytes) / Math.log(k)); <div className="flex items-center justify-center min-h-[60vh]">
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; <div className="text-center">
}; <div className="w-16 h-16 bg-gradient-to-br from-primary to-primary/80 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg">
<Film className="h-8 w-8 text-primary-foreground" />
</div>
<p className="text-muted-foreground font-medium">Loading videos...</p>
</div>
</div>
</div>
</div>
);
}
return ( return (
<> <>
<InfiniteVirtualGrid <div className="min-h-screen p-6">
type="video" <div className="max-w-7xl mx-auto">
onItemClick={handleVideoClick} {/* Header */}
onBookmark={handleBookmark} <div className="mb-8">
onUnbookmark={handleUnbookmark} <div className="flex items-center gap-4 mb-4">
onRate={handleRate} <div className="w-12 h-12 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center shadow-lg">
/> <Film className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">
Videos
</h1>
<p className="text-muted-foreground">
{videos.length} {videos.length === 1 ? 'video' : 'videos'} in your library
</p>
</div>
</div>
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search videos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-background border-border"
/>
</div>
<Button variant="outline" className="shrink-0">
<Filter className="h-4 w-4 mr-2" />
Filter
</Button>
</div>
</div>
{/* Videos Grid */}
{filteredVideos.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6">
{filteredVideos.map((video) => (
<Card
key={video.id}
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden"
onClick={() => handleVideoClick(video)}
>
<div className="aspect-video relative overflow-hidden bg-muted">
<img
src={video.thumbnail || "/placeholder-video.svg"}
alt={video.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-video.svg';
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{/* Play Button Overlay */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="w-12 h-12 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center shadow-lg">
<Play className="h-5 w-5 text-foreground ml-0.5" />
</div>
</div>
{/* Video Type Badge */}
<div className="absolute top-2 right-2">
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
<Film className="h-3 w-3 text-white" />
</div>
</div>
</div>
<CardContent className="p-4">
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-2 group-hover:text-primary transition-colors">
{video.title}
</h3>
{/* Star Rating */}
{(video.avg_rating > 0 || video.star_count > 0) && (
<div className="mb-2">
<StarRating
rating={video.avg_rating || 0}
count={video.star_count}
size="sm"
showCount={true}
/>
</div>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span>{formatFileSize(video.size)}</span>
</div>
</div>
<p
className="text-xs text-muted-foreground mt-2 line-clamp-1 cursor-help"
title={video.path}
>
{formatFilePath(video.path)}
</p>
</CardContent>
</Card>
))}
</div>
) : searchTerm ? (
<div className="text-center py-20">
<div className="max-w-sm mx-auto">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">No videos found</h3>
<p className="text-muted-foreground mb-4">Try adjusting your search terms</p>
<Button
variant="outline"
onClick={() => setSearchTerm("")}
>
Clear search
</Button>
</div>
</div>
) : (
<div className="text-center py-20">
<div className="max-w-sm mx-auto">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<Film className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">No Videos Found</h3>
<p className="text-muted-foreground mb-6">Add media libraries and scan for videos to get started</p>
<Link href="/settings">
<Button>
<Film className="h-4 w-4 mr-2" />
Add Library
</Button>
</Link>
</div>
</div>
)}
</div>
</div>
{/* Video Viewer */} {/* Video Viewer */}
<VideoViewer <VideoViewer

View File

@ -1,569 +0,0 @@
'use client';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { FixedSizeGrid } from 'react-window';
import { Card, CardContent } from '@/components/ui/card';
import { StarRating } from '@/components/star-rating';
import { Film, Image as ImageIcon, HardDrive, Search, Bookmark } from 'lucide-react';
import { Input } from '@/components/ui/input';
interface MediaItem {
id: number;
title: string;
path: string;
size: number;
thumbnail: string;
type: string;
bookmark_count: number;
avg_rating: number;
star_count: number;
}
interface InfiniteVirtualGridProps {
type: 'video' | 'photo' | 'bookmark';
onItemClick: (item: MediaItem) => void;
onBookmark: (id: number) => Promise<void>;
onUnbookmark: (id: number) => Promise<void>;
onRate: (id: number, rating: number) => Promise<void>;
}
const ITEM_HEIGHT = 300;
const ITEMS_PER_BATCH = 50;
const BUFFER_SIZE = 200;
export default function InfiniteVirtualGrid({
type,
onItemClick,
onBookmark,
onUnbookmark,
onRate
}: InfiniteVirtualGridProps) {
const [totalItems, setTotalItems] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [containerWidth, setContainerWidth] = useState(0);
const [isLoadingInitial, setIsLoadingInitial] = useState(true);
const loadingRef = useRef<Set<number>>(new Set());
const dataCacheRef = useRef<Map<number, MediaItem[]>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<any>(null);
const formatFileSize = useCallback((bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}, []);
const formatFilePath = useCallback((path: string) => {
if (!path) return '';
const lastSlashIndex = path.lastIndexOf('/');
const lastBackslashIndex = path.lastIndexOf('\\');
const lastSeparatorIndex = Math.max(lastSlashIndex, lastBackslashIndex);
if (lastSeparatorIndex === -1) {
return path;
}
const directory = path.substring(0, lastSeparatorIndex);
const filename = path.substring(lastSeparatorIndex + 1);
if (directory.length <= 30) {
return `${directory}/${filename}`;
}
const maxDirLength = 25;
const startLength = Math.floor(maxDirLength / 2);
const endLength = maxDirLength - startLength - 3;
const truncatedDir = directory.length > maxDirLength
? `${directory.substring(0, startLength)}...${directory.substring(directory.length - endLength)}`
: directory;
return `${truncatedDir}/${filename}`;
}, []);
const getColumnCount = useCallback(() => {
if (containerWidth === 0) return 6;
if (containerWidth < 640) return 2;
if (containerWidth < 768) return 3;
if (containerWidth < 1024) return 4;
if (containerWidth < 1280) return 5;
if (containerWidth < 1536) return 6;
return 7;
}, [containerWidth]);
const getColumnWidth = useCallback(() => {
const cols = getColumnCount();
const availableWidth = containerWidth - 32;
const gapWidth = (cols - 1) * 16;
return Math.floor((availableWidth - gapWidth) / cols);
}, [containerWidth, getColumnCount]);
const rowCount = useMemo(() => {
return Math.ceil(totalItems / getColumnCount());
}, [totalItems, getColumnCount]);
const fetchItems = useCallback(async (startIndex: number, endIndex: number) => {
const batchStart = Math.floor(startIndex / ITEMS_PER_BATCH) * ITEMS_PER_BATCH;
const batchKey = Math.floor(startIndex / ITEMS_PER_BATCH);
if (loadingRef.current.has(batchKey) || dataCacheRef.current.has(batchKey)) {
return;
}
loadingRef.current.add(batchKey);
try {
const limit = Math.min(ITEMS_PER_BATCH, totalItems - batchStart);
if (limit <= 0) return;
const params = new URLSearchParams({
limit: limit.toString(),
offset: batchStart.toString(),
...(searchTerm && { search: searchTerm })
});
const endpoint = type === 'bookmark' ? 'bookmarks' : `${type}s`;
const response = await fetch(`/api/${endpoint}?${params}`);
const data = await response.json();
const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
const items = data[itemsKey] || [];
dataCacheRef.current.set(batchKey, items);
} catch (error) {
console.error(`Error fetching ${type}s:`, error);
} finally {
loadingRef.current.delete(batchKey);
}
}, [type, searchTerm, totalItems]);
const fetchTotalCount = useCallback(async () => {
try {
const endpoint = type === 'bookmark' ? 'bookmarks' : `${type}s`;
const params = new URLSearchParams({
limit: '50',
offset: '0',
...(searchTerm && { search: searchTerm })
});
const response = await fetch(`/api/${endpoint}?${params}`);
const data = await response.json();
const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
const items = data[itemsKey] || [];
if (items.length > 0) {
dataCacheRef.current.set(0, items);
}
setTotalItems(data.pagination.total || 0);
setIsLoadingInitial(false);
} catch (error) {
console.error(`Error fetching total count for ${type}:`, error);
setTotalItems(0);
setIsLoadingInitial(false);
}
}, [type, searchTerm]);
useEffect(() => {
dataCacheRef.current.clear();
loadingRef.current.clear();
setTotalItems(0);
setIsLoadingInitial(true);
fetchTotalCount();
}, [type, searchTerm, fetchTotalCount]);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
setContainerWidth(width);
}
};
// Use ResizeObserver for more reliable width detection
const resizeObserver = new ResizeObserver(entries => {
if (entries[0]) {
setContainerWidth(entries[0].contentRect.width);
}
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
updateWidth(); // Initial measurement
}
// Window resize fallback
const handleResize = () => {
updateWidth();
};
window.addEventListener('resize', handleResize);
// Fallback to manual measurement if ResizeObserver not available
const fallbackUpdate = () => {
setTimeout(updateWidth, 100);
};
fallbackUpdate();
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', handleResize);
};
}, []);
// Aggressive width detection fallback
useEffect(() => {
const aggressiveWidthCheck = () => {
// Try to find any suitable container
const containers = [
containerRef.current,
document.querySelector('.flex-1') as HTMLElement,
document.querySelector('.max-w-7xl') as HTMLElement,
document.querySelector('.w-full') as HTMLElement
].filter(Boolean);
for (const container of containers) {
if (container) {
const width = (container as HTMLElement).offsetWidth || (container as HTMLElement).clientWidth;
if (width > 0) {
setContainerWidth(width);
return;
}
}
}
// As a last resort, use window width minus some padding
const fallbackWidth = Math.max(window.innerWidth - 64, 320);
setContainerWidth(fallbackWidth);
};
// Try multiple times with increasing delays
aggressiveWidthCheck();
const timeouts = [
setTimeout(aggressiveWidthCheck, 50),
setTimeout(aggressiveWidthCheck, 100),
setTimeout(aggressiveWidthCheck, 500)
];
return () => {
timeouts.forEach(timeout => clearTimeout(timeout));
};
}, []);
const getItemData = useCallback((index: number): MediaItem | null => {
if (index >= totalItems) return null;
const batchKey = Math.floor(index / ITEMS_PER_BATCH);
const batchItems = dataCacheRef.current.get(batchKey);
if (!batchItems) {
return null;
}
const itemIndex = index % ITEMS_PER_BATCH;
return batchItems[itemIndex] || null;
}, [totalItems]);
const handleItemsRendered = useCallback((params: any) => {
const { visibleRowStartIndex, visibleRowStopIndex } = params;
console.log('onItemsRendered triggered:', { visibleRowStartIndex, visibleRowStopIndex, totalItems, columnCount: getColumnCount() });
if (typeof visibleRowStartIndex !== 'number' || typeof visibleRowStopIndex !== 'number') {
console.log('Invalid indices received');
return;
}
if (totalItems === 0) {
console.log('No total items');
return;
}
const columnCount = getColumnCount();
// Convert row indices to absolute item indices
const startIndex = visibleRowStartIndex * columnCount;
const endIndex = Math.min((visibleRowStopIndex + 1) * columnCount - 1, totalItems - 1);
console.log('Calculated indices:', { startIndex, endIndex, columnCount });
// Check what batches we need to load
const startBatch = Math.floor(startIndex / ITEMS_PER_BATCH);
const endBatch = Math.floor(endIndex / ITEMS_PER_BATCH);
console.log('Batch range needed:', { startBatch, endBatch });
// Load missing batches
let loadedAny = false;
for (let batch = startBatch; batch <= endBatch; batch++) {
const batchStart = batch * ITEMS_PER_BATCH;
const batchEnd = Math.min((batch + 1) * ITEMS_PER_BATCH, totalItems);
if (!dataCacheRef.current.has(batch) && !loadingRef.current.has(batch)) {
console.log(`Loading batch ${batch}: ${batchStart}-${batchEnd}`);
fetchItems(batchStart, batchEnd);
loadedAny = true;
}
}
// Also check if we need to load more for infinite scroll
if (!loadedAny) {
const lastLoadedBatch = Math.max(...Array.from(dataCacheRef.current.keys()), -1);
const nextBatch = lastLoadedBatch + 1;
const nextBatchStart = nextBatch * ITEMS_PER_BATCH;
if (nextBatchStart < totalItems && endIndex + BUFFER_SIZE >= nextBatchStart) {
const batchEnd = Math.min(nextBatchStart + ITEMS_PER_BATCH, totalItems);
console.log(`Loading next batch for infinite scroll ${nextBatch}: ${nextBatchStart}-${batchEnd}`);
fetchItems(nextBatchStart, batchEnd);
}
}
}, [totalItems, fetchItems, getColumnCount]);
const Cell = ({ columnIndex, rowIndex, style }: any) => {
const columnCount = getColumnCount();
const index = rowIndex * columnCount + columnIndex;
if (index >= totalItems) return null;
const item = getItemData(index);
if (!item) {
return (
<div style={style} className="p-2">
<Card className="h-full animate-pulse bg-muted/50">
<div className={`${type === 'video' ? 'aspect-video' : 'aspect-square'} bg-muted`} />
<CardContent className="p-3">
<div className="h-4 bg-muted rounded mb-2" />
<div className="h-3 bg-muted rounded mb-1" />
</CardContent>
</Card>
</div>
);
}
return (
<div style={style} className="p-2">
<Card
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden h-full"
onClick={() => onItemClick(item)}
>
<div className={`relative overflow-hidden bg-muted ${type === 'video' ? 'aspect-video' : 'aspect-square'}`}>
<img
src={item.thumbnail || (type === 'video' ? "/placeholder-video.svg" : "/placeholder-photo.svg")}
alt={item.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).src = type === 'video' ? "/placeholder-video.svg" : "/placeholder-photo.svg";
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="w-10 h-10 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center shadow-lg">
{type === 'video' ?
<Film className="h-5 w-5 text-foreground" /> :
<ImageIcon className="h-5 w-5 text-foreground" />
}
</div>
</div>
<div className="absolute top-2 right-2">
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
{type === 'video' ?
<Film className="h-3 w-3 text-white" /> :
<ImageIcon className="h-3 w-3 text-white" />
}
</div>
</div>
</div>
<CardContent className="p-3">
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-2 group-hover:text-primary transition-colors">
{item.title || item.path.split('/').pop()}
</h3>
{(item.avg_rating > 0 || item.star_count > 0) && (
<div className="mb-2">
<StarRating
rating={item.avg_rating || 0}
count={item.star_count}
size="sm"
showCount={true}
/>
</div>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span>{formatFileSize(item.size)}</span>
</div>
</div>
<p
className="text-xs text-muted-foreground mt-1 line-clamp-1 cursor-help"
title={item.path}
>
{formatFilePath(item.path)}
</p>
</CardContent>
</Card>
</div>
);
};
const getAvailableHeight = useCallback(() => {
if (typeof window === 'undefined') return 600;
const headerHeight = 180;
const bottomPadding = 20;
return Math.max(window.innerHeight - headerHeight - bottomPadding, 400);
}, []);
if (isLoadingInitial) {
return (
<div className="h-screen flex flex-col overflow-hidden">
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-br from-primary to-primary/80 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg">
{type === 'video' ?
<Film className="h-8 w-8 text-primary-foreground" /> :
<ImageIcon className="h-8 w-8 text-primary-foreground" />
}
</div>
<p className="text-muted-foreground font-medium">Loading {type}s...</p>
</div>
</div>
</div>
);
}
if (totalItems === 0) {
return (
<div className="h-screen flex flex-col overflow-hidden">
<div ref={containerRef} className="flex-1 flex flex-col max-w-7xl mx-auto w-full overflow-hidden">
<div className="flex-shrink-0 p-6 pb-4">
<div className="flex items-center gap-4 mb-4">
<div className={`w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-lg ${
type === 'video' ? 'from-red-500 to-red-600' :
type === 'photo' ? 'from-green-500 to-green-600' :
'from-blue-500 to-blue-600'
}`}>
{type === 'video' ?
<Film className="h-6 w-6 text-white" /> :
type === 'photo' ?
<ImageIcon className="h-6 w-6 text-white" /> :
<Bookmark className="h-6 w-6 text-white" />
}
</div>
<div>
<h1 className="text-3xl font-bold text-foreground tracking-tight capitalize">
{type === 'bookmark' ? 'Bookmarked Items' : `${type}s`}
</h1>
<p className="text-muted-foreground">
0 {type === 'bookmark' ? 'item' : type}{type === 'bookmark' || type === 'photo' ? 's' : ''} in your library
</p>
</div>
</div>
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search ${type}s...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-background border-border"
/>
</div>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-sm">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
No {type === 'bookmark' ? 'bookmarked items' : `${type}s`} found
</h3>
<p className="text-muted-foreground">
{searchTerm ? 'Try adjusting your search terms' :
type === 'bookmark' ? 'Start bookmarking videos and photos to see them here' :
'Add media libraries and scan for content to get started'}
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col overflow-hidden">
<div ref={containerRef} className="flex-1 flex flex-col max-w-7xl mx-auto w-full overflow-hidden">
<div className="flex-shrink-0 p-6 pb-4">
<div className="flex items-center gap-4 mb-4">
<div className={`w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-lg ${
type === 'video' ? 'from-red-500 to-red-600' :
type === 'photo' ? 'from-green-500 to-green-600' :
'from-blue-500 to-blue-600'
}`}>
{type === 'video' ?
<Film className="h-6 w-6 text-white" /> :
type === 'photo' ?
<ImageIcon className="h-6 w-6 text-white" /> :
<Bookmark className="h-6 w-6 text-white" />
}
</div>
<div>
<h1 className="text-3xl font-bold text-foreground tracking-tight capitalize">
{type === 'bookmark' ? 'Bookmarked Items' : `${type}s`}
</h1>
<p className="text-muted-foreground">
{totalItems.toLocaleString()} {type === 'bookmark' ? 'item' : type}{totalItems === 1 ? '' : 's'} in your library
</p>
</div>
</div>
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search ${type}s...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-background border-border"
/>
</div>
</div>
<div ref={containerRef} className="flex-1 relative overflow-hidden">
{containerWidth > 0 && (
<FixedSizeGrid
ref={gridRef}
columnCount={getColumnCount()}
columnWidth={getColumnWidth()}
height={getAvailableHeight()}
rowCount={rowCount}
rowHeight={ITEM_HEIGHT}
width={containerWidth}
itemData={null}
onItemsRendered={handleItemsRendered}
overscanRowCount={5}
>
{Cell}
</FixedSizeGrid>
)}
{containerWidth === 0 && (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -1,409 +0,0 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { FixedSizeGrid } from 'react-window';
import { Card, CardContent } from '@/components/ui/card';
import { StarRating } from '@/components/star-rating';
import { Film, Image as ImageIcon, HardDrive, Search, Bookmark } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
interface MediaItem {
id: number;
title: string;
path: string;
size: number;
thumbnail: string;
type: string;
bookmark_count: number;
avg_rating: number;
star_count: number;
}
interface PaginationInfo {
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
interface VirtualizedMediaGridProps {
type: 'video' | 'photo' | 'bookmark';
onItemClick: (item: MediaItem) => void;
onBookmark: (id: number) => Promise<void>;
onUnbookmark: (id: number) => Promise<void>;
onRate: (id: number, rating: number) => Promise<void>;
}
export default function VirtualizedMediaGrid({
type,
onItemClick,
onBookmark,
onUnbookmark,
onRate
}: VirtualizedMediaGridProps) {
const [items, setItems] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [pagination, setPagination] = useState<PaginationInfo>({
total: 0,
limit: 50,
offset: 0,
hasMore: true
});
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const observerTarget = useRef<HTMLDivElement>(null);
const loadingRef = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatFilePath = (path: string) => {
if (!path) return '';
const lastSlashIndex = path.lastIndexOf('/');
const lastBackslashIndex = path.lastIndexOf('\\');
const lastSeparatorIndex = Math.max(lastSlashIndex, lastBackslashIndex);
if (lastSeparatorIndex === -1) {
return path;
}
const directory = path.substring(0, lastSeparatorIndex);
const filename = path.substring(lastSeparatorIndex + 1);
if (directory.length <= 30) {
return `${directory}/${filename}`;
}
const maxDirLength = 25;
const startLength = Math.floor(maxDirLength / 2);
const endLength = maxDirLength - startLength - 3;
const truncatedDir = directory.length > maxDirLength
? `${directory.substring(0, startLength)}...${directory.substring(directory.length - endLength)}`
: directory;
return `${truncatedDir}/${filename}`;
};
const fetchItems = useCallback(async (offset: number, search?: string) => {
if (loadingRef.current) return;
loadingRef.current = true;
setIsLoadingMore(offset > 0);
try {
const params = new URLSearchParams({
limit: '50',
offset: offset.toString(),
...(search && { search })
});
const endpoint = type === 'bookmark' ? 'bookmarks' : `${type}s`;
const response = await fetch(`/api/${endpoint}?${params}`);
const data = await response.json();
const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
if (offset === 0) {
setItems(data[itemsKey] || []);
} else {
setItems(prev => [...prev, ...(data[itemsKey] || [])]);
}
setPagination(data.pagination);
} catch (error) {
console.error(`Error fetching ${type}s:`, error);
} finally {
setLoading(false);
setIsLoadingMore(false);
loadingRef.current = false;
}
}, [type]);
const handleSearch = useCallback((term: string) => {
setSearchTerm(term);
setItems([]);
setPagination({
total: 0,
limit: 50,
offset: 0,
hasMore: true
});
fetchItems(0, term);
}, [fetchItems]);
const loadMoreItems = useCallback(() => {
if (pagination.hasMore && !loadingRef.current) {
fetchItems(pagination.offset + pagination.limit, searchTerm);
}
}, [pagination, searchTerm, fetchItems]);
// Calculate responsive column count and width
const getColumnCount = useCallback(() => {
if (containerWidth === 0) return 6;
if (containerWidth < 640) return 2;
if (containerWidth < 768) return 3;
if (containerWidth < 1024) return 4;
if (containerWidth < 1280) return 5;
if (containerWidth < 1536) return 6;
return 7;
}, [containerWidth]);
const getColumnWidth = useCallback(() => {
const cols = getColumnCount();
// Account for padding (16px on each side) and gaps between cards (16px total per row)
const availableWidth = containerWidth - 32; // 16px padding on each side
const gapWidth = (cols - 1) * 16; // 16px gap between each column
return Math.floor((availableWidth - gapWidth) / cols);
}, [containerWidth, getColumnCount]);
// Update container width on resize
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth);
}
};
updateWidth();
window.addEventListener('resize', updateWidth);
return () => window.removeEventListener('resize', updateWidth);
}, []);
useEffect(() => {
setLoading(true);
fetchItems(0, searchTerm);
}, [type]);
// Disabled automatic loading to prevent premature batch loading
// Users will manually click "Load More" button when they want more content
const Cell = ({ columnIndex, rowIndex, style }: any) => {
const columnCount = getColumnCount();
const index = rowIndex * columnCount + columnIndex;
const item = items[index];
if (!item) return null;
return (
<div style={style} className="p-2">
<Card
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden h-full"
onClick={() => onItemClick(item)}
>
<div className={`relative overflow-hidden bg-muted ${type === 'video' ? 'aspect-video' : 'aspect-square'}`}>
<img
src={item.thumbnail || (type === 'video' ? "/placeholder-video.svg" : "/placeholder-photo.svg")}
alt={item.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).src = type === 'video' ? "/placeholder-video.svg" : "/placeholder-photo.svg";
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="w-10 h-10 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center shadow-lg">
{type === 'video' ?
<Film className="h-5 w-5 text-foreground" /> :
<ImageIcon className="h-5 w-5 text-foreground" />
}
</div>
</div>
<div className="absolute top-2 right-2">
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
{type === 'video' ?
<Film className="h-3 w-3 text-white" /> :
<ImageIcon className="h-3 w-3 text-white" />
}
</div>
</div>
</div>
<CardContent className="p-3">
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-2 group-hover:text-primary transition-colors">
{item.title || item.path.split('/').pop()}
</h3>
{(item.avg_rating > 0 || item.star_count > 0) && (
<div className="mb-2">
<StarRating
rating={item.avg_rating || 0}
count={item.star_count}
size="sm"
showCount={true}
/>
</div>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span>{formatFileSize(item.size)}</span>
</div>
</div>
<p
className="text-xs text-muted-foreground mt-1 line-clamp-1 cursor-help"
title={item.path}
>
{formatFilePath(item.path)}
</p>
</CardContent>
</Card>
</div>
);
};
const columnCount = getColumnCount();
const columnWidth = getColumnWidth();
const rowCount = Math.ceil(items.length / columnCount);
// Calculate available height for the grid more precisely
const getAvailableHeight = useCallback(() => {
if (typeof window === 'undefined') return 600;
// Calculate the actual header height and other UI elements
const headerHeight = 180; // Title, description, search bar
const bottomPadding = 120; // Load more button area
const availableHeight = window.innerHeight - headerHeight - bottomPadding;
// Ensure minimum height and maximum height
return Math.max(Math.min(availableHeight, window.innerHeight - 100), 400);
}, []);
if (loading && items.length === 0) {
return (
<div className="h-screen flex flex-col overflow-hidden">
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-br from-primary to-primary/80 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg">
{type === 'video' ?
<Film className="h-8 w-8 text-primary-foreground" /> :
<ImageIcon className="h-8 w-8 text-primary-foreground" />
}
</div>
<p className="text-muted-foreground font-medium">Loading {type}s...</p>
</div>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col overflow-hidden">
<div ref={containerRef} className="flex-1 flex flex-col max-w-7xl mx-auto w-full overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 p-6 pb-4">
<div className="flex items-center gap-4 mb-4">
<div className={`w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-lg ${
type === 'video' ? 'from-red-500 to-red-600' :
type === 'photo' ? 'from-green-500 to-green-600' :
'from-blue-500 to-blue-600'
}`}>
{type === 'video' ?
<Film className="h-6 w-6 text-white" /> :
type === 'photo' ?
<ImageIcon className="h-6 w-6 text-white" /> :
<Bookmark className="h-6 w-6 text-white" />
}
</div>
<div>
<h1 className="text-3xl font-bold text-foreground tracking-tight capitalize">
{type === 'bookmark' ? 'Bookmarked Items' : `${type}s`}
</h1>
<p className="text-muted-foreground">
{pagination.total.toLocaleString()} {type === 'bookmark' ? 'item' : type}{pagination.total === 1 ? '' : 's'} in your library
</p>
</div>
</div>
{/* Search */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search ${type}s...`}
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10 bg-background border-border"
/>
</div>
</div>
{/* Media Grid Container */}
<div className="flex-1 relative overflow-hidden">
{items.length > 0 && containerWidth > 0 ? (
<div className="h-full relative overflow-hidden">
<FixedSizeGrid
columnCount={columnCount}
columnWidth={columnWidth}
height={getAvailableHeight()}
rowCount={rowCount}
rowHeight={300}
width={containerWidth}
itemData={items}
className="custom-scrollbar"
>
{Cell}
</FixedSizeGrid>
{/* Fancy scroll indicator */}
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 w-2 h-32 bg-gradient-to-b from-primary/20 via-primary/40 to-primary/20 rounded-full opacity-0 hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<div className="w-full h-full bg-gradient-to-b from-primary/60 via-primary to-primary/60 rounded-full scroll-indicator"></div>
</div>
{pagination.hasMore && (
<div className="flex justify-center py-8">
{isLoadingMore ? (
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span className="text-sm text-muted-foreground">Loading more {type === 'bookmark' ? 'items' : `${type}s`}...</span>
</div>
) : (
<Button
onClick={loadMoreItems}
disabled={loadingRef.current}
className="px-6 py-2"
>
Load More {type === 'bookmark' ? 'Items' : `${type}s`}
</Button>
)}
</div>
)}
</div>
) : items.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-sm">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
No {type === 'bookmark' ? 'bookmarked items' : `${type}s`} found
</h3>
<p className="text-muted-foreground">
{searchTerm ? 'Try adjusting your search terms' :
type === 'bookmark' ? 'Start bookmarking videos and photos to see them here' :
'Add media libraries and scan for content to get started'}
</p>
</div>
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -56,13 +56,4 @@ db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_bookmark_count ON media(bookmark_count);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_media_bookmark_count ON media(bookmark_count);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_star_count ON media(star_count);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_media_star_count ON media(star_count);`);
// Pagination and filtering indexes
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_type_created_at ON media(type, created_at);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media(path);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_library_id ON media(library_id);`);
// Full-text search indexes
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_title ON media(title);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_type_path ON media(type, path);`);
export default db; export default db;