feat(folder-bookmarks): add folder bookmarks feature with migration support
- Introduce new folder_bookmarks table with unique folder_path and timestamp fields - Implement API endpoints for folder bookmarks CRUD operations - Enhance bookmarks API to combine and paginate media and folder bookmarks - Update bookmarks page UI to handle and display folder bookmarks - Add folder viewer support for bookmarking current folder with toggle functionality - Create automated migration script with backup, schema verification, and rollback instructions - Provide detailed migration guides for manual and Docker deployments - Add comprehensive testing and verification steps for migration and new feature integration
This commit is contained in:
parent
76154123b8
commit
ac06835850
BIN
data/media.db
BIN
data/media.db
Binary file not shown.
|
|
@ -0,0 +1,271 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
# 📁 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. 📁✨
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
#!/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!"
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '@/db';
|
||||
import { getDatabase, getFolderBookmarks } from '@/db';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
|
@ -27,34 +27,80 @@ export async function GET(request: Request) {
|
|||
|
||||
try {
|
||||
const db = getDatabase();
|
||||
// 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(`
|
||||
SELECT m.*, l.path as library_path
|
||||
|
||||
// Get ALL bookmarks first (both media and folder), then sort and paginate
|
||||
// This ensures proper interleaving of both types
|
||||
|
||||
// Get all media bookmarks (without offset/limit for now)
|
||||
let mediaQuery = `
|
||||
SELECT
|
||||
m.*,
|
||||
l.path as library_path,
|
||||
b.created_at as bookmark_created_at,
|
||||
b.updated_at as bookmark_updated_at,
|
||||
'media' as bookmark_type
|
||||
FROM bookmarks b
|
||||
JOIN media m ON b.media_id = m.id
|
||||
JOIN libraries l ON m.library_id = l.id
|
||||
${whereClause}
|
||||
ORDER BY b.${sortColumn} ${sortDirection}
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset);
|
||||
ORDER BY b.updated_at DESC
|
||||
`;
|
||||
|
||||
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({
|
||||
bookmarks,
|
||||
bookmarks: paginatedBookmarks,
|
||||
folderBookmarks: folderBookmarksWithType,
|
||||
allBookmarks: sortedBookmarks,
|
||||
pagination: {
|
||||
total,
|
||||
total: combinedTotal,
|
||||
mediaTotal,
|
||||
folderTotal,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < total
|
||||
hasMore: offset + limit < combinedTotal
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -5,26 +5,35 @@ import InfiniteVirtualGrid from '@/components/infinite-virtual-grid';
|
|||
import UnifiedVideoPlayer from '@/components/unified-video-player';
|
||||
import PhotoViewer from '@/components/photo-viewer';
|
||||
import { ArtPlayerTestBanner } from '@/components/video-player-debug';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Bookmark, Folder } from 'lucide-react';
|
||||
|
||||
interface MediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
id?: number;
|
||||
title?: string;
|
||||
name?: string;
|
||||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
type: string;
|
||||
bookmark_count: number;
|
||||
star_count: number;
|
||||
avg_rating: number;
|
||||
size?: number;
|
||||
thumbnail?: string;
|
||||
type?: string;
|
||||
bookmark_count?: number;
|
||||
star_count?: number;
|
||||
avg_rating?: number;
|
||||
bookmark_type?: 'media' | 'folder';
|
||||
folder_path?: string;
|
||||
}
|
||||
|
||||
export default function BookmarksPage() {
|
||||
const router = useRouter();
|
||||
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const [isVideoPlayerOpen, setIsVideoPlayerOpen] = useState(false);
|
||||
|
||||
const handleItemClick = (item: MediaItem) => {
|
||||
if (item.type === 'video') {
|
||||
if (item.bookmark_type === 'folder') {
|
||||
// Navigate to folder
|
||||
router.push(`/folder-viewer?path=${encodeURIComponent(item.folder_path || item.path)}`);
|
||||
} else if (item.type === 'video') {
|
||||
setSelectedItem(item);
|
||||
setIsVideoPlayerOpen(true);
|
||||
} else {
|
||||
|
|
@ -43,17 +52,36 @@ export default function BookmarksPage() {
|
|||
setSelectedItem(null);
|
||||
};
|
||||
|
||||
const handleBookmark = async (id: number) => {
|
||||
const handleBookmark = async (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${id}`, { method: 'POST' });
|
||||
if (bookmarkType === 'folder' && folderPath) {
|
||||
// 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) {
|
||||
console.error('Error bookmarking item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbookmark = async (id: number) => {
|
||||
const handleUnbookmark = async (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${id}`, { method: 'DELETE' });
|
||||
if (bookmarkType === 'folder' && folderPath) {
|
||||
// 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) {
|
||||
console.error('Error unbookmarking item:', error);
|
||||
}
|
||||
|
|
@ -86,27 +114,41 @@ export default function BookmarksPage() {
|
|||
{/* Test banner to show ArtPlayer is active */}
|
||||
{process.env.NODE_ENV === 'development' && <ArtPlayerTestBanner />}
|
||||
|
||||
<InfiniteVirtualGrid
|
||||
type="bookmark"
|
||||
onItemClick={handleItemClick}
|
||||
onBookmark={handleBookmark}
|
||||
onUnbookmark={handleUnbookmark}
|
||||
onRate={handleRate}
|
||||
/>
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<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">
|
||||
<Bookmark className="h-5 w-5 text-white" />
|
||||
</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 */}
|
||||
{selectedItem && selectedItem.type === 'video' && (
|
||||
{selectedItem && selectedItem.type === 'video' && selectedItem.id && (
|
||||
<UnifiedVideoPlayer
|
||||
video={{
|
||||
id: selectedItem.id,
|
||||
title: selectedItem.title,
|
||||
title: selectedItem.title || '',
|
||||
path: selectedItem.path,
|
||||
size: selectedItem.size,
|
||||
thumbnail: selectedItem.thumbnail,
|
||||
size: selectedItem.size || 0,
|
||||
thumbnail: selectedItem.thumbnail || '',
|
||||
type: selectedItem.type,
|
||||
bookmark_count: selectedItem.bookmark_count,
|
||||
star_count: selectedItem.star_count,
|
||||
avg_rating: selectedItem.avg_rating
|
||||
bookmark_count: selectedItem.bookmark_count || 0,
|
||||
star_count: selectedItem.star_count || 0,
|
||||
avg_rating: selectedItem.avg_rating || 0
|
||||
}}
|
||||
isOpen={isVideoPlayerOpen}
|
||||
onClose={handleCloseVideoPlayer}
|
||||
|
|
@ -120,17 +162,27 @@ export default function BookmarksPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Photo Viewer */}
|
||||
{selectedItem && selectedItem.type === 'photo' && (
|
||||
{/* Photo Viewer - only for actual photo items with IDs */}
|
||||
{selectedItem && selectedItem.type === 'photo' && selectedItem.id && (
|
||||
<PhotoViewer
|
||||
photo={selectedItem}
|
||||
photo={{
|
||||
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}
|
||||
onClose={handleClosePhotoViewer}
|
||||
showBookmarks={true}
|
||||
showRatings={true}
|
||||
onBookmark={handleBookmark}
|
||||
onUnbookmark={handleUnbookmark}
|
||||
onRate={handleRate}
|
||||
onBookmark={() => handleBookmark(selectedItem.id!, 'media')}
|
||||
onUnbookmark={() => handleUnbookmark(selectedItem.id!, 'media')}
|
||||
onRate={(rating) => handleRate(selectedItem.id!, rating)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -43,11 +43,18 @@ const FolderViewerPage = () => {
|
|||
const [selectedText, setSelectedText] = useState<FileSystemItem | null>(null);
|
||||
const [isTextViewerOpen, setIsTextViewerOpen] = useState(false);
|
||||
const [libraries, setLibraries] = useState<{id: number, path: string}[]>([]);
|
||||
const [isCurrentFolderBookmarked, setIsCurrentFolderBookmarked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (path) {
|
||||
checkCurrentFolderBookmarkStatus();
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
const fetchLibraries = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/libraries');
|
||||
|
|
@ -58,6 +65,54 @@ 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) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
|
|
@ -464,6 +519,8 @@ const FolderViewerPage = () => {
|
|||
breadcrumbs={getBreadcrumbs(path)}
|
||||
libraries={libraries}
|
||||
onItemsLoaded={setCurrentItems}
|
||||
isCurrentFolderBookmarked={isCurrentFolderBookmarked}
|
||||
onCurrentFolderBookmark={handleCurrentFolderBookmark}
|
||||
/>
|
||||
|
||||
{/* Photo Viewer */}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import InfiniteVirtualGrid from '@/components/infinite-virtual-grid';
|
||||
import InfiniteVirtualGrid, { MediaItem } from '@/components/infinite-virtual-grid';
|
||||
import PhotoViewer from '@/components/photo-viewer';
|
||||
|
||||
interface Photo {
|
||||
|
|
@ -22,15 +22,19 @@ export default function PhotosPage() {
|
|||
const [photosList, setPhotosList] = useState<Photo[]>([]);
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
|
||||
const handlePhotoClick = (photo: Photo, index?: number) => {
|
||||
setSelectedPhoto(photo);
|
||||
const handlePhotoClick = (photo: MediaItem, index?: number) => {
|
||||
// Cast to Photo since photos always have IDs
|
||||
const photoWithId = photo as Photo;
|
||||
setSelectedPhoto(photoWithId);
|
||||
if (index !== undefined) {
|
||||
setCurrentPhotoIndex(index);
|
||||
}
|
||||
setIsViewerOpen(true);
|
||||
};
|
||||
|
||||
const handlePhotosData = (photos: Photo[]) => {
|
||||
const handlePhotosData = (items: MediaItem[]) => {
|
||||
// Cast to Photo[] since this is the photos page and all items should be photos with IDs
|
||||
const photos = items as Photo[];
|
||||
setPhotosList(photos);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import InfiniteVirtualGrid from "@/components/infinite-virtual-grid";
|
||||
import InfiniteVirtualGrid, { MediaItem } from "@/components/infinite-virtual-grid";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
interface TextFile {
|
||||
|
|
@ -21,12 +21,14 @@ const TextsPage = () => {
|
|||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const [textContent, setTextContent] = useState<string>("");
|
||||
|
||||
const handleTextClick = async (text: TextFile) => {
|
||||
const handleTextClick = async (text: MediaItem) => {
|
||||
// Cast to TextFile since texts always have IDs
|
||||
const textFile = text as TextFile;
|
||||
try {
|
||||
const response = await fetch(`/api/texts/${text.id}`);
|
||||
const response = await fetch(`/api/texts/${textFile.id}`);
|
||||
const data = await response.json();
|
||||
setTextContent(data.content);
|
||||
setSelectedText(text);
|
||||
setSelectedText(textFile);
|
||||
setIsViewerOpen(true);
|
||||
} catch (error) {
|
||||
console.error('Error loading text file:', error);
|
||||
|
|
@ -39,7 +41,7 @@ const TextsPage = () => {
|
|||
setTextContent("");
|
||||
};
|
||||
|
||||
const handleBookmark = async (textId: number) => {
|
||||
const handleBookmark = async (textId: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${textId}`, { method: 'POST' });
|
||||
} catch (error) {
|
||||
|
|
@ -47,7 +49,7 @@ const TextsPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUnbookmark = async (textId: number) => {
|
||||
const handleUnbookmark = async (textId: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${textId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import InfiniteVirtualGrid from "@/components/infinite-virtual-grid";
|
||||
import InfiniteVirtualGrid, { MediaItem } from "@/components/infinite-virtual-grid";
|
||||
import UnifiedVideoPlayer from '@/components/unified-video-player';
|
||||
import { ArtPlayerTestBanner } from '@/components/video-player-debug';
|
||||
|
||||
|
|
@ -21,11 +21,13 @@ const VideosPage = () => {
|
|||
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||
|
||||
const handleVideoClick = (video: Video) => {
|
||||
console.log('[VideosPage] handleVideoClick called with video:', video);
|
||||
setSelectedVideo(video);
|
||||
const handleVideoClick = (video: MediaItem) => {
|
||||
// Cast to Video since videos always have IDs
|
||||
const videoWithId = video as Video;
|
||||
console.log('[VideosPage] handleVideoClick called with video:', videoWithId);
|
||||
setSelectedVideo(videoWithId);
|
||||
setIsPlayerOpen(true);
|
||||
console.log('[VideosPage] State updated - selectedVideo:', video, 'isPlayerOpen:', true);
|
||||
console.log('[VideosPage] State updated - selectedVideo:', videoWithId, 'isPlayerOpen:', true);
|
||||
};
|
||||
|
||||
const handleClosePlayer = () => {
|
||||
|
|
@ -33,7 +35,7 @@ const VideosPage = () => {
|
|||
setSelectedVideo(null);
|
||||
};
|
||||
|
||||
const handleBookmark = async (videoId: number) => {
|
||||
const handleBookmark = async (videoId: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${videoId}`, { method: 'POST' });
|
||||
} catch (error) {
|
||||
|
|
@ -41,7 +43,7 @@ const VideosPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUnbookmark = async (videoId: number) => {
|
||||
const handleUnbookmark = async (videoId: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${videoId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -4,26 +4,29 @@ 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, FileText } from 'lucide-react';
|
||||
import { Film, Image as ImageIcon, HardDrive, Search, Bookmark, FileText, Folder } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface MediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
id?: number;
|
||||
title?: string;
|
||||
name?: string;
|
||||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
type: string;
|
||||
bookmark_count: number;
|
||||
avg_rating: number;
|
||||
star_count: number;
|
||||
size?: number;
|
||||
thumbnail?: string;
|
||||
type?: string;
|
||||
bookmark_count?: number;
|
||||
avg_rating?: number;
|
||||
star_count?: number;
|
||||
bookmark_type?: 'media' | 'folder';
|
||||
folder_path?: string;
|
||||
}
|
||||
|
||||
interface InfiniteVirtualGridProps {
|
||||
type: 'video' | 'photo' | 'text' | 'bookmark';
|
||||
onItemClick: (item: MediaItem, index?: number) => void;
|
||||
onBookmark: (id: number) => Promise<void>;
|
||||
onUnbookmark: (id: number) => Promise<void>;
|
||||
onBookmark: (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => Promise<void>;
|
||||
onUnbookmark: (id: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => Promise<void>;
|
||||
onRate: (id: number, rating: number) => Promise<void>;
|
||||
onDataUpdate?: (items: MediaItem[]) => void;
|
||||
}
|
||||
|
|
@ -132,8 +135,14 @@ export default function InfiniteVirtualGrid({
|
|||
const response = await fetch(`/api/${endpoint}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
|
||||
const items = data[itemsKey] || [];
|
||||
let items: MediaItem[] = [];
|
||||
if (type === 'bookmark') {
|
||||
// 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);
|
||||
|
||||
|
|
@ -374,51 +383,67 @@ export default function InfiniteVirtualGrid({
|
|||
onClick={() => onItemClick(item, index)}
|
||||
>
|
||||
<div className="relative overflow-hidden bg-muted aspect-video">
|
||||
<img
|
||||
src={item.thumbnail || (type === 'video' ? "/placeholder-video.svg" : type === 'text' ? "/placeholder.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" : 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" />
|
||||
}
|
||||
{item.bookmark_type === 'folder' ? (
|
||||
// Folder bookmark styling
|
||||
<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">
|
||||
<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"
|
||||
style={{ transform: 'perspective(100px) rotateY(-5deg) rotateX(5deg)' }}>
|
||||
<Folder className="h-8 w-8 text-white" />
|
||||
</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="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
|
||||
{type === 'video' ?
|
||||
<Film className="h-3 w-3 text-white" /> :
|
||||
type === 'text' ?
|
||||
<FileText className="h-3 w-3 text-white" /> :
|
||||
<ImageIcon className="h-3 w-3 text-white" />
|
||||
}
|
||||
</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" /> :
|
||||
type === 'text' ?
|
||||
<FileText className="h-3 w-3 text-white" /> :
|
||||
<ImageIcon className="h-3 w-3 text-white" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardContent className="p-2.5">
|
||||
<div className="flex items-start justify-between min-h-[2rem]">
|
||||
<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">
|
||||
{item.title || item.path.split('/').pop()}
|
||||
{item.bookmark_type === 'folder'
|
||||
? (item.folder_path || item.path).split('/').pop() || 'Folder'
|
||||
: item.title || item.name || item.path.split('/').pop()
|
||||
}
|
||||
</h3>
|
||||
|
||||
{(item.avg_rating > 0 || item.star_count > 0) && (
|
||||
{((item.avg_rating || 0) > 0 || (item.star_count || 0) > 0) && (
|
||||
<div className="mt-0.5">
|
||||
<StarRating
|
||||
rating={item.avg_rating || 0}
|
||||
count={item.star_count}
|
||||
count={item.star_count || 0}
|
||||
size="xs"
|
||||
showCount={false}
|
||||
/>
|
||||
|
|
@ -427,10 +452,28 @@ export default function InfiniteVirtualGrid({
|
|||
</div>
|
||||
|
||||
<div className="flex gap-1 ml-1 flex-shrink-0">
|
||||
{(type === 'video' || type === 'text') && item.bookmark_count > 0 && (
|
||||
<div className="text-xs text-yellow-500">
|
||||
<Bookmark className="h-2.5 w-2.5 fill-yellow-500" />
|
||||
</div>
|
||||
{type === 'bookmark' ? (
|
||||
// Bookmark controls for bookmark page
|
||||
<button
|
||||
onClick={(e) => {
|
||||
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>
|
||||
|
|
@ -439,9 +482,9 @@ export default function InfiniteVirtualGrid({
|
|||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-2.5 w-2.5" />
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
<span>{item.bookmark_type === 'folder' ? 'Folder' : formatFileSize(item.size || 0)}</span>
|
||||
</div>
|
||||
{(type === 'video' || type === 'text') && item.bookmark_count > 0 && (
|
||||
{(type === 'video' || type === 'text') && (item.bookmark_count || 0) > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.bookmark_count}
|
||||
</span>
|
||||
|
|
@ -617,4 +660,6 @@ export default function InfiniteVirtualGrid({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type { MediaItem };
|
||||
|
|
@ -4,7 +4,7 @@ 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, Folder, Play, ChevronLeft, Home, FileText } from 'lucide-react';
|
||||
import { Film, Image as ImageIcon, HardDrive, Search, Folder, Play, ChevronLeft, Home, FileText, Bookmark, BookmarkCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
|
@ -37,6 +37,8 @@ interface VirtualizedFolderGridProps {
|
|||
breadcrumbs: BreadcrumbItem[];
|
||||
libraries: {id: number, path: string}[];
|
||||
onItemsLoaded?: (items: FileSystemItem[]) => void;
|
||||
isCurrentFolderBookmarked?: boolean;
|
||||
onCurrentFolderBookmark?: () => void;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 280; // Increased for folder cards
|
||||
|
|
@ -50,12 +52,15 @@ export default function VirtualizedFolderGrid({
|
|||
onBreadcrumbClick,
|
||||
breadcrumbs,
|
||||
libraries,
|
||||
onItemsLoaded
|
||||
onItemsLoaded,
|
||||
isCurrentFolderBookmarked,
|
||||
onCurrentFolderBookmark
|
||||
}: VirtualizedFolderGridProps) {
|
||||
const [items, setItems] = useState<FileSystemItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [folderBookmarkStatus, setFolderBookmarkStatus] = useState<Record<string, boolean>>({});
|
||||
const router = useRouter();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -150,6 +155,11 @@ export default function VirtualizedFolderGrid({
|
|||
setError('Invalid response from server');
|
||||
} else {
|
||||
setItems(data);
|
||||
// Check bookmark status for folders
|
||||
const folders = data.filter((item: FileSystemItem) => item.isDirectory);
|
||||
folders.forEach((folder: FileSystemItem) => {
|
||||
checkFolderBookmarkStatus(folder.path);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching items:', error);
|
||||
|
|
@ -160,6 +170,64 @@ 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) => {
|
||||
if (item.isDirectory) return <Folder className="text-blue-500" size={48} />;
|
||||
if (item.type === 'photo') return <ImageIcon className="text-green-500" size={48} />;
|
||||
|
|
@ -248,6 +316,22 @@ export default function VirtualizedFolderGrid({
|
|||
style={{ transform: 'perspective(100px) rotateY(-5deg) rotateX(5deg)' }}>
|
||||
<Folder className="h-8 w-8 text-white" />
|
||||
</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>
|
||||
) : isMediaFile(item) ? (
|
||||
<div className="relative overflow-hidden aspect-[4/3] bg-black rounded-t-xl">
|
||||
|
|
@ -398,6 +482,27 @@ export default function VirtualizedFolderGrid({
|
|||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</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>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
|
|
|
|||
|
|
@ -66,9 +66,20 @@ 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
|
||||
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_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_star_count ON media(star_count);`);
|
||||
|
||||
|
|
@ -88,5 +99,49 @@ export function getDatabase(): DatabaseType {
|
|||
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
|
||||
export default getDatabase;
|
||||
|
|
|
|||
Loading…
Reference in New Issue