UNPKG

react-statix

Version:

React components for statix localization management

1,144 lines (1,109 loc) 51.4 kB
'use strict'; var jsxRuntime = require('react/jsx-runtime'); var React = require('react'); var reactI18next = require('react-i18next'); // src/context/StatixContext.tsx const StatixContext = React.createContext(undefined); const useStatix = () => { const context = React.useContext(StatixContext); if (!context) { throw new Error("useStatix must be used within a StatixProvider"); } return context; }; const StatixButton = ({ onClick }) => { return (jsxRuntime.jsx("button", { "data-testid": "statix-button", onClick: onClick, style: { position: "fixed", backgroundColor: "white", bottom: "12px", right: "12px", width: "50px", height: "50px", borderRadius: "8px", border: "1px solid #e0e0e0", boxShadow: "0 2px 10px rgba(0,0,0,0.1)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", zIndex: 10000000, padding: "5px", }, children: jsxRuntime.jsx("svg", { width: "40", height: "40", viewBox: "0 0 32 32", fill: "none", xmlns: "http://www.w3.org/2000/svg", children: jsxRuntime.jsxs("g", { children: [jsxRuntime.jsxs("g", { id: "rotating-group", children: [jsxRuntime.jsx("path", { d: "M1.34391 26.0377L8.87169 18.9046C8.34648 17.8841 8.05 16.7267 8.05 15.5C8.05 14.6485 8.19286 13.8303 8.45593 13.0682L1.34098 6.434C1.02131 6.13593 0.5 6.36262 0.5 6.7997V25.6748C0.5 26.1138 1.02526 26.3397 1.34391 26.0377Z", fill: "black" }), jsxRuntime.jsx("path", { d: "M15.5 8.05C16.6959 8.05 17.8261 8.3318 18.8276 8.83261L26.0145 1.34627C26.3196 1.02846 26.0943 0.5 25.6538 0.5H6.11285C5.66963 0.5 5.44555 1.03396 5.75603 1.35026L12.82 8.5466C13.6516 8.22585 14.5553 8.05 15.5 8.05Z", fill: "black" }), jsxRuntime.jsx("path", { d: "M22.95 15.5C22.95 16.4683 22.7653 17.3934 22.4292 18.2421L30.6561 26.0377C30.9747 26.3397 31.5 25.6748 31.5 25.6748V6.36728C31.5 5.92519 30.9684 5.70061 30.6514 6.00882L22.7332 13.7086C22.8749 14.2824 22.95 14.8824 22.95 15.5Z", fill: "black" }), jsxRuntime.jsx("path", { d: "M15.5 22.95C16.4343 22.95 17.3284 22.778 18.1524 22.464L26.0145 30.6537C26.3196 30.9715 26.0943 31.5 25.6538 31.5H6.34622C5.90567 31.5 5.68043 30.9715 5.98552 30.6537L13.6126 22.7088C14.2154 22.8662 14.8479 22.95 15.5 22.95Z", fill: "black" })] }), jsxRuntime.jsx("path", { d: "M18 9.5L12 16.5833H15.4286L14.2857 22L20 14.4167H16.5714L18 9.5Z", fill: "black", children: jsxRuntime.jsx("animate", { attributeName: "opacity", values: "0.5;1;0.5", dur: "1.5s", repeatCount: "indefinite" }) })] }) }) })); }; // Design Tokens const colors = { // Background colors bg: { primary: '#ffffff', secondary: '#f9fafb', header: '#e3e3e3', body: '#fafafa'}, // Border colors border: { primary: '#e3e3e3', light: '#f3f4f6', textarea: '#e3e3e3', }, // Text colors text: { primary: '#3a3a3a', secondary: '#6b7280'}, // State colors state: { info: '#3b82f6', } }; const typography = { fontSize: { xs: '0.75rem', // 12px sm: '0.875rem', // 14px lg: '1.125rem', // 18px xl: '1.25rem', // 20px }, fontWeight: { normal: '400', medium: '500', semibold: '600'}, lineHeight: { tight: '1.25', normal: '1.5', relaxed: '1.75', } }; const spacing = { xs: '0.25rem', // 4px sm: '0.5rem', // 8px md: '0.75rem', // 12px lg: '1rem', // 16px xl: '1.5rem', // 24px '2xl': '2rem', // 32px }; const borderRadius = { sm: '0.25rem', md: '0.375rem', table: '0.625rem'}; const shadows = { sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', textareaFocus: '0 0 0 1px rgba(160, 160, 160, 0.3)', }; const transitions = { fast: '150ms ease'}; const useStyle = () => { return React.useMemo(() => ({ // Container styles container: { padding: spacing.lg, backgroundColor: colors.bg.secondary, minHeight: '100vh', }, title: { fontSize: typography.fontSize.xl, lineHeight: typography.lineHeight.relaxed, fontWeight: typography.fontWeight.semibold, marginBottom: spacing.xl, textAlign: 'center', color: colors.text.primary, }, // Table container tableWrapper: { position: 'relative', overflow: 'hidden', borderRadius: borderRadius.table, border: `1px solid ${colors.border.primary}`, backgroundColor: colors.bg.primary, }, scrollContainer: { overflowY: 'auto', overflowX: 'auto', position: 'relative', flex: 1, borderRadius: borderRadius.table, border: `1px solid ${colors.border.primary}`, zIndex: 10, maxHeight: 'max-content', }, table: { tableLayout: 'fixed', borderCollapse: 'collapse', }, // Header styles thead: { position: 'sticky', top: 0, backgroundColor: colors.bg.header, color: colors.text.primary, fontWeight: typography.fontWeight.medium, zIndex: 20, }, th: (options = {}) => ({ padding: `${spacing.md} ${spacing.lg}`, textAlign: 'left', fontWeight: typography.fontWeight.medium, fontSize: typography.fontSize.sm, lineHeight: typography.lineHeight.tight, position: 'relative', ...(options.isFirstColumn && { position: 'sticky', left: 0, backgroundColor: colors.bg.header, zIndex: 30, }), }), // Row styles tr: { backgroundColor: colors.bg.body, transition: transitions.fast, borderBottom: `1px solid ${colors.border.primary}`, borderTop: `1px solid ${colors.border.primary}`, borderRight: `1px solid ${colors.border.primary}`, }, // Cell styles td: (options = {}) => ({ padding: `${spacing.xs} ${spacing.lg}`, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.normal, lineHeight: typography.lineHeight.tight, color: colors.text.primary, verticalAlign: 'middle', ...(options.isFirstColumn && { position: 'sticky', left: 0, zIndex: 10, backgroundColor: colors.bg.body, }), }), // Input/Textarea styles textarea: { width: '100%', padding: spacing.sm, border: `1px solid ${colors.border.textarea}`, borderRadius: borderRadius.sm, fontSize: typography.fontSize.sm, color: colors.text.primary, fontWeight: typography.fontWeight.normal, lineHeight: typography.lineHeight.tight, outline: 'none', resize: 'none', overflow: 'hidden', minHeight: '24px', backgroundColor: colors.bg.body, transition: transitions.fast, }, textareaFocus: { borderColor: colors.border.textarea, backgroundColor: colors.bg.body, boxShadow: shadows.textareaFocus, }, // Search input styles (textarea-like design) searchInput: { width: '100%', maxWidth: '400px', padding: spacing.sm, border: `1px solid ${colors.border.textarea}`, borderRadius: borderRadius.md, fontSize: typography.fontSize.sm, color: colors.text.primary, fontWeight: typography.fontWeight.normal, lineHeight: typography.lineHeight.normal, outline: 'none', backgroundColor: colors.bg.body, transition: transitions.fast, fontFamily: 'inherit', }, searchInputFocus: { borderColor: colors.border.textarea, backgroundColor: colors.bg.body, boxShadow: shadows.textareaFocus, }, // Key/Path display keyDisplay: { fontSize: typography.fontSize.xs, color: colors.text.secondary, }, // Resizer handle resizer: { position: 'absolute', top: 0, right: 0, width: spacing.sm, height: '100%', cursor: 'col-resize', opacity: 1, transition: 'background-color 150ms ease-in-out, box-shadow 150ms ease-in-out', backgroundColor: colors.text.secondary, }, resizerVisible: { opacity: 1, }, // Button styles button: { display: 'inline-flex', justifyContent: 'center', alignItems: 'center', width: '100%', border: `1px solid ${colors.border.textarea}`, borderRadius: borderRadius.md, fontSize: typography.fontSize.sm, color: colors.text.primary, fontWeight: typography.fontWeight.normal, lineHeight: typography.lineHeight.normal, boxShadow: shadows.sm, padding: spacing.sm, cursor: 'pointer', transition: transitions.fast, backgroundColor: colors.bg.body, }, buttonFocus: { borderColor: colors.border.textarea, backgroundColor: colors.bg.body, boxShadow: shadows.textareaFocus, }, // Dropdown styles dropdown: { position: 'relative', display: 'inline-block', textAlign: 'left', }, dropdownMenu: (options = {}) => ({ ...(options.isOpen ? {} : { display: 'none' }), position: 'absolute', right: 0, marginTop: spacing.sm, width: '14rem', borderRadius: borderRadius.md, backgroundColor: colors.bg.body, borderColor: colors.border.textarea, boxShadow: shadows.textareaFocus, zIndex: 50, }), dropdownItem: { display: 'flex', alignItems: 'center', padding: `${spacing.sm} ${spacing.lg}`, fontSize: typography.fontSize.sm, lineHeight: typography.lineHeight.tight, color: colors.text.primary, cursor: 'pointer', backgroundColor: 'transparent', }, // Checkbox styles checkbox: { appearance: 'none', backgroundColor: colors.bg.primary, border: `1px solid ${colors.border.light}`, borderRadius: borderRadius.sm, display: 'inline-block', flexShrink: 0, height: spacing.lg, width: spacing.lg, color: colors.state.info, transition: transitions.fast, marginRight: spacing.sm, }, checkboxChecked: { backgroundColor: 'currentColor', borderColor: 'transparent', backgroundImage: `url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='m13.854 3.646-7.5 7.5a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6 10.293l7.146-7.147a.5.5 0 0 1 .708.708z'/%3e%3c/svg%3e")`, }, // No data styles noData: { textAlign: 'center', padding: spacing['2xl'], color: colors.text.secondary, fontSize: typography.fontSize.lg, lineHeight: typography.lineHeight.relaxed, }, // Icon styles icon: { marginLeft: spacing.sm, marginRight: `-${spacing.xs}`, height: spacing.lg, width: spacing.lg, }, }), []); }; const StatixContent = ({ isOpen, children, }) => { return (jsxRuntime.jsx("div", { style: { position: "fixed", bottom: isOpen ? "0" : "-70vh", left: "0", width: "100%", height: "70vh", backgroundColor: colors.bg.body, boxShadow: "0 -2px 10px rgba(0,0,0,0.1)", transition: "bottom 0.3s ease-in-out", zIndex: 100000, borderTopLeftRadius: "10px", borderTopRightRadius: "10px", padding: "20px", }, children: children })); }; const StatixDrawer = ({ children }) => { const { editable } = useStatix(); const [isOpen, setIsOpen] = React.useState(false); const toggleDrawer = () => { setIsOpen(!isOpen); }; if (!editable) return null; return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(StatixButton, { onClick: toggleDrawer }), jsxRuntime.jsx(StatixContent, { isOpen: isOpen, children: children })] })); }; const getNestedValue = (obj, path, defaultValue = undefined) => { if (!obj || !path) return defaultValue; const keys = path.split("."); let current = obj; for (const key of keys) { if (current === null || current === undefined || typeof current !== "object") { return defaultValue; } current = current[key]; } return current !== undefined ? current : defaultValue; }; const setNestedValue = (obj, path, value) => { if (!obj || !path) return obj; const keys = path.split("."); const lastKey = keys.pop(); let current = obj; // Create or traverse the nested structure for (const key of keys) { if (current[key] === undefined || current[key] === null || typeof current[key] !== "object") { current[key] = {}; } current = current[key]; } // Set the value at the final level current[lastKey] = value; return obj; }; const removeNestedValue = (obj, path) => { if (!obj || !path) return obj; const keys = path.split("."); const lastKey = keys.pop(); // If no nested keys, simple deletion if (keys.length === 0) { const result = { ...obj }; delete result[lastKey]; return result; } // Deep clone and remove nested value const result = { ...obj }; let current = result; const parents = [result]; // Traverse to the parent of the target key for (const key of keys) { if (current[key] === undefined || current[key] === null || typeof current[key] !== "object") { // Path doesn't exist, nothing to remove return obj; } current[key] = { ...current[key] }; current = current[key]; parents.push(current); } // Remove the value at the final level delete current[lastKey]; // Clean up empty parent objects for (let i = parents.length - 1; i >= 1; i--) { const parent = parents[i]; if (Object.keys(parent).length === 0) { const grandParent = parents[i - 1]; const keyToDelete = keys[i - 1]; delete grandParent[keyToDelete]; } else { break; } } return result; }; // Recursive function to clean nested objects const cleanObjectRecursively = (changesObj, localeObj, currentPath = "") => { if (!changesObj || typeof changesObj !== 'object') { return changesObj; } const cleaned = {}; let hasChanges = false; for (const key in changesObj) { if (changesObj.hasOwnProperty(key)) { const value = changesObj[key]; const fullPath = currentPath ? `${currentPath}.${key}` : key; if (typeof value === 'object' && value !== null) { // If it's a nested object, recurse const nestedCleaned = cleanObjectRecursively(value, localeObj, fullPath); if (nestedCleaned && Object.keys(nestedCleaned).length > 0) { cleaned[key] = nestedCleaned; hasChanges = true; } } else { // It's a primitive value, compare with original const originalValue = getNestedValue(localeObj, fullPath); if (originalValue !== value) { cleaned[key] = value; hasChanges = true; } } } } return hasChanges ? cleaned : {}; }; // Clean pending changes that match original locale values const cleanRedundantChanges = (pendingChanges, locales) => { const cleaned = {}; for (const lang in pendingChanges) { if (pendingChanges.hasOwnProperty(lang)) { const changes = pendingChanges[lang]; const localeData = locales[lang] || {}; const cleanedLangChanges = cleanObjectRecursively(changes, localeData); // Only include language if it has actual changes if (cleanedLangChanges && Object.keys(cleanedLangChanges).length > 0) { cleaned[lang] = cleanedLangChanges; } } } return cleaned; }; const fetchJSON = async (url) => { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}, url: ${url}`); } return await response.json(); }; const loadLocaleFiles = async (config) => { const locales = {}; const langs = Object.keys(config.languagesKeys || {}); for (const lang of langs) { const filename = `${config.localePath}/${lang}/translation.json`; try { locales[lang] = await fetchJSON(filename); } catch (e) { console.warn(`Language file could not be loaded: ${filename}`, e); } } return locales; }; const flattenLocales = (locales) => { const result = []; const languages = Object.keys(locales); const collectKeys = (obj, currentPath = "") => { const keys = []; if (typeof obj === "object" && obj !== null) { Object.keys(obj).forEach((key) => { const fullPath = currentPath ? `${currentPath}.${key}` : key; if (typeof obj[key] === "object" && obj[key] !== null) { keys.push(...collectKeys(obj[key], fullPath)); } else { keys.push(fullPath); } }); } return keys; }; const allKeys = new Set(); languages.forEach((lang) => { collectKeys(locales[lang]).forEach((key) => allKeys.add(key)); }); Array.from(allKeys).forEach((fullKey) => { const pathParts = fullKey.split("."); const key = pathParts[pathParts.length - 1]; const path = pathParts.slice(0, -1).join("."); const values = {}; languages.forEach((lang) => { const value = getNestedValue(locales[lang], fullKey); values[lang] = value || ""; }); result.push({ path: path || "", key, values, }); }); return result; }; /** * Creates table columns for locale data * @param languages Array of language codes * @returns Array of column definitions */ const createLocaleColumns = (languages) => [ { id: 'key', header: 'Key/Path', accessor: 'key', width: 250 }, ...languages.map(lang => ({ id: lang, header: lang.toUpperCase(), accessor: lang, width: 200 })) ]; /** * Transforms flattened locale data into table rows * @param flattenedData Flattened locale data * @returns Array of table row data */ const transformToTableData = (flattenedData) => { return flattenedData.map(row => { const fullKey = row.path ? `${row.path}.${row.key}` : row.key; return { id: fullKey, key: row.key, path: row.path, values: row.values }; }); }; /** * Gets display value for a translation key in a specific language * @param fullKey The full translation key path * @param lang Language code * @param originalValue Original translation value * @param pendingChanges Pending changes object * @returns The display value (pending change or original) */ const getDisplayValueForTable = (fullKey, lang, originalValue, pendingChanges) => { // Return originalValue directly if pendingChanges is null or undefined if (pendingChanges == null) { return originalValue; } const pendingValue = getNestedValue(pendingChanges[lang], fullKey); return pendingValue !== undefined ? pendingValue : originalValue; }; /** * Filters table data based on used locales and search term * @param tableData Array of table rows * @param searchTerm Search string * @param languages Array of language codes * @param pendingChanges Pending changes object * @param usedLocales Set of used locale keys * @returns Filtered table data */ const filterTableData = (tableData, searchTerm, languages, pendingChanges, usedLocales) => { let filteredData = tableData; // First filter by used locales if provided if (usedLocales && usedLocales.size > 0) { const usedLocalesArray = Array.from(usedLocales); filteredData = tableData.filter((row) => { return usedLocalesArray.indexOf(row.id) !== -1; }); } // Then apply search filter if (!searchTerm.trim()) return filteredData; const searchLower = searchTerm.toLowerCase().trim(); return filteredData.filter((row) => { const fullKey = row.id; // Search in key path if (fullKey.toLowerCase().includes(searchLower)) { return true; } // Search in language values return languages.some((lang) => { const value = getDisplayValueForTable(fullKey, lang, row.values[lang], pendingChanges); return value && value.toLowerCase().includes(searchLower); }); }); }; var LocalStorageKeys; (function (LocalStorageKeys) { LocalStorageKeys["LOCALE_EDITS"] = "localeEdits"; })(LocalStorageKeys || (LocalStorageKeys = {})); // Creating Table Context const TableContext = React.createContext(undefined); // Custom hook: For using TableContext const useTableContext = () => { const context = React.useContext(TableContext); if (context === undefined) { throw new Error('useTableContext must be used within a TableProvider'); } return context; }; // TableProvider Component const TableProvider = ({ initialColumns, initialData, children, getDisplayValue, updateLocalValue }) => { const [columnWidths, setColumnWidths] = React.useState({}); const [columnVisibility, setColumnVisibility] = React.useState({}); // Resizer states const startX = React.useRef(0); const startWidth = React.useRef(0); const currentColumn = React.useRef(null); // Set column widths and visibility on first render React.useEffect(() => { const initialWidths = {}; const initialVisibility = {}; // First, set the key column width and visibility const keyColumn = initialColumns[0]; const keyColumnWidth = keyColumn.width || 250; initialWidths[keyColumn.id] = keyColumnWidth; initialVisibility[keyColumn.id] = true; // Calculate the minimum width for language columns (12.5rem = 200px if 1rem = 16px) const minLanguageColumnWidth = 200; // 12.5rem // Get language columns (all columns except the first one) const languageColumns = initialColumns.slice(1); // Set visibility for all language columns languageColumns.forEach(col => { initialVisibility[col.id] = true; }); // If there are language columns, set equal widths with minimum of 12.5rem if (languageColumns.length > 0) { // Set equal width for all language columns (minimum 12.5rem) // Use a reasonable default for window.innerWidth in case it's not available (e.g., during SSR) const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 1200; const languageColumnWidth = Math.max(minLanguageColumnWidth, (windowWidth - keyColumnWidth) / languageColumns.length); languageColumns.forEach(col => { initialWidths[col.id] = languageColumnWidth; }); } setColumnWidths(initialWidths); setColumnVisibility(initialVisibility); }, [initialColumns]); // Update column widths when window is resized React.useEffect(() => { const handleResize = () => { if (initialColumns.length <= 1) return; const keyColumn = initialColumns[0]; const keyColumnWidth = columnWidths[keyColumn.id]; const languageColumns = initialColumns.slice(1); const minLanguageColumnWidth = 200; // 12.5rem // Calculate equal width for language columns // Use a reasonable default for window.innerWidth in case it's not available const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 1200; const languageColumnWidth = Math.max(minLanguageColumnWidth, (windowWidth - keyColumnWidth) / languageColumns.length); setColumnWidths(prev => { const newWidths = { ...prev }; languageColumns.forEach(col => { newWidths[col.id] = languageColumnWidth; }); return newWidths; }); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [initialColumns, columnWidths]); // Start of column resizing const onMouseDown = React.useCallback((e, columnId) => { startX.current = e.clientX; startWidth.current = columnWidths[columnId]; currentColumn.current = columnId; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }, [columnWidths]); // During column resizing const onMouseMove = React.useCallback((e) => { if (currentColumn.current) { const diff = e.clientX - startX.current; setColumnWidths(prevWidths => ({ ...prevWidths, [currentColumn.current]: Math.max(50, startWidth.current + diff), // Minimum width 50px })); } }, []); // End of column resizing const onMouseUp = React.useCallback(() => { currentColumn.current = null; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }, [onMouseMove]); // Change column visibility const toggleColumnVisibility = React.useCallback((columnId) => { // The first column must always be visible if (columnId === initialColumns[0].id) return; setColumnVisibility(prev => ({ ...prev, [columnId]: !prev[columnId], })); }, [initialColumns]); // Filter visible columns const visibleColumns = React.useMemo(() => initialColumns.filter(col => columnVisibility[col.id]), [initialColumns, columnVisibility]); const contextValue = React.useMemo(() => ({ columns: initialColumns, data: initialData, columnWidths, columnVisibility, onMouseDown, toggleColumnVisibility, visibleColumns, getDisplayValue, updateLocalValue, }), [initialColumns, initialData, columnWidths, columnVisibility, onMouseDown, toggleColumnVisibility, visibleColumns, getDisplayValue, updateLocalValue]); return (jsxRuntime.jsx(TableContext.Provider, { value: contextValue, children: children })); }; const HeadTableCell = ({ column, isFirstColumn }) => { const { columnWidths, onMouseDown } = useTableContext(); const styles = useStyle(); return (jsxRuntime.jsxs("th", { style: { ...styles.th({ isFirstColumn }), width: columnWidths[column.id], minWidth: columnWidths[column.id] }, children: [column.header, jsxRuntime.jsx("div", { className: "resizer", onMouseDown: (e) => onMouseDown(e, column.id), style: { ...styles.resizer, } })] }, column.id)); }; const NoData = ({ colSpan }) => { const styles = useStyle(); return (jsxRuntime.jsx("tr", { children: jsxRuntime.jsx("td", { colSpan: colSpan, style: styles.noData, children: "No data" }) })); }; const EditableTextarea = ({ value, onChange, onFocus, onBlur }) => { const styles = useStyle(); const textareaRef = React.useRef(null); // Function to adjust textarea height const adjustTextareaHeight = React.useCallback((textarea) => { if (textarea) { textarea.style.height = "auto"; textarea.style.height = `${textarea.scrollHeight}px`; } }, []); // Adjust height when value changes React.useEffect(() => { adjustTextareaHeight(textareaRef.current); }, [value, adjustTextareaHeight]); const handleFocus = (e) => { const target = e.target; target.style.borderColor = styles.textareaFocus.borderColor; target.style.backgroundColor = styles.textareaFocus.backgroundColor; target.style.boxShadow = styles.textareaFocus.boxShadow; onFocus?.(); }; const handleBlur = (e) => { const target = e.target; target.style.borderColor = styles.textarea.border.split(' ')[2]; target.style.backgroundColor = styles.textarea.backgroundColor; target.style.boxShadow = 'none'; onBlur?.(); }; const handleInput = (e) => { adjustTextareaHeight(e.currentTarget); }; const handleChange = (e) => { onChange(e.target.value); }; return (jsxRuntime.jsx("textarea", { ref: textareaRef, value: value, onChange: handleChange, onInput: handleInput, onFocus: handleFocus, onBlur: handleBlur, rows: 1, style: styles.textarea })); }; const BodyRow = ({ row, rowIndex }) => { const { visibleColumns, columnWidths, getDisplayValue, updateLocalValue } = useTableContext(); const styles = useStyle(); const fullKey = row.path ? `${row.path}.${row.key}` : row.key; return (jsxRuntime.jsx("tr", { style: { ...styles.tr, }, children: visibleColumns.map((column, colIndex) => (jsxRuntime.jsx("td", { style: { ...styles.td({ isFirstColumn: colIndex === 0, isEvenRow: rowIndex % 2 === 0 }), width: columnWidths[column.id], minWidth: columnWidths[column.id] }, children: column.id === "key" ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [row.path && jsxRuntime.jsxs("span", { style: styles.keyDisplay, children: [row.path, "."] }), row.key] })) : (jsxRuntime.jsx(EditableTextarea, { value: getDisplayValue ? getDisplayValue(fullKey, column.id, row.values[column.id]) : row.values[column.id], onChange: (value) => { if (updateLocalValue) { updateLocalValue(column.id, fullKey, value); } } })) }, `${row.id}-${column.id}`))) }, row.id)); }; const SimpleTable = () => { const { data, visibleColumns } = useTableContext(); const styles = useStyle(); const scrollContainerRef = React.useRef(null); return (jsxRuntime.jsx("div", { ref: scrollContainerRef, style: { ...styles.scrollContainer }, children: jsxRuntime.jsxs("table", { style: styles.table, children: [jsxRuntime.jsx("thead", { style: styles.thead, children: jsxRuntime.jsx("tr", { children: visibleColumns.map((column, index) => (jsxRuntime.jsx(HeadTableCell, { column: column, index: index, isFirstColumn: index === 0 }, column.id))) }) }), jsxRuntime.jsx("tbody", { children: data.length === 0 ? (jsxRuntime.jsx(NoData, { colSpan: visibleColumns.length })) : (data.map((row, rowIndex) => (jsxRuntime.jsx(BodyRow, { row: row, rowIndex: rowIndex }, row.id)))) })] }) })); }; const SearchInput = ({ value, onChange, placeholder = "Search...", onFocus, onBlur }) => { const styles = useStyle(); const handleFocus = (e) => { const target = e.currentTarget; target.style.borderColor = styles.searchInputFocus.borderColor; target.style.backgroundColor = styles.searchInputFocus.backgroundColor; target.style.boxShadow = styles.searchInputFocus.boxShadow; onFocus?.(); }; const handleBlur = (e) => { const target = e.currentTarget; target.style.borderColor = styles.searchInput.border.split(' ')[2]; target.style.backgroundColor = styles.searchInput.backgroundColor; target.style.boxShadow = 'none'; onBlur?.(); }; const handleChange = (e) => { onChange(e.target.value); }; return (jsxRuntime.jsx("input", { type: "text", placeholder: placeholder, value: value, onChange: handleChange, onFocus: handleFocus, onBlur: handleBlur, style: styles.searchInput })); }; const Tooltip = ({ children, label }) => { const [isVisible, setIsVisible] = React.useState(false); return (jsxRuntime.jsxs("div", { style: { position: 'relative', display: 'inline-block' }, onMouseEnter: () => setIsVisible(true), onMouseLeave: () => setIsVisible(false), children: [children, isVisible && (jsxRuntime.jsxs("div", { style: { position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)', backgroundColor: '#333', color: 'white', padding: '8px 12px', borderRadius: '6px', fontSize: '14px', whiteSpace: 'nowrap', zIndex: 1000, marginBottom: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }, children: [label, jsxRuntime.jsx("div", { style: { position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)', width: 0, height: 0, borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '6px solid #333' } })] }))] })); }; const IconButton = ({ label, onClick, children }) => { return (jsxRuntime.jsx(Tooltip, { label: label, children: jsxRuntime.jsx("button", { onClick: onClick, style: { padding: '8px', border: 'none', background: 'transparent', cursor: 'pointer', borderRadius: '6px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'background-color 0.2s ease', color: colors.text.secondary, }, onMouseEnter: (e) => { e.currentTarget.style.backgroundColor = '#f0f0f0'; }, onMouseLeave: (e) => { e.currentTarget.style.backgroundColor = 'transparent'; }, children: children }) })); }; const SaveStatix = () => { const { saveChanges, resetChanges } = useStatix(); return (jsxRuntime.jsxs("div", { style: { display: 'flex', alignItems: 'center', }, children: [jsxRuntime.jsx(IconButton, { label: "Save changes", onClick: saveChanges, children: jsxRuntime.jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: "lucide lucide-save-all-icon lucide-save-all", children: [jsxRuntime.jsx("path", { d: "M10 2v3a1 1 0 0 0 1 1h5" }), jsxRuntime.jsx("path", { d: "M18 18v-6a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6" }), jsxRuntime.jsx("path", { d: "M18 22H4a2 2 0 0 1-2-2V6" }), jsxRuntime.jsx("path", { d: "M8 18a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9.172a2 2 0 0 1 1.414.586l2.828 2.828A2 2 0 0 1 22 6.828V16a2 2 0 0 1-2.01 2z" })] }) }), jsxRuntime.jsx(IconButton, { label: "Reset changes", onClick: resetChanges, children: jsxRuntime.jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: "lucide lucide-save-off-icon lucide-save-off", children: [jsxRuntime.jsx("path", { d: "M13 13H8a1 1 0 0 0-1 1v7" }), jsxRuntime.jsx("path", { d: "M14 8h1" }), jsxRuntime.jsx("path", { d: "M17 21v-4" }), jsxRuntime.jsx("path", { d: "m2 2 20 20" }), jsxRuntime.jsx("path", { d: "M20.41 20.41A2 2 0 0 1 19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 .59-1.41" }), jsxRuntime.jsx("path", { d: "M29.5 11.5s5 5 4 5" }), jsxRuntime.jsx("path", { d: "M9 3h6.2a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V15" })] }) })] })); }; const ColumnVisibilityToggle = () => { const { columns, columnVisibility, toggleColumnVisibility } = useTableContext(); const styles = useStyle(); const [isOpen, setIsOpen] = React.useState(false); const dropdownRef = React.useRef(null); // Listen for clicks outside React.useEffect(() => { const handleClickOutside = (event) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [dropdownRef]); return (jsxRuntime.jsxs("div", { style: styles.dropdown, ref: dropdownRef, children: [jsxRuntime.jsx(IconButton, { onClick: () => setIsOpen(!isOpen), label: "Column Visibility", children: jsxRuntime.jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: "lucide lucide-settings-icon lucide-settings", children: [jsxRuntime.jsx("path", { d: "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" }), jsxRuntime.jsx("circle", { cx: "12", cy: "12", r: "3" })] }) }), jsxRuntime.jsx("div", { style: styles.dropdownMenu({ isOpen }), role: "menu", "aria-orientation": "vertical", "aria-labelledby": "options-menu", children: columns.map((column) => { const isDisabled = column.id === columns[0].id; if (isDisabled) return null; return (jsxRuntime.jsxs("label", { style: styles.dropdownItem, role: "menuitem", children: [jsxRuntime.jsx("input", { type: "checkbox", style: { ...styles.checkbox, ...(columnVisibility[column.id] ? styles.checkboxChecked : {}) }, checked: columnVisibility[column.id], onChange: () => toggleColumnVisibility(column.id), disabled: isDisabled }), jsxRuntime.jsx("span", { children: column.header })] }, column.id)); }) })] })); }; const TableHeader = ({ searchTerm, onSearchChange }) => (jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 0" }, children: [jsxRuntime.jsx(SearchInput, { value: searchTerm, onChange: onSearchChange, placeholder: "Search keys or translations..." }), jsxRuntime.jsxs("div", { style: { display: 'flex', gap: '8px', alignItems: 'center' }, children: [jsxRuntime.jsx(SaveStatix, {}), jsxRuntime.jsx(ColumnVisibilityToggle, {})] })] })); const LocaleTable = ({ localeData }) => { const { updateLocalValue, pendingChanges, usedLocales } = useStatix(); const [searchTerm, setSearchTerm] = React.useState(""); // Extract languages and flatten data const languages = React.useMemo(() => Object.keys(localeData), [localeData]); const flattenedData = React.useMemo(() => flattenLocales(localeData), [localeData]); // Create a table structure const columns = React.useMemo(() => createLocaleColumns(languages), [languages]); const tableData = React.useMemo(() => transformToTableData(flattenedData), [flattenedData]); // Create display value function with current context const getDisplayValue = React.useMemo(() => (fullKey, lang, originalValue) => getDisplayValueForTable(fullKey, lang, originalValue, pendingChanges), [pendingChanges]); // Filter data: Apply both used locales and search filters in one function const filteredData = React.useMemo(() => filterTableData(tableData, searchTerm, languages, pendingChanges, usedLocales), [tableData, searchTerm, languages, pendingChanges, usedLocales]); return (jsxRuntime.jsx("div", { style: { height: "100%", display: "flex", flexDirection: "column" }, children: jsxRuntime.jsxs(TableProvider, { initialColumns: columns, initialData: filteredData, getDisplayValue: getDisplayValue, updateLocalValue: updateLocalValue, children: [jsxRuntime.jsx(TableHeader, { searchTerm: searchTerm, onSearchChange: setSearchTerm }), jsxRuntime.jsx(SimpleTable, {})] }) })); }; const defaultConfig = { localePath: "public/locales", languagesKeys: {}, editable: false, }; const StatixProvider = ({ config = defaultConfig, children, }) => { const [locales, setLocales] = React.useState({}); const usedLocales = React.useRef(new Set()); // Cache localStorage value to avoid multiple reads const savedLocaleEdits = React.useMemo(() => { return localStorage.getItem(LocalStorageKeys.LOCALE_EDITS); }, []); const [pendingChanges, setPendingChanges] = React.useState(() => { if (savedLocaleEdits) { try { return JSON.parse(savedLocaleEdits); } catch (error) { console.warn("Invalid localStorage data for localeEdits, using empty object"); return {}; } } return {}; }); // Load language files React.useEffect(() => { const init = async () => { const loadedLocales = await loadLocaleFiles(config); setLocales(loadedLocales); }; init(); }, []); // Load and filter pending changes from LocalStorage React.useEffect(() => { if (savedLocaleEdits && locales && Object.keys(locales).length > 0) { try { const parsedChanges = JSON.parse(savedLocaleEdits); // Clean redundant changes that match original locale values const cleanedChanges = cleanRedundantChanges(parsedChanges, locales); // Always set cleaned changes, even if they're the same setPendingChanges(cleanedChanges); // Force localStorage update if changes were cleaned const originalCount = JSON.stringify(parsedChanges).length; const cleanedCount = JSON.stringify(cleanedChanges).length; if (originalCount !== cleanedCount) { console.log('Forcing localStorage update due to cleanup'); localStorage.setItem(LocalStorageKeys.LOCALE_EDITS, JSON.stringify(cleanedChanges)); } } catch (error) { console.warn("Invalid localStorage data for localeEdits, skipping"); } } }, [locales]); // Write to LocalStorage React.useEffect(() => { if (Object.keys(pendingChanges).length > 0) { localStorage.setItem(LocalStorageKeys.LOCALE_EDITS, JSON.stringify(pendingChanges)); } else { // If no pending changes, remove from localStorage localStorage.removeItem(LocalStorageKeys.LOCALE_EDITS); } }, [pendingChanges]); const updateLocalValue = (lang, key, value) => { setPendingChanges((prev) => { const updated = { ...prev }; updated[lang] = updated[lang] || {}; // Use prev (current state) instead of pendingChanges for comparison const originalValue = getNestedValue(locales[lang] || {}, key); const isSame = value === originalValue; if (isSame) { // If value is same as original, remove from pending changes return removeNestedValue(updated, `${lang}.${key}`); } else { // Use setNestedValue to handle nested paths updated[lang] = setNestedValue(updated[lang], key, value); return updated; } }); }; const addUsedLocale = (key) => { usedLocales.current.add(key); }; const resetChanges = () => { setPendingChanges({}); localStorage.removeItem(LocalStorageKeys.LOCALE_EDITS); }; const saveChanges = () => { // Log the payload console.log("Payload:", pendingChanges); if (config.onSave) { // Use the custom save handler if provided config.onSave(pendingChanges); } else { // Default behavior alert("Changes are ready!"); } // Optionally clear changes after saving if (window.confirm("Changes saved. Do you want to clear the local cache?")) { resetChanges(); } }; const contextValue = React.useMemo(() => ({ editable: config.editable ?? false, locales, updateLocalValue, pendingChanges, resetChanges, saveChanges, usedLocales: usedLocales.current, addUsedLocale, }), [config.editable, locales, pendingChanges]); return (jsxRuntime.jsxs(StatixContext.Provider, { value: contextValue, children: [children, jsxRuntime.jsx(StatixDrawer, { children: jsxRuntime.jsx(LocaleTable, { localeData: locales }) })] })); }; const Statix = ({ children, keyPath, lang }) => { const { editable, locales, pendingChanges, updateLocalValue } = useStatix(); const triggerRef = React.useRef(null); const [position, setPosition] = React.useState({ top: 0, left: 0 }); const [show, setShow] = React.useState(false); if (!editable) return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children }); const detectedKey = keyPath || children; const currentLang = lang || Object.keys(locales)[0] || 'en'; const handleMouseEnter = () => { if (triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect(); setPosition({ top: rect.bottom + window.scrollY, left: rect.left + window.scrollX, }); } setShow(true); }; const handleChange = (lang, e) => { console.log(e.target.value); updateLocalValue(lang, detectedKey, e.target.value); }; const translatedValue = getNestedValue(pendingChanges?.[currentLang], detectedKey) ?? getNestedValue(locales[currentLang], detectedKey); return (jsxRuntime.jsxs("span", { ref: triggerRef, onMouseEnter: handleMouseEnter, onMouseLeave: () => setShow(false), children: [translatedValue || children, show && (jsxRuntime.jsx("div", { style: { position: "absolute", top: `${position.top}px`, left: `${position.left}px`, zIndex: 9999, }, onMouseLeave: () => setShow(false), children: jsxRuntime.jsxs("div", { style: { padding: "8px", margin: "8px", minWidth: "200px", fontSize: "14px", background: "#fff", border: "1px solid #ccc", borderRadius: "4px", boxShadow: "0 4px 12px rgba(0,0,0,0.1)", }, children: [jsxRuntime.jsx("strong", { style: { display: "block", marginBottom: "8px" }, children: detectedKey }), Object.keys(locales).map((lang) => (jsxRuntime.jsxs("div", { style: { marginBottom: "6px" }, children: [jsxRuntime.jsx("label", { style: { fontWeight: "bold", display: "block", fontSize: "12px", }, children: lang.toUpperCase() }), jsxRuntime.jsx("input", { type: "text", defaultValue: getNestedValue(pendingChanges?.[lang], detectedKey) ?? getNestedValue(locales[lang], detectedKey), onChange: (e) => handleChange(lang, e), style: { width: "100%", padding: "4px", border: "1px solid #ccc", borderRadius: "4px", fontSize: "13px", } })] }, lang)))] }) }))] })); }; const useEditableTranslation = () => { const { t, i18n } = reactI18next.useTranslation(); const { editable, addU