strapi-to-lokalise-plugin
Version:
Preview and sync Lokalise translations from Strapi admin
1,264 lines (1,160 loc) • 158 kB
JSX
// @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]