UNPKG

strapi-to-lokalise-plugin

Version:

Preview and sync Lokalise translations from Strapi admin

1,264 lines (1,160 loc) 158 kB
// @ts-nocheck import React, { useState, useMemo, useEffect, useCallback, useRef, startTransition } from 'react'; import * as DesignSystem from '../../ui/design-system.jsx'; import { ChevronDown, ChevronUp, Check, Minus } from '../../ui/icons.jsx'; // Import Strapi's built-in fetch client hook (handles auth automatically) // Official Strapi approach: Import from @strapi/helper-plugin // Works in both v4 and v5 let useFetchClient; try { const helperPlugin = require('@strapi/helper-plugin'); useFetchClient = helperPlugin.useFetchClient || helperPlugin.default?.useFetchClient; if (!useFetchClient) { console.warn('⚠️ [lokalise-sync] useFetchClient not found in @strapi/helper-plugin'); } } catch (e) { console.warn('⚠️ [lokalise-sync] Could not import @strapi/helper-plugin:', e.message); useFetchClient = null; } const { EmptyStateLayout, Button, Table, Thead, Tbody, Tr, Th, Td, Typography, Box, Flex, Checkbox, Loader, TextInput, IconButton, Tag, Alert, } = DesignSystem; const JOB_STORAGE_KEY = 'lokaliseSyncActiveJobId'; const SETTINGS_FORM_STORAGE_KEY = 'lokaliseSyncSettingsForm'; const INITIAL_KEY_LIMIT = 10; // Very small initial render for instant expansion const LIMIT_STEP = 25; // Smaller increments for smoother loading const INITIAL_SLUG_GROUP_LIMIT = 20; // Limit slug groups shown initially const SLUG_GROUP_STEP = 20; // Increment for slug groups const SMALL_JOB_FAST_THRESHOLD = 25; // Match backend default for "instant" jobs const DEFAULT_LOKALISE_BASE_URL = 'https://api.lokalise.com/api2'; const FAST_POLL_INTERVAL = 1000; // Helper to get the correct API base path based on Strapi version // v4: /lokalise-sync/* (plain array routes - per compatibility guide) // v5: /admin/api/lokalise-sync/* (namespaced under admin API) // CRITICAL FIX: v4 admin panel ALSO runs at /admin/*, so we CANNOT use pathname to detect version // We must check for explicit v5 indicators only, otherwise default to v4 const getApiBasePath = () => { let detectedVersion = null; let basePath = null; if (typeof window !== 'undefined') { console.log('[lokalise-sync] 🔍 ========== VERSION DETECTION START =========='); console.log('[lokalise-sync] 🔍 Checking window.STRAPI_VERSION:', window.STRAPI_VERSION); console.log('[lokalise-sync] 🔍 Checking window.strapi?.version:', window.strapi?.version); console.log('[lokalise-sync] 🔍 Checking window.location?.pathname:', window.location?.pathname); // Only check for explicit v5 indicators - do NOT check pathname // Check window.STRAPI_VERSION if explicitly set by Strapi (most reliable) if (window.STRAPI_VERSION && String(window.STRAPI_VERSION).startsWith('5')) { detectedVersion = 'v5'; basePath = '/admin/api/lokalise-sync'; console.log('[lokalise-sync] ✅ Detected Strapi v5 from STRAPI_VERSION:', window.STRAPI_VERSION); console.log('[lokalise-sync] ✅ Using v5 path:', basePath); console.log('[lokalise-sync] 🔍 ========== VERSION DETECTION END =========='); return basePath; } // Check for v5-specific global variables if (window.strapi && window.strapi.version && String(window.strapi.version).startsWith('5')) { detectedVersion = 'v5'; basePath = '/admin/api/lokalise-sync'; console.log('[lokalise-sync] ✅ Detected Strapi v5 from window.strapi.version:', window.strapi.version); console.log('[lokalise-sync] ✅ Using v5 path:', basePath); console.log('[lokalise-sync] 🔍 ========== VERSION DETECTION END =========='); return basePath; } // REMOVED: window.location.pathname.startsWith('/admin') check // This was causing v4 to be incorrectly detected as v5 // v4 admin panel runs at /admin/* just like v5 // DEFAULT: Assume v4 unless explicitly told otherwise // v4 routes (plain array) are accessible at /lokalise-sync/* (per compatibility guide) detectedVersion = 'v4'; basePath = '/lokalise-sync'; // v4 routes use /lokalise-sync/* (per compatibility guide) console.log('[lokalise-sync] ✅ Defaulting to Strapi v4 (no v5 indicators found)'); console.log('[lokalise-sync] ✅ Using v4 path:', basePath); console.log('[lokalise-sync] 🔍 Detection context:', { pathname: window.location?.pathname, STRAPI_VERSION: window.STRAPI_VERSION, strapiVersion: window.strapi?.version, strapiAdminConfig: window.strapi?.admin?.config, }); console.log('[lokalise-sync] 🔍 ========== VERSION DETECTION END =========='); return basePath; } // DEFAULT: v4 format (/lokalise-sync/*) // v4 routes (plain array) are accessible at /lokalise-sync/* (per compatibility guide) console.log('[lokalise-sync] ✅ No window object - defaulting to v4 path: /lokalise-sync'); return '/lokalise-sync'; }; // Note: We'll call getApiBasePath() dynamically since window.location may not be available at module load const SLOW_POLL_INTERVAL = 4000; const VERY_SLOW_POLL_INTERVAL = 8000; const FAST_POLL_WINDOW_MS = 20000; const VERY_SLOW_POLL_START_MS = 60000; const STUCK_WARNING_THRESHOLD_MS = 180000; // Helper function to highlight matching text in search results const highlightText = (text, searchTerm) => { if (!searchTerm || !text) return text; const textStr = String(text); const searchStr = String(searchTerm).trim(); if (!searchStr || searchStr.length === 0) return text; // Normalize both for case-insensitive matching (handle spaces/hyphens) const normalizedText = textStr.toLowerCase().replace(/[\s-]/g, ' '); const normalizedSearch = searchStr.toLowerCase().trim().replace(/[\s-]/g, ' '); // Find all matches (case-insensitive, handling space/hyphen variations) const matches = []; // Strategy 1: Direct substring match (case-insensitive) const textLower = textStr.toLowerCase(); const searchLower = searchStr.toLowerCase(); let searchIndex = 0; while ((searchIndex = textLower.indexOf(searchLower, searchIndex)) !== -1) { matches.push({ start: searchIndex, end: searchIndex + searchLower.length, type: 'exact', }); searchIndex += searchLower.length; } // Strategy 2: If no exact matches, try normalized matching (handles space/hyphen differences) if (matches.length === 0) { // Try to find normalized search in normalized text const normalizedTextForMatch = textStr.toLowerCase().replace(/\s+/g, ' ').replace(/-/g, ' '); const normalizedSearchForMatch = searchStr.toLowerCase().trim().replace(/\s+/g, ' ').replace(/-/g, ' '); if (normalizedTextForMatch.includes(normalizedSearchForMatch)) { // Find the position in the original text by matching words const searchWords = normalizedSearchForMatch.split(/\s+/).filter(w => w.length > 0); if (searchWords.length > 0) { // Find first word in original text const firstWord = searchWords[0]; const firstWordIndex = textLower.indexOf(firstWord); if (firstWordIndex !== -1) { // Estimate the match span (approximate, but good enough for highlighting) const estimatedLength = Math.min( searchStr.length + 5, // Add some buffer for hyphens/spaces textStr.length - firstWordIndex ); matches.push({ start: firstWordIndex, end: Math.min(firstWordIndex + estimatedLength, textStr.length), type: 'normalized', }); } } } } // If no matches found, return original text if (matches.length === 0) return text; // Sort matches by start position and merge overlapping matches matches.sort((a, b) => a.start - b.start); const mergedMatches = []; let currentMatch = matches[0]; for (let i = 1; i < matches.length; i++) { if (matches[i].start <= currentMatch.end) { // Merge overlapping matches currentMatch.end = Math.max(currentMatch.end, matches[i].end); } else { mergedMatches.push(currentMatch); currentMatch = matches[i]; } } mergedMatches.push(currentMatch); // Build highlighted text with React elements const parts = []; let lastIndex = 0; mergedMatches.forEach((match, index) => { // Add text before the match if (match.start > lastIndex) { parts.push(textStr.substring(lastIndex, match.start)); } // Add highlighted match with nice neon color (bright but readable) parts.push( <span key={`highlight-${index}`} style={{ backgroundColor: '#ffff00', // Bright neon yellow (highly visible, good contrast) color: '#000000', // Black text for maximum readability fontWeight: 600, padding: '2px 4px', borderRadius: '4px', boxShadow: '0 0 3px rgba(255, 255, 0, 0.5)', // Subtle neon yellow glow effect }} > {textStr.substring(match.start, match.end)} </span> ); lastIndex = match.end; }); // Add remaining text after last match if (lastIndex < textStr.length) { parts.push(textStr.substring(lastIndex)); } return parts.length === 1 ? parts[0] : <>{parts}</>; }; // Memoized row component with optimistic checkbox updates const KeyRow = React.memo(({ entry, type, typeLabel, isSelected, onToggle, isExpanded, onToggleExpand, searchTerm }) => { const content = entry.preview || ''; const shouldTruncate = !isExpanded && content.length > 160; const displayText = shouldTruncate ? `${content.slice(0, 160)}…` : content; // Optimistic local state for instant checkbox feedback const [localChecked, setLocalChecked] = React.useState(isSelected); const prevIsSelectedRef = React.useRef(isSelected); const pendingUserActionRef = React.useRef(null); // Sync local state when prop changes - use useLayoutEffect for synchronous updates React.useLayoutEffect(() => { if (prevIsSelectedRef.current !== isSelected) { // If there's a pending user action, check if it matches if (pendingUserActionRef.current === isSelected) { // This matches the user's action, keep the optimistic state pendingUserActionRef.current = null; } else { // This is a programmatic change (group selection), update immediately setLocalChecked(isSelected); } prevIsSelectedRef.current = isSelected; } }, [isSelected]); const handleToggle = React.useCallback((value) => { const newValue = Boolean(value); // Update locally immediately for instant feedback setLocalChecked(newValue); // Track pending user action pendingUserActionRef.current = newValue; // Then call the actual handler (which will update state) onToggle(newValue); }, [onToggle]); return ( <Tr> <Td> <Checkbox checked={localChecked} onCheckedChange={handleToggle} /> </Td> <Td> <Typography textColor="neutral800"> {searchTerm ? highlightText(typeLabel, searchTerm) : typeLabel} </Typography> </Td> <Td> <Typography textColor="neutral800"> {searchTerm ? highlightText(entry.displayName || entry.keyName, searchTerm) : (entry.displayName || entry.keyName)} </Typography> </Td> <Td> <Typography textColor={entry.keyId ? 'neutral800' : 'danger600'}> {entry.keyId ? (searchTerm ? highlightText(String(entry.keyId), searchTerm) : String(entry.keyId)) : '—'} </Typography> </Td> <Td> <Typography textColor={entry.entryId ? 'neutral800' : 'danger600'}> {entry.entryId ? (searchTerm ? highlightText(String(entry.entryId), searchTerm) : String(entry.entryId)) : '—'} </Typography> </Td> <Td> <Box> <Typography textColor="neutral600" style={{ whiteSpace: 'pre-wrap' }} > {content ? (searchTerm ? highlightText(displayText, searchTerm) : displayText) : '—'} </Typography> {content.length > 160 && ( <Box paddingTop={2}> <Button variant="tertiary" size="S" onClick={onToggleExpand} > {isExpanded ? 'Show less' : 'Read more'} </Button> </Box> )} </Box> </Td> </Tr> ); }, (prevProps, nextProps) => { // Custom comparison function for React.memo return ( prevProps.entry.keyName === nextProps.entry.keyName && prevProps.isSelected === nextProps.isSelected && prevProps.isExpanded === nextProps.isExpanded && prevProps.entry.preview === nextProps.entry.preview && prevProps.entry.displayName === nextProps.entry.displayName && prevProps.entry.keyId === nextProps.entry.keyId && prevProps.entry.entryId === nextProps.entry.entryId && prevProps.searchTerm === nextProps.searchTerm ); }); KeyRow.displayName = 'KeyRow'; const toTitleCase = (value = '') => value .split(/[-_.]/) .filter(Boolean) .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(' ') || 'General'; // Build structure only (no selection counts) - memoize separately const buildGroupedStructure = (entries) => { const typeMap = new Map(); entries.forEach((entry) => { const typeKey = entry.type || 'general'; if (!typeMap.has(typeKey)) { typeMap.set(typeKey, { type: typeKey, label: toTitleCase(typeKey), keys: [], slugMap: new Map(), }); } const typeGroup = typeMap.get(typeKey); typeGroup.keys.push(entry); const slugKey = entry.slug || 'general'; if (!typeGroup.slugMap.has(slugKey)) { typeGroup.slugMap.set(slugKey, { slug: slugKey, label: slugKey === 'general' ? 'General' : toTitleCase(slugKey), keys: [], }); } typeGroup.slugMap.get(slugKey).keys.push(entry); }); return Array.from(typeMap.values()) .map((typeGroup) => { const slugGroups = Array.from(typeGroup.slugMap.values()).map((slugGroup) => ({ ...slugGroup, total: slugGroup.keys.length, })); return { type: typeGroup.type, label: typeGroup.label, total: typeGroup.keys.length, keys: typeGroup.keys, slugGroups: slugGroups.sort((a, b) => a.label.localeCompare(b.label)), }; }) .sort((a, b) => a.label.localeCompare(b.label)); }; // Add selection counts to structure (fast - only recalculates counts) // Accepts selectionRef for instant lookups and affectedType to skip unrelated types const addSelectionCounts = (structure, selection, selectionRef = null, affectedType = null) => { // Use ref if provided (for instant updates), otherwise use selection state const selectionToUse = selectionRef?.current || selection; // Pre-build selection lookup Sets per type for O(1) access (faster than Map) const selectionByType = new Map(); Object.entries(selectionToUse).forEach(([type, set]) => { if (set instanceof Set && set.size > 0) { selectionByType.set(type, set); } }); return structure.map((typeGroup) => { const typeKey = typeGroup.type; // If we know which type changed and this isn't it, return cached structure // (We can't actually cache here, but we can skip if no selections for this type) const typeSelectionSet = selectionByType.get(typeKey); let typeSelectedCount = 0; // If no selections for this type, all counts are 0 if (!typeSelectionSet) { return { ...typeGroup, selected: 0, slugGroups: typeGroup.slugGroups.map((slugGroup) => ({ ...slugGroup, selected: 0, })), }; } // Count selections efficiently using Set.has (O(1)) // Only iterate through keys in this type (not all types) const slugGroups = typeGroup.slugGroups.map((slugGroup) => { let selectedCount = 0; // Use for loop for better performance than forEach const keys = slugGroup.keys; for (let i = 0; i < keys.length; i++) { const key = keys[i]; // CRITICAL: Extract keyName - handle both object and string formats const keyName = typeof key === 'string' ? key : (key?.keyName || key?.key_name); if (keyName && typeSelectionSet.has(keyName)) { selectedCount++; } } typeSelectedCount += selectedCount; return { ...slugGroup, selected: selectedCount, }; }); return { ...typeGroup, selected: typeSelectedCount, slugGroups, }; }); }; const buildGroupedData = (entries, selection) => { const structure = buildGroupedStructure(entries); return addSelectionCounts(structure, selection); }; const PreviewTable = ({ groups, errors, onToggleKey, expandedKeys, onToggleExpand, collapsedTypes, onToggleTypeCollapse, collapsedSlugs, onToggleSlugCollapse, onToggleTypeSelect, onToggleSlugSelect, forceExpandGroups, isKeySelected, keyDisplayLimit, onLoadMoreKeys, slugGroupDisplayLimit, onLoadMoreSlugGroups, searchTerm, }) => { const limitMap = keyDisplayLimit instanceof Map ? keyDisplayLimit : new Map(); const slugLimitMap = slugGroupDisplayLimit instanceof Map ? slugGroupDisplayLimit : new Map(); const handleLoadMore = typeof onLoadMoreKeys === 'function' ? onLoadMoreKeys : () => {}; const hasGroups = groups && groups.length > 0; const hasErrors = errors && errors.length > 0; if (!hasGroups && !hasErrors) { return <EmptyStateLayout content="No keys found." />; } const totalRows = (hasErrors ? errors.length : 0) + groups.reduce( (count, typeGroup) => count + 1 + typeGroup.slugGroups.reduce( (slugCount, slugGroup) => slugCount + 1 + slugGroup.keys.length, 0 ), 0 ); return ( <Box padding={4} background="neutral0" hasRadius shadow="tableShadow"> <Table colCount={6} rowCount={totalRows}> <Thead> <Tr> <Th> <Typography variant="sigma">Select</Typography> </Th> <Th> <Typography variant="sigma">Content type</Typography> </Th> <Th> <Typography variant="sigma">Key</Typography> </Th> <Th> <Typography variant="sigma">Key ID</Typography> </Th> <Th> <Typography variant="sigma">Entry ID</Typography> </Th> <Th> <Typography variant="sigma">Preview</Typography> </Th> </Tr> </Thead> <Tbody> {hasErrors && errors.map((error, index) => ( <Tr key={`error-${error.type}-${index}`}> <Td /> <Td> <Typography textColor="danger700">{error.type || 'Error'}</Typography> </Td> <Td colSpan={4}> <Typography textColor="danger600"> {error.message || 'Unable to load keys'} </Typography> </Td> </Tr> ))} {groups.map((group, groupIndex) => { const typeCollapsed = !forceExpandGroups && collapsedTypes.has(group.type); return ( <React.Fragment key={group.type}> <Tr> <Td colSpan={6}> <Box padding={2} background="neutral100" hasRadius marginTop={groupIndex === 0 ? 0 : 4} style={{ paddingLeft: '16px', paddingRight: '16px' }} > <Flex justifyContent="space-between" alignItems="center"> <Flex gap={2} alignItems="center"> <IconButton label={ typeCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}` } onClick={() => onToggleTypeCollapse(group.type)} disabled={forceExpandGroups} > {typeCollapsed ? <ChevronDown /> : <ChevronUp />} </IconButton> <Typography variant="epsilon"> {searchTerm ? highlightText(group.label, searchTerm) : group.label} </Typography> <Typography textColor="neutral600"> {group.selected}/{group.total} selected </Typography> </Flex> <Button variant="tertiary" size="S" startIcon={group.selected === group.total ? <Minus /> : <Check />} onClick={(e) => { e?.preventDefault(); e?.stopPropagation(); // Capture values - use the exact group object from this render const targetType = group.type; const targetKeys = group.keys; const targetSelected = group.selected; // Call handler with captured values if (targetType && Array.isArray(targetKeys) && targetKeys.length > 0) { onToggleTypeSelect(targetType, targetKeys, targetSelected); } }} > {group.selected === group.total ? 'Deselect all' : 'Select all'} </Button> </Flex> </Box> </Td> </Tr> {!typeCollapsed && (() => { const typeKey = group.type; const slugLimit = slugLimitMap.get(typeKey) ?? INITIAL_SLUG_GROUP_LIMIT; const slugGroupsToRender = group.slugGroups.slice(0, slugLimit); const hasMoreSlugGroups = group.slugGroups.length > slugLimit; return ( <> {slugGroupsToRender.map((slugGroup) => { const slugKey = `${group.type}|${slugGroup.slug}`; const slugCollapsed = !forceExpandGroups && collapsedSlugs.has(slugKey); const limit = (limitMap instanceof Map && limitMap.get(slugKey) !== undefined ? limitMap.get(slugKey) : INITIAL_KEY_LIMIT); const keysToRender = slugGroup.keys.slice(0, limit); const hasMore = slugGroup.keys.length > limit; return ( <React.Fragment key={slugKey}> <Tr> <Td colSpan={6}> <Box paddingLeft={6} paddingTop={2}> <Flex justifyContent="space-between" alignItems="center" background="neutral200" padding={2} hasRadius marginTop={1} > <Flex gap={2} alignItems="center"> <IconButton label={ slugCollapsed ? `Expand ${slugGroup.label}` : `Collapse ${slugGroup.label}` } onClick={() => onToggleSlugCollapse(slugKey)} disabled={forceExpandGroups} > {slugCollapsed ? <ChevronDown /> : <ChevronUp />} </IconButton> <Typography> {searchTerm ? highlightText(slugGroup.label, searchTerm) : slugGroup.label} </Typography> <Typography textColor="neutral600"> {slugGroup.selected}/{slugGroup.total} selected </Typography> </Flex> <Button variant="tertiary" size="S" startIcon={ slugGroup.selected === slugGroup.total ? <Minus /> : <Check /> } onClick={(e) => { e?.stopPropagation(); onToggleSlugSelect(group.type, slugGroup.slug, slugGroup.keys, slugGroup.selected); }} > {slugGroup.selected === slugGroup.total ? 'Deselect group' : 'Select group'} </Button> </Flex> </Box> </Td> </Tr> {!slugCollapsed && ( <> {keysToRender.map((entry) => ( <KeyRow key={entry.keyName} entry={entry} type={group.type} typeLabel={group.label} isSelected={isKeySelected(group.type, entry.keyName)} onToggle={(value) => onToggleKey(group.type, entry.keyName, Boolean(value))} isExpanded={expandedKeys.has(entry.keyName)} onToggleExpand={() => onToggleExpand(entry.keyName)} searchTerm={searchTerm} /> ))} {hasMore && ( <Tr key={`${slugKey}-load-more`}> <Td colSpan={6}> <Flex justifyContent="center"> <Button variant="tertiary" size="S" onClick={() => handleLoadMore(slugKey, slugGroup.keys.length) } > Show more ({Math.min( slugGroup.keys.length, limit + LIMIT_STEP )} /{slugGroup.keys.length}) </Button> </Flex> </Td> </Tr> )} </> )} </React.Fragment> ); })} {hasMoreSlugGroups && ( <Tr key={`${typeKey}-load-more-slugs`}> <Td colSpan={6}> <Flex justifyContent="center" paddingTop={2}> <Button variant="tertiary" size="S" onClick={() => { if (typeof onLoadMoreSlugGroups === 'function') { onLoadMoreSlugGroups(typeKey, group.slugGroups.length); } }} > Show more slug groups ({Math.min( group.slugGroups.length, slugLimit + SLUG_GROUP_STEP )}/{group.slugGroups.length}) </Button> </Flex> </Td> </Tr> )} </> ); })()} </React.Fragment> ); })} </Tbody> </Table> </Box> ); }; // Memoize PreviewTable to prevent re-renders when props haven't changed // Note: React.memo comparison returns true if props are equal (skip re-render), false if different (re-render) const MemoizedPreviewTable = React.memo(PreviewTable, (prevProps, nextProps) => { // Re-render if these key props change (return false = different, re-render) if (prevProps.groups.length !== nextProps.groups.length) return false; if (prevProps.errors.length !== nextProps.errors.length) return false; if (prevProps.expandedKeys.size !== nextProps.expandedKeys.size) return false; if (prevProps.collapsedTypes.size !== nextProps.collapsedTypes.size) return false; if (prevProps.collapsedSlugs.size !== nextProps.collapsedSlugs.size) return false; if (prevProps.forceExpandGroups !== nextProps.forceExpandGroups) return false; if (prevProps.searchTerm !== nextProps.searchTerm) return false; // Re-render when search term changes (for highlighting) // Check if keyDisplayLimit map has changed if (prevProps.keyDisplayLimit.size !== nextProps.keyDisplayLimit.size) return false; for (const [key, value] of prevProps.keyDisplayLimit) { if (nextProps.keyDisplayLimit.get(key) !== value) return false; } // Check if slugGroupDisplayLimit map has changed if (prevProps.slugGroupDisplayLimit?.size !== nextProps.slugGroupDisplayLimit?.size) return false; if (prevProps.slugGroupDisplayLimit && nextProps.slugGroupDisplayLimit) { for (const [key, value] of prevProps.slugGroupDisplayLimit) { if (nextProps.slugGroupDisplayLimit.get(key) !== value) return false; } } // Check if groups data changed by comparing object references and selected counts // If objects are reused (same reference), they haven't changed for (let i = 0; i < prevProps.groups.length; i++) { const prevGroup = prevProps.groups[i]; const nextGroup = nextProps.groups[i]; // If same object reference, it hasn't changed (thanks to our object reuse) if (prevGroup === nextGroup) continue; // Different reference - check if data actually changed if (prevGroup.type !== nextGroup.type) return false; if (prevGroup.selected !== nextGroup.selected) return false; if (prevGroup.total !== nextGroup.total) return false; // Check slug groups if (prevGroup.slugGroups.length !== nextGroup.slugGroups.length) return false; for (let j = 0; j < prevGroup.slugGroups.length; j++) { const prevSlugGroup = prevGroup.slugGroups[j]; const nextSlugGroup = nextGroup.slugGroups[j]; // If same object reference, it hasn't changed if (prevSlugGroup === nextSlugGroup) continue; // Different reference - check if data actually changed if (prevSlugGroup.slug !== nextSlugGroup.slug) return false; if (prevSlugGroup.selected !== nextSlugGroup.selected) return false; if (prevSlugGroup.total !== nextSlugGroup.total) return false; } } return true; // Props are equal, skip re-render }); MemoizedPreviewTable.displayName = 'MemoizedPreviewTable'; const App = () => { // Use Strapi's built-in fetch client hook (automatically handles authentication) // Official Strapi approach: Call hook conditionally with proper error handling let strapiFetchClient = null; if (useFetchClient && typeof useFetchClient === 'function') { try { strapiFetchClient = useFetchClient(); } catch (e) { console.warn('⚠️ [lokalise-sync] useFetchClient hook failed:', e.message); } } useEffect(() => { const apiBasePath = getApiBasePath(); console.log('🚀 [lokalise-sync] App component loaded! Version: 0.1.69'); console.log('[lokalise-sync] 🔍 Version Detection Results:'); console.log('[lokalise-sync] - window.location.pathname:', window.location?.pathname); console.log('[lokalise-sync] - window.STRAPI_VERSION:', window.STRAPI_VERSION); console.log('[lokalise-sync] - window.strapi:', window.strapi); console.log('[lokalise-sync] - Detected API Base Path:', apiBasePath); console.log('[lokalise-sync] - Expected v4 path: /lokalise-sync'); console.log('[lokalise-sync] - Expected v5 path: /admin/api/lokalise-sync'); if (strapiFetchClient && strapiFetchClient.get && strapiFetchClient.post) { console.log('✅ [lokalise-sync] Using Strapi useFetchClient (automatic authentication)'); } else { console.warn('⚠️ [lokalise-sync] useFetchClient not available, using fallback'); } }, [strapiFetchClient]); // Official Strapi approach: Use fetchClient with proper error handling const getClient = useCallback(() => { // Return Strapi's fetchClient if available (handles auth automatically) if (strapiFetchClient && strapiFetchClient.get && strapiFetchClient.post) { return strapiFetchClient; } // Fallback: Create a simple authenticated fetch client console.warn('⚠️ [lokalise-sync] Using fallback fetch client'); const baseURL = typeof window !== 'undefined' && window.location?.origin ? window.location.origin : 'http://localhost:1337'; return { get: async (url, config = {}) => { let apiUrl = url; if (!url.startsWith('http') && !url.startsWith('/admin/')) { // Check if URL already includes base path (from getApiBasePath()) const basePath = getApiBasePath(); if (url.startsWith(basePath)) { apiUrl = url.startsWith('/') ? url : `/${url}`; } else if (url.startsWith('/lokalise-sync') || url.startsWith('lokalise-sync')) { // Legacy v4 paths - replace with dynamic base path apiUrl = url.replace(/^\/?lokalise-sync/, basePath); } else if (!url.startsWith('/api/')) { apiUrl = `/api${url.startsWith('/') ? url : `/${url}`}`; } } const fullUrl = url.startsWith('http') ? url : `${baseURL}${apiUrl}`; const response = await fetch(fullUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', ...config.headers, }, credentials: 'include', ...config, }); if (!response.ok) { const error = new Error(`HTTP ${response.status}: ${response.statusText}`); error.response = { status: response.status, statusText: response.statusText, data: await response.json().catch(() => ({ error: { message: response.statusText } })), }; throw error; } return { data: await response.json().catch(() => null), status: response.status, statusText: response.statusText, }; }, post: async (url, body, config = {}) => { let apiUrl = url; if (!url.startsWith('http') && !url.startsWith('/admin/')) { // Check if URL already includes base path (from getApiBasePath()) const basePath = getApiBasePath(); if (url.startsWith(basePath)) { apiUrl = url.startsWith('/') ? url : `/${url}`; } else if (url.startsWith('/lokalise-sync') || url.startsWith('lokalise-sync')) { // Legacy v4 paths - replace with dynamic base path apiUrl = url.replace(/^\/?lokalise-sync/, basePath); } else if (!url.startsWith('/api/')) { apiUrl = `/api${url.startsWith('/') ? url : `/${url}`}`; } } const fullUrl = url.startsWith('http') ? url : `${baseURL}${apiUrl}`; const httpResponse = await fetch(fullUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...config.headers, }, credentials: 'include', body: JSON.stringify(body), ...config, }); if (!httpResponse.ok) { const errorObj = new Error(`HTTP ${httpResponse.status}: ${httpResponse.statusText}`); errorObj.response = { status: httpResponse.status, statusText: httpResponse.statusText, data: await httpResponse.json().catch(() => ({ error: { message: httpResponse.statusText } })), }; throw errorObj; } const responseData = await httpResponse.json().catch(() => null); return { data: responseData, status: httpResponse.status, statusText: httpResponse.statusText, }; }, }; }, [strapiFetchClient]); const [loading, setLoading] = useState(false); const [progress, setProgress] = useState(0); const [progressMessage, setProgressMessage] = useState(''); // Track preview loading separately from sync/job loading so the "Run preview" button doesn't show sync progress const [isPreviewLoading, setIsPreviewLoading] = useState(false); // Debounce rapid progress UI updates to keep the UI smooth const lastUiUpdateRef = useRef(0); const updateProgress = useCallback((percent, text) => { const now = Date.now(); if (now - lastUiUpdateRef.current < 800) { // Too soon to update UI again; keep things smooth return; } lastUiUpdateRef.current = now; if (typeof percent === 'number') { setProgress(percent); } if (typeof text === 'string') { setProgressMessage(text); } }, []); const [preview, setPreview] = useState(null); const [lastUnfilteredPreview, setLastUnfilteredPreview] = useState(null); // Store last unfiltered preview const [selection, setSelection] = useState({}); const [message, setMessage] = useState(null); const [expandedPreviews, setExpandedPreviews] = useState(() => new Set()); const [selectionUpdateCounter, setSelectionUpdateCounter] = useState(0); // Force groupedData update when selection changes const [searchTerm, setSearchTerm] = useState(''); // Input value (updates immediately for responsive typing) const [appliedSearchTerm, setAppliedSearchTerm] = useState(''); // Applied search term (only updates on Enter/Apply) const [collapsedTypes, setCollapsedTypes] = useState(() => new Set()); const [collapsedSlugs, setCollapsedSlugs] = useState(() => new Set()); const [syncDialogOpen, setSyncDialogOpen] = useState(false); const [tagName, setTagName] = useState(''); const [locale, setLocale] = useState('en'); const availableLocales = ['en', 'ar']; const [keyDisplayLimit, setKeyDisplayLimit] = useState(() => new Map()); const [slugGroupDisplayLimit, setSlugGroupDisplayLimit] = useState(() => new Map()); const [activeJob, setActiveJob] = useState(null); const jobRef = useRef(null); const [availableContentTypes, setAvailableContentTypes] = useState([]); const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false); const [selectedPreviewTypes, setSelectedPreviewTypes] = useState(() => new Set()); const [selectedSyncStatus, setSelectedSyncStatus] = useState(() => new Set()); const [activePreviewFilters, setActivePreviewFilters] = useState(null); const [previewTypesLoading, setPreviewTypesLoading] = useState(true); const [previewTypesError, setPreviewTypesError] = useState(null); const [groupNameSearch, setGroupNameSearch] = useState(''); const [settingsLoading, setSettingsLoading] = useState(true); const [settingsSaving, setSettingsSaving] = useState(false); const [settingsTesting, setSettingsTesting] = useState(false); // Load form state from localStorage on mount const loadFormFromStorage = useCallback(() => { try { const stored = window?.localStorage?.getItem(SETTINGS_FORM_STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored); return { lokaliseProjectId: parsed.lokaliseProjectId || '', lokaliseApiToken: parsed.lokaliseApiToken || '', lokaliseBaseUrl: parsed.lokaliseBaseUrl || '', }; } } catch (err) { console.warn('Failed to load settings form from localStorage:', err); } return { lokaliseProjectId: '', lokaliseApiToken: '', lokaliseBaseUrl: '', }; }, []); // Save form state to localStorage const saveFormToStorage = useCallback((formData) => { try { window?.localStorage?.setItem(SETTINGS_FORM_STORAGE_KEY, JSON.stringify(formData)); } catch (err) { console.warn('Failed to save settings form to localStorage:', err); } }, []); const [settingsForm, setSettingsForm] = useState(() => loadFormFromStorage()); const [settingsInfo, setSettingsInfo] = useState({ tokenConfigured: false, projectIdMasked: null, baseUrl: DEFAULT_LOKALISE_BASE_URL, encryptionEnabled: false, updatedAt: null, updatedBy: null, }); const [settingsAlert, setSettingsAlert] = useState(null); const [testFeedback, setTestFeedback] = useState(null); const [settingsCollapsed, setSettingsCollapsed] = useState(false); // Ref to track selection for instant lookups const selectionRef = useRef(selection); // Keep ref in sync with state useEffect(() => { selectionRef.current = selection; }, [selection]); useEffect(() => { let isMounted = true; (async () => { try { setPreviewTypesLoading(true); const client = getClient(); const { data } = await client.get(`${getApiBasePath()}/types`); if (!isMounted) { return; } const types = Array.isArray(data?.types) ? data.types : []; setAvailableContentTypes(types); setPreviewTypesError(null); } catch (err) { if (isMounted) { const message = err?.response?.data?.error?.message || err.message || 'Failed to load content types'; setPreviewTypesError(message); } } finally { if (isMounted) { setPreviewTypesLoading(false); } } })(); return () => { isMounted = false; }; }, [getClient]); const typeLabelMap = useMemo(() => { const map = new Map(); availableContentTypes.forEach((item) => { if (item && item.type) { map.set(item.type, item.displayName || item.type); } }); return map; }, [availableContentTypes]); const previewScopeSummary = useMemo(() => { const typeSummary = selectedPreviewTypes.size === 0 ? 'All content types' : Array.from(selectedPreviewTypes) .map((type) => typeLabelMap.get(type) || type) .join(', '); return `Scope: ${typeSummary}`; }, [selectedPreviewTypes, typeLabelMap]); const appliedPreviewSummary = useMemo(() => { if (!activePreviewFilters) { return null; } const filterTypes = Array.isArray(activePreviewFilters.types) ? activePreviewFilters.types : []; const typeSummary = filterTypes.length === 0 ? 'All content types' : filterTypes.map((type) => typeLabelMap.get(type) || type).join(', '); const slugs = Array.isArray(activePreviewFilters.slugs) && activePreviewFilters.slugs.length > 0 ? activePreviewFilters.slugs.join(', ') : null; if (slugs) { return `Last preview: ${typeSummary} | Slug filter: ${slugs}`; } return `Last preview: ${typeSummary}`; }, [activePreviewFilters, typeLabelMap]); const clearPreviewTypeSelection = useCallback(() => { setSelectedPreviewTypes(new Set()); }, []); const togglePreviewType = useCallback((type, value) => { setSelectedPreviewTypes((prev) => { const next = new Set(prev); if (value) { next.add(type); } else { next.delete(type); } return next; }); }, []); const updateSettingsForm = useCallback((field, value) => { setSettingsForm((prev) => { const updated = { ...prev, [field]: value, }; // Save to localStorage whenever form changes saveFormToStorage(updated); return updated; }); }, [saveFormToStorage]); const loadSettings = useCallback(async () => { setSettingsLoading(true); try { const client = getClient(); const { data } = await client.get(`${getApiBasePath()}/settings`); const response = data?.settings || {}; setSettingsInfo({ tokenConfigured: Boolean(response.tokenConfigured), projectIdMasked: response.lokaliseProjectIdMasked || null, baseUrl: response.lokaliseBaseUrl || DEFAULT_LOKALISE_BASE_URL, encryptionEnabled: Boolean(response?.encryption?.enabled), updatedAt: response.updatedAt || null, updatedBy: response.updatedBy || null, }); // Don't clear form on load - keep existing values from localStorage // Only update baseUrl if it's different from default and not already set setSettingsForm((prev) => { const updated = { lokaliseProjectId: prev.lokaliseProjectId || '', lokaliseApiToken: prev.lokaliseApiToken || '', lokaliseBaseUrl: prev.lokaliseBaseUrl || (response.lokaliseBaseUrl && response.lokaliseBaseUrl !== DEFAULT_LOKALISE_BASE_URL ? response.lokaliseBaseUrl : ''), }; // Save to localStorage saveFormToStorage(updated); return updated; }); setSettingsAlert(null); setTestFeedback(null); } catch (err) { const message = err?.response?.data?.error?.message || err.message || 'Failed to load Lokalise settings'; setSettingsAlert({ tone: 'danger', message }); } finally { setSettingsLoading(false); } }, [getClient]); useEffect(() => { loadSettings(); }, [loadSettings]); const handleSaveSettings = useCallback(async () => { // Clear previous alerts setSettingsAlert(null); setTestFeedback(null); setSettingsSaving(true); console.log('\n========================================'); console.log('[lokalise-sync] 🔘 SAVE SETTINGS BUTTON CLICKED'); console.log('========================================'); try { // Build payload const payload = {}; if (settingsForm.lokaliseProjectId && settingsForm.lokaliseProjectId.trim()) { payload.lokaliseProjectId = settingsForm.lokaliseProjectId.trim(); } if (settingsForm.lokaliseApiToken && settingsForm.lokaliseApiToken.trim()) { payload.lokaliseApiToken = settingsForm.lokaliseApiToken.trim(); } if (settingsForm.lokaliseBaseUrl && settingsForm.lokaliseBaseUrl.trim()) { payload.lokaliseBaseUrl = settingsForm.lokaliseBaseUrl.trim(); } else if ( settingsInfo.baseUrl && settingsInfo.baseUrl !== DEFAULT_LOKALISE_BASE_URL ) { payload.lokaliseBaseUrl = DEFAULT_LOKALISE_BASE_URL; } console.log('[lokalise-sync] 📦 Payload built:', { hasProjectId: !!payload.lokaliseProjectId, hasApiToken: !!payload.lokaliseApiToken, hasBaseUrl: !!payload.lokaliseBaseUrl, payloadKeys: Object.keys(payload), }); // Check if payload is empty if (Object.keys(payload).length === 0) { console.log('[lokalise-sync] ⚠️ Payload is empty - aborting'); setSettingsAlert({ tone: 'danger', message: 'Please enter at least one setting to save.', }); setSettingsSaving(false); return; } // Get client - this might throw, so we catch it let client; try { client = getClient(); console.log('[lokalise-sync] ✅ Fetch client obtained:', { hasClient: !!client, hasPost: typeof client?.post === 'function', }); } catch (clientErr) { console.error('[lokalise-sync] ❌ Failed to get fetch client:', clientErr); setSettingsAlert({ tone: 'danger', message: clientErr?.message || 'Strapi fetch client is not available. Please reload the admin panel and try again.', }); setSettingsSaving(false); return; } // Get API base path - CRITICAL: Must be /lokalise-sync for v4 const apiBasePath = getApiBasePath(); const fullUrl = `${apiBasePath}/settings`; console.log('\n========================================'); console.log('[lokalise-sync] 🚀 SAVE SETTINGS - BEFORE API CALL'); console.log('========================================'); console.log('[lokalise-sync] 📍 Window location:', window.location?.pathname); console.log('[lokalise-sync] 📍 Window href:', window.location?.href); console.log('[lokalise-sync] 📍 STRAPI_VERSION:', window.STRAPI_VERSION); console.log('[lokalise-sync] 📍 window.strapi:', window.strapi); console.log('[lokalise-sync] 📍 window.strapi?.version:', window.strapi?.version); console.log('[lokalise-sync]