/** * Image Optimization Settings - Unified Design with TypeScript */ import { useState, useEffect, useRef } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { TextareaControl, RadioControl, RangeControl, Spinner, } from '@wordpress/components'; import apiFetch from '@wordpress/api-fetch'; import { useNotification } from '../../../contexts/NotificationContext'; import ProrankToggleSlider from '../../../components/ProrankToggleSlider'; import ProrankButton from '../../../components/ProrankButton'; import ImageDeliveryHealthPanel from './ImageDeliveryHealthPanel'; import * as React from 'react'; interface ImageOptimizationSettingsProps { onClose?: () => void; embedded?: boolean; moduleEnabled?: boolean; } interface ImageSettings { optimization_method: 'local' | 'cloud' | 'smart'; webp_enabled: boolean; webp_quality: number; avif_enabled: boolean; avif_quality: number; jxl_enabled: boolean; jxl_quality: number; png_quality: number; png_pipeline: boolean; jpegli_enabled: boolean; compression_type: 'lossless' | 'glossy' | 'lossy' | 'aggressive'; smart_compression: boolean; optimize_on_upload: boolean; background_mode: boolean; backup_originals: boolean; optimize_original_source: boolean; lazyload_images: boolean; lazyload_iframes: boolean; lazyload_videos: boolean; lazyload_threshold: number; cdn_enabled: boolean; cdn_url: string; exclude_paths: string; cloud_opt_in: boolean; cloud_opt_in_at?: number | null; // Advanced settings png_to_jpeg: boolean; cmyk_to_rgb: boolean; remove_exif: boolean; optimize_pdf: boolean; generate_retina: boolean; auto_generate_missing_sizes: boolean; oversized_threshold: number; no_ai_training: boolean; resize_large_enabled: boolean; resize_max_width: number; resize_max_height: number; delivery_method: 'picture' | 'accept_header' | 'htaccess' | 'cdn' | 'none'; } interface ImageStats { total_images: number; optimized_images: number; saved_bytes: number; optimization_percentage: number; pending?: number; unresolved_images?: number; failed_images?: number; retryable_failed_images?: number; exhausted_failed_images?: number; no_gain_images?: number; bulk_retry_limit?: number; backup_size?: number; backup_size_formatted?: string; already_modern_images?: number; unsupported_images?: number; source_images?: number; } interface BulkProgress { current: number; total: number; current_image: string; bytes_saved: number; status: 'idle' | 'running' | 'completed' | 'error' | 'stale'; error_message?: string; updated_at?: number | null; started_at?: number | null; job_id?: string | null; eta_seconds?: number | null; stale_after_seconds?: number; log?: Array<{ image: string; status: string; bytes_saved?: number; reason?: string; ts?: number; }>; percent?: number; active_index?: number | null; summary?: { processed: number; saved_bytes: number; failed?: number; }; } interface ServerSupport { gd: boolean; imagick: boolean; webp: boolean; avif: boolean; jxl: boolean; jpegli: boolean; htaccess: boolean; details: { gd_version?: string; imagick_version?: string; libvips_version?: string; libjxl_version?: string; }; server: { software: string; php_version: string; memory_limit: string; max_execution_time: string; upload_max_filesize: string; }; cloud: { available: boolean; formats: string[]; version?: string; capabilities?: { jxl?: boolean; jpegli?: boolean; png_pipeline?: boolean; }; }; } interface CloudQuota { tier?: string; quota?: { yearly: number; monthly?: number; period_start?: number; period_end?: number; period_source?: string; }; quota_formatted?: { yearly: string; monthly?: string; }; used?: number; used_formatted?: string; remaining?: number; remaining_formatted?: string; next_reset?: string; status?: 'active' | 'exhausted' | 'no_license'; percentage?: number; } const ImageOptimizationSettings: React.FC = ({ onClose, embedded = false, moduleEnabled: initialModuleEnabled = true, }) => { const { showNotification } = useNotification(); const restData = (window as any).prorankSeo || (window as any).prorankSeoAdmin || (window as any).proRankSEO || (window as any).proRankSeoAdmin || {}; const restNonce = restData.restNonce || restData.nonce || (window as any).wpApiSettings?.nonce || ''; const restRoot = restData.restUrl || (window as any).wpApiSettings?.root || '/wp-json/'; const buildRestUrl = (path: string) => { if (path.startsWith('http')) { return path; } const base = restRoot.endsWith('/') ? restRoot : `${restRoot}/`; const normalizedPath = path.startsWith('/') ? path.slice(1) : path; return `${base}${normalizedPath}`; }; const apiRequest = (options: { path: string; method?: string; data?: any; headers?: Record; }) => { const { path, ...rest } = options; if (path.startsWith('http')) { return apiFetch({ ...rest, url: buildRestUrl(path), credentials: 'same-origin', headers: { ...(rest.headers || {}), ...(restNonce ? { 'X-WP-Nonce': restNonce } : {}), }, }); } return apiFetch({ ...rest, path, credentials: 'same-origin', headers: { ...(rest.headers || {}), ...(restNonce ? { 'X-WP-Nonce': restNonce } : {}), }, }); }; const [settings, setSettings] = useState({ optimization_method: 'local', webp_enabled: true, webp_quality: 85, avif_enabled: false, avif_quality: 80, jxl_enabled: false, jxl_quality: 85, png_quality: 80, png_pipeline: false, jpegli_enabled: false, compression_type: 'glossy', smart_compression: true, optimize_on_upload: true, background_mode: false, backup_originals: true, optimize_original_source: false, lazyload_images: true, lazyload_iframes: false, lazyload_videos: false, lazyload_threshold: 200, cdn_enabled: false, cdn_url: '', exclude_paths: '', cloud_opt_in: false, cloud_opt_in_at: null, // Advanced settings defaults png_to_jpeg: true, cmyk_to_rgb: true, remove_exif: true, optimize_pdf: false, generate_retina: false, auto_generate_missing_sizes: false, oversized_threshold: 0, no_ai_training: false, resize_large_enabled: false, resize_max_width: 2560, resize_max_height: 2560, delivery_method: 'picture', }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [bulkProcessing, setBulkProcessing] = useState(false); const [enablingRuntime, setEnablingRuntime] = useState(false); const [moduleEnabled, setModuleEnabled] = useState(initialModuleEnabled); const [stats, setStats] = useState({ total_images: 0, optimized_images: 0, saved_bytes: 0, optimization_percentage: 0, backup_size: 0, already_modern_images: 0, unsupported_images: 0, source_images: 0, }); const [progress, setProgress] = useState({ current: 0, total: 0, current_image: '', bytes_saved: 0, status: 'idle', log: [], }); const [browserAssistMode, setBrowserAssistMode] = useState(false); const [recoveryMessage, setRecoveryMessage] = useState(''); const [startTime, setStartTime] = useState(null); const [serverSupport, setServerSupport] = useState(null); const [cloudQuota, setCloudQuota] = useState(null); const effectiveFormatOutputEnabled = settings.optimization_method === 'local' ? settings.webp_enabled : settings.webp_enabled || settings.avif_enabled || settings.jxl_enabled; const lastProgressRef = useRef<{ current: number; currentImage: string; updatedAt: number | null; bytesSaved: number }>({ current: 0, currentImage: '', updatedAt: null, bytesSaved: 0, }); const stalledSinceRef = useRef(null); const recoveryRunningRef = useRef(false); const recoveryNoticeShownRef = useRef(false); const recoveryMessageShownRef = useRef(false); const browserAssistModeRef = useRef(false); const lastRecoveryAttemptRef = useRef(null); const STALLED_PROGRESS_GRACE_MS = 45000; const WARMUP_STALLED_PROGRESS_GRACE_MS = 15000; const RECOVERY_COOLDOWN_MS = 20000; const getRecoveryGraceMs = (): number => ( progress.current <= 1 ? WARMUP_STALLED_PROGRESS_GRACE_MS : STALLED_PROGRESS_GRACE_MS ); const presetQualities = { aggressive: { compression_type: 'aggressive', webp: 65, avif: 55, jxl: 65, png: 60 }, lossy: { compression_type: 'lossy', webp: 75, avif: 65, jxl: 75, png: 70 }, glossy: { compression_type: 'glossy', webp: 82, avif: 75, jxl: 82, png: 80 }, lossless: { compression_type: 'lossless', webp: 100, avif: 100, jxl: 100, png: 100 }, }; useEffect(() => { loadSettings(); loadStats(); loadServerSupport(); }, []); useEffect(() => { const resumeExistingBulkJob = async () => { try { const response = await apiRequest<{ success?: boolean; progress?: BulkProgress; }>({ path: '/prorank-seo/v1/image-optimization/progress', }); const existing = response?.progress; if (!existing) { return; } const resumableStatuses = ['running', 'stale', 'processing']; if (resumableStatuses.includes(existing.status)) { setProgress(existing); setBulkProcessing(true); setStartTime(existing.started_at ? existing.started_at * 1000 : Date.now()); } } catch (error) { // Ignore on initial load; the page can still be used normally. } }; resumeExistingBulkJob(); }, []); // Refresh stats when bulk finishes useEffect(() => { if (progress.status === 'completed') { loadStats(); } }, [progress.status]); // Poll for progress when bulk processing is active useEffect(() => { if (!bulkProcessing) return; const attemptBrowserRecovery = async () => { if (recoveryRunningRef.current) { return; } recoveryRunningRef.current = true; stalledSinceRef.current = Date.now(); lastRecoveryAttemptRef.current = Date.now(); browserAssistModeRef.current = true; setBrowserAssistMode(true); setRecoveryMessage( __('Background processing appears to be stalled on this host. ProRank is switching to browser-assisted mode for the next image. Keep this page open while recovery runs.', 'prorank-seo') ); if (!recoveryNoticeShownRef.current) { showNotification( __('Background processing appears to be stalled. ProRank is switching to browser-assisted mode. Keep this page open while recovery runs.', 'prorank-seo'), 'warning' ); recoveryNoticeShownRef.current = true; } let consecutiveFailures = 0; const MAX_CONSECUTIVE_FAILURES = 5; // Process images one at a time in a loop // eslint-disable-next-line no-constant-condition while (true) { try { const queueResponse = await apiRequest<{ attachment_ids?: number[] }>({ path: '/prorank-seo/v1/image-optimization/images/unoptimized', }); const attachmentId = queueResponse?.attachment_ids?.[0]; if (!attachmentId) { showNotification( __('Browser-assisted optimization complete. All images processed.', 'prorank-seo'), 'success' ); break; } await apiRequest({ path: '/prorank-seo/v1/image-optimization/images/optimize', method: 'POST', data: { attachment_id: attachmentId }, }); consecutiveFailures = 0; if (!recoveryMessageShownRef.current) { showNotification( __('Browser-assisted mode active. Processing images one at a time. Keep this page open.', 'prorank-seo'), 'info' ); recoveryMessageShownRef.current = true; } } catch (error: any) { consecutiveFailures++; if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { setRecoveryMessage( __('Too many consecutive failures. Try again with only WebP enabled, or configure a real server cron job.', 'prorank-seo') ); showNotification( error?.message || __('Browser-assisted recovery stopped after repeated failures.', 'prorank-seo'), 'error' ); break; } // Single failure: image was marked as failed server-side, continue to next continue; } } recoveryRunningRef.current = false; }; const pollProgress = async () => { try { const response = await apiRequest<{ progress: BulkProgress; running?: boolean; remaining?: number; optimized?: number; total?: number; }>({ path: '/prorank-seo/v1/image-optimization/progress', }); if (response?.progress) { const isCompleted = response.progress.status === 'completed' || ( response.running === false && (response.remaining ?? 0) === 0 && (response.total ?? response.progress.total ?? 0) > 0 ); const normalizedProgress: BulkProgress = isCompleted ? { ...response.progress, current: response.optimized ?? response.total ?? response.progress.current, total: response.total ?? response.progress.total, status: 'completed', } : response.progress; const progressAdvanced = normalizedProgress.current > lastProgressRef.current.current || normalizedProgress.current_image !== lastProgressRef.current.currentImage || normalizedProgress.bytes_saved > lastProgressRef.current.bytesSaved; if (progressAdvanced) { stalledSinceRef.current = null; if (browserAssistModeRef.current) { browserAssistModeRef.current = false; setBrowserAssistMode(false); setRecoveryMessage(''); } } else if (['running', 'processing', 'stale'].includes(normalizedProgress.status)) { if (!stalledSinceRef.current) { stalledSinceRef.current = Date.now(); } else if ( Date.now() - stalledSinceRef.current >= getRecoveryGraceMs() && ( !lastRecoveryAttemptRef.current || Date.now() - lastRecoveryAttemptRef.current >= RECOVERY_COOLDOWN_MS ) ) { await attemptBrowserRecovery(); } } else { stalledSinceRef.current = null; } lastProgressRef.current = { current: normalizedProgress.current, currentImage: normalizedProgress.current_image, updatedAt: normalizedProgress.updated_at ?? null, bytesSaved: normalizedProgress.bytes_saved ?? 0, }; setProgress(normalizedProgress); if (isCompleted) { setBulkProcessing(false); setStartTime(null); browserAssistModeRef.current = false; setBrowserAssistMode(false); setRecoveryMessage(''); stalledSinceRef.current = null; lastRecoveryAttemptRef.current = null; recoveryNoticeShownRef.current = false; recoveryMessageShownRef.current = false; showNotification( sprintf( __('Optimization complete! %d images processed, %s saved.', 'prorank-seo'), normalizedProgress.current, formatBytes(normalizedProgress.bytes_saved) ), 'success' ); await loadStats(); } else if (normalizedProgress.status === 'error') { setBulkProcessing(false); setStartTime(null); browserAssistModeRef.current = false; setBrowserAssistMode(false); stalledSinceRef.current = null; lastRecoveryAttemptRef.current = null; recoveryNoticeShownRef.current = false; recoveryMessageShownRef.current = false; showNotification( normalizedProgress.error_message || __('An error occurred during optimization', 'prorank-seo'), 'error' ); } else if (normalizedProgress.status === 'stale') { await attemptBrowserRecovery(); } } } catch (error) { console.error('Failed to poll progress:', error); } }; const interval = setInterval(pollProgress, 1500); pollProgress(); // Initial poll return () => clearInterval(interval); }, [bulkProcessing]); // Format bytes to human readable const formatBytes = (bytes: number): string => { if (bytes >= 1048576) { return sprintf(__('%s MB', 'prorank-seo'), (bytes / 1048576).toFixed(2)); } return sprintf(__('%s KB', 'prorank-seo'), (bytes / 1024).toFixed(2)); }; // Calculate estimated time remaining const getTimeRemaining = (): string => { if (!startTime || progress.current === 0 || progress.total === 0) return ''; const elapsed = (Date.now() - startTime) / 1000; const rate = progress.current / elapsed; const remaining = (progress.total - progress.current) / rate; if (remaining < 60) return sprintf(__('%d seconds', 'prorank-seo'), Math.round(remaining)); if (remaining < 3600) return sprintf(__('%d minutes', 'prorank-seo'), Math.round(remaining / 60)); return sprintf(__('%d hours', 'prorank-seo'), Math.round(remaining / 3600)); }; const formatLogTime = (ts?: number): string => { if (!ts) return ''; const d = new Date(ts * 1000); return d.toLocaleTimeString(); }; const getActiveIndex = (): number => { if (typeof progress.active_index === 'number') { return progress.active_index; } if (progress.current_image) { return (progress.current || 0) + 1; } return progress.current || 0; }; const getOverallPercent = (): number => { if (typeof progress.percent === 'number') { return progress.percent; } if (progress.total > 0) { return Math.round((progress.current / progress.total) * 100); } return 0; }; const queueableRemaining = Math.max( 0, stats.pending ?? (stats.total_images - stats.optimized_images) ); const retryableFailedImages = Math.max(0, stats.retryable_failed_images ?? 0); const exhaustedFailedImages = Math.max(0, stats.exhausted_failed_images ?? 0); const noGainImages = Math.max(0, stats.no_gain_images ?? 0); const bulkRetryLimit = Math.max(1, stats.bulk_retry_limit ?? 3); const normalizeDeliveryMethod = ( value?: string ): ImageSettings['delivery_method'] => { if (value === 'accept') { return 'accept_header'; } if (value === 'rewrite') { return 'htaccess'; } if (value === 'picture_global') { return 'picture'; } if (value === 'picture' || value === 'accept_header' || value === 'htaccess' || value === 'cdn' || value === 'none') { return value; } return 'picture'; }; const formatFailureReason = (reason?: string): string => { if (!reason) { return __('Optimization failed', 'prorank-seo'); } if (reason === 'no_effective_output') { return __('No smaller output was produced for this image.', 'prorank-seo'); } return reason.replace(/_/g, ' '); }; const normalizeBooleanSetting = (value: unknown): boolean => { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value !== 0; } if (typeof value === 'string') { const normalized = value.trim().toLowerCase(); if (['1', 'true', 'yes', 'on', 'enabled'].includes(normalized)) { return true; } if (['0', 'false', 'no', 'off', 'disabled', ''].includes(normalized)) { return false; } } return Boolean(value); }; const normalizeIntegerSetting = ( value: unknown, fallback: number, min: number, max: number ): number => { const numeric = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN; if (!Number.isFinite(numeric)) { return fallback; } return Math.min(max, Math.max(min, Math.round(numeric))); }; const normalizeOversizedThreshold = (value: unknown): number => { const threshold = normalizeIntegerSetting(value, 0, 0, 8192); return threshold > 0 ? Math.max(640, threshold) : 0; }; const normalizeFreeSettings = (value: Partial): Partial => ({ ...value, optimization_method: 'local', compression_type: value.compression_type === 'lossless' || value.compression_type === 'glossy' || value.compression_type === 'lossy' || value.compression_type === 'aggressive' ? value.compression_type : 'glossy', webp_enabled: normalizeBooleanSetting(value.webp_enabled), webp_quality: normalizeIntegerSetting(value.webp_quality, 85, 0, 100), avif_enabled: normalizeBooleanSetting(value.avif_enabled), avif_quality: normalizeIntegerSetting(value.avif_quality, 80, 0, 100), jxl_enabled: normalizeBooleanSetting(value.jxl_enabled), jxl_quality: normalizeIntegerSetting(value.jxl_quality, 85, 0, 100), jpegli_enabled: normalizeBooleanSetting(value.jpegli_enabled), png_quality: normalizeIntegerSetting(value.png_quality, 80, 0, 100), png_pipeline: normalizeBooleanSetting(value.png_pipeline), smart_compression: normalizeBooleanSetting(value.smart_compression), optimize_on_upload: normalizeBooleanSetting(value.optimize_on_upload), background_mode: normalizeBooleanSetting(value.background_mode), backup_originals: normalizeBooleanSetting(value.backup_originals), optimize_original_source: normalizeBooleanSetting(value.optimize_original_source), lazyload_images: normalizeBooleanSetting(value.lazyload_images), lazyload_iframes: normalizeBooleanSetting(value.lazyload_iframes), lazyload_videos: normalizeBooleanSetting(value.lazyload_videos), lazyload_threshold: normalizeIntegerSetting(value.lazyload_threshold, 200, 0, 1000), cdn_enabled: normalizeBooleanSetting(value.cdn_enabled), cdn_url: typeof value.cdn_url === 'string' ? value.cdn_url : '', exclude_paths: typeof value.exclude_paths === 'string' ? value.exclude_paths : '', png_to_jpeg: normalizeBooleanSetting(value.png_to_jpeg), cmyk_to_rgb: normalizeBooleanSetting(value.cmyk_to_rgb), remove_exif: normalizeBooleanSetting(value.remove_exif), optimize_pdf: normalizeBooleanSetting(value.optimize_pdf), generate_retina: normalizeBooleanSetting(value.generate_retina), auto_generate_missing_sizes: normalizeBooleanSetting(value.auto_generate_missing_sizes), oversized_threshold: normalizeOversizedThreshold(value.oversized_threshold), no_ai_training: normalizeBooleanSetting(value.no_ai_training), resize_large_enabled: normalizeBooleanSetting(value.resize_large_enabled), resize_max_width: normalizeIntegerSetting(value.resize_max_width, 2560, 320, 8192), resize_max_height: normalizeIntegerSetting(value.resize_max_height, 2560, 320, 8192), cloud_opt_in: false, cloud_opt_in_at: null, }); const loadSettings = async (): Promise => { try { const response = await apiRequest({ path: '/prorank-seo/v1/performance/image-optimization/settings', }); if (response?.settings) { const merged = normalizeFreeSettings({ ...settings, ...response.settings, delivery_method: normalizeDeliveryMethod(response.settings.delivery_method), }); setSettings(merged as ImageSettings); } } catch (error: any) { showNotification( error?.message || __('Failed to load settings. Please refresh the page.', 'prorank-seo'), 'error' ); } finally { setLoading(false); } }; const loadStats = async (): Promise => { try { const response = await apiRequest<{ stats?: ImageStats; module_enabled?: boolean; }>({ path: '/prorank-seo/v1/performance/image-optimization/stats', }); setModuleEnabled(response?.module_enabled !== false); if (response?.stats) { setStats(response.stats); } } catch (error: any) { // Stats are non-critical, show warning only console.warn('Failed to load image stats:', error); } }; const loadServerSupport = async (): Promise => { try { const response = await apiRequest<{ success: boolean; support: ServerSupport }>({ path: '/prorank-seo/v1/image-optimization/check-support', }); if (response?.support) { setServerSupport(response.support); } } catch (error: any) { // Server support is non-critical console.warn('Failed to load server support:', error); } }; const applyPreset = (presetKey: keyof typeof presetQualities): void => { const preset = presetQualities[presetKey]; if (!preset) return; setSettings((prev) => ({ ...prev, compression_type: preset.compression_type, webp_quality: preset.webp, avif_quality: preset.avif, jxl_quality: preset.jxl, png_quality: preset.png, })); showNotification(__('Preset applied', 'prorank-seo'), 'success'); }; const describePreset = (presetKey: keyof typeof presetQualities): string => { const preset = presetQualities[presetKey]; if (!preset) return ''; return `WebP ${preset.webp}% · AVIF ${preset.avif}% · JXL ${preset.jxl}% · PNG ${preset.png}%`; }; const buildNormalizedSettingsPayload = (nextSettings?: ImageSettings): Partial => { const isEventPayload = nextSettings && typeof nextSettings === 'object' && 'preventDefault' in nextSettings; const safeSettings = isEventPayload ? undefined : nextSettings; const payload = safeSettings ?? settings; return normalizeFreeSettings({ ...payload, delivery_method: normalizeDeliveryMethod(payload.delivery_method), }); }; const persistSettings = async (nextSettings?: ImageSettings, notifySuccess = true): Promise> => { const normalizedPayload = buildNormalizedSettingsPayload(nextSettings); await apiRequest({ path: '/prorank-seo/v1/performance/image-optimization/settings', method: 'POST', data: { settings: normalizedPayload }, }); if (notifySuccess) { showNotification(__('Settings saved successfully', 'prorank-seo'), 'success'); } return normalizedPayload; }; const saveSettings = async (nextSettings?: ImageSettings): Promise => { setSaving(true); try { await persistSettings(nextSettings, true); } catch (error: any) { showNotification(error?.message || __('Failed to save settings', 'prorank-seo'), 'error'); } finally { setSaving(false); } }; const startBulkOptimization = async (forceReoptimization = false): Promise => { if (!moduleEnabled) { showNotification( __('Image Optimization is currently disabled. Enable the Image Optimisation module first, then try again.', 'prorank-seo'), 'warning' ); return; } if (!effectiveFormatOutputEnabled) { showNotification( __('Enable WebP before starting local bulk optimization. Save settings first if you just changed the format toggle.', 'prorank-seo'), 'warning' ); return; } // Reset progress state setProgress({ current: 0, total: 0, current_image: '', bytes_saved: 0, status: 'running', }); setBrowserAssistMode(false); setRecoveryMessage(''); browserAssistModeRef.current = false; lastProgressRef.current = { current: 0, currentImage: '', updatedAt: null, bytesSaved: 0 }; stalledSinceRef.current = null; recoveryRunningRef.current = false; recoveryNoticeShownRef.current = false; recoveryMessageShownRef.current = false; lastRecoveryAttemptRef.current = null; setStartTime(Date.now()); setBulkProcessing(true); try { await persistSettings(settings, false); const response = await apiRequest<{ message?: string; total?: number; scheduled?: number; complete?: boolean; no_retryable_images?: boolean; failed_images?: number; no_gain_images?: number; unresolved_images?: number; }>({ path: forceReoptimization ? '/prorank-seo/v1/image-optimization/reoptimize' : '/prorank-seo/v1/image-optimization/bulk-convert', method: 'POST', }); // Update total if returned if (response?.total) { setProgress(prev => ({ ...prev, total: response.total })); } if (response?.scheduled) { setProgress(prev => ({ ...prev, total: response.scheduled })); } if (response?.no_retryable_images) { setProgress({ current: 0, total: 0, current_image: '', bytes_saved: stats.saved_bytes ?? 0, status: 'idle', }); setBulkProcessing(false); setStartTime(null); setBrowserAssistMode(false); setRecoveryMessage(''); browserAssistModeRef.current = false; stalledSinceRef.current = null; lastRecoveryAttemptRef.current = null; recoveryRunningRef.current = false; recoveryNoticeShownRef.current = false; recoveryMessageShownRef.current = false; await loadStats(); showNotification( response?.message || __('No retryable images remain right now. Review the failed-image count below.', 'prorank-seo'), 'warning' ); return; } if (response?.complete) { const completedTotal = response.total ?? stats.total_images ?? 0; setProgress({ current: completedTotal, total: completedTotal, current_image: '', bytes_saved: stats.saved_bytes ?? 0, status: 'completed', }); setBulkProcessing(false); setStartTime(null); setBrowserAssistMode(false); setRecoveryMessage(''); browserAssistModeRef.current = false; stalledSinceRef.current = null; lastRecoveryAttemptRef.current = null; recoveryRunningRef.current = false; recoveryNoticeShownRef.current = false; recoveryMessageShownRef.current = false; await loadStats(); showNotification( response?.message || __('All images have already been optimized', 'prorank-seo'), 'success' ); return; } showNotification( response?.message || (response?.scheduled ? sprintf(__('Scheduled %s images for optimization', 'prorank-seo'), response.scheduled) : __('Bulk optimization started', 'prorank-seo')), 'info' ); // Note: Don't set bulkProcessing to false here - the polling useEffect will handle it } catch (error: any) { if ( error?.data?.status === 409 || error?.statusCode === 409 || error?.code === 'already_running' || (typeof error?.message === 'string' && error.message.toLowerCase().includes('already running')) ) { try { const existing = await apiRequest<{ success?: boolean; progress?: BulkProgress; }>({ path: '/prorank-seo/v1/image-optimization/progress', }); const existingProgress = existing?.progress; const resumableStatuses = ['running', 'stale', 'processing']; if (existingProgress && resumableStatuses.includes(existingProgress.status)) { setProgress(existingProgress); setBulkProcessing(true); setStartTime(existingProgress.started_at ? existingProgress.started_at * 1000 : Date.now()); showNotification( __('An existing bulk optimization job is already running on this site. ProRank resumed its progress view instead of starting a duplicate job.', 'prorank-seo'), 'info' ); return; } } catch (resumeError) { console.error('Failed to resume existing bulk optimization job:', resumeError); } } setBulkProcessing(false); setStartTime(null); setBrowserAssistMode(false); setRecoveryMessage(''); browserAssistModeRef.current = false; setProgress(prev => ({ ...prev, status: 'error' })); showNotification( error?.message || __('Failed to start bulk optimization', 'prorank-seo'), 'error' ); } }; const stopBulkOptimization = async (): Promise => { try { await apiRequest({ path: '/prorank-seo/v1/image-optimization/stop', method: 'POST', }); setBulkProcessing(false); setStartTime(null); setBrowserAssistMode(false); setRecoveryMessage(''); browserAssistModeRef.current = false; stalledSinceRef.current = null; lastRecoveryAttemptRef.current = null; recoveryNoticeShownRef.current = false; recoveryMessageShownRef.current = false; setProgress(prev => ({ ...prev, status: 'idle' })); showNotification(__('Optimization stopped', 'prorank-seo'), 'info'); await loadStats(); } catch (error: any) { showNotification( error?.message || __('Failed to stop optimization', 'prorank-seo'), 'error' ); } }; const restoreBackups = async (): Promise => { if (!moduleEnabled) { showNotification( __('Image Optimization is currently disabled. Enable the Image Optimisation module first, then try again.', 'prorank-seo'), 'warning' ); return; } try { setBulkProcessing(true); const response = await apiRequest<{ restored: number; failed: number; missing_files?: number; message?: string }>({ path: '/prorank-seo/v1/image-optimization/restore-backups', method: 'POST', }); const notificationType = (response?.restored ?? 0) === 0 && (response?.missing_files ?? 0) > 0 ? 'warning' : 'success'; showNotification( response?.message || sprintf(__('Restored %d images from backups', 'prorank-seo'), response?.restored ?? 0), notificationType ); await loadStats(); } catch (error: any) { showNotification( error?.message || __('Failed to restore backups', 'prorank-seo'), 'error' ); } finally { setBulkProcessing(false); } }; const enableImageRuntime = async (): Promise => { setEnablingRuntime(true); try { await apiRequest({ path: '/prorank-seo/v1/modules/image_optimization/toggle', method: 'POST', data: { enabled: true }, }); setModuleEnabled(true); await loadStats(); showNotification(__('Image Optimization runtime enabled', 'prorank-seo'), 'success'); } catch (error: any) { showNotification( error?.message || __('Failed to enable Image Optimization runtime', 'prorank-seo'), 'error' ); } finally { setEnablingRuntime(false); } }; if (loading) { return (

{__('Loading settings...', 'prorank-seo')}

); } return (
{/* Statistics Overview Card */}

{__('Optimization Statistics', 'prorank-seo')}

{stats.total_images}
{__('Media Library Images', 'prorank-seo')}
{stats.optimized_images}
{__('Modern-ready Images', 'prorank-seo')}
{stats.saved_bytes > 1048576 ? sprintf(__('%s MB', 'prorank-seo'), (stats.saved_bytes / 1048576).toFixed(2)) : sprintf(__('%s KB', 'prorank-seo'), (stats.saved_bytes / 1024).toFixed(2))}
{__('Space Saved', 'prorank-seo')}
{stats.optimization_percentage}%
{__('Optimization Rate', 'prorank-seo')}
{typeof stats.backup_size === 'number' && (
{stats.backup_size_formatted || (stats.backup_size > 1048576 ? sprintf(__('%s MB', 'prorank-seo'), (stats.backup_size / 1048576).toFixed(2)) : sprintf(__('%s KB', 'prorank-seo'), (stats.backup_size / 1024).toFixed(2)))}
{__('Backup Storage Used', 'prorank-seo')}
)}
{(stats.already_modern_images || stats.unsupported_images) ? (
{__('Counts WordPress media attachments only, not generated thumbnails or format variants.', 'prorank-seo')}
{queueableRemaining > 0 ? (
{sprintf( __('%d source image(s) are still queueable for conversion before they are modern-format ready.', 'prorank-seo'), queueableRemaining )}
) : null} {retryableFailedImages > 0 ? (
{sprintf( __('%1$d image(s) previously failed and will be retried during manual bulk runs.', 'prorank-seo'), retryableFailedImages )}
) : null} {exhaustedFailedImages > 0 ? (
{sprintf( __('%1$d image(s) hit the automatic retry limit of %2$d attempts and will not be retried again until their optimization state is reset.', 'prorank-seo'), exhaustedFailedImages, bulkRetryLimit )}
) : null} {noGainImages > 0 ? (
{sprintf( __('%d image(s) were evaluated and left unchanged because the optimizer could not produce a meaningfully smaller output.', 'prorank-seo'), noGainImages )}
) : null} {stats.already_modern_images ? (
{sprintf( __('%d image(s) are already in modern formats and do not need conversion.', 'prorank-seo'), stats.already_modern_images )}
) : null} {stats.unsupported_images ? (
{sprintf( __('%d image(s) use source formats that local optimization does not convert.', 'prorank-seo'), stats.unsupported_images )}
) : null}
) : (
{__('Counts WordPress media attachments only, not generated thumbnails or format variants.', 'prorank-seo')}
)}
{/* Server Capabilities Card */} {serverSupport && (

{__('Server Capabilities', 'prorank-seo')}

{/* Image Libraries */}
{__('Image Libraries', 'prorank-seo')}
GD Library {serverSupport.details.gd_version && `(${serverSupport.details.gd_version})`}
ImageMagick
{/* Format Support */}
{__('Format Support', 'prorank-seo')}
WebP
AVIF
JPEG XL {serverSupport.details.libjxl_version && `(${serverSupport.details.libjxl_version})`}
Jpegli
.htaccess rewrite
{/* Server Info */}
{__('Server Info', 'prorank-seo')}
PHP: {serverSupport.server.php_version}
Memory: {serverSupport.server.memory_limit}
Max Upload: {serverSupport.server.upload_max_filesize}
Timeout: {serverSupport.server.max_execution_time}s
{/* Warnings */} {(!serverSupport.webp || !serverSupport.avif || !serverSupport.jxl) && (
{!serverSupport.webp && !serverSupport.avif && !serverSupport.jxl ? ( __('Your server does not support local WebP, AVIF, or JPEG XL generation yet. Install or enable the required GD, ImageMagick, or CLI encoders to use these formats.', 'prorank-seo') ) : !serverSupport.jxl ? ( __('JPEG XL is not available on this server. Install local JXL support if you want to generate JPEG XL files.', 'prorank-seo') ) : !serverSupport.avif ? ( __('AVIF support requires PHP 8.1+ with GD or ImageMagick.', 'prorank-seo') ) : ( __('WebP support is not available. Enable GD or ImageMagick with WebP support to generate WebP files locally.', 'prorank-seo') )}
)}
)} {/* Image Formats Card */}

{__('Image Formats', 'prorank-seo')}

{effectiveFormatOutputEnabled ? __('Active', 'prorank-seo') : __('Inactive', 'prorank-seo')}
{settings.optimization_method === 'local' && !settings.webp_enabled && (
{__('Local bulk optimization needs WebP enabled to produce a modern output in this build. Enable WebP before starting a bulk run.', 'prorank-seo')}
)}
{__('Generate WebP', 'prorank-seo')} {__('25-35% smaller than JPEG with similar quality', 'prorank-seo')}
setSettings({ ...settings, webp_enabled })} />
{settings.webp_enabled && (
setSettings({ ...settings, webp_quality })} min={60} max={100} step={5} />
)}
{__('Generate AVIF', 'prorank-seo')} {__('50% smaller than JPEG, requires modern browser support', 'prorank-seo')}
setSettings({ ...settings, avif_enabled })} />
{settings.avif_enabled && (
setSettings({ ...settings, avif_quality })} min={50} max={100} step={5} />
)}
{__('Generate JPEG XL (JXL)', 'prorank-seo')} {__('60% smaller than JPEG when local JXL support is available on your server', 'prorank-seo')}
setSettings({ ...settings, jxl_enabled })} disabled={!serverSupport?.jxl} />
{settings.jxl_enabled && (
setSettings({ ...settings, jxl_quality })} min={50} max={100} step={5} />
)}
{__('PNG Advanced Pipeline', 'prorank-seo')} {__('pngquant → oxipng → ECT for smaller PNGs (slower on large files)', 'prorank-seo')}
setSettings({ ...settings, png_pipeline })} />
setSettings({ ...settings, png_quality })} min={50} max={100} step={5} />
{(['aggressive', 'lossy', 'glossy', 'lossless'] as const).map((key) => ( setSettings((prev) => ({ ...prev, png_quality: presetQualities[key].png }))} > {sprintf(__('PNG %s%%', 'prorank-seo'), presetQualities[key].png)} ))}
{__('Jpegli JPEG Encoding', 'prorank-seo')} {__('25-35% better JPEG compression when the local encoder is installed on your server', 'prorank-seo')}
setSettings({ ...settings, jpegli_enabled })} disabled={!serverSupport?.jpegli} />
{/* Compression Settings Card */}

