react-statix
Version:
React components for statix localization management
1,144 lines (1,109 loc) • 51.4 kB
JavaScript
'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