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:
tigeren 2025-10-10 15:31:45 +00:00
parent 76154123b8
commit ac06835850
15 changed files with 1299 additions and 124 deletions

Binary file not shown.

View File

@ -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.

162
docs/MIGRATION_README.md Normal file
View File

@ -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. 📁✨

View File

@ -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!"

View File

@ -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) {

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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)}
/>
)}
</>

View File

@ -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 */}

View File

@ -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);
};

View File

@ -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) {

View File

@ -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) {

View File

@ -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 };

View File

@ -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 */}

View File

@ -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;