{__('Compression Settings', 'prorank-seo')}

setSettings({ ...settings, compression_type })} />
{(['aggressive', 'lossy', 'glossy', 'lossless'] as const).map((key) => ( applyPreset(key)}> {describePreset(key)} ))}
{__('Smart Compression', 'prorank-seo')} {__('Automatically lower quality for very large / high-resolution images to cut weight with minimal visible loss.', 'prorank-seo')}
setSettings({ ...settings, smart_compression })} />
setSettings({ ...settings, optimization_method: 'local' })} />
{/* Automatic Optimization Card */}

{__('Automatic Optimization', 'prorank-seo')}

{settings.optimize_on_upload ? __('Enabled', 'prorank-seo') : __('Disabled', 'prorank-seo')}
{__('Optimize on Upload', 'prorank-seo')} {__('Automatically optimize new images when uploaded', 'prorank-seo')}
setSettings({ ...settings, optimize_on_upload })} />
{__('Background Processing', 'prorank-seo')} {__('Process images in background to avoid timeouts', 'prorank-seo')}
setSettings({ ...settings, background_mode })} />
{__('Backup Original Images', 'prorank-seo')} {__('Keep original images for restoration if needed', 'prorank-seo')}
setSettings({ ...settings, backup_originals })} />
{__('Shrink Original Attachment URLs', 'prorank-seo')} {__('Try to reduce the bytes served at the original media URL itself. Helpful for crawler audits that inspect raw image URLs.', 'prorank-seo')}
setSettings({ ...settings, optimize_original_source })} />
{__('Resize Oversized Originals', 'prorank-seo')} {__('Downscale very large original attachments before in-place optimization so raw image URLs can clear crawler size audits.', 'prorank-seo')}
setSettings({ ...settings, resize_large_enabled })} />
{settings.resize_large_enabled && (
setSettings({ ...settings, resize_max_width: resize_max_width || 1920 })} min={800} max={3840} step={64} /> setSettings({ ...settings, resize_max_height: resize_max_height || 1920 })} min={800} max={3840} step={64} />
)}
{__('Generate Missing Thumbnail Sizes', 'prorank-seo')} {__('Create any registered image subsizes that WordPress missed when a new image is uploaded.', 'prorank-seo')}
setSettings({ ...settings, auto_generate_missing_sizes })} />
0 ? __('Future uploads above this width or height are scaled by WordPress before optimization.', 'prorank-seo') : __('0 keeps the WordPress default. Use 640 or higher to override the upload scaling threshold.', 'prorank-seo')} value={settings.oversized_threshold} onChange={(oversized_threshold) => setSettings({ ...settings, oversized_threshold: normalizeOversizedThreshold(oversized_threshold) })} min={0} max={8192} step={128} />
{/* Lazy Loading Card */}

