feat: implement performance optimization plan and pagination for media APIs

- Added a comprehensive performance optimization plan detailing phases for API pagination, frontend memory optimization, file system scanning, database performance, and caching strategies.
- Implemented pagination for bookmarks, photos, and videos APIs, including limit/offset parameters and server-side filtering and sorting.
- Enhanced database queries with indexes for improved performance and added total count for pagination responses.
- Updated frontend components to utilize virtualized lists for better memory management and user experience.
This commit is contained in:
tigeren 2025-08-28 09:10:38 +00:00
parent 2442d0dde7
commit 6aef5daa74
14 changed files with 892 additions and 638 deletions

View File

@ -28,4 +28,60 @@ 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.

View File

@ -28,4 +28,60 @@ 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,4 +28,60 @@ 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,6 +20,8 @@
"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"
}, },
@ -28,6 +30,7 @@
"@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",
@ -46,6 +49,15 @@
"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",
@ -832,6 +844,16 @@
"@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",
@ -1707,6 +1729,12 @@
"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",
@ -2233,6 +2261,36 @@
"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,6 +20,8 @@
"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"
}, },
@ -28,6 +30,7 @@
"@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,16 +2,60 @@ 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
ORDER BY b.updated_at DESC ${whereClause}
`).all(); ORDER BY b.${sortColumn} ${sortDirection}
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
return NextResponse.json(bookmarks); return NextResponse.json({
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,8 +1,41 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import db from '@/db'; import db from '@/db';
export async function GET() { export async function GET(request: Request) {
const photos = db.prepare(` 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') || '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,
@ -22,7 +55,20 @@ export async function GET() {
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
WHERE m.type = ? ${whereClause}
`).all('photo'); ORDER BY m.${sortColumn} ${sortDirection}
return NextResponse.json(photos); LIMIT ? OFFSET ?
`;
const photos = db.prepare(query).all(...params, limit, offset);
return NextResponse.json({
photos,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total
}
});
} }

View File

@ -2,7 +2,74 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import db from "@/db"; import db from "@/db";
export async function GET() { export async function GET(request: Request) {
const videos = db.prepare("SELECT * FROM media WHERE type = 'video'").all(); const { searchParams } = new URL(request.url);
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, useEffect } from 'react'; import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import VirtualizedMediaGrid from '@/components/virtualized-media-grid';
import InlineVideoPlayer from "@/components/inline-video-player"; import VideoViewer from '@/components/video-viewer';
import { Bookmark, Heart, Star, Film, Image as ImageIcon } from 'lucide-react'; import PhotoViewer from '@/components/photo-viewer';
interface MediaItem { interface MediaItem {
id: number; id: number;
@ -15,29 +15,59 @@ 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 [isPlayerOpen, setIsPlayerOpen] = useState(false); const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [scrollPosition, setScrollPosition] = useState(0); const [isViewerOpen, setIsViewerOpen] = useState(false);
const [isVideoPlayerOpen, setIsVideoPlayerOpen] = useState(false);
useEffect(() => { const handleItemClick = (item: MediaItem) => {
fetchBookmarkedItems(); if (item.type === 'video') {
}, []); setSelectedItem(item);
setIsVideoPlayerOpen(true);
} else {
setSelectedItem(item);
setIsViewerOpen(true);
}
};
const fetchBookmarkedItems = async () => { const handleCloseVideoPlayer = () => {
setIsVideoPlayerOpen(false);
setSelectedItem(null);
};
const handleClosePhotoViewer = () => {
setIsViewerOpen(false);
setSelectedItem(null);
};
const handleBookmark = async (id: number) => {
try { try {
const response = await fetch('/api/bookmarks'); await fetch(`/api/bookmarks/${id}`, { method: 'POST' });
const data = await response.json();
setItems(data);
} catch (error) { } catch (error) {
console.error('Error fetching bookmarked items:', error); console.error('Error bookmarking item:', error);
} finally { }
setLoading(false); };
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);
} }
}; };
@ -49,143 +79,45 @@ export default function BookmarksPage() {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}; };
const handleItemClick = (item: MediaItem) => {
setScrollPosition(window.scrollY);
setSelectedItem(item);
setIsPlayerOpen(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleClosePlayer = () => {
setIsPlayerOpen(false);
setSelectedItem(null);
// Restore scroll position
setTimeout(() => {
window.scrollTo({ top: scrollPosition, behavior: 'smooth' });
}, 100);
};
if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <>
<div className="text-center"> <VirtualizedMediaGrid
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div> type="bookmark"
<p className="text-muted-foreground">Loading bookmarked items...</p> onItemClick={handleItemClick}
</div> onBookmark={handleBookmark}
</div> onUnbookmark={handleUnbookmark}
); onRate={handleRate}
}
return (
<div className="min-h-screen bg-zinc-950">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
<Bookmark className="h-6 w-6 text-white" />
</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>
{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 */} {/* Video Player */}
<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' && (
<InlineVideoPlayer <VideoViewer
video={{ video={selectedItem}
id: selectedItem.id, isOpen={isVideoPlayerOpen}
title: selectedItem.title || selectedItem.path.split('/').pop() || 'Untitled', onClose={handleCloseVideoPlayer}
path: selectedItem.path, showBookmarks={true}
size: selectedItem.size, showRatings={true}
thumbnail: selectedItem.thumbnail || '', formatFileSize={formatFileSize}
}} onBookmark={handleBookmark}
isOpen={isPlayerOpen} onUnbookmark={handleUnbookmark}
onClose={handleClosePlayer} onRate={handleRate}
scrollPosition={scrollPosition}
/> />
)} )}
</div>
{/* Photo Viewer */}
{selectedItem && selectedItem.type === 'photo' && (
<PhotoViewer
photo={selectedItem}
isOpen={isViewerOpen}
onClose={handleClosePhotoViewer}
showBookmarks={true}
showRatings={true}
formatFileSize={formatFileSize}
onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark}
onRate={handleRate}
/>
)}
</>
); );
} }

