Compare commits
4 Commits
2442d0dde7
...
44aedcbee6
| Author | SHA1 | Date |
|---|---|---|
|
|
44aedcbee6 | |
|
|
6ce4e5a877 | |
|
|
c264fd551d | |
|
|
6aef5daa74 |
|
|
@ -28,4 +28,6 @@ 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.
|
||||||
|
|
|
||||||
56
GEMINI.md
56
GEMINI.md
|
|
@ -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
56
PRD.md
|
|
@ -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.
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 InfiniteVirtualGrid from '@/components/infinite-virtual-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,177 +15,98 @@ 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 [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||||
const [scrollPosition, setScrollPosition] = useState(0);
|
const [isVideoPlayerOpen, setIsVideoPlayerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleItemClick = (item: MediaItem) => {
|
||||||
fetchBookmarkedItems();
|
if (item.type === 'video') {
|
||||||
}, []);
|
setSelectedItem(item);
|
||||||
|
setIsVideoPlayerOpen(true);
|
||||||
const fetchBookmarkedItems = async () => {
|
} else {
|
||||||
try {
|
setSelectedItem(item);
|
||||||
const response = await fetch('/api/bookmarks');
|
setIsViewerOpen(true);
|
||||||
const data = await response.json();
|
|
||||||
setItems(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching bookmarked items:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const handleCloseVideoPlayer = () => {
|
||||||
if (bytes === 0) return '0 Bytes';
|
setIsVideoPlayerOpen(false);
|
||||||
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) => {
|
|
||||||
setScrollPosition(window.scrollY);
|
|
||||||
setSelectedItem(item);
|
|
||||||
setIsPlayerOpen(true);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClosePlayer = () => {
|
|
||||||
setIsPlayerOpen(false);
|
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
// Restore scroll position
|
|
||||||
setTimeout(() => {
|
|
||||||
window.scrollTo({ top: scrollPosition, behavior: 'smooth' });
|
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const handleClosePhotoViewer = () => {
|
||||||
return (
|
setIsViewerOpen(false);
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
setSelectedItem(null);
|
||||||
<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>
|
|
||||||
<p className="text-muted-foreground">Loading bookmarked items...</p>
|
const handleBookmark = async (id: number) => {
|
||||||
</div>
|
try {
|
||||||
</div>
|
await fetch(`/api/bookmarks/${id}`, { method: 'POST' });
|
||||||
);
|
} 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">
|
<>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<InfiniteVirtualGrid
|
||||||
<div className="mb-8">
|
type="bookmark"
|
||||||
<div className="flex items-center gap-3 mb-4">
|
onItemClick={handleItemClick}
|
||||||
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
|
onBookmark={handleBookmark}
|
||||||
<Bookmark className="h-6 w-6 text-white" />
|
onUnbookmark={handleUnbookmark}
|
||||||
</div>
|
onRate={handleRate}
|
||||||
<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 ? (
|
{/* Video Player */}
|
||||||
<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' && (
|
||||||
<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 || '',
|
onBookmark={handleBookmark}
|
||||||
}}
|
onUnbookmark={handleUnbookmark}
|
||||||
isOpen={isPlayerOpen}
|
onRate={handleRate}
|
||||||
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -82,15 +82,75 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@apply bg-muted/50;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@apply bg-muted-foreground/30 rounded-full;
|
background: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
@apply bg-muted-foreground/50;
|
background: hsl(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-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 */
|
||||||
|
|
|
||||||
|
|
@ -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 overflow-y-auto bg-background/50 backdrop-blur-sm">
|
<main className="flex-1 bg-background/50 backdrop-blur-sm">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import InfiniteVirtualGrid from '@/components/infinite-virtual-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,78 +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 [isViewerOpen, setIsViewerOpen] = useState(false);
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const handlePhotoClick = (photo: Photo) => {
|
||||||
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -101,26 +30,9 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +41,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,184 +53,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) {
|
|
||||||
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 (
|
||||||
<>
|
<>
|
||||||
<div className="min-h-screen p-6">
|
<InfiniteVirtualGrid
|
||||||
<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
|
||||||
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}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import InfiniteVirtualGrid from "@/components/infinite-virtual-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">
|
<InfiniteVirtualGrid
|
||||||
<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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,569 @@
|
||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,409 @@
|
||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue