'use client'; import React, { useState, useEffect } from 'react'; import Button from '../ui/Button'; import { databaseApi, configApi, searchAdminApi } from '../../lib/api'; interface SystemSettingsProps { // No props needed - this component manages its own state } export default function SystemSettings({}: SystemSettingsProps) { const [searchEngineStatus, setSearchEngineStatus] = useState<{ currentEngine: string; solrAvailable: boolean; loading: boolean; message: string; success?: boolean; }>({ currentEngine: 'solr', solrAvailable: false, loading: false, message: '' }); const [solrStatus, setSolrStatus] = useState<{ reindex: { loading: boolean; message: string; success?: boolean }; recreate: { loading: boolean; message: string; success?: boolean }; migrate: { loading: boolean; message: string; success?: boolean }; }>({ reindex: { loading: false, message: '' }, recreate: { loading: false, message: '' }, migrate: { loading: false, message: '' } }); const [databaseStatus, setDatabaseStatus] = useState<{ completeBackup: { loading: boolean; message: string; success?: boolean; jobId?: string; progress?: number; downloadReady?: boolean; }; completeRestore: { loading: boolean; message: string; success?: boolean }; completeClear: { loading: boolean; message: string; success?: boolean }; }>({ completeBackup: { loading: false, message: '', progress: 0 }, completeRestore: { loading: false, message: '' }, completeClear: { loading: false, message: '' } }); const [cleanupStatus, setCleanupStatus] = useState<{ preview: { loading: boolean; message: string; success?: boolean; data?: any }; execute: { loading: boolean; message: string; success?: boolean }; }>({ preview: { loading: false, message: '' }, execute: { loading: false, message: '' } }); const [migrationStatus, setMigrationStatus] = useState<{ preview: { loading: boolean; message: string; success?: boolean; data?: any }; execute: { loading: boolean; message: string; success?: boolean }; }>({ preview: { loading: false, message: '' }, execute: { loading: false, message: '' } }); const [hoveredImage, setHoveredImage] = useState<{ src: string; alt: string } | null>(null); const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const handleImageHover = (filePath: string, fileName: string, event: React.MouseEvent) => { // Convert backend file path to frontend image URL const imageUrl = filePath.replace(/^.*\/images\//, '/images/'); setHoveredImage({ src: imageUrl, alt: fileName }); setMousePosition({ x: event.clientX, y: event.clientY }); }; const handleImageLeave = () => { setHoveredImage(null); }; const isImageFile = (fileName: string): boolean => { const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']; return imageExtensions.some(ext => fileName.toLowerCase().endsWith(ext)); }; const handleCompleteBackup = async () => { setDatabaseStatus(prev => ({ ...prev, completeBackup: { loading: true, message: 'Starting backup...', success: undefined, progress: 0, downloadReady: false } })); try { // Start the async backup job const startResponse = await databaseApi.backupComplete(); const jobId = startResponse.jobId; setDatabaseStatus(prev => ({ ...prev, completeBackup: { ...prev.completeBackup, jobId, message: 'Backup in progress...' } })); // Poll for progress const pollInterval = setInterval(async () => { try { const status = await databaseApi.getBackupStatus(jobId); if (status.status === 'COMPLETED') { clearInterval(pollInterval); setDatabaseStatus(prev => ({ ...prev, completeBackup: { loading: false, message: 'Backup completed! Ready to download.', success: true, jobId, progress: 100, downloadReady: true } })); // Clear message after 30 seconds (keep download button visible) setTimeout(() => { setDatabaseStatus(prev => ({ ...prev, completeBackup: { ...prev.completeBackup, message: '' } })); }, 30000); } else if (status.status === 'FAILED') { clearInterval(pollInterval); setDatabaseStatus(prev => ({ ...prev, completeBackup: { loading: false, message: `Backup failed: ${status.errorMessage}`, success: false, progress: 0, downloadReady: false } })); } else { // Update progress setDatabaseStatus(prev => ({ ...prev, completeBackup: { ...prev.completeBackup, progress: status.progress, message: `Creating backup... ${status.progress}%` } })); } } catch (pollError: any) { clearInterval(pollInterval); setDatabaseStatus(prev => ({ ...prev, completeBackup: { loading: false, message: `Failed to check backup status: ${pollError.message}`, success: false, progress: 0, downloadReady: false } })); } }, 2000); // Poll every 2 seconds } catch (error: any) { setDatabaseStatus(prev => ({ ...prev, completeBackup: { loading: false, message: error.message || 'Failed to start backup', success: false, progress: 0, downloadReady: false } })); } }; const handleDownloadBackup = (jobId: string) => { const downloadUrl = databaseApi.downloadBackup(jobId); const link = document.createElement('a'); link.href = downloadUrl; link.download = ''; // Filename will be set by server document.body.appendChild(link); link.click(); document.body.removeChild(link); // Clear the download ready state after download setDatabaseStatus(prev => ({ ...prev, completeBackup: { loading: false, message: 'Backup downloaded successfully', success: true, progress: 100, downloadReady: false } })); }; const handleCompleteRestore = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; // Reset the input so the same file can be selected again event.target.value = ''; if (!file.name.endsWith('.zip')) { setDatabaseStatus(prev => ({ ...prev, completeRestore: { loading: false, message: 'Please select a .zip file', success: false } })); return; } const confirmed = window.confirm( 'Are you sure you want to restore the complete backup? This will PERMANENTLY DELETE all current data AND files (cover images, avatars) and replace them with the backup data. This action cannot be undone!' ); if (!confirmed) return; setDatabaseStatus(prev => ({ ...prev, completeRestore: { loading: true, message: 'Restoring complete backup...', success: undefined } })); try { const result = await databaseApi.restoreComplete(file); setDatabaseStatus(prev => ({ ...prev, completeRestore: { loading: false, message: result.success ? result.message : result.message, success: result.success } })); } catch (error: any) { setDatabaseStatus(prev => ({ ...prev, completeRestore: { loading: false, message: error.message || 'Complete restore failed', success: false } })); } // Clear message after 10 seconds for restore (longer because it's important) setTimeout(() => { setDatabaseStatus(prev => ({ ...prev, completeRestore: { loading: false, message: '', success: undefined } })); }, 10000); }; const handleCompleteClear = async () => { const confirmed = window.confirm( 'Are you ABSOLUTELY SURE you want to clear the entire database AND all files? This will PERMANENTLY DELETE ALL stories, authors, series, tags, collections, AND all uploaded images (covers, avatars). This action cannot be undone!' ); if (!confirmed) return; const doubleConfirmed = window.confirm( 'This is your final warning! Clicking OK will DELETE EVERYTHING in your StoryCove database AND all uploaded files. Are you completely certain you want to proceed?' ); if (!doubleConfirmed) return; setDatabaseStatus(prev => ({ ...prev, completeClear: { loading: true, message: 'Clearing database and files...', success: undefined } })); try { const result = await databaseApi.clearComplete(); setDatabaseStatus(prev => ({ ...prev, completeClear: { loading: false, message: result.success ? `Database and files cleared successfully. Deleted ${result.deletedRecords} records.` : result.message, success: result.success } })); } catch (error: any) { setDatabaseStatus(prev => ({ ...prev, completeClear: { loading: false, message: error.message || 'Clear operation failed', success: false } })); } // Clear message after 10 seconds for clear (longer because it's important) setTimeout(() => { setDatabaseStatus(prev => ({ ...prev, completeClear: { loading: false, message: '', success: undefined } })); }, 10000); }; const handleImageCleanupPreview = async () => { setCleanupStatus(prev => ({ ...prev, preview: { loading: true, message: 'Scanning for orphaned images...', success: undefined } })); try { const result = await configApi.previewImageCleanup(); if (result.success) { setCleanupStatus(prev => ({ ...prev, preview: { loading: false, message: `Found ${result.orphanedCount} orphaned images (${result.formattedSize}) and ${result.foldersToDelete} empty folders. Referenced images: ${result.referencedImagesCount}`, success: true, data: result } })); } else { setCleanupStatus(prev => ({ ...prev, preview: { loading: false, message: result.error || 'Preview failed', success: false } })); } } catch (error: any) { setCleanupStatus(prev => ({ ...prev, preview: { loading: false, message: error.message || 'Network error occurred', success: false } })); } // Note: Preview message no longer auto-clears to allow users to review file details }; const handleImageCleanupExecute = async () => { if (!cleanupStatus.preview.data || cleanupStatus.preview.data.orphanedCount === 0) { setCleanupStatus(prev => ({ ...prev, execute: { loading: false, message: 'Please run preview first to see what will be deleted', success: false } })); return; } const confirmed = window.confirm( `Are you sure you want to delete ${cleanupStatus.preview.data.orphanedCount} orphaned images (${cleanupStatus.preview.data.formattedSize})? This action cannot be undone!` ); if (!confirmed) return; setCleanupStatus(prev => ({ ...prev, execute: { loading: true, message: 'Deleting orphaned images...', success: undefined } })); try { const result = await configApi.executeImageCleanup(); if (result.success) { setCleanupStatus(prev => ({ ...prev, execute: { loading: false, message: `Successfully deleted ${result.deletedCount} orphaned images (${result.formattedSize}) and ${result.foldersDeleted} empty folders`, success: true }, preview: { loading: false, message: '', success: undefined, data: undefined } // Clear preview after successful cleanup })); } else { setCleanupStatus(prev => ({ ...prev, execute: { loading: false, message: result.error || 'Cleanup failed', success: false } })); } } catch (error: any) { setCleanupStatus(prev => ({ ...prev, execute: { loading: false, message: error.message || 'Network error occurred', success: false } })); } // Clear message after 10 seconds setTimeout(() => { setCleanupStatus(prev => ({ ...prev, execute: { loading: false, message: '', success: undefined } })); }, 10000); }; const handleEpubMigrationPreview = async () => { setMigrationStatus(prev => ({ ...prev, preview: { loading: true, message: 'Scanning for misplaced EPUB images...', success: undefined } })); try { const result = await configApi.previewEpubImageMigration(); if (result.success) { const matchedCount = result.movedFiles.filter(f => f.action !== 'unmatched').length; setMigrationStatus(prev => ({ ...prev, preview: { loading: false, message: `Found ${matchedCount} image(s) to move into story subfolders, ${result.unmatchedCount} unmatched (no story reference found).`, success: true, data: result } })); } else { setMigrationStatus(prev => ({ ...prev, preview: { loading: false, message: result.error || 'Preview failed', success: false } })); } } catch (error: any) { setMigrationStatus(prev => ({ ...prev, preview: { loading: false, message: error.message || 'Network error occurred', success: false } })); } }; const handleEpubMigrationExecute = async () => { const matchedCount = migrationStatus.preview.data?.movedFiles?.filter((f: any) => f.action !== 'unmatched').length ?? 0; if (!migrationStatus.preview.data || matchedCount === 0) { setMigrationStatus(prev => ({ ...prev, execute: { loading: false, message: 'Please run preview first, or there are no images to migrate.', success: false } })); return; } const confirmed = window.confirm( `Move ${matchedCount} EPUB image(s) into their story subfolders? Story content URLs will be updated automatically.` ); if (!confirmed) return; setMigrationStatus(prev => ({ ...prev, execute: { loading: true, message: 'Migrating EPUB images...', success: undefined } })); try { const result = await configApi.executeEpubImageMigration(); if (result.success) { setMigrationStatus(prev => ({ ...prev, execute: { loading: false, message: `Successfully moved ${result.movedCount} image(s). ${result.unmatchedCount} unmatched file(s) left in place.${result.hasErrors ? ` Errors: ${result.errors.length}` : ''}`, success: true }, preview: { loading: false, message: '', success: undefined, data: undefined } })); } else { setMigrationStatus(prev => ({ ...prev, execute: { loading: false, message: result.error || 'Migration failed', success: false } })); } } catch (error: any) { setMigrationStatus(prev => ({ ...prev, execute: { loading: false, message: error.message || 'Network error occurred', success: false } })); } setTimeout(() => { setMigrationStatus(prev => ({ ...prev, execute: { loading: false, message: '', success: undefined } })); }, 10000); }; // Search Engine Management Functions const loadSearchEngineStatus = async () => { try { const status = await searchAdminApi.getStatus(); setSearchEngineStatus(prev => ({ ...prev, currentEngine: status.primaryEngine, solrAvailable: status.solrAvailable, })); } catch (error: any) { console.error('Failed to load search engine status:', error); } }; const handleSolrReindex = async () => { setSolrStatus(prev => ({ ...prev, reindex: { loading: true, message: 'Reindexing Solr...', success: undefined } })); try { const result = await searchAdminApi.reindexSolr(); setSolrStatus(prev => ({ ...prev, reindex: { loading: false, message: result.success ? result.message : (result.error || 'Reindex failed'), success: result.success } })); setTimeout(() => { setSolrStatus(prev => ({ ...prev, reindex: { loading: false, message: '', success: undefined } })); }, 8000); } catch (error: any) { setSolrStatus(prev => ({ ...prev, reindex: { loading: false, message: error.message || 'Network error occurred', success: false } })); setTimeout(() => { setSolrStatus(prev => ({ ...prev, reindex: { loading: false, message: '', success: undefined } })); }, 8000); } }; const handleSolrRecreate = async () => { setSolrStatus(prev => ({ ...prev, recreate: { loading: true, message: 'Recreating Solr indices...', success: undefined } })); try { const result = await searchAdminApi.recreateSolrIndices(); setSolrStatus(prev => ({ ...prev, recreate: { loading: false, message: result.success ? result.message : (result.error || 'Recreation failed'), success: result.success } })); setTimeout(() => { setSolrStatus(prev => ({ ...prev, recreate: { loading: false, message: '', success: undefined } })); }, 8000); } catch (error: any) { setSolrStatus(prev => ({ ...prev, recreate: { loading: false, message: error.message || 'Network error occurred', success: false } })); setTimeout(() => { setSolrStatus(prev => ({ ...prev, recreate: { loading: false, message: '', success: undefined } })); }, 8000); } }; const handleLibraryMigration = async () => { const confirmed = window.confirm( 'This will migrate Solr to support library separation. It will clear existing search data and reindex with library context. Continue?' ); if (!confirmed) return; setSolrStatus(prev => ({ ...prev, migrate: { loading: true, message: 'Migrating to library-aware schema...', success: undefined } })); try { const result = await searchAdminApi.migrateLibrarySchema(); setSolrStatus(prev => ({ ...prev, migrate: { loading: false, message: result.success ? `${result.message}${result.note ? ` Note: ${result.note}` : ''}` : (result.error || result.details || 'Migration failed'), success: result.success } })); setTimeout(() => { setSolrStatus(prev => ({ ...prev, migrate: { loading: false, message: '', success: undefined } })); }, 10000); // Longer timeout for migration messages } catch (error: any) { setSolrStatus(prev => ({ ...prev, migrate: { loading: false, message: error.message || 'Network error occurred', success: false } })); setTimeout(() => { setSolrStatus(prev => ({ ...prev, migrate: { loading: false, message: '', success: undefined } })); }, 10000); } }; // Load status on component mount useEffect(() => { loadSearchEngineStatus(); }, []); return (
{/* Search Management */}

Search Management

Manage Solr indices for stories and authors. Use these tools if search isn't returning expected results.

{/* Current Status */}

Search Status

Solr: {searchEngineStatus.solrAvailable ? 'Available' : 'Unavailable'}
{/* Search Operations */}

Search Operations

Perform maintenance operations on search indices. Use these if search isn't returning expected results.

{/* Library Migration Section */}

Library Separation Migration

Migrate Solr to support proper library separation. This ensures search results are isolated between different libraries (password-based access).

{/* Status Messages */} {solrStatus.reindex.message && (
{solrStatus.reindex.message}
)} {solrStatus.recreate.message && (
{solrStatus.recreate.message}
)} {solrStatus.migrate.message && (
{solrStatus.migrate.message}
)}

When to use these tools:

  • Reindex All: Refresh all search data while keeping existing schemas (fixes data sync issues)
  • Recreate Indices: Delete and rebuild all search indexes from scratch (fixes schema and structure issues)
  • Migrate Library Schema: One-time migration to enable library separation (isolates search results by library)

⚠️ Library Migration:

Only run this once to enable library-aware search. Requires Solr schema to support libraryId field.

{/* Storage Management */}

Storage Management

Clean up orphaned content images that are no longer referenced in any story. This can help free up disk space.

{/* Image Cleanup Section */}

🖼️ Content Images Cleanup

Scan for and remove orphaned content images that are no longer referenced in any story content. This includes images from deleted stories and unused downloaded images.

{cleanupStatus.preview.message && ( )}
{/* Preview Results */} {cleanupStatus.preview.message && (
{cleanupStatus.preview.message} {cleanupStatus.preview.data && cleanupStatus.preview.data.hasErrors && (
View Errors ({cleanupStatus.preview.data.errors.length})
    {cleanupStatus.preview.data.errors.map((error: string, index: number) => (
  • • {error}
  • ))}
)}
)} {/* Execute Results */} {cleanupStatus.execute.message && (
{cleanupStatus.execute.message}
)} {/* Detailed Preview Information */} {cleanupStatus.preview.data && cleanupStatus.preview.success && (
Orphaned Images: {cleanupStatus.preview.data.orphanedCount}
Total Size: {cleanupStatus.preview.data.formattedSize}
Empty Folders: {cleanupStatus.preview.data.foldersToDelete}
Referenced Images: {cleanupStatus.preview.data.referencedImagesCount}
{/* Detailed File List */} {cleanupStatus.preview.data.orphanedFiles && cleanupStatus.preview.data.orphanedFiles.length > 0 && (
📁 View Files to be Deleted ({cleanupStatus.preview.data.orphanedFiles.length})
{cleanupStatus.preview.data.orphanedFiles.map((file: any, index: number) => ( ))}
File Name Size Story Status
handleImageHover(file.filePath, file.fileName, e) : undefined} onMouseMove={isImageFile(file.fileName) ? (e) => setMousePosition({ x: e.clientX, y: e.clientY }) : undefined} onMouseLeave={isImageFile(file.fileName) ? handleImageLeave : undefined} > {isImageFile(file.fileName) && '🖼️ '}{file.fileName}
{file.filePath}
{file.formattedSize} {file.storyExists && file.storyTitle ? ( {file.storyTitle} ) : file.storyId !== 'unknown' && file.storyId !== 'error' ? ( Deleted Story ) : ( Unknown )} {file.storyExists ? ( Orphaned ) : file.storyId !== 'unknown' && file.storyId !== 'error' ? ( Story Deleted ) : ( Unknown Folder )}
)}
)}

📝 How it works:

  • Preview: Scans all stories to find images no longer referenced in content
  • Execute: Permanently deletes orphaned images and empty story directories
  • Safe: Only removes images not found in any story content
  • Backup recommended: Consider backing up before large cleanups
{/* EPUB Image Migration Section */}

📦 EPUB Image Migration

Move images from EPUB imports into the correct story subfolders. These images are still referenced by stories and display correctly, but were placed flat in the content directory due to an import bug. Run this once to reorganise them.

{migrationStatus.preview.data && ( )}
{migrationStatus.preview.message && (
{migrationStatus.preview.message}
)} {migrationStatus.execute.message && (
{migrationStatus.execute.message}
)} {migrationStatus.preview.data && migrationStatus.preview.success && (
To Move:{' '} {migrationStatus.preview.data.movedFiles?.filter((f: any) => f.action !== 'unmatched').length ?? 0}
Unmatched:{' '} {migrationStatus.preview.data.unmatchedCount}
{migrationStatus.preview.data.movedFiles?.length > 0 && (
📁 View Files ({migrationStatus.preview.data.movedFiles.length})
{migrationStatus.preview.data.movedFiles.map((file: any, index: number) => ( ))}
File Name Size Story Action
🖼️ {file.fileName}
{file.formattedSize} {file.storyTitle ? ( {file.storyTitle} ) : ( No match )} {file.action === 'move' && Move} {file.action === 'copy' && Copy} {file.action === 'unmatched' && Skip}
)}
)}