View File

@ -1,11 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import VirtualizedMediaGrid from '@/components/virtualized-media-grid';
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 {
@ -21,76 +17,11 @@ 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 [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [isViewerOpen, setIsViewerOpen] = useState(false); const [isViewerOpen, setIsViewerOpen] = useState(false);
useEffect(() => { const handlePhotoClick = (photo: Photo, index: number = 0) => {
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); setCurrentPhotoIndex(index);
setIsViewerOpen(true); setIsViewerOpen(true);
@ -102,25 +33,18 @@ export default function PhotosPage() {
}; };
const handleNextPhoto = () => { const handleNextPhoto = () => {
if (filteredPhotos.length > 0) { // This would need to be implemented with the virtualized grid
const nextIndex = (currentPhotoIndex + 1) % filteredPhotos.length; // For now, we'll keep the current simple behavior
setCurrentPhotoIndex(nextIndex);
setSelectedPhoto(filteredPhotos[nextIndex]);
}
}; };
const handlePrevPhoto = () => { const handlePrevPhoto = () => {
if (filteredPhotos.length > 0) { // This would need to be implemented with the virtualized grid
const prevIndex = (currentPhotoIndex - 1 + filteredPhotos.length) % filteredPhotos.length; // For now, we'll keep the current simple behavior
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);
} }
@ -129,7 +53,6 @@ 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);
} }
@ -142,172 +65,28 @@ 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) { const formatFileSize = (bytes: number) => {
return ( if (bytes === 0) return '0 Bytes';
<div className="min-h-screen p-6"> const k = 1024;
<div className="max-w-7xl mx-auto"> const sizes = ['Bytes', 'KB', 'MB', 'GB'];
<div className="flex items-center justify-center min-h-[60vh]"> const i = Math.floor(Math.log(bytes) / Math.log(k));
<div className="text-center"> return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
<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 (
<> <>
<div className="min-h-screen p-6"> <VirtualizedMediaGrid
<div className="max-w-7xl mx-auto"> type="photo"
{/* Header */} onItemClick={handlePhotoClick}
<div className="mb-8"> onBookmark={handleBookmark}
<div className="flex items-center gap-4 mb-4"> onUnbookmark={handleUnbookmark}
<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"> onRate={handleRate}
<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
@ -316,7 +95,7 @@ export default function PhotosPage() {
onClose={handleCloseViewer} onClose={handleCloseViewer}
onNext={handleNextPhoto} onNext={handleNextPhoto}
onPrev={handlePrevPhoto} onPrev={handlePrevPhoto}
showNavigation={filteredPhotos.length > 1} showNavigation={false}
showBookmarks={true} showBookmarks={true}
showRatings={true} showRatings={true}
formatFileSize={formatFileSize} formatFileSize={formatFileSize}

View File

@ -1,13 +1,8 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState } from "react";
import Link from "next/link"; import VirtualizedMediaGrid from "@/components/virtualized-media-grid";
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;
@ -22,75 +17,9 @@ 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);
@ -104,7 +33,6 @@ 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);
} }
@ -113,7 +41,6 @@ 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);
} }
@ -126,170 +53,28 @@ 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);
} }
}; };
if (loading) { const formatFileSize = (bytes: number) => {
return ( if (bytes === 0) return '0 Bytes';
<div className="min-h-screen p-6"> const k = 1024;
<div className="max-w-7xl mx-auto"> const sizes = ['Bytes', 'KB', 'MB', 'GB'];
<div className="flex items-center justify-center min-h-[60vh]"> const i = Math.floor(Math.log(bytes) / Math.log(k));
<div className="text-center"> return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
<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 (
<> <>
<div className="min-h-screen p-6"> <VirtualizedMediaGrid
<div className="max-w-7xl mx-auto"> type="video"
{/* Header */} onItemClick={handleVideoClick}
<div className="mb-8"> onBookmark={handleBookmark}
<div className="flex items-center gap-4 mb-4"> onUnbookmark={handleUnbookmark}
<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"> onRate={handleRate}
<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

@ -0,0 +1,363 @@
'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 observerTarget = useRef<HTMLDivElement>(null);
const loadingRef = useRef(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 '';
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]);
useEffect(() => {
setLoading(true);
fetchItems(0, searchTerm);
}, [type]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && pagination.hasMore && !loadingRef.current) {
loadMoreItems();
}
},
{ threshold: 0.1 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [loadMoreItems, pagination.hasMore]);
const Cell = ({ columnIndex, rowIndex, style }: any) => {
const columnCount = 6;
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 getColumnCount = () => {
if (typeof window === 'undefined') return 6;
const width = window.innerWidth;
if (width < 640) return 2;
if (width < 768) return 3;
if (width < 1024) return 4;
if (width < 1280) return 5;
if (width < 1536) return 6;
return 7;
};
const columnCount = getColumnCount();
const rowCount = Math.ceil(items.length / columnCount);
if (loading && items.length === 0) {
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">
{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>
</div>
);
}
return (
<div className="min-h-screen p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<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 */}
{items.length > 0 ? (
<div className="w-full">
<FixedSizeGrid
columnCount={columnCount}
columnWidth={window.innerWidth / columnCount - 16}
height={Math.min(window.innerHeight - 200, rowCount * 300)}
rowCount={rowCount}
rowHeight={300}
width={window.innerWidth - 48}
itemData={items}
>
{Cell}
</FixedSizeGrid>
{pagination.hasMore && (
<div ref={observerTarget} className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</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">
<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>
);
}

View File

@ -56,4 +56,13 @@ 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;