Compare commits

..

No commits in common. "fd07b25abfaecd4864f00041134cd43194753433" and "76154123b8fc35e6b29bd4fd1829c007159ede2c" have entirely different histories.

17 changed files with 266 additions and 1565 deletions

Binary file not shown.

View File

@ -1,271 +0,0 @@
# Database Migration Guide - Folder Bookmarks Feature
## Overview
This guide provides step-by-step instructions to safely update your existing NextAV database schema to support folder bookmarking without losing any existing data.
## ⚠️ Important Notes
- **Backup your database first** - Always create a backup before performing migrations
- **Test in staging** - If possible, test the migration on a staging environment first
- **Minimal downtime** - The migration adds a new table, so downtime should be minimal
## 🔧 Prerequisites
1. **Database Location**: Your database is typically located at:
- Development: `./data/media.db`
- Production: `/app/data/media.db` (inside Docker container)
2. **Backup Tools**: Ensure you have:
- SQLite command-line tools installed
- Access to your database file
- Write permissions to the database directory
## 📋 Migration Steps
### Step 1: Create a Full Database Backup
```bash
# For local development
cp data/media.db data/media.db.backup-$(date +%Y%m%d-%H%M%S)
# For Docker deployment
docker cp nextav-nextav-1:/app/data/media.db ./media.db.backup-$(date +%Y%m%d-%H%M%S)
```
### Step 2: Verify Current Database Schema
Connect to your database and verify the current structure:
```bash
sqlite3 data/media.db
```
Run these commands to check your current tables:
```sql
.tables
.schema libraries
.schema media
.schema bookmarks
.schema stars
```
### Step 3: Create the Migration Script
Create a new file `migrate-folder-bookmarks.sql`:
```sql
-- Migration Script: Add Folder Bookmarks Support
-- Run this script to add folder bookmarking capability to existing NextAV databases
-- Step 1: Create the folder_bookmarks table
CREATE TABLE IF NOT EXISTS folder_bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_path TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Step 2: Create index for performance
CREATE INDEX IF NOT EXISTS idx_folder_bookmarks_path ON folder_bookmarks(folder_path);
-- Step 3: Verify the migration
SELECT name FROM sqlite_master WHERE type='table' AND name='folder_bookmarks';
SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_folder_bookmarks_path';
-- Step 4: Test with a sample query
SELECT COUNT(*) as folder_bookmark_table_created FROM folder_bookmarks;
```
### Step 4: Execute the Migration
#### Option A: Direct SQLite Execution
```bash
# Run the migration script
sqlite3 data/media.db < migrate-folder-bookmarks.sql
# Verify the migration
sqlite3 data/media.db "SELECT name FROM sqlite_master WHERE type='table' AND name='folder_bookmarks';"
```
#### Option B: Manual Execution
```bash
sqlite3 data/media.db
```
Then execute each command manually:
```sql
-- Create the folder_bookmarks table
CREATE TABLE IF NOT EXISTS folder_bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_path TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Create index for performance
CREATE INDEX IF NOT EXISTS idx_folder_bookmarks_path ON folder_bookmarks(folder_path);
-- Verify the table was created
.tables
-- Exit SQLite
.quit
```
### Step 5: Update Application Code
The application code changes are already implemented in the codebase. You just need to:
1. **Pull the latest code** with folder bookmark support
2. **Restart your application** to load the new code
#### For Docker Deployment:
```bash
# Pull latest changes
git pull origin main
# Rebuild and restart
docker-compose down
docker-compose up --build -d
```
#### For Local Development:
```bash
# Pull latest changes
git pull origin main
# Install dependencies
pnpm install
# Build the application
pnpm build
# Start the development server
pnpm dev
```
### Step 6: Verify the Migration
#### 6.1 Test Database Connectivity
```bash
# Check if the application can connect to the database
curl http://localhost:3000/api/health
```
#### 6.2 Test Folder Bookmark API
```bash
# Test folder bookmark creation
curl -X POST "http://localhost:3000/api/folder-bookmarks/test-folder-path" \
-H "Content-Type: application/json"
# Test folder bookmark retrieval
curl "http://localhost:3000/api/folder-bookmarks"
```
#### 6.3 Test Combined Bookmarks
```bash
# Check that your existing bookmarks still work
curl "http://localhost:3000/api/bookmarks"
```
### Step 7: Functional Testing
1. **Navigate to folder viewer** and verify bookmark buttons appear
2. **Bookmark a folder** and check that the icon changes
3. **Go to bookmarks page** and verify folder bookmarks appear
4. **Click a folder bookmark** and verify navigation works
5. **Unbookmark a folder** and verify it disappears from bookmarks
## 🔄 Rollback Procedure
If you need to rollback the migration:
### Step 1: Stop the Application
```bash
docker-compose down
# or for local development
# Stop the dev server
```
### Step 2: Restore Database
```bash
# Restore from backup
cp data/media.db.backup-[timestamp] data/media.db
# For Docker
docker cp ./media.db.backup-[timestamp] nextav-nextav-1:/app/data/media.db
```
### Step 3: Revert Code (if needed)
```bash
# Revert to previous commit
git checkout [previous-commit-hash]
# Or reset to previous state
git reset --hard [previous-commit-hash]
```
### Step 4: Restart Application
```bash
# Restart with restored database
docker-compose up -d
# or for local development
pnpm dev
```
## 📊 Verification Checklist
After migration, verify:
- [ ] Database backup created successfully
- [ ] `folder_bookmarks` table exists in database
- [ ] Index `idx_folder_bookmarks_path` created
- [ ] Application starts without errors
- [ ] Existing media bookmarks still work
- [ ] Folder bookmark API endpoints respond
- [ ] Folder bookmark UI appears in folder viewer
- [ ] Current folder bookmark button works
- [ ] Bookmarks page shows both media and folder bookmarks
- [ ] Clicking folder bookmarks navigates correctly
## 🐛 Common Issues and Solutions
### Issue 1: "Database is locked"
**Solution**: Ensure the application is stopped before migration
```bash
docker-compose down
# Wait a few seconds, then run migration
```
### Issue 2: "Permission denied"
**Solution**: Check file permissions
```bash
chmod 644 data/media.db
chown [your-user]:[your-group] data/media.db
```
### Issue 3: "Table already exists"
**Solution**: This is fine - the `IF NOT EXISTS` clause handles this
### Issue 4: API endpoints return 404
**Solution**: Ensure the application was restarted after code update
## 📞 Support
If you encounter issues:
1. Check the application logs: `docker logs nextav-nextav-1`
2. Verify database schema: `sqlite3 data/media.db ".schema"`
3. Test API endpoints manually with curl
4. Check browser console for frontend errors
## 📝 Migration Record
After successful migration, record:
- Migration date and time
- Database backup location
- Any issues encountered and solutions
- Performance observations
---
**Note**: This migration is designed to be non-destructive. The new folder bookmarks functionality is additive and doesn't modify existing data structures.

View File

@ -1,162 +0,0 @@
# 📁 Folder Bookmarks Migration - Quick Start
This folder contains the migration tools to add folder bookmarking support to your existing NextAV instance.
## 🚀 Quick Migration (Recommended)
### For Docker Deployments
```bash
# 1. Navigate to your NextAV directory
cd /path/to/your/nextav
# 2. Pull the latest code with folder bookmark support
git pull origin main
# 3. Run the automated migration script
docker exec -it nextav-nextav-1 /bin/sh -c "cd /app && ./scripts/migrate-folder-bookmarks.sh"
# 4. Restart the application
docker-compose down
docker-compose up -d
```
### For Local Development
```bash
# 1. Navigate to your NextAV directory
cd /path/to/your/nextav
# 2. Pull the latest code
git pull origin main
# 3. Run the migration script
./scripts/migrate-folder-bookmarks.sh
# 4. Start the development server
pnpm dev
```
## 🔧 Manual Migration (Alternative)
If the automated script doesn't work for your setup, follow the manual process:
### Step 1: Backup Your Database
```bash
# Create a backup
cp data/media.db data/media.db.backup-$(date +%Y%m%d-%H%M%S)
```
### Step 2: Connect to Database
```bash
sqlite3 data/media.db
```
### Step 3: Execute Migration SQL
```sql
-- Create folder_bookmarks table
CREATE TABLE IF NOT EXISTS folder_bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_path TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Create index for performance
CREATE INDEX IF NOT EXISTS idx_folder_bookmarks_path ON folder_bookmarks(folder_path);
-- Verify creation
.tables
```
### Step 4: Exit and Restart
```sql
.quit
```
Then restart your application.
## ✅ Verification
After migration, verify everything works:
1. **Check API endpoints:**
```bash
curl http://localhost:3000/api/folder-bookmarks
curl http://localhost:3000/api/bookmarks
```
2. **Test UI functionality:**
- Navigate to any folder in the folder viewer
- Click the bookmark icon (should turn yellow)
- Go to Bookmarks page
- Verify folder bookmarks appear with folder icons
3. **Test navigation:**
- Click on a folder bookmark
- Verify it navigates to the correct folder
## 🔄 Rollback Instructions
If you need to rollback:
```bash
# 1. Stop the application
docker-compose down
# 2. Restore from backup
cp data/media.db.backup-[timestamp] data/media.db
# 3. Restart with previous code
git checkout [previous-commit-hash]
docker-compose up -d
```
## 📊 Migration Checklist
- [ ] Database backed up successfully
- [ ] Migration script executed without errors
- [ ] Application starts normally
- [ ] Folder bookmark API endpoints work
- [ ] UI shows bookmark buttons on folders
- [ ] Bookmarks page displays folder bookmarks
- [ ] Folder bookmark navigation works
- [ ] Existing media bookmarks still work
## 🆘 Troubleshooting
### "Database is locked"
- Ensure the application is stopped during migration
- Wait a few moments and try again
### "Permission denied"
```bash
chmod +x scripts/migrate-folder-bookmarks.sh
chmod 644 data/media.db
```
### "Table already exists"
- This is normal - the migration handles existing tables gracefully
### API endpoints return 404
- Ensure you restarted the application after code update
- Check application logs: `docker logs nextav-nextav-1`
## 📞 Support
If you encounter issues:
1. **Check the detailed migration guide:** `docs/DATABASE_MIGRATION_GUIDE.md`
2. **Verify your backup:** Ensure your backup file is valid
3. **Check logs:** Review application logs for error messages
4. **Test manually:** Use SQLite CLI to verify database state
## 📁 Files in This Directory
- `DATABASE_MIGRATION_GUIDE.md` - Detailed step-by-step migration guide
- `MIGRATION_README.md` - This quick start guide
- `../scripts/migrate-folder-bookmarks.sh` - Automated migration script
---
**🎉 Enjoy your new folder bookmarking feature!**
You can now bookmark important folders alongside your media files for quick access to frequently used directories. 📁✨