ℹ️ Notes:

  • Safe to run multiple times — after migration, no flat images remain and the scan returns zero
  • Unmatched files have no story reference and are left in place — use the orphaned cleanup to remove them
  • • Story content URLs are updated automatically so images continue to display correctly
{/* Database Management */}

Database Management

Backup, restore, or clear your StoryCove database and files. These comprehensive operations include both your data and uploaded images.

{/* Complete Backup Section */}

📦 Create Backup

Download a complete backup as a ZIP file. This includes your database AND all uploaded files (cover images, avatars). This is a comprehensive backup of your entire StoryCove installation.

{databaseStatus.completeBackup.downloadReady && databaseStatus.completeBackup.jobId && ( )}
{databaseStatus.completeBackup.loading && databaseStatus.completeBackup.progress !== undefined && (
Progress {databaseStatus.completeBackup.progress}%
)} {databaseStatus.completeBackup.message && (
{databaseStatus.completeBackup.message}
)}
{/* Restore Section */}

📥 Restore Backup

⚠️ Warning: This will completely replace your current database AND all files with the backup. All existing data and uploaded files will be permanently deleted.

{databaseStatus.completeRestore.message && (
{databaseStatus.completeRestore.message}
)} {databaseStatus.completeRestore.loading && (
Restoring backup...
)}
{/* Clear Everything Section */}

🗑️ Clear Everything

⚠️ Danger Zone: This will permanently delete ALL data from your database AND all uploaded files (cover images, avatars). Everything will be completely removed. This action cannot be undone!

{databaseStatus.completeClear.message && (
{databaseStatus.completeClear.message}
)}

💡 Best Practices:

  • Always backup before performing restore or clear operations
  • Store backups safely in multiple locations for important data
  • Test restores in a development environment when possible
  • Backup files (.zip) contain both database and all uploaded files
  • Verify backup files are complete before relying on them
{/* Image Preview Overlay */} {hoveredImage && (
window.innerWidth - 300 ? 'translateX(-100%)' : 'none' }} > {hoveredImage.alt} { // Hide preview if image fails to load setHoveredImage(null); }} />
{hoveredImage.alt}
)}
); }