1281 lines
51 KiB
TypeScript
1281 lines
51 KiB
TypeScript
'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<HTMLInputElement>) => {
|
||
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 (
|
||
<div className="space-y-6">
|
||
{/* Search Management */}
|
||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||
<h2 className="text-xl font-semibold theme-header mb-4">Search Management</h2>
|
||
<p className="theme-text mb-6">
|
||
Manage Solr indices for stories and authors. Use these tools if search isn't returning expected results.
|
||
</p>
|
||
|
||
<div className="space-y-6">
|
||
{/* Current Status */}
|
||
<div className="border theme-border rounded-lg p-4">
|
||
<h3 className="text-lg font-semibold theme-header mb-3">Search Status</h3>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||
<div className="flex justify-between">
|
||
<span>Solr:</span>
|
||
<span className={`font-medium ${searchEngineStatus.solrAvailable ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||
{searchEngineStatus.solrAvailable ? 'Available' : 'Unavailable'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search Operations */}
|
||
<div className="border theme-border rounded-lg p-4">
|
||
<h3 className="text-lg font-semibold theme-header mb-3">Search Operations</h3>
|
||
<p className="text-sm theme-text mb-4">
|
||
Perform maintenance operations on search indices. Use these if search isn't returning expected results.
|
||
</p>
|
||
|
||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||
<Button
|
||
onClick={handleSolrReindex}
|
||
disabled={solrStatus.reindex.loading || solrStatus.recreate.loading || solrStatus.migrate.loading || !searchEngineStatus.solrAvailable}
|
||
loading={solrStatus.reindex.loading}
|
||
variant="ghost"
|
||
className="flex-1"
|
||
>
|
||
{solrStatus.reindex.loading ? 'Reindexing...' : '🔄 Reindex All'}
|
||
</Button>
|
||
<Button
|
||
onClick={handleSolrRecreate}
|
||
disabled={solrStatus.reindex.loading || solrStatus.recreate.loading || solrStatus.migrate.loading || !searchEngineStatus.solrAvailable}
|
||
loading={solrStatus.recreate.loading}
|
||
variant="secondary"
|
||
className="flex-1"
|
||
>
|
||
{solrStatus.recreate.loading ? 'Recreating...' : '🏗️ Recreate Indices'}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Library Migration Section */}
|
||
<div className="border-t theme-border pt-4">
|
||
<h4 className="text-md font-medium theme-header mb-2">Library Separation Migration</h4>
|
||
<p className="text-sm theme-text mb-3">
|
||
Migrate Solr to support proper library separation. This ensures search results are isolated between different libraries (password-based access).
|
||
</p>
|
||
<Button
|
||
onClick={handleLibraryMigration}
|
||
disabled={solrStatus.reindex.loading || solrStatus.recreate.loading || solrStatus.migrate.loading || !searchEngineStatus.solrAvailable}
|
||
loading={solrStatus.migrate.loading}
|
||
variant="primary"
|
||
className="w-full sm:w-auto"
|
||
>
|
||
{solrStatus.migrate.loading ? 'Migrating...' : '🔒 Migrate Library Schema'}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Status Messages */}
|
||
{solrStatus.reindex.message && (
|
||
<div className={`text-sm p-3 rounded mb-3 ${
|
||
solrStatus.reindex.success
|
||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||
}`}>
|
||
{solrStatus.reindex.message}
|
||
</div>
|
||
)}
|
||
|
||
{solrStatus.recreate.message && (
|
||
<div className={`text-sm p-3 rounded mb-3 ${
|
||
solrStatus.recreate.success
|
||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||
}`}>
|
||
{solrStatus.recreate.message}
|
||
</div>
|
||
)}
|
||
|
||
{solrStatus.migrate.message && (
|
||
<div className={`text-sm p-3 rounded mb-3 ${
|
||
solrStatus.migrate.success
|
||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||
}`}>
|
||
{solrStatus.migrate.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||
<p className="font-medium mb-1">When to use these tools:</p>
|
||
<ul className="text-xs space-y-1 ml-4">
|
||
<li>• <strong>Reindex All:</strong> Refresh all search data while keeping existing schemas (fixes data sync issues)</li>
|
||
<li>• <strong>Recreate Indices:</strong> Delete and rebuild all search indexes from scratch (fixes schema and structure issues)</li>
|
||
<li>• <strong>Migrate Library Schema:</strong> One-time migration to enable library separation (isolates search results by library)</li>
|
||
</ul>
|
||
<div className="mt-2 pt-2 border-t border-blue-200 dark:border-blue-700">
|
||
<p className="font-medium text-xs">⚠️ Library Migration:</p>
|
||
<p className="text-xs">Only run this once to enable library-aware search. Requires Solr schema to support libraryId field.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Storage Management */}
|
||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||
<h2 className="text-xl font-semibold theme-header mb-4">Storage Management</h2>
|
||
<p className="theme-text mb-6">
|
||
Clean up orphaned content images that are no longer referenced in any story. This can help free up disk space.
|
||
</p>
|
||
|
||
<div className="space-y-6">
|
||
{/* Image Cleanup Section */}
|
||
<div className="border theme-border rounded-lg p-4">
|
||
<h3 className="text-lg font-semibold theme-header mb-3">🖼️ Content Images Cleanup</h3>
|
||
<p className="text-sm theme-text mb-4">
|
||
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.
|
||
</p>
|
||
|
||
<div className="flex flex-col sm:flex-row gap-3 mb-3">
|
||
<Button
|
||
onClick={handleImageCleanupPreview}
|
||
disabled={cleanupStatus.preview.loading}
|
||
loading={cleanupStatus.preview.loading}
|
||
variant="ghost"
|
||
className="flex-1"
|
||
>
|
||
{cleanupStatus.preview.loading ? 'Scanning...' : 'Preview Cleanup'}
|
||
</Button>
|
||
<Button
|
||
onClick={handleImageCleanupExecute}
|
||
disabled={cleanupStatus.execute.loading || !cleanupStatus.preview.data || cleanupStatus.preview.data.orphanedCount === 0}
|
||
loading={cleanupStatus.execute.loading}
|
||
variant="secondary"
|
||
className="flex-1"
|
||
>
|
||
{cleanupStatus.execute.loading ? 'Cleaning...' : 'Execute Cleanup'}
|
||
</Button>
|
||
{cleanupStatus.preview.message && (
|
||
<Button
|
||
onClick={() => setCleanupStatus(prev => ({
|
||
...prev,
|
||
preview: { loading: false, message: '', success: undefined, data: undefined }
|
||
}))}
|
||
variant="ghost"
|
||
className="px-4 py-2 text-sm"
|
||
>
|
||
Clear Preview
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Preview Results */}
|
||
{cleanupStatus.preview.message && (
|
||
<div className={`text-sm p-3 rounded mb-3 ${
|
||
cleanupStatus.preview.success
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200'
|
||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||
}`}>
|
||
{cleanupStatus.preview.message}
|
||
{cleanupStatus.preview.data && cleanupStatus.preview.data.hasErrors && (
|
||
<div className="mt-2 text-xs">
|
||
<details>
|
||
<summary className="cursor-pointer font-medium">View Errors ({cleanupStatus.preview.data.errors.length})</summary>
|
||
<ul className="mt-1 ml-4 space-y-1">
|
||
{cleanupStatus.preview.data.errors.map((error: string, index: number) => (
|
||
<li key={index} className="text-red-600 dark:text-red-400">• {error}</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Execute Results */}
|
||
{cleanupStatus.execute.message && (
|
||
<div className={`text-sm p-3 rounded mb-3 ${
|
||
cleanupStatus.execute.success
|
||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||
}`}>
|
||
{cleanupStatus.execute.message}
|
||
</div>
|
||
)}
|
||
|
||
{/* Detailed Preview Information */}
|
||
{cleanupStatus.preview.data && cleanupStatus.preview.success && (
|
||
<div className="text-sm theme-text bg-gray-50 dark:bg-gray-800 p-3 rounded border">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<span className="font-medium">Orphaned Images:</span> {cleanupStatus.preview.data.orphanedCount}
|
||
</div>
|
||
<div>
|
||
<span className="font-medium">Total Size:</span> {cleanupStatus.preview.data.formattedSize}
|
||
</div>
|
||
<div>
|
||
<span className="font-medium">Empty Folders:</span> {cleanupStatus.preview.data.foldersToDelete}
|
||
</div>
|
||
<div>
|
||
<span className="font-medium">Referenced Images:</span> {cleanupStatus.preview.data.referencedImagesCount}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Detailed File List */}
|
||
{cleanupStatus.preview.data.orphanedFiles && cleanupStatus.preview.data.orphanedFiles.length > 0 && (
|
||
<div className="mt-4">
|
||
<details className="cursor-pointer">
|
||
<summary className="font-medium text-sm theme-header mb-2">
|
||
📁 View Files to be Deleted ({cleanupStatus.preview.data.orphanedFiles.length})
|
||
</summary>
|
||
<div className="mt-3 max-h-96 overflow-y-auto border theme-border rounded">
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-gray-100 dark:bg-gray-800 sticky top-0">
|
||
<tr>
|
||
<th className="text-left p-2 font-medium">File Name</th>
|
||
<th className="text-left p-2 font-medium">Size</th>
|
||
<th className="text-left p-2 font-medium">Story</th>
|
||
<th className="text-left p-2 font-medium">Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{cleanupStatus.preview.data.orphanedFiles.map((file: any, index: number) => (
|
||
<tr key={index} className="border-t theme-border hover:bg-gray-50 dark:hover:bg-gray-800">
|
||
<td className="p-2">
|
||
<div
|
||
className={`truncate max-w-xs ${isImageFile(file.fileName) ? 'cursor-pointer text-blue-600 dark:text-blue-400' : ''}`}
|
||
title={file.fileName}
|
||
onMouseEnter={isImageFile(file.fileName) ? (e) => 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}
|
||
</div>
|
||
<div className="text-xs text-gray-500 truncate max-w-xs" title={file.filePath}>
|
||
{file.filePath}
|
||
</div>
|
||
</td>
|
||
<td className="p-2">{file.formattedSize}</td>
|
||
<td className="p-2">
|
||
{file.storyExists && file.storyTitle ? (
|
||
<a
|
||
href={`/stories/${file.storyId}`}
|
||
className="text-blue-600 dark:text-blue-400 hover:underline truncate max-w-xs block"
|
||
title={file.storyTitle}
|
||
>
|
||
{file.storyTitle}
|
||
</a>
|
||
) : file.storyId !== 'unknown' && file.storyId !== 'error' ? (
|
||
<span className="text-gray-500" title={`Story ID: ${file.storyId}`}>
|
||
Deleted Story
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-400">Unknown</span>
|
||
)}
|
||
</td>
|
||
<td className="p-2">
|
||
{file.storyExists ? (
|
||
<span className="text-orange-600 dark:text-orange-400 text-xs">Orphaned</span>
|
||
) : file.storyId !== 'unknown' && file.storyId !== 'error' ? (
|
||
<span className="text-red-600 dark:text-red-400 text-xs">Story Deleted</span>
|
||
) : (
|
||
<span className="text-gray-500 text-xs">Unknown Folder</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||
<p className="font-medium mb-1">📝 How it works:</p>
|
||
<ul className="text-xs space-y-1 ml-4">
|
||
<li>• <strong>Preview:</strong> Scans all stories to find images no longer referenced in content</li>
|
||
<li>• <strong>Execute:</strong> Permanently deletes orphaned images and empty story directories</li>
|
||
<li>• <strong>Safe:</strong> Only removes images not found in any story content</li>
|
||
<li>• <strong>Backup recommended:</strong> Consider backing up before large cleanups</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{/* EPUB Image Migration Section */}
|
||
<div className="border theme-border rounded-lg p-4">
|
||
<h3 className="text-lg font-semibold theme-header mb-3">📦 EPUB Image Migration</h3>
|
||
<p className="text-sm theme-text mb-4">
|
||
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.
|
||
</p>
|
||
|
||
<div className="flex flex-col sm:flex-row gap-3 mb-3">
|
||
<Button
|
||
onClick={handleEpubMigrationPreview}
|
||
disabled={migrationStatus.preview.loading || migrationStatus.execute.loading}
|
||
loading={migrationStatus.preview.loading}
|
||
variant="ghost"
|
||
className="flex-1"
|
||
>
|
||
{migrationStatus.preview.loading ? 'Scanning...' : 'Preview Migration'}
|
||
</Button>
|
||
<Button
|
||
onClick={handleEpubMigrationExecute}
|
||
disabled={
|
||
migrationStatus.execute.loading ||
|
||
!migrationStatus.preview.data ||
|
||
migrationStatus.preview.data.movedFiles?.filter((f: any) => f.action !== 'unmatched').length === 0
|
||
}
|
||
loading={migrationStatus.execute.loading}
|
||
variant="secondary"
|
||
className="flex-1"
|
||
>
|
||
{migrationStatus.execute.loading ? 'Migrating...' : 'Execute Migration'}
|
||
</Button>
|
||
{migrationStatus.preview.data && (
|
||
<Button
|
||
onClick={() => setMigrationStatus(prev => ({
|
||
...prev,
|
||
preview: { loading: false, message: '', success: undefined, data: undefined }
|
||
}))}
|
||
variant="ghost"
|
||
className="px-4 py-2 text-sm"
|
||
>
|
||
Clear Preview
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{migrationStatus.preview.message && (
|
||
<div className={`text-sm p-3 rounded mb-3 ${
|
||
migrationStatus.preview.success
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200'
|
||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||
}`}>
|
||
{migrationStatus.preview.message}
|
||
</div>
|
||
)}
|
||
|
||
{migrationStatus.execute.message && (
|
||
<div className={`text-sm p-3 rounded mb-3 ${
|
||
migrationStatus.execute.success
|
||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||
}`}>
|
||
{migrationStatus.execute.message}
|
||
</div>
|
||
)}
|
||
|
||
{migrationStatus.preview.data && migrationStatus.preview.success && (
|
||
<div className="text-sm theme-text bg-gray-50 dark:bg-gray-800 p-3 rounded border">
|
||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||
<div>
|
||
<span className="font-medium">To Move:</span>{' '}
|
||
{migrationStatus.preview.data.movedFiles?.filter((f: any) => f.action !== 'unmatched').length ?? 0}
|
||
</div>
|
||
<div>
|
||
<span className="font-medium">Unmatched:</span>{' '}
|
||
{migrationStatus.preview.data.unmatchedCount}
|
||
</div>
|
||
</div>
|
||
|
||
{migrationStatus.preview.data.movedFiles?.length > 0 && (
|
||
<details>
|
||
<summary className="cursor-pointer font-medium text-sm theme-header mb-2">
|
||
📁 View Files ({migrationStatus.preview.data.movedFiles.length})
|
||
</summary>
|
||
<div className="mt-3 max-h-80 overflow-y-auto border theme-border rounded">
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-gray-100 dark:bg-gray-800 sticky top-0">
|
||
<tr>
|
||
<th className="text-left p-2 font-medium">File Name</th>
|
||
<th className="text-left p-2 font-medium">Size</th>
|
||
<th className="text-left p-2 font-medium">Story</th>
|
||
<th className="text-left p-2 font-medium">Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{migrationStatus.preview.data.movedFiles.map((file: any, index: number) => (
|
||
<tr key={index} className="border-t theme-border hover:bg-gray-50 dark:hover:bg-gray-800">
|
||
<td className="p-2">
|
||
<div className="truncate max-w-xs" title={file.fileName}>
|
||
🖼️ {file.fileName}
|
||
</div>
|
||
</td>
|
||
<td className="p-2">{file.formattedSize}</td>
|
||
<td className="p-2">
|
||
{file.storyTitle ? (
|
||
<a
|
||
href={`/stories/${file.storyId}`}
|
||
className="text-blue-600 dark:text-blue-400 hover:underline truncate max-w-xs block"
|
||
title={file.storyTitle}
|
||
>
|
||
{file.storyTitle}
|
||
</a>
|
||
) : (
|
||
<span className="text-gray-400">No match</span>
|
||
)}
|
||
</td>
|
||
<td className="p-2">
|
||
{file.action === 'move' && <span className="text-blue-600 dark:text-blue-400">Move</span>}
|
||
{file.action === 'copy' && <span className="text-purple-600 dark:text-purple-400">Copy</span>}
|
||
{file.action === 'unmatched' && <span className="text-gray-500">Skip</span>}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg mt-3">
|
||
<p className="font-medium mb-1">ℹ️ Notes:</p>
|
||
<ul className="text-xs space-y-1 ml-4">
|
||
<li>• <strong>Safe to run multiple times</strong> — after migration, no flat images remain and the scan returns zero</li>
|
||
<li>• <strong>Unmatched files</strong> have no story reference and are left in place — use the orphaned cleanup to remove them</li>
|
||
<li>• Story content URLs are updated automatically so images continue to display correctly</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Database Management */}
|
||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||
<h2 className="text-xl font-semibold theme-header mb-4">Database Management</h2>
|
||
<p className="theme-text mb-6">
|
||
Backup, restore, or clear your StoryCove database and files. These comprehensive operations include both your data and uploaded images.
|
||
</p>
|
||
|
||
<div className="space-y-6">
|
||
{/* Complete Backup Section */}
|
||
<div className="border theme-border rounded-lg p-4 border-blue-200 dark:border-blue-800">
|
||
<h3 className="text-lg font-semibold theme-header mb-3">📦 Create Backup</h3>
|
||
<p className="text-sm theme-text mb-3">
|
||
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.
|
||
</p>
|
||
<div className="space-y-3">
|
||
<Button
|
||
onClick={handleCompleteBackup}
|
||
disabled={databaseStatus.completeBackup.loading || databaseStatus.completeBackup.downloadReady}
|
||
loading={databaseStatus.completeBackup.loading}
|
||
variant="primary"
|
||
className="w-full sm:w-auto"
|
||
>
|
||
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Create Backup'}
|
||
</Button>
|
||
|
||
{databaseStatus.completeBackup.downloadReady && databaseStatus.completeBackup.jobId && (
|
||
<Button
|
||
onClick={() => handleDownloadBackup(databaseStatus.completeBackup.jobId!)}
|
||
variant="primary"
|
||
className="w-full sm:w-auto ml-0 sm:ml-3 bg-green-600 hover:bg-green-700"
|
||
>
|
||
⬇️ Download Backup
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{databaseStatus.completeBackup.loading && databaseStatus.completeBackup.progress !== undefined && (
|
||
<div className="mt-3">
|
||
<div className="flex justify-between text-sm theme-text mb-1">
|
||
<span>Progress</span>
|
||
<span>{databaseStatus.completeBackup.progress}%</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
|
||
<div
|
||
className="bg-blue-600 dark:bg-blue-500 h-2.5 rounded-full transition-all duration-300"
|
||
style={{ width: `${databaseStatus.completeBackup.progress}%` }}
|
||
></div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{databaseStatus.completeBackup.message && (
|
||
<div className={`text-sm p-2 rounded mt-3 ${
|
||
databaseStatus.completeBackup.success
|
||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||
: databaseStatus.completeBackup.success === false
|
||
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200'
|
||
}`}>
|
||
{databaseStatus.completeBackup.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Restore Section */}
|
||
<div className="border theme-border rounded-lg p-4 border-orange-200 dark:border-orange-800">
|
||
<h3 className="text-lg font-semibold theme-header mb-3">📥 Restore Backup</h3>
|
||
<p className="text-sm theme-text mb-3">
|
||
<strong className="text-orange-600 dark:text-orange-400">⚠️ Warning:</strong> This will completely replace your current database AND all files with the backup. All existing data and uploaded files will be permanently deleted.
|
||
</p>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="file"
|
||
accept=".zip"
|
||
onChange={handleCompleteRestore}
|
||
disabled={databaseStatus.completeRestore.loading}
|
||
className="flex-1 text-sm theme-text file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:theme-accent-bg file:text-white hover:file:bg-opacity-90 file:cursor-pointer"
|
||
/>
|
||
</div>
|
||
{databaseStatus.completeRestore.message && (
|
||
<div className={`text-sm p-2 rounded mt-3 ${
|
||
databaseStatus.completeRestore.success
|
||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||
}`}>
|
||
{databaseStatus.completeRestore.message}
|
||
</div>
|
||
)}
|
||
{databaseStatus.completeRestore.loading && (
|
||
<div className="text-sm theme-text mt-3 flex items-center gap-2">
|
||
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||
Restoring backup...
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Clear Everything Section */}
|
||
<div className="border theme-border rounded-lg p-4 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/10">
|
||
<h3 className="text-lg font-semibold theme-header mb-3">🗑️ Clear Everything</h3>
|
||
<p className="text-sm theme-text mb-3">
|
||
<strong className="text-red-600 dark:text-red-400">⚠️ Danger Zone:</strong> 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!
|
||
</p>
|
||
<Button
|
||
onClick={handleCompleteClear}
|
||
disabled={databaseStatus.completeClear.loading}
|
||
loading={databaseStatus.completeClear.loading}
|
||
variant="secondary"
|
||
className="w-full sm:w-auto bg-red-700 hover:bg-red-800 text-white border-red-700"
|
||
>
|
||
{databaseStatus.completeClear.loading ? 'Clearing Everything...' : 'Clear Everything'}
|
||
</Button>
|
||
{databaseStatus.completeClear.message && (
|
||
<div className={`text-sm p-2 rounded mt-3 ${
|
||
databaseStatus.completeClear.success
|
||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||
}`}>
|
||
{databaseStatus.completeClear.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||
<p className="font-medium mb-1">💡 Best Practices:</p>
|
||
<ul className="text-xs space-y-1 ml-4">
|
||
<li>• <strong>Always backup</strong> before performing restore or clear operations</li>
|
||
<li>• <strong>Store backups safely</strong> in multiple locations for important data</li>
|
||
<li>• <strong>Test restores</strong> in a development environment when possible</li>
|
||
<li>• <strong>Backup files (.zip)</strong> contain both database and all uploaded files</li>
|
||
<li>• <strong>Verify backup files</strong> are complete before relying on them</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Image Preview Overlay */}
|
||
{hoveredImage && (
|
||
<div
|
||
className="fixed pointer-events-none z-50 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl p-2 max-w-sm"
|
||
style={{
|
||
left: mousePosition.x + 10,
|
||
top: mousePosition.y - 100,
|
||
transform: mousePosition.x > window.innerWidth - 300 ? 'translateX(-100%)' : 'none'
|
||
}}
|
||
>
|
||
<img
|
||
src={hoveredImage.src}
|
||
alt={hoveredImage.alt}
|
||
className="max-w-full max-h-64 object-contain rounded"
|
||
onError={() => {
|
||
// Hide preview if image fails to load
|
||
setHoveredImage(null);
|
||
}}
|
||
/>
|
||
<div className="text-xs theme-text mt-1 truncate">
|
||
{hoveredImage.alt}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} |