View File

@ -1,258 +0,0 @@
#!/bin/bash
# NextAV Folder Bookmarks Database Migration Script
# This script safely migrates your existing database to support folder bookmarks
set -e # Exit on any error
echo "🔧 NextAV Folder Bookmarks Database Migration"
echo "=============================================="
# Configuration
DB_PATH="${DB_PATH:-./data/media.db}"
BACKUP_DIR="./backups"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/media.db.backup-${TIMESTAMP}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if SQLite is installed
check_sqlite() {
if ! command -v sqlite3 &> /dev/null; then
print_error "SQLite3 is not installed. Please install SQLite3 first."
exit 1
fi
}
# Check if database file exists
check_database() {
if [ ! -f "$DB_PATH" ]; then
print_error "Database file not found at: $DB_PATH"
print_error "Please ensure the DB_PATH environment variable is set correctly."
exit 1
fi
print_status "Found database at: $DB_PATH"
}
# Create backup directory
create_backup_dir() {
if [ ! -d "$BACKUP_DIR" ]; then
mkdir -p "$BACKUP_DIR"
print_status "Created backup directory: $BACKUP_DIR"
fi
}
# Create database backup
create_backup() {
print_status "Creating database backup..."
# Check if database is in use (basic check)
if lsof "$DB_PATH" &> /dev/null; then
print_warning "Database appears to be in use. It's recommended to stop the application first."
read -p "Continue anyway? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_error "Migration cancelled."
exit 1
fi
fi
cp "$DB_PATH" "$BACKUP_FILE"
if [ $? -eq 0 ]; then
print_status "✅ Backup created successfully: $BACKUP_FILE"
else
print_error "❌ Failed to create backup"
exit 1
fi
}
# Verify current database schema
verify_schema() {
print_status "Verifying current database schema..."
# Check existing tables
TABLES=$(sqlite3 "$DB_PATH" ".tables")
if echo "$TABLES" | grep -q "media"; then
print_status "✅ Media table found"
else
print_error "❌ Media table not found"
exit 1
fi
if echo "$TABLES" | grep -q "bookmarks"; then
print_status "✅ Bookmarks table found"
else
print_error "❌ Bookmarks table not found"
exit 1
fi
# Check if folder_bookmarks already exists
if echo "$TABLES" | grep -q "folder_bookmarks"; then
print_warning "folder_bookmarks table already exists. Migration may have been run before."
read -p "Continue anyway? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_error "Migration cancelled."
exit 1
fi
fi
}
# Execute migration
execute_migration() {
print_status "Executing database migration..."
# SQL commands to add folder bookmarks support
sqlite3 "$DB_PATH" << EOF
-- Create folder_bookmarks table
CREATE TABLE IF NOT EXISTS folder_bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_path TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Create index for performance
CREATE INDEX IF NOT EXISTS idx_folder_bookmarks_path ON folder_bookmarks(folder_path);
-- Verify the migration
SELECT 'folder_bookmarks table created successfully' as status
WHERE EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name='folder_bookmarks');
SELECT 'idx_folder_bookmarks_path index created successfully' as status
WHERE EXISTS (SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_folder_bookmarks_path');
EOF
if [ $? -eq 0 ]; then
print_status "✅ Migration executed successfully"
else
print_error "❌ Migration failed"
exit 1
fi
}
# Verify migration results
verify_migration() {
print_status "Verifying migration results..."
# Check if table was created
RESULT=$(sqlite3 "$DB_PATH" "SELECT name FROM sqlite_master WHERE type='table' AND name='folder_bookmarks';")
if [ -n "$RESULT" ]; then
print_status "✅ folder_bookmarks table verified"
else
print_error "❌ folder_bookmarks table not found"
exit 1
fi
# Check if index was created
RESULT=$(sqlite3 "$DB_PATH" "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_folder_bookmarks_path';")
if [ -n "$RESULT" ]; then
print_status "✅ idx_folder_bookmarks_path index verified"
else
print_warning "⚠️ idx_folder_bookmarks_path index not found (this is not critical)"
fi
# Test that we can query the new table
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM folder_bookmarks;")
print_status "✅ New table is accessible (current count: $COUNT)"
}
# Test API endpoints (if server is running)
test_api() {
print_status "Testing API endpoints..."
# Check if server is running
if curl -s -f "http://localhost:3000/api/health" &> /dev/null; then
print_status "Server is running, testing API endpoints..."
# Test folder bookmarks endpoint
if curl -s -f "http://localhost:3000/api/folder-bookmarks" &> /dev/null; then
print_status "✅ Folder bookmarks API endpoint is working"
else
print_warning "⚠️ Folder bookmarks API endpoint not responding (server may need restart)"
fi
# Test combined bookmarks endpoint
if curl -s -f "http://localhost:3000/api/bookmarks" &> /dev/null; then
print_status "✅ Combined bookmarks API endpoint is working"
else
print_warning "⚠️ Combined bookmarks API endpoint not responding"
fi
else
print_warning "⚠️ Server is not running - API tests skipped"
print_status "Please start your application and test the new functionality manually"
fi
}
# Print migration summary
print_summary() {
echo
echo "🎉 Migration completed successfully!"
echo "====================================="
echo
echo "📋 Summary:"
echo " • Database backup created: $BACKUP_FILE"
echo " • folder_bookmarks table added"
echo " • Performance index created"
echo " • API endpoints ready for use"
echo
echo "🚀 Next steps:"
echo " 1. Start your NextAV application"
echo " 2. Navigate to a folder in the folder viewer"
echo " 3. Click the bookmark icon to test folder bookmarking"
echo " 4. Go to the Bookmarks page to see your folder bookmarks"
echo
echo "🔄 Rollback instructions:"
echo " If you need to rollback, restore the backup:"
echo " cp $BACKUP_FILE $DB_PATH"
echo
echo "📞 Support:"
echo " If you encounter issues, check:"
echo " • Application logs: docker logs nextav-nextav-1"
echo " • Database integrity: sqlite3 $DB_PATH \".tables\""
echo " • API health: curl http://localhost:3000/api/health"
}
# Main execution
main() {
print_status "Starting NextAV folder bookmarks migration..."
check_sqlite
check_database
create_backup_dir
create_backup
verify_schema
execute_migration
verify_migration
test_api
print_summary
}
# Handle script interruption
trap 'print_error "Migration interrupted"; exit 1' INT TERM
# Run main function
main
echo
echo "✨ Migration process completed!"
echo " Your NextAV instance now supports folder bookmarking."
echo " Enjoy organizing your media libraries with bookmarks!"