{__('Lazy Loading', 'prorank-seo')}

{settings.lazyload_images ? __('Enabled', 'prorank-seo') : __('Disabled', 'prorank-seo')}
{__('Lazy Load Images', 'prorank-seo')} {__('Load images only when they enter viewport', 'prorank-seo')}
setSettings({ ...settings, lazyload_images })} />
{__('Lazy Load Iframes', 'prorank-seo')} {__('Defer loading of embedded content', 'prorank-seo')}
setSettings({ ...settings, lazyload_iframes })} />
{__('Lazy Load Videos', 'prorank-seo')} {__('Defer loading of video elements', 'prorank-seo')}
setSettings({ ...settings, lazyload_videos })} />
setSettings({ ...settings, lazyload_threshold })} min={0} max={500} step={50} />
{/* CDN Settings Card */}

{__('CDN Settings', 'prorank-seo')}

{settings.cdn_enabled ? __('Active', 'prorank-seo') : __('Inactive', 'prorank-seo')}
{__('Enable CDN', 'prorank-seo')} {__('Serve images from CDN for faster loading', 'prorank-seo')}
setSettings({ ...settings, cdn_enabled })} />
{settings.cdn_enabled && (
setSettings({ ...settings, cdn_url: e.target.value })} />

{__('Enter your CDN URL without trailing slash', 'prorank-seo')}

)}
setSettings({ ...settings, exclude_paths })} rows={5} placeholder="/wp-admin/ /cart/ /checkout/" />
{/* Advanced Settings Card */}

{__('Advanced Settings', 'prorank-seo')}

{__('Convert PNG to JPEG', 'prorank-seo')} {__('Automatically convert PNG images to JPEG if they have no transparency', 'prorank-seo')}
setSettings({ ...settings, png_to_jpeg })} />
{__('Convert CMYK to RGB', 'prorank-seo')} {__('Convert print-ready CMYK images to web-friendly RGB color space', 'prorank-seo')}
setSettings({ ...settings, cmyk_to_rgb })} />
{__('Strip EXIF Data', 'prorank-seo')} {__('Remove metadata from images (keeps orientation)', 'prorank-seo')}
setSettings({ ...settings, remove_exif })} />
{__('Optimize PDF Files', 'prorank-seo')} {__('Compress and optimize uploaded PDF documents', 'prorank-seo')}
setSettings({ ...settings, optimize_pdf })} />
{__('Generate Retina (@2x)', 'prorank-seo')} {__('Create high-resolution versions for Retina displays', 'prorank-seo')}
setSettings({ ...settings, generate_retina })} />
{__('AI Training Protection', 'prorank-seo')} {__('Add noai meta tag to prevent images from being used in AI training', 'prorank-seo')}
setSettings({ ...settings, no_ai_training })} />
tag (Recommended)', 'prorank-seo'), value: 'picture' }, { label: __('Accept header detection', 'prorank-seo'), value: 'accept_header' }, { label: __('.htaccess rewrite rules', 'prorank-seo'), value: 'htaccess' }, { label: __('CDN-based delivery', 'prorank-seo'), value: 'cdn' }, { label: __('None (manual)', 'prorank-seo'), value: 'none' }, ]} onChange={(delivery_method: 'picture' | 'accept_header' | 'htaccess' | 'cdn' | 'none') => setSettings({ ...settings, delivery_method })} />
{/* Bulk Optimization Card */}

