support webp, some fixes.
This commit is contained in:
@@ -56,6 +56,14 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
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 });
|
||||
|
||||
@@ -401,6 +409,115 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
}, 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 {
|
||||
@@ -856,6 +973,147 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
<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>
|
||||
|
||||
|
||||
@@ -763,6 +763,52 @@ export const configApi = {
|
||||
const response = await api.post('/config/cleanup/images/execute');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
previewEpubImageMigration: async (): Promise<{
|
||||
success: boolean;
|
||||
movedCount: number;
|
||||
unmatchedCount: number;
|
||||
errors: string[];
|
||||
hasErrors: boolean;
|
||||
dryRun: boolean;
|
||||
movedFiles: {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
targetPath?: string;
|
||||
fileSize: number;
|
||||
formattedSize: string;
|
||||
storyId: string | null;
|
||||
storyTitle: string | null;
|
||||
action: 'move' | 'copy' | 'unmatched';
|
||||
}[];
|
||||
error?: string;
|
||||
}> => {
|
||||
const response = await api.post('/config/migrate/epub-images/preview');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
executeEpubImageMigration: async (): Promise<{
|
||||
success: boolean;
|
||||
movedCount: number;
|
||||
unmatchedCount: number;
|
||||
errors: string[];
|
||||
hasErrors: boolean;
|
||||
dryRun: boolean;
|
||||
movedFiles: {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
targetPath?: string;
|
||||
fileSize: number;
|
||||
formattedSize: string;
|
||||
storyId: string | null;
|
||||
storyTitle: string | null;
|
||||
action: 'move' | 'copy' | 'unmatched';
|
||||
}[];
|
||||
error?: string;
|
||||
}> => {
|
||||
const response = await api.post('/config/migrate/epub-images/execute');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Search Engine Management API
|
||||
|
||||
Reference in New Issue
Block a user