View File

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getDatabase, getFolderBookmarks } from '@/db'; import { getDatabase } from '@/db';
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -27,80 +27,34 @@ export async function GET(request: Request) {
try { try {
const db = getDatabase(); const db = getDatabase();
// Get total count for pagination
// Get ALL bookmarks first (both media and folder), then sort and paginate const countQuery = `
// This ensures proper interleaving of both types SELECT COUNT(*) as total
FROM bookmarks b
// Get all media bookmarks (without offset/limit for now) JOIN media m ON b.media_id = m.id
let mediaQuery = ` ${whereClause}
SELECT `;
m.*, const totalResult = db.prepare(countQuery).get(...params) as { total: number };
l.path as library_path, const total = totalResult.total;
b.created_at as bookmark_created_at,
b.updated_at as bookmark_updated_at, // Get paginated results
'media' as bookmark_type const bookmarks = db.prepare(`
SELECT m.*, l.path as library_path
FROM bookmarks b FROM bookmarks b
JOIN media m ON b.media_id = m.id JOIN media m ON b.media_id = m.id
JOIN libraries l ON m.library_id = l.id JOIN libraries l ON m.library_id = l.id
${whereClause} ${whereClause}
ORDER BY b.updated_at DESC ORDER BY b.${sortColumn} ${sortDirection}
`; LIMIT ? OFFSET ?
`).all(...params, limit, offset);
const mediaBookmarks = db.prepare(mediaQuery).all(...params);
const mediaTotal = mediaBookmarks.length;
// Get all folder bookmarks (without offset/limit for now)
const { bookmarks: allFolderBookmarks, total: folderTotal } = getFolderBookmarks(1000, 0);
// Format media bookmarks
const mediaBookmarksWithType = mediaBookmarks.map((bookmark: any) => ({
...bookmark,
bookmark_type: 'media',
created_at: bookmark.bookmark_created_at,
updated_at: bookmark.bookmark_updated_at
}));
// Format folder bookmarks
const folderBookmarksWithType = allFolderBookmarks.map((bookmark: any) => ({
id: bookmark.id,
title: null,
name: bookmark.folder_path.split('/').pop() || bookmark.folder_path,
path: bookmark.folder_path,
size: 0,
thumbnail: '',
type: 'folder',
bookmark_count: 0,
star_count: 0,
avg_rating: 0,
bookmark_type: 'folder',
folder_path: bookmark.folder_path,
created_at: bookmark.created_at,
updated_at: bookmark.updated_at
}));
// Combine and sort all bookmarks
const allBookmarks = [...mediaBookmarksWithType, ...folderBookmarksWithType];
const sortedBookmarks = allBookmarks.sort((a: any, b: any) => {
const aTime = new Date(a.updated_at || a.created_at).getTime();
const bTime = new Date(b.updated_at || b.created_at).getTime();
return bTime - aTime;
});
// Apply pagination to the combined sorted results
const paginatedBookmarks = sortedBookmarks.slice(offset, offset + limit);
const combinedTotal = sortedBookmarks.length;
return NextResponse.json({ return NextResponse.json({
bookmarks: paginatedBookmarks, bookmarks,
folderBookmarks: folderBookmarksWithType,
allBookmarks: sortedBookmarks,
pagination: { pagination: {
total: combinedTotal, total,
mediaTotal,
folderTotal,
limit, limit,
offset, offset,
hasMore: offset + limit < combinedTotal hasMore: offset + limit < total
} }
}); });
} catch (error: any) { } catch (error: any) {

View File

@ -1,69 +0,0 @@
import { NextResponse } from 'next/server';
import { addFolderBookmark, removeFolderBookmark, isFolderBookmarked } from '@/db';
// Helper function to encode/decode folder paths for URL safety
function encodeFolderPath(path: string): string {
return encodeURIComponent(path);
}
function decodeFolderPath(encodedPath: string): string {
return decodeURIComponent(encodedPath);
}
export async function GET(request: Request, { params }: { params: Promise<{ path: string }> }) {
try {
const { path } = await params;
const folderPath = decodeFolderPath(path);
const isBookmarked = isFolderBookmarked(folderPath);
return NextResponse.json({
isBookmarked,
folderPath
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request: Request, { params }: { params: Promise<{ path: string }> }) {
try {
const { path } = await params;
const folderPath = decodeFolderPath(path);
// Check if already bookmarked
if (isFolderBookmarked(folderPath)) {
return NextResponse.json({ error: 'Folder already bookmarked' }, { status: 400 });
}
// Add folder bookmark
const id = addFolderBookmark(folderPath);
return NextResponse.json({ id, folderPath });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(request: Request, { params }: { params: Promise<{ path: string }> }) {
try {
const { path } = await params;
const folderPath = decodeFolderPath(path);
// Check if bookmark exists
if (!isFolderBookmarked(folderPath)) {
return NextResponse.json({ error: 'Folder bookmark not found' }, { status: 404 });
}
// Remove folder bookmark
const success = removeFolderBookmark(folderPath);
if (success) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: 'Failed to remove folder bookmark' }, { status: 500 });
}
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -1,47 +0,0 @@
import { NextResponse } from 'next/server';
import { getDatabase, getFolderBookmarks, addFolderBookmark, removeFolderBookmark, isFolderBookmarked } from '@/db';
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');
try {
const { bookmarks, total } = getFolderBookmarks(limit, offset);
return NextResponse.json({
folderBookmarks: bookmarks,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total
}
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const { folderPath } = await request.json();
if (!folderPath) {
return NextResponse.json({ error: 'folderPath is required' }, { status: 400 });
}
// Check if already bookmarked
if (isFolderBookmarked(folderPath)) {
return NextResponse.json({ error: 'Folder already bookmarked' }, { status: 400 });
}
// Insert folder bookmark
const id = addFolderBookmark(folderPath);
return NextResponse.json({ id });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -5,35 +5,26 @@ import InfiniteVirtualGrid from '@/components/infinite-virtual-grid';
import UnifiedVideoPlayer from '@/components/unified-video-player'; import UnifiedVideoPlayer from '@/components/unified-video-player';
import PhotoViewer from '@/components/photo-viewer'; import PhotoViewer from '@/components/photo-viewer';
import { ArtPlayerTestBanner } from '@/components/video-player-debug'; import { ArtPlayerTestBanner } from '@/components/video-player-debug';
import { useRouter } from 'next/navigation';
import { Bookmark, Folder } from 'lucide-react';
interface MediaItem { interface MediaItem {
id?: number; id: number;
title?: string; title: string;
name?: string;
path: string; path: string;
size?: number; size: number;
thumbnail?: string; thumbnail: string;
type?: string; type: string;
bookmark_count?: number; bookmark_count: number;
star_count?: number; star_count: number;
avg_rating?: number; avg_rating: number;
bookmark_type?: 'media' | 'folder';
folder_path?: string;
} }
export default function BookmarksPage() { export default function BookmarksPage() {
const router = useRouter();
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null); const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false); const [isViewerOpen, setIsViewerOpen] = useState(false);
const [isVideoPlayerOpen, setIsVideoPlayerOpen] = useState(false); const [isVideoPlayerOpen, setIsVideoPlayerOpen] = useState(false);
const handleItemClick = (item: MediaItem) => { const handleItemClick = (item: MediaItem) => {
if (item.bookmark_type === 'folder') { if (item.type === 'video') {
// Navigate to folder
router.push(`/folder-viewer?path=${encodeURIComponent(item.folder_path || item.path)}`);
} else if (item.type === 'video') {
setSelectedItem(item); setSelectedItem(item);
setIsVideoPlayerOpen(true); setIsVideoPlayerOpen(true);
} else { } else {
@ -52,36 +43,17 @@ export default function BookmarksPage() {
setSelectedItem(null); setSelectedItem(null);
}; };
const handleBookmark = async (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => { const handleBookmark = async (id: number) => {
try { try {
if (bookmarkType === 'folder' && folderPath) { await fetch(`/api/bookmarks/${id}`, { method: 'POST' });
// Handle folder bookmark
const encodedPath = encodeURIComponent(folderPath);
await fetch(`/api/folder-bookmarks/${encodedPath}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
} else if (id) {
// Handle media bookmark
await fetch(`/api/bookmarks/${id}`, { method: 'POST' });
}
} catch (error) { } catch (error) {
console.error('Error bookmarking item:', error); console.error('Error bookmarking item:', error);
} }
}; };
const handleUnbookmark = async (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => { const handleUnbookmark = async (id: number) => {
try { try {
if (bookmarkType === 'folder' && folderPath) { await fetch(`/api/bookmarks/${id}`, { method: 'DELETE' });
// Handle folder unbookmark
const encodedPath = encodeURIComponent(folderPath);
await fetch(`/api/folder-bookmarks/${encodedPath}`, { method: 'DELETE' });
} else if (id) {
// Handle media unbookmark
await fetch(`/api/bookmarks/${id}`, { method: 'DELETE' });
}
} catch (error) { } catch (error) {
console.error('Error unbookmarking item:', error); console.error('Error unbookmarking item:', error);
} }
@ -114,41 +86,27 @@ export default function BookmarksPage() {
{/* Test banner to show ArtPlayer is active */} {/* Test banner to show ArtPlayer is active */}
{process.env.NODE_ENV === 'development' && <ArtPlayerTestBanner />} {process.env.NODE_ENV === 'development' && <ArtPlayerTestBanner />}
<div className="min-h-screen bg-zinc-950"> <InfiniteVirtualGrid
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> type="bookmark"
<div className="mb-8"> onItemClick={handleItemClick}
<div className="flex items-center gap-3 mb-2"> onBookmark={handleBookmark}
<div className="w-10 h-10 bg-gradient-to-br from-yellow-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg"> onUnbookmark={handleUnbookmark}
<Bookmark className="h-5 w-5 text-white" /> onRate={handleRate}
</div> />
<h1 className="text-3xl font-bold text-white">Bookmarks</h1>
</div>
<p className="text-zinc-400 text-lg">Your saved media and folders</p>
</div>
<InfiniteVirtualGrid
type="bookmark"
onItemClick={handleItemClick}
onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark}
onRate={handleRate}
/>
</div>
</div>
{/* Video Player - Only ArtPlayer, no overlay */} {/* Video Player - Only ArtPlayer, no overlay */}
{selectedItem && selectedItem.type === 'video' && selectedItem.id && ( {selectedItem && selectedItem.type === 'video' && (
<UnifiedVideoPlayer <UnifiedVideoPlayer
video={{ video={{
id: selectedItem.id, id: selectedItem.id,
title: selectedItem.title || '', title: selectedItem.title,
path: selectedItem.path, path: selectedItem.path,
size: selectedItem.size || 0, size: selectedItem.size,
thumbnail: selectedItem.thumbnail || '', thumbnail: selectedItem.thumbnail,
type: selectedItem.type, type: selectedItem.type,
bookmark_count: selectedItem.bookmark_count || 0, bookmark_count: selectedItem.bookmark_count,
star_count: selectedItem.star_count || 0, star_count: selectedItem.star_count,
avg_rating: selectedItem.avg_rating || 0 avg_rating: selectedItem.avg_rating
}} }}
isOpen={isVideoPlayerOpen} isOpen={isVideoPlayerOpen}
onClose={handleCloseVideoPlayer} onClose={handleCloseVideoPlayer}
@ -162,27 +120,17 @@ export default function BookmarksPage() {
/> />
)} )}
{/* Photo Viewer - only for actual photo items with IDs */} {/* Photo Viewer */}
{selectedItem && selectedItem.type === 'photo' && selectedItem.id && ( {selectedItem && selectedItem.type === 'photo' && (
<PhotoViewer <PhotoViewer
photo={{ photo={selectedItem}
id: selectedItem.id,
title: selectedItem.title || '',
path: selectedItem.path,
size: selectedItem.size || 0,
thumbnail: selectedItem.thumbnail || '',
type: selectedItem.type,
bookmark_count: selectedItem.bookmark_count || 0,
star_count: selectedItem.star_count || 0,
avg_rating: selectedItem.avg_rating || 0
}}
isOpen={isViewerOpen} isOpen={isViewerOpen}
onClose={handleClosePhotoViewer} onClose={handleClosePhotoViewer}
showBookmarks={true} showBookmarks={true}
showRatings={true} showRatings={true}
onBookmark={() => handleBookmark(selectedItem.id!, 'media')} onBookmark={handleBookmark}
onUnbookmark={() => handleUnbookmark(selectedItem.id!, 'media')} onUnbookmark={handleUnbookmark}
onRate={(rating) => handleRate(selectedItem.id!, rating)} onRate={handleRate}
/> />
)} )}
</> </>

View File

@ -43,18 +43,11 @@ const FolderViewerPage = () => {
const [selectedText, setSelectedText] = useState<FileSystemItem | null>(null); const [selectedText, setSelectedText] = useState<FileSystemItem | null>(null);
const [isTextViewerOpen, setIsTextViewerOpen] = useState(false); const [isTextViewerOpen, setIsTextViewerOpen] = useState(false);
const [libraries, setLibraries] = useState<{id: number, path: string}[]>([]); const [libraries, setLibraries] = useState<{id: number, path: string}[]>([]);
const [isCurrentFolderBookmarked, setIsCurrentFolderBookmarked] = useState(false);
useEffect(() => { useEffect(() => {
fetchLibraries(); fetchLibraries();
}, []); }, []);
useEffect(() => {
if (path) {
checkCurrentFolderBookmarkStatus();
}
}, [path]);
const fetchLibraries = async () => { const fetchLibraries = async () => {
try { try {
const res = await fetch('/api/libraries'); const res = await fetch('/api/libraries');
@ -65,54 +58,6 @@ const FolderViewerPage = () => {
} }
}; };
const checkCurrentFolderBookmarkStatus = async () => {
if (!path) return;
try {
const encodedPath = encodeURIComponent(path);
const response = await fetch(`/api/folder-bookmarks/${encodedPath}`);
if (response.ok) {
const data = await response.json();
setIsCurrentFolderBookmarked(data.isBookmarked);
}
} catch (error) {
console.error('Error checking current folder bookmark status:', error);
}
};
const handleCurrentFolderBookmark = async () => {
if (!path) return;
try {
const encodedPath = encodeURIComponent(path);
if (isCurrentFolderBookmarked) {
// Remove bookmark
const response = await fetch(`/api/folder-bookmarks/${encodedPath}`, {
method: 'DELETE',
});
if (response.ok) {
setIsCurrentFolderBookmarked(false);
}
} else {
// Add bookmark
const response = await fetch(`/api/folder-bookmarks/${encodedPath}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
setIsCurrentFolderBookmarked(true);
}
}
} catch (error) {
console.error('Error toggling current folder bookmark:', error);
}
};
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
@ -519,8 +464,6 @@ const FolderViewerPage = () => {
breadcrumbs={getBreadcrumbs(path)} breadcrumbs={getBreadcrumbs(path)}
libraries={libraries} libraries={libraries}
onItemsLoaded={setCurrentItems} onItemsLoaded={setCurrentItems}
isCurrentFolderBookmarked={isCurrentFolderBookmarked}
onCurrentFolderBookmark={handleCurrentFolderBookmark}
/> />
{/* Photo Viewer */} {/* Photo Viewer */}

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import InfiniteVirtualGrid, { MediaItem } from '@/components/infinite-virtual-grid'; import InfiniteVirtualGrid from '@/components/infinite-virtual-grid';
import PhotoViewer from '@/components/photo-viewer'; import PhotoViewer from '@/components/photo-viewer';
interface Photo { interface Photo {
@ -22,19 +22,15 @@ export default function PhotosPage() {
const [photosList, setPhotosList] = useState<Photo[]>([]); const [photosList, setPhotosList] = useState<Photo[]>([]);
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const handlePhotoClick = (photo: MediaItem, index?: number) => { const handlePhotoClick = (photo: Photo, index?: number) => {
// Cast to Photo since photos always have IDs setSelectedPhoto(photo);
const photoWithId = photo as Photo;
setSelectedPhoto(photoWithId);
if (index !== undefined) { if (index !== undefined) {
setCurrentPhotoIndex(index); setCurrentPhotoIndex(index);
} }
setIsViewerOpen(true); setIsViewerOpen(true);
}; };
const handlePhotosData = (items: MediaItem[]) => { const handlePhotosData = (photos: Photo[]) => {
// Cast to Photo[] since this is the photos page and all items should be photos with IDs
const photos = items as Photo[];
setPhotosList(photos); setPhotosList(photos);
}; };

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import InfiniteVirtualGrid, { MediaItem } from "@/components/infinite-virtual-grid"; import InfiniteVirtualGrid from "@/components/infinite-virtual-grid";
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
interface TextFile { interface TextFile {
@ -21,14 +21,12 @@ const TextsPage = () => {
const [isViewerOpen, setIsViewerOpen] = useState(false); const [isViewerOpen, setIsViewerOpen] = useState(false);
const [textContent, setTextContent] = useState<string>(""); const [textContent, setTextContent] = useState<string>("");
const handleTextClick = async (text: MediaItem) => { const handleTextClick = async (text: TextFile) => {
// Cast to TextFile since texts always have IDs
const textFile = text as TextFile;
try { try {
const response = await fetch(`/api/texts/${textFile.id}`); const response = await fetch(`/api/texts/${text.id}`);
const data = await response.json(); const data = await response.json();
setTextContent(data.content); setTextContent(data.content);
setSelectedText(textFile); setSelectedText(text);
setIsViewerOpen(true); setIsViewerOpen(true);
} catch (error) { } catch (error) {
console.error('Error loading text file:', error); console.error('Error loading text file:', error);
@ -41,7 +39,7 @@ const TextsPage = () => {
setTextContent(""); setTextContent("");
}; };
const handleBookmark = async (textId: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => { const handleBookmark = async (textId: number) => {
try { try {
await fetch(`/api/bookmarks/${textId}`, { method: 'POST' }); await fetch(`/api/bookmarks/${textId}`, { method: 'POST' });
} catch (error) { } catch (error) {
@ -49,7 +47,7 @@ const TextsPage = () => {
} }
}; };
const handleUnbookmark = async (textId: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => { const handleUnbookmark = async (textId: number) => {
try { try {
await fetch(`/api/bookmarks/${textId}`, { method: 'DELETE' }); await fetch(`/api/bookmarks/${textId}`, { method: 'DELETE' });
} catch (error) { } catch (error) {

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import InfiniteVirtualGrid, { MediaItem } from "@/components/infinite-virtual-grid"; import InfiniteVirtualGrid from "@/components/infinite-virtual-grid";
import UnifiedVideoPlayer from '@/components/unified-video-player'; import UnifiedVideoPlayer from '@/components/unified-video-player';
import { ArtPlayerTestBanner } from '@/components/video-player-debug'; import { ArtPlayerTestBanner } from '@/components/video-player-debug';
@ -21,13 +21,11 @@ const VideosPage = () => {
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null); const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [isPlayerOpen, setIsPlayerOpen] = useState(false); const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const handleVideoClick = (video: MediaItem) => { const handleVideoClick = (video: Video) => {
// Cast to Video since videos always have IDs console.log('[VideosPage] handleVideoClick called with video:', video);
const videoWithId = video as Video; setSelectedVideo(video);
console.log('[VideosPage] handleVideoClick called with video:', videoWithId);
setSelectedVideo(videoWithId);
setIsPlayerOpen(true); setIsPlayerOpen(true);
console.log('[VideosPage] State updated - selectedVideo:', videoWithId, 'isPlayerOpen:', true); console.log('[VideosPage] State updated - selectedVideo:', video, 'isPlayerOpen:', true);
}; };
const handleClosePlayer = () => { const handleClosePlayer = () => {
@ -35,7 +33,7 @@ const VideosPage = () => {
setSelectedVideo(null); setSelectedVideo(null);
}; };
const handleBookmark = async (videoId: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => { const handleBookmark = async (videoId: number) => {
try { try {
await fetch(`/api/bookmarks/${videoId}`, { method: 'POST' }); await fetch(`/api/bookmarks/${videoId}`, { method: 'POST' });
} catch (error) { } catch (error) {
@ -43,7 +41,7 @@ const VideosPage = () => {
} }
}; };
const handleUnbookmark = async (videoId: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => { const handleUnbookmark = async (videoId: number) => {
try { try {
await fetch(`/api/bookmarks/${videoId}`, { method: 'DELETE' }); await fetch(`/api/bookmarks/${videoId}`, { method: 'DELETE' });
} catch (error) { } catch (error) {

View File

@ -4,29 +4,26 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { FixedSizeGrid } from 'react-window'; import { FixedSizeGrid } from 'react-window';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { StarRating } from '@/components/star-rating'; import { StarRating } from '@/components/star-rating';
import { Film, Image as ImageIcon, HardDrive, Search, Bookmark, FileText, Folder } from 'lucide-react'; import { Film, Image as ImageIcon, HardDrive, Search, Bookmark, FileText } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
interface MediaItem { interface MediaItem {
id?: number; id: number;
title?: string; title: string;
name?: string;
path: string; path: string;
size?: number; size: number;
thumbnail?: string; thumbnail: string;
type?: string; type: string;
bookmark_count?: number; bookmark_count: number;
avg_rating?: number; avg_rating: number;
star_count?: number; star_count: number;
bookmark_type?: 'media' | 'folder';
folder_path?: string;
} }
interface InfiniteVirtualGridProps { interface InfiniteVirtualGridProps {
type: 'video' | 'photo' | 'text' | 'bookmark'; type: 'video' | 'photo' | 'text' | 'bookmark';
onItemClick: (item: MediaItem, index?: number) => void; onItemClick: (item: MediaItem, index?: number) => void;
onBookmark: (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => Promise<void>; onBookmark: (id: number) => Promise<void>;
onUnbookmark: (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => Promise<void>; onUnbookmark: (id: number) => Promise<void>;
onRate: (id: number, rating: number) => Promise<void>; onRate: (id: number, rating: number) => Promise<void>;
onDataUpdate?: (items: MediaItem[]) => void; onDataUpdate?: (items: MediaItem[]) => void;
} }
@ -135,14 +132,8 @@ export default function InfiniteVirtualGrid({
const response = await fetch(`/api/${endpoint}?${params}`); const response = await fetch(`/api/${endpoint}?${params}`);
const data = await response.json(); const data = await response.json();
let items: MediaItem[] = []; const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
if (type === 'bookmark') { const items = data[itemsKey] || [];
// Use the bookmarks array which now contains both media and folder bookmarks combined
items = data.bookmarks || [];
} else {
const itemsKey = `${type}s`;
items = data[itemsKey] || [];
}
dataCacheRef.current.set(batchKey, items); dataCacheRef.current.set(batchKey, items);
@ -383,67 +374,51 @@ export default function InfiniteVirtualGrid({
onClick={() => onItemClick(item, index)} onClick={() => onItemClick(item, index)}
> >
<div className="relative overflow-hidden bg-muted aspect-video"> <div className="relative overflow-hidden bg-muted aspect-video">
{item.bookmark_type === 'folder' ? ( <img
// Folder bookmark styling src={item.thumbnail || (type === 'video' ? "/placeholder-video.svg" : type === 'text' ? "/placeholder.svg" : "/placeholder-photo.svg")}
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-900/20 dark:to-indigo-900/20 flex items-center justify-center"> alt={item.title}
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg" className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
style={{ transform: 'perspective(100px) rotateY(-5deg) rotateX(5deg)' }}> onError={(e) => {
<Folder className="h-8 w-8 text-white" /> (e.target as HTMLImageElement).src = type === 'video' ? "/placeholder-video.svg" : type === 'text' ? "/placeholder.svg" : "/placeholder-photo.svg";
</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 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" /> :
type === 'text' ?
<FileText className="h-5 w-5 text-foreground" /> :
<ImageIcon className="h-5 w-5 text-foreground" />
}
</div> </div>
) : ( </div>
// Media bookmark styling
<>
<img
src={item.thumbnail || (type === 'video' ? "/placeholder-video.svg" : type === 'text' ? "/placeholder.svg" : "/placeholder-photo.svg")}
alt={item.title || item.name}
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" : type === 'text' ? "/placeholder.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" /> :
type === 'text' ?
<FileText 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="absolute top-2 right-2">
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1"> <div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
{type === 'video' ? {type === 'video' ?
<Film className="h-3 w-3 text-white" /> : <Film className="h-3 w-3 text-white" /> :
type === 'text' ? type === 'text' ?
<FileText className="h-3 w-3 text-white" /> : <FileText className="h-3 w-3 text-white" /> :
<ImageIcon className="h-3 w-3 text-white" /> <ImageIcon className="h-3 w-3 text-white" />
} }
</div> </div>
</div> </div>
</>
)}
</div> </div>
<CardContent className="p-2.5"> <CardContent className="p-2.5">
<div className="flex items-start justify-between min-h-[2rem]"> <div className="flex items-start justify-between min-h-[2rem]">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-xs line-clamp-2 group-hover:text-primary transition-colors leading-tight"> <h3 className="font-medium text-foreground text-xs line-clamp-2 group-hover:text-primary transition-colors leading-tight">
{item.bookmark_type === 'folder' {item.title || item.path.split('/').pop()}
? (item.folder_path || item.path).split('/').pop() || 'Folder'
: item.title || item.name || item.path.split('/').pop()
}
</h3> </h3>
{((item.avg_rating || 0) > 0 || (item.star_count || 0) > 0) && ( {(item.avg_rating > 0 || item.star_count > 0) && (
<div className="mt-0.5"> <div className="mt-0.5">
<StarRating <StarRating
rating={item.avg_rating || 0} rating={item.avg_rating || 0}
count={item.star_count || 0} count={item.star_count}
size="xs" size="xs"
showCount={false} showCount={false}
/> />
@ -452,28 +427,10 @@ export default function InfiniteVirtualGrid({
</div> </div>
<div className="flex gap-1 ml-1 flex-shrink-0"> <div className="flex gap-1 ml-1 flex-shrink-0">
{type === 'bookmark' ? ( {(type === 'video' || type === 'text') && item.bookmark_count > 0 && (
// Bookmark controls for bookmark page <div className="text-xs text-yellow-500">
<button <Bookmark className="h-2.5 w-2.5 fill-yellow-500" />
onClick={(e) => { </div>
e.stopPropagation();
if (item.bookmark_type === 'folder') {
onUnbookmark(item.id || 0, 'folder', item.folder_path || item.path);
} else {
onUnbookmark(item.id || 0, 'media');
}
}}
className="text-yellow-500 hover:text-yellow-400 transition-colors"
title="Remove bookmark"
>
<Bookmark className="h-3 w-3 fill-yellow-500" />
</button>
) : (
(type === 'video' || type === 'text') && (item.bookmark_count || 0) > 0 && (
<div className="text-xs text-yellow-500">
<Bookmark className="h-2.5 w-2.5 fill-yellow-500" />
</div>
)
)} )}
</div> </div>
</div> </div>
@ -482,9 +439,9 @@ export default function InfiniteVirtualGrid({
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<HardDrive className="h-2.5 w-2.5" /> <HardDrive className="h-2.5 w-2.5" />
<span>{item.bookmark_type === 'folder' ? 'Folder' : formatFileSize(item.size || 0)}</span> <span>{formatFileSize(item.size)}</span>
</div> </div>
{(type === 'video' || type === 'text') && (item.bookmark_count || 0) > 0 && ( {(type === 'video' || type === 'text') && item.bookmark_count > 0 && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{item.bookmark_count} {item.bookmark_count}
</span> </span>
@ -660,6 +617,4 @@ export default function InfiniteVirtualGrid({
</div> </div>
</div> </div>
); );
} }
export type { MediaItem };

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
@ -11,9 +11,8 @@ import {
PlayCircle, PlayCircle,
Monitor, Monitor,
Settings, Settings,
X, HelpCircle,
Bookmark, X
Star
} from 'lucide-react'; } from 'lucide-react';
import { VideoFormat, VideoFile } from '@/lib/video-format-detector'; import { VideoFormat, VideoFile } from '@/lib/video-format-detector';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -23,7 +22,6 @@ import {
shouldAutoLaunch, shouldAutoLaunch,
PlayerPreferences PlayerPreferences
} from '@/lib/player-preferences'; } from '@/lib/player-preferences';
import { StarRating } from '@/components/star-rating';
interface LocalPlayerLauncherProps { interface LocalPlayerLauncherProps {
video: VideoFile; video: VideoFile;
@ -32,11 +30,6 @@ interface LocalPlayerLauncherProps {
onPlayerSelect?: (player: string) => void; onPlayerSelect?: (player: string) => void;
formatFileSize?: (bytes: number) => string; formatFileSize?: (bytes: number) => string;
className?: string; className?: string;
onBookmark?: (id: number) => Promise<void>;
onUnbookmark?: (id: number) => Promise<void>;
onRate?: (id: number, rating: number) => Promise<void>;
showBookmarks?: boolean;
showRatings?: boolean;
} }
interface PlayerInfo { interface PlayerInfo {
@ -61,6 +54,15 @@ const PLAYER_INFO: Record<string, PlayerInfo> = {
protocolUrl: 'vlc://', protocolUrl: 'vlc://',
commandLine: 'vlc' commandLine: 'vlc'
}, },
elmedia: {
id: 'elmedia',
name: 'Elmedia Player',
icon: '🍎',
description: 'Advanced media player for macOS with streaming capabilities',
platforms: ['macOS'],
downloadUrl: 'https://www.elmedia-video-player.com/',
commandLine: 'open -a "Elmedia Player"'
},
potplayer: { potplayer: {
id: 'potplayer', id: 'potplayer',
name: 'PotPlayer', name: 'PotPlayer',
@ -144,115 +146,53 @@ export default function LocalPlayerLauncher({
onClose, onClose,
onPlayerSelect, onPlayerSelect,
formatFileSize, formatFileSize,
className, className
onBookmark,
onUnbookmark,
onRate,
showBookmarks = true,
showRatings = true
}: LocalPlayerLauncherProps) { }: LocalPlayerLauncherProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]); const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]);
const [isDetecting, setIsDetecting] = useState(true); const [isDetecting, setIsDetecting] = useState(true);
const [launchStatus, setLaunchStatus] = useState<'idle' | 'launching' | 'success' | 'error'>('idle'); const [launchStatus, setLaunchStatus] = useState<'idle' | 'launching' | 'success' | 'error'>('idle');
const [launchedPlayerId, setLaunchedPlayerId] = useState<string | null>(null); // Track which player was launched
const [preferences, setPreferences] = useState<PlayerPreferences | null>(null); const [preferences, setPreferences] = useState<PlayerPreferences | null>(null);
const [showingAutoLaunchConfirm, setShowingAutoLaunchConfirm] = useState(false); const [showingAutoLaunchConfirm, setShowingAutoLaunchConfirm] = useState(false);
const [hasAutoLaunched, setHasAutoLaunched] = useState(false); // Prevent repeated auto-launch
// Bookmark and rating states
const [isBookmarked, setIsBookmarked] = useState(false);
const [bookmarkLoading, setBookmarkLoading] = useState(false);
const [currentRating, setCurrentRating] = useState(0);
const [ratingLoading, setRatingLoading] = useState(false);
const streamUrl = getPlayerSpecificUrl(video.id, 'vlc'); // Use optimized endpoint const streamUrl = getPlayerSpecificUrl(video.id, 'vlc'); // Use optimized endpoint
const recommendedPlayers = format.recommendedPlayers || ['vlc', 'iina', 'potplayer']; const recommendedPlayers = format.recommendedPlayers || ['vlc', 'iina', 'elmedia', 'potplayer'];
// Check bookmark and rating status on mount // Load preferences and check for auto-launch on mount
useEffect(() => { useEffect(() => {
if (video.id) { const prefs = loadPlayerPreferences();
checkBookmarkStatus(); setPreferences(prefs);
checkRatingStatus();
}
}, [video.id]);
// Check bookmark status
const checkBookmarkStatus = useCallback(async () => {
if (!video.id || !showBookmarks) return;
try { const autoLaunchCheck = shouldAutoLaunch();
const response = await fetch(`/api/bookmarks/${video.id}`);
if (response.ok) {
const data = await response.json();
setIsBookmarked(data.isBookmarked || false);
}
} catch (error) {
console.error('Error checking bookmark status:', error);
}
}, [video.id, showBookmarks]);
// Check rating status
const checkRatingStatus = useCallback(async () => {
if (!video.id || !showRatings) return;
try { if (autoLaunchCheck.autoLaunch && autoLaunchCheck.playerId) {
const response = await fetch(`/api/stars/${video.id}`); if (autoLaunchCheck.needsConfirmation) {
if (response.ok) { setShowingAutoLaunchConfirm(true);
const data = await response.json();
setCurrentRating(data.rating || 0);
}
} catch (error) {
console.error('Error checking rating status:', error);
}
}, [video.id, showRatings]);
// Handle bookmark toggle
const handleBookmarkToggle = useCallback(async () => {
if (!video.id || bookmarkLoading) return;
setBookmarkLoading(true);
try {
if (isBookmarked) {
await onUnbookmark?.(video.id);
setIsBookmarked(false);
} else { } else {
await onBookmark?.(video.id); // Auto-launch immediately
setIsBookmarked(true); handlePlayerLaunch(autoLaunchCheck.playerId, true);
} }
} catch (error) { } else {
console.error('Error toggling bookmark:', error); // Detect available players normally
} finally { detectAvailablePlayers();
setBookmarkLoading(false);
} }
}, [video.id, isBookmarked, bookmarkLoading, onBookmark, onUnbookmark]); }, []);
// Handle rating change // Handle auto-launch confirmation
const handleRatingChange = useCallback(async (rating: number) => { const handleConfirmAutoLaunch = () => {
if (!video.id || ratingLoading) return; const autoLaunchCheck = shouldAutoLaunch();
if (autoLaunchCheck.playerId) {
setRatingLoading(true); setShowingAutoLaunchConfirm(false);
try { handlePlayerLaunch(autoLaunchCheck.playerId, true);
await onRate?.(video.id, rating);
setCurrentRating(rating);
} catch (error) {
console.error('Error updating rating:', error);
} finally {
setRatingLoading(false);
} }
}, [video.id, ratingLoading, onRate]);
const getPlatform = (): string => {
if (typeof window === 'undefined') return 'Unknown';
const userAgent = window.navigator.userAgent.toLowerCase();
if (userAgent.includes('mac')) return 'macOS';
if (userAgent.includes('win')) return 'Windows';
if (userAgent.includes('linux')) return 'Linux';
return 'Unknown';
}; };
const detectAvailablePlayers = useCallback(async () => { const handleCancelAutoLaunch = () => {
setShowingAutoLaunchConfirm(false);
detectAvailablePlayers();
};
const detectAvailablePlayers = async () => {
setIsDetecting(true); setIsDetecting(true);
try { try {
// In a real implementation, this would test protocol handlers // In a real implementation, this would test protocol handlers
@ -275,99 +215,17 @@ export default function LocalPlayerLauncher({
} finally { } finally {
setIsDetecting(false); setIsDetecting(false);
} }
}, [recommendedPlayers]); };
const handlePlayerLaunch = useCallback(async (playerId: string, isAutoLaunch: boolean = false) => { const getPlatform = (): string => {
const player = PLAYER_INFO[playerId]; if (typeof window === 'undefined') return 'Unknown';
if (!player) return;
setLaunchStatus('launching');
setLaunchedPlayerId(playerId);
try { const userAgent = window.navigator.userAgent.toLowerCase();
if (player.protocolUrl) { if (userAgent.includes('mac')) return 'macOS';
const protocolUrl = player.protocolUrl + encodeURIComponent(streamUrl); if (userAgent.includes('win')) return 'Windows';
window.location.href = protocolUrl; if (userAgent.includes('linux')) return 'Linux';
setLaunchStatus('success'); return 'Unknown';
} else { };
console.log(`Would launch ${player.name} with: ${streamUrl}`);
setLaunchStatus('success');
}
onPlayerSelect?.(playerId);
if (!isAutoLaunch && preferences && !preferences.preferredPlayer && preferences.rememberChoice) {
const shouldRemember = confirm(`Would you like to remember ${player.name} as your preferred video player? You can change this later in Settings.`);
if (shouldRemember) {
const updatedPrefs = {
...preferences,
preferredPlayer: playerId,
autoLaunch: confirm('Would you like to automatically launch videos with this player in the future?')
};
savePlayerPreferences(updatedPrefs);
setPreferences(updatedPrefs);
}
}
const hasInteractiveFeatures = showBookmarks || showRatings;
if (!hasInteractiveFeatures) {
setTimeout(() => {
onClose();
}, 2000);
}
} catch (error) {
console.error('Failed to launch player:', error);
setLaunchStatus('error');
setLaunchedPlayerId(null);
setTimeout(() => {
setLaunchStatus('idle');
}, 3000);
}
}, [streamUrl, onPlayerSelect, preferences, showBookmarks, showRatings, onClose]);
// Load preferences and check for auto-launch on mount
useEffect(() => {
const prefs = loadPlayerPreferences();
setPreferences(prefs);
const autoLaunchCheck = shouldAutoLaunch();
// Only auto-launch once per dialog session
if (!hasAutoLaunched && autoLaunchCheck.autoLaunch && autoLaunchCheck.playerId) {
if (autoLaunchCheck.needsConfirmation) {
setShowingAutoLaunchConfirm(true);
} else {
// Auto-launch immediately, but still show player detection
setHasAutoLaunched(true); // Mark as launched to prevent repeats
detectAvailablePlayers().then(() => {
handlePlayerLaunch(autoLaunchCheck.playerId!, true);
});
}
} else {
// Detect available players normally
detectAvailablePlayers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only once on mount
// Handle auto-launch confirmation
const handleConfirmAutoLaunch = useCallback(() => {
const autoLaunchCheck = shouldAutoLaunch();
if (autoLaunchCheck.playerId) {
setShowingAutoLaunchConfirm(false);
setHasAutoLaunched(true); // Mark as launched
handlePlayerLaunch(autoLaunchCheck.playerId, true);
}
}, [handlePlayerLaunch]);
const handleCancelAutoLaunch = useCallback(() => {
setShowingAutoLaunchConfirm(false);
detectAvailablePlayers();
}, [detectAvailablePlayers]);
const handleCopyUrl = async () => { const handleCopyUrl = async () => {
try { try {
@ -388,7 +246,55 @@ export default function LocalPlayerLauncher({
} }
}; };
const handlePlayerLaunch = async (playerId: string, isAutoLaunch: boolean = false) => {
const player = PLAYER_INFO[playerId];
if (!player) return;
setLaunchStatus('launching');
try {
// Try protocol handler first (requires user gesture)
if (player.protocolUrl) {
const protocolUrl = player.protocolUrl + encodeURIComponent(streamUrl);
window.location.href = protocolUrl;
setLaunchStatus('success');
} else {
// Fallback to command line approach (would need server-side support)
console.log(`Would launch ${player.name} with: ${streamUrl}`);
setLaunchStatus('success');
}
onPlayerSelect?.(playerId);
// If this was manual selection and user hasn't set preferences, offer to remember
if (!isAutoLaunch && preferences && !preferences.preferredPlayer && preferences.rememberChoice) {
const shouldRemember = confirm(`Would you like to remember ${player.name} as your preferred video player? You can change this later in Settings.`);
if (shouldRemember) {
const updatedPrefs = {
...preferences,
preferredPlayer: playerId,
autoLaunch: confirm('Would you like to automatically launch videos with this player in the future?')
};
savePlayerPreferences(updatedPrefs);
setPreferences(updatedPrefs);
}
}
// Auto-close after successful launch
setTimeout(() => {
onClose();
}, 2000);
} catch (error) {
console.error('Failed to launch player:', error);
setLaunchStatus('error');
// Reset status after showing error
setTimeout(() => {
setLaunchStatus('idle');
}, 3000);
}
};
const handleManualOpen = () => { const handleManualOpen = () => {
// Open the stream URL in a new tab for manual copy/paste // Open the stream URL in a new tab for manual copy/paste
@ -400,8 +306,8 @@ export default function LocalPlayerLauncher({
if (!player) return null; if (!player) return null;
const isAvailable = detectedPlayers.includes(playerId); const isAvailable = detectedPlayers.includes(playerId);
const isLaunching = launchStatus === 'launching' && launchedPlayerId === playerId; const isLaunching = launchStatus === 'launching';
const isSuccess = launchStatus === 'success' && launchedPlayerId === playerId; const isSuccess = launchStatus === 'success';
return ( return (
<Button <Button
@ -519,10 +425,10 @@ export default function LocalPlayerLauncher({
return ( return (
<div className={cn("fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4", className)}> <div className={cn("fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4", className)}>
<Card className="w-full max-w-2xl"> <Card className="w-full max-w-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4"> <div className="flex items-center justify-between">
<div className="flex-1"> <div>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Monitor className="h-5 w-5" /> <Monitor className="h-5 w-5" />
Local Video Player Required Local Video Player Required
@ -535,7 +441,7 @@ export default function LocalPlayerLauncher({
onClick={onClose} onClick={onClose}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 flex-shrink-0 -mt-1" className="h-8 w-8"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@ -543,64 +449,12 @@ export default function LocalPlayerLauncher({
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Video Info with Bookmark & Rating */} {/* Video Info */}
<div className="bg-gradient-to-br from-muted/50 to-muted rounded-lg p-4 border border-border"> <div className="bg-muted rounded-lg p-3">
<div className="flex items-start justify-between gap-4"> <div className="font-medium text-sm">{video.title || 'Untitled Video'}</div>
<div className="flex-1 min-w-0"> <div className="text-xs text-muted-foreground">
<div className="flex items-center gap-2 mb-2"> Format: {format.streamInfo?.contentType || 'Unknown'}
<h3 className="font-semibold text-base line-clamp-1">{video.title || 'Untitled Video'}</h3> Size: {formatFileSize ? formatFileSize(video.size) : `${(video.size / 1024 / 1024).toFixed(1)} MB`}
</div>
<div className="text-xs text-muted-foreground space-y-1">
<div className="flex items-center gap-2">
<span>Format: {format.streamInfo?.contentType || 'Unknown'}</span>
<span></span>
<span>Size: {formatFileSize ? formatFileSize(video.size) : `${(video.size / 1024 / 1024).toFixed(1)} MB`}</span>
</div>
<div className="text-xs text-muted-foreground/70 line-clamp-1" title={video.path}>
{video.path}
</div>
</div>
</div>
{/* Bookmark & Rating Controls */}
<div className="flex flex-col gap-2 items-end flex-shrink-0">
{showBookmarks && (
<Button
onClick={handleBookmarkToggle}
disabled={bookmarkLoading}
variant={isBookmarked ? "default" : "outline"}
size="sm"
className={cn(
"h-9 px-3",
isBookmarked && "bg-yellow-500 hover:bg-yellow-600 border-yellow-500"
)}
title={isBookmarked ? "Remove bookmark" : "Add bookmark"}
>
<Bookmark className={cn("h-4 w-4", isBookmarked && "fill-current")} />
<span className="ml-2 text-sm">
{bookmarkLoading ? '...' : isBookmarked ? 'Bookmarked' : 'Bookmark'}
</span>
</Button>
)}
{showRatings && (
<div className="flex items-center gap-1.5">
<StarRating
rating={currentRating}
count={0}
size="sm"
showCount={false}
interactive={true}
onRate={handleRatingChange}
/>
{currentRating > 0 && (
<span className="text-xs text-muted-foreground ml-1">
{currentRating}/5
</span>
)}
</div>
)}
</div>
</div> </div>
</div> </div>
@ -610,13 +464,6 @@ export default function LocalPlayerLauncher({
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" /> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
Detecting available players... Detecting available players...
</div> </div>
) : launchStatus === 'success' ? (
<Alert className="bg-green-500/10 border-green-500/20">
<Check className="h-4 w-4 text-green-400" />
<AlertDescription className="text-green-400">
Player launched successfully! You can now bookmark or rate this video before closing.
</AlertDescription>
</Alert>
) : ( ) : (
<Alert> <Alert>
<Monitor className="h-4 w-4" /> <Monitor className="h-4 w-4" />
@ -688,6 +535,40 @@ export default function LocalPlayerLauncher({
Settings Settings
</Button> </Button>
</div> </div>
{/* Help Section */}
<div className="bg-muted rounded-lg p-3 text-xs">
<div className="flex items-center gap-2 font-medium mb-1">
<HelpCircle className="h-3 w-3" />
Better Streaming Solution
</div>
<div className="space-y-2 text-muted-foreground">
<div className="bg-blue-500/10 border border-blue-500/20 rounded p-2">
<div className="font-medium text-blue-400 mb-1">💡 Recommended Solution</div>
<div className="text-xs">
Use our <strong>External Streaming API</strong> for better compatibility:
<br />
<code className="bg-black/20 px-1 rounded text-xs mt-1 inline-block">
{getPlayerSpecificUrl(video.id, 'vlc')}
</code>
</div>
</div>
<ul className="space-y-1">
<li> Proper HTTP range support for seeking</li>
<li> Optimized chunked streaming</li>
<li> Works with VLC, MPV, PotPlayer, etc.</li>
<li> No transcoding needed</li>
</ul>
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded">
<div className="font-medium text-green-400">Quick Setup:</div>
<div className="text-xs mt-1">
1. Copy URL above<br />
2. Open VLC Media Open Network Stream<br />
3. Paste URL and click Play
</div>
</div>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -175,11 +175,6 @@ export default function UnifiedVideoPlayer({
console.log(`Selected player: ${playerId}`); console.log(`Selected player: ${playerId}`);
}} }}
formatFileSize={formatFileSize} formatFileSize={formatFileSize}
onBookmark={handleBookmarkToggle}
onUnbookmark={handleUnbookmark}
onRate={handleRatingUpdate}
showBookmarks={showBookmarks}
showRatings={showRatings}
/> />
); );
} }

View File

@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { FixedSizeGrid } from 'react-window'; import { FixedSizeGrid } from 'react-window';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { StarRating } from '@/components/star-rating'; import { StarRating } from '@/components/star-rating';
import { Film, Image as ImageIcon, HardDrive, Search, Folder, Play, ChevronLeft, Home, FileText, Bookmark, BookmarkCheck } from 'lucide-react'; import { Film, Image as ImageIcon, HardDrive, Search, Folder, Play, ChevronLeft, Home, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@ -37,8 +37,6 @@ interface VirtualizedFolderGridProps {
breadcrumbs: BreadcrumbItem[]; breadcrumbs: BreadcrumbItem[];
libraries: {id: number, path: string}[]; libraries: {id: number, path: string}[];
onItemsLoaded?: (items: FileSystemItem[]) => void; onItemsLoaded?: (items: FileSystemItem[]) => void;
isCurrentFolderBookmarked?: boolean;
onCurrentFolderBookmark?: () => void;
} }
const ITEM_HEIGHT = 280; // Increased for folder cards const ITEM_HEIGHT = 280; // Increased for folder cards
@ -52,15 +50,12 @@ export default function VirtualizedFolderGrid({
onBreadcrumbClick, onBreadcrumbClick,
breadcrumbs, breadcrumbs,
libraries, libraries,
onItemsLoaded, onItemsLoaded
isCurrentFolderBookmarked,
onCurrentFolderBookmark
}: VirtualizedFolderGridProps) { }: VirtualizedFolderGridProps) {
const [items, setItems] = useState<FileSystemItem[]>([]); const [items, setItems] = useState<FileSystemItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
const [folderBookmarkStatus, setFolderBookmarkStatus] = useState<Record<string, boolean>>({});
const router = useRouter(); const router = useRouter();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -155,11 +150,6 @@ export default function VirtualizedFolderGrid({
setError('Invalid response from server'); setError('Invalid response from server');
} else { } else {
setItems(data); setItems(data);
// Check bookmark status for folders
const folders = data.filter((item: FileSystemItem) => item.isDirectory);
folders.forEach((folder: FileSystemItem) => {
checkFolderBookmarkStatus(folder.path);
});
} }
} catch (error) { } catch (error) {
console.error('Error fetching items:', error); console.error('Error fetching items:', error);
@ -170,64 +160,6 @@ export default function VirtualizedFolderGrid({
} }
}, []); }, []);
// Folder bookmark functions
const checkFolderBookmarkStatus = async (folderPath: string) => {
try {
const encodedPath = encodeURIComponent(folderPath);
const response = await fetch(`/api/folder-bookmarks/${encodedPath}`);
if (response.ok) {
const data = await response.json();
setFolderBookmarkStatus(prev => ({
...prev,
[folderPath]: data.isBookmarked
}));
}
} catch (error) {
console.error('Error checking folder bookmark status:', error);
}
};
const handleFolderBookmark = async (folderPath: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
const encodedPath = encodeURIComponent(folderPath);
const isCurrentlyBookmarked = folderBookmarkStatus[folderPath];
if (isCurrentlyBookmarked) {
// Remove bookmark
const response = await fetch(`/api/folder-bookmarks/${encodedPath}`, {
method: 'DELETE',
});
if (response.ok) {
setFolderBookmarkStatus(prev => ({
...prev,
[folderPath]: false
}));
}
} else {
// Add bookmark
const response = await fetch(`/api/folder-bookmarks/${encodedPath}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
setFolderBookmarkStatus(prev => ({
...prev,
[folderPath]: true
}));
}
}
} catch (error) {
console.error('Error toggling folder bookmark:', error);
}
};
const getFileIcon = (item: FileSystemItem) => { const getFileIcon = (item: FileSystemItem) => {
if (item.isDirectory) return <Folder className="text-blue-500" size={48} />; if (item.isDirectory) return <Folder className="text-blue-500" size={48} />;
if (item.type === 'photo') return <ImageIcon className="text-green-500" size={48} />; if (item.type === 'photo') return <ImageIcon className="text-green-500" size={48} />;
@ -316,22 +248,6 @@ export default function VirtualizedFolderGrid({
style={{ transform: 'perspective(100px) rotateY(-5deg) rotateX(5deg)' }}> style={{ transform: 'perspective(100px) rotateY(-5deg) rotateX(5deg)' }}>
<Folder className="h-8 w-8 text-white" /> <Folder className="h-8 w-8 text-white" />
</div> </div>
{/* Folder bookmark button */}
<button
onClick={(e) => handleFolderBookmark(item.path, e)}
className={`absolute top-2 right-2 p-1.5 rounded-full transition-all duration-200 ${
folderBookmarkStatus[item.path]
? 'bg-yellow-500 text-white shadow-lg'
: 'bg-white/80 dark:bg-black/60 text-gray-600 dark:text-gray-300 hover:bg-yellow-500 hover:text-white'
}`}
title={folderBookmarkStatus[item.path] ? 'Remove bookmark' : 'Add bookmark'}
>
{folderBookmarkStatus[item.path] ? (
<BookmarkCheck className="h-4 w-4" />
) : (
<Bookmark className="h-4 w-4" />
)}
</button>
</div> </div>
) : isMediaFile(item) ? ( ) : isMediaFile(item) ? (
<div className="relative overflow-hidden aspect-[4/3] bg-black rounded-t-xl"> <div className="relative overflow-hidden aspect-[4/3] bg-black rounded-t-xl">
@ -482,27 +398,6 @@ export default function VirtualizedFolderGrid({
<ChevronLeft className="h-4 w-4 mr-2" /> <ChevronLeft className="h-4 w-4 mr-2" />
Back Back
</Button> </Button>
{/* Current folder bookmark button */}
{onCurrentFolderBookmark && (
<Button
variant="ghost"
size="sm"
onClick={onCurrentFolderBookmark}
className={`transition-colors ${
isCurrentFolderBookmarked
? 'text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
}`}
title={isCurrentFolderBookmarked ? 'Remove folder bookmark' : 'Add folder bookmark'}
>
{isCurrentFolderBookmarked ? (
<BookmarkCheck className="h-4 w-4" />
) : (
<Bookmark className="h-4 w-4" />
)}
</Button>
)}
</div> </div>
{/* Breadcrumb Navigation */} {/* Breadcrumb Navigation */}

View File

@ -66,20 +66,9 @@ function initializeDatabase() {
); );
`); `);
// Create folder bookmarks table
db.exec(`
CREATE TABLE IF NOT EXISTS folder_bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_path TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// Create indexes for performance // Create indexes for performance
db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_folder_bookmarks_path ON folder_bookmarks(folder_path);`);
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);`);
@ -99,49 +88,5 @@ export function getDatabase(): DatabaseType {
return initializeDatabase(); return initializeDatabase();
} }
// Helper functions for folder bookmarks
export function addFolderBookmark(folderPath: string): number {
const db = getDatabase();
const result = db.prepare(`
INSERT OR REPLACE INTO folder_bookmarks (folder_path, updated_at)
VALUES (?, CURRENT_TIMESTAMP)
`).run(folderPath);
return result.lastInsertRowid as number;
}
export function removeFolderBookmark(folderPath: string): boolean {
const db = getDatabase();
const result = db.prepare(`
DELETE FROM folder_bookmarks WHERE folder_path = ?
`).run(folderPath);
return result.changes > 0;
}
export function isFolderBookmarked(folderPath: string): boolean {
const db = getDatabase();
const result = db.prepare(`
SELECT id FROM folder_bookmarks WHERE folder_path = ?
`).get(folderPath) as { id: number } | undefined;
return !!result;
}
export function getFolderBookmarks(limit: number = 50, offset: number = 0) {
const db = getDatabase();
const bookmarks = db.prepare(`
SELECT * FROM folder_bookmarks
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
`).all(limit, offset);
const totalResult = db.prepare(`
SELECT COUNT(*) as total FROM folder_bookmarks
`).get() as { total: number };
return {
bookmarks,
total: totalResult.total
};
}
// For backward compatibility, export the database instance getter // For backward compatibility, export the database instance getter
export default getDatabase; export default getDatabase;