{__('Bulk Optimization', 'prorank-seo')}

{bulkProcessing && (
{__('Running', 'prorank-seo')}
)}
{!moduleEnabled && (
{__('The image optimization runtime is currently disabled. Toggle the Image Optimisation module on to use bulk conversion and backup restore actions.', 'prorank-seo')}
{enablingRuntime ? __('Enabling...', 'prorank-seo') : __('Enable Image Optimization Runtime', 'prorank-seo')}
)} {!bulkProcessing && (stats.failed_images ?? 0) > 0 && (
{__('Previous failures detected.', 'prorank-seo')}{' '} {exhaustedFailedImages > 0 ? sprintf( __('%1$d image(s) have already hit the retry limit of %2$d attempts, so a new bulk run will only retry the remaining recoverable failures.', 'prorank-seo'), exhaustedFailedImages, bulkRetryLimit ) : sprintf( __('%1$d image(s) failed in an earlier pass. ProRank will retry them automatically during the next bulk run.', 'prorank-seo'), stats.failed_images ?? 0 )}
)} {!bulkProcessing ? ( <>

{__('Optimize all existing images in your media library at once.', 'prorank-seo')}

startBulkOptimization(false)} disabled={!moduleEnabled || !effectiveFormatOutputEnabled} > {__('Start Bulk Optimization', 'prorank-seo')} startBulkOptimization(true)} disabled={!moduleEnabled || !effectiveFormatOutputEnabled} > {__('Re-optimize Existing Images', 'prorank-seo')} {__('Restore All Backups', 'prorank-seo')}

{__('Use re-optimization after changing Smart Compression or format settings to recompress existing WebP/AVIF files that were generated by an older profile.', 'prorank-seo')}

{__('Important:', 'prorank-seo')} {__('Bulk optimization can take significant time for large libraries. We recommend running it during low-traffic periods.', 'prorank-seo')}
) : ( <> {progress.status === 'completed' && (
{__('Bulk optimization completed', 'prorank-seo')}
{sprintf(__('Processed: %d images', 'prorank-seo'), progress.summary?.processed ?? progress.current)} {sprintf(__('Saved: %s', 'prorank-seo'), formatBytes(progress.summary?.saved_bytes ?? progress.bytes_saved))}
)} {recoveryMessage && (
{browserAssistMode ? __('Browser-assisted recovery active.', 'prorank-seo') : __('Background queue stalled.', 'prorank-seo')}{' '} {recoveryMessage}
{__('If this host keeps stalling, enable a real server cron job in your hosting panel and retry with only WebP enabled first.', 'prorank-seo')}
)} {/* Progress Bar */}
{progress.total > 0 ? sprintf(__('Processing %d of %d images', 'prorank-seo'), getActiveIndex(), progress.total) : __('Initializing...', 'prorank-seo')} {progress.total > 0 ? `${getOverallPercent()}%` : '0%'}
0 ? getOverallPercent() : 0}%`, borderRadius: '8px', transition: 'width 0.3s ease', }} />
{/* Current Image */} {progress.current_image && (
{sprintf( __('Optimizing %1$s of %2$s (%3$s%%)', 'prorank-seo'), getActiveIndex(), progress.total || 0, getOverallPercent() )}
{progress.current_image}
)} {/* Stats Grid */}
{formatBytes(progress.bytes_saved)}
{__('Space Saved', 'prorank-seo')}
{progress.current}
{__('Images Done', 'prorank-seo')}
{getTimeRemaining() || '...'}
{__('Est. Remaining', 'prorank-seo')}
{/* Activity Log */} {progress.log && progress.log.length > 0 && (
{__('Activity Log', 'prorank-seo')}
{progress.log.slice(-10).reverse().map((entry, idx) => (
{entry.image} {entry.status === 'done' && entry.bytes_saved !== undefined && ( {sprintf(__('Saved %s', 'prorank-seo'), formatBytes(entry.bytes_saved))} )} {(entry.status === 'failed' || entry.status === 'skipped') && ( {formatFailureReason(entry.reason)} )}
{entry.status === 'done' ? __('Done', 'prorank-seo') : entry.status === 'failed' ? __('Failed', 'prorank-seo') : entry.status === 'skipped' ? __('Unchanged', 'prorank-seo') : __('Processing', 'prorank-seo')}
{formatLogTime(entry.ts)}
))}
)} {/* Stop Button */}
{__('Stop Optimization', 'prorank-seo')}
{browserAssistMode ? __('Keep this window open while ProRank processes images directly in the browser because the background queue appears to be stalled on this host.', 'prorank-seo') : __('You can close this window - optimization will continue in the background.', 'prorank-seo')}
)}
{/* Save Button */}
saveSettings()} disabled={saving} style={{ minWidth: '200px' }} > {saving ? __('Saving...', 'prorank-seo') : __('Save All Settings', 'prorank-seo')}
); }; export default ImageOptimizationSettings;