@tuhama/translation-manager 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/Storage.js +10 -4
- package/src/core/TranslatorManager.js +133 -4
- package/src/server.js +44 -4
- package/web/dist/assets/index-DtMLkoQW.css +1 -0
- package/web/dist/assets/index-p37Bqu5O.js +21 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-5lMTDHig.css +0 -1
- package/web/dist/assets/index-ByUmql2E.js +0 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuhama/translation-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "A modern, web-based UI for managing i18n translation files in React and other JavaScript projects.",
|
|
5
5
|
"author": "Tuhama <tuhama.gh.qlyshi@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
package/src/core/Storage.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('fs-extra');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const Utilities = require('./Utilities');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Interface-like class for managing translation file storage.
|
|
@@ -62,19 +63,24 @@ class Storage {
|
|
|
62
63
|
/**
|
|
63
64
|
* Writes a single translation file (or all of them).
|
|
64
65
|
*/
|
|
65
|
-
async write(lang, content) {
|
|
66
|
+
async write(lang, content, options = {}) {
|
|
66
67
|
const localesDir = await this.getLocalesDir();
|
|
67
68
|
const filePath = path.join(localesDir, `${lang}.json`);
|
|
68
|
-
|
|
69
|
+
|
|
70
|
+
// Sort the content before writing if sorting is enabled (default: true)
|
|
71
|
+
const shouldSort = options.sort !== false;
|
|
72
|
+
const finalContent = shouldSort ? Utilities.sortObject(content) : content;
|
|
73
|
+
|
|
74
|
+
await fs.writeJson(filePath, finalContent, { spaces: 2 });
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
/**
|
|
72
78
|
* Writes all translations.
|
|
73
79
|
*/
|
|
74
|
-
async writeAll(translations) {
|
|
80
|
+
async writeAll(translations, options = {}) {
|
|
75
81
|
const languages = Object.keys(translations);
|
|
76
82
|
for (const lang of languages) {
|
|
77
|
-
await this.write(lang, translations[lang]);
|
|
83
|
+
await this.write(lang, translations[lang], options);
|
|
78
84
|
}
|
|
79
85
|
}
|
|
80
86
|
}
|
|
@@ -65,14 +65,14 @@ class TranslatorManager {
|
|
|
65
65
|
/**
|
|
66
66
|
* Saves a translation key across all language files.
|
|
67
67
|
*/
|
|
68
|
-
async saveTranslation(key, values) {
|
|
68
|
+
async saveTranslation(key, values, options = {}) {
|
|
69
69
|
const translations = await this.storage.readAll();
|
|
70
70
|
const languages = Object.keys(translations);
|
|
71
71
|
|
|
72
72
|
for (const lang of languages) {
|
|
73
73
|
if (values[lang] !== undefined) {
|
|
74
74
|
lodash.set(translations[lang], key, values[lang]);
|
|
75
|
-
await this.storage.write(lang, translations[lang]);
|
|
75
|
+
await this.storage.write(lang, translations[lang], options);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
}
|
|
@@ -200,7 +200,7 @@ class TranslatorManager {
|
|
|
200
200
|
* Saves multiple translation keys across languages.
|
|
201
201
|
* @param {Object} data - { lang: { key: value } }
|
|
202
202
|
*/
|
|
203
|
-
async saveBulkTranslations(data) {
|
|
203
|
+
async saveBulkTranslations(data, options = {}) {
|
|
204
204
|
const translations = await this.storage.readAll();
|
|
205
205
|
const languages = Object.keys(translations);
|
|
206
206
|
|
|
@@ -209,11 +209,140 @@ class TranslatorManager {
|
|
|
209
209
|
for (const key in data[lang]) {
|
|
210
210
|
lodash.set(translations[lang], key, data[lang][key]);
|
|
211
211
|
}
|
|
212
|
-
await this.storage.write(lang, translations[lang]);
|
|
212
|
+
await this.storage.write(lang, translations[lang], options);
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Exports missing translation keys in the format: {"key": {"lang1": "", "lang2": ""}}
|
|
219
|
+
* @param {string} sourceLang - The source language to exclude from missing keys
|
|
220
|
+
* @returns {Object} - Export data with missing keys and empty values for each target language
|
|
221
|
+
*/
|
|
222
|
+
async exportMissingKeys(sourceLang = 'en') {
|
|
223
|
+
const { languages, translations, allKeys } = await this.scan();
|
|
224
|
+
const exportData = {};
|
|
225
|
+
|
|
226
|
+
// Get all missing keys across all languages
|
|
227
|
+
const allMissingKeys = new Set();
|
|
228
|
+
|
|
229
|
+
languages.forEach(lang => {
|
|
230
|
+
if (lang === sourceLang) return;
|
|
231
|
+
|
|
232
|
+
allKeys.forEach(key => {
|
|
233
|
+
const val = lodash.get(translations[lang], key);
|
|
234
|
+
if (val === undefined || val === '') {
|
|
235
|
+
allMissingKeys.add(key);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Build export structure: {"key": {"lang1": "", "lang2": ""}}
|
|
241
|
+
Array.from(allMissingKeys).forEach(key => {
|
|
242
|
+
exportData[key] = {};
|
|
243
|
+
languages.forEach(lang => {
|
|
244
|
+
if (lang !== sourceLang) {
|
|
245
|
+
const val = lodash.get(translations[lang], key);
|
|
246
|
+
exportData[key][lang] = (val === undefined || val === '') ? '' : val;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
exportData,
|
|
253
|
+
metadata: {
|
|
254
|
+
sourceLang,
|
|
255
|
+
targetLanguages: languages.filter(lang => lang !== sourceLang),
|
|
256
|
+
totalKeys: allMissingKeys.size,
|
|
257
|
+
exportedAt: new Date().toISOString()
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Exports keys that are used in code but not in translation files.
|
|
264
|
+
* @returns {Object} - Export data with missing keys and empty values for each language
|
|
265
|
+
*/
|
|
266
|
+
async exportMissingFromFiles() {
|
|
267
|
+
const { languages, missingFromFiles } = await this.scan();
|
|
268
|
+
const exportData = {};
|
|
269
|
+
|
|
270
|
+
// Build structure: {"key": {"lang1": "", "lang2": ""}}
|
|
271
|
+
missingFromFiles.forEach(key => {
|
|
272
|
+
exportData[key] = {};
|
|
273
|
+
languages.forEach(lang => {
|
|
274
|
+
exportData[key][lang] = '';
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
exportData,
|
|
280
|
+
metadata: {
|
|
281
|
+
languages,
|
|
282
|
+
totalKeys: missingFromFiles.length,
|
|
283
|
+
exportedAt: new Date().toISOString()
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Imports translated keys from the export format back into translation files
|
|
290
|
+
* @param {Object} importData - Data in format {"key": {"lang1": "translation", "lang2": "translation"}}
|
|
291
|
+
* @param {Object} options - Import options (merge strategy, formatting, etc.)
|
|
292
|
+
*/
|
|
293
|
+
async importTranslations(importData, options = {}) {
|
|
294
|
+
const {
|
|
295
|
+
overwriteExisting = false,
|
|
296
|
+
skipEmpty = true,
|
|
297
|
+
format = true
|
|
298
|
+
} = options;
|
|
299
|
+
|
|
300
|
+
const translations = await this.storage.readAll();
|
|
301
|
+
const languages = Object.keys(translations);
|
|
302
|
+
const importStats = {
|
|
303
|
+
imported: 0,
|
|
304
|
+
skipped: 0,
|
|
305
|
+
errors: []
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
for (const key in importData) {
|
|
309
|
+
const keyTranslations = importData[key];
|
|
310
|
+
|
|
311
|
+
for (const lang in keyTranslations) {
|
|
312
|
+
const translation = keyTranslations[lang];
|
|
313
|
+
|
|
314
|
+
// Skip if language doesn't exist in project
|
|
315
|
+
if (!languages.includes(lang)) {
|
|
316
|
+
importStats.errors.push(`Language '${lang}' not found in project`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Skip empty translations if configured
|
|
321
|
+
if (skipEmpty && (!translation || translation.trim() === '')) {
|
|
322
|
+
importStats.skipped++;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Check if key already has a value
|
|
327
|
+
const existingValue = lodash.get(translations[lang], key);
|
|
328
|
+
if (existingValue && existingValue !== '' && !overwriteExisting) {
|
|
329
|
+
importStats.skipped++;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Import the translation
|
|
334
|
+
lodash.set(translations[lang], key, translation);
|
|
335
|
+
importStats.imported++;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Save all updated translations
|
|
340
|
+
const saveOptions = { sort: format };
|
|
341
|
+
await this.storage.writeAll(translations, saveOptions);
|
|
342
|
+
|
|
343
|
+
return importStats;
|
|
344
|
+
}
|
|
345
|
+
|
|
217
346
|
/**
|
|
218
347
|
* Saves the current configuration to the config file.
|
|
219
348
|
*/
|
package/src/server.js
CHANGED
|
@@ -26,8 +26,9 @@ function startServer(targetDir, port = 3000, config = {}) {
|
|
|
26
26
|
|
|
27
27
|
app.post('/api/translations', async (req, res) => {
|
|
28
28
|
try {
|
|
29
|
-
const { key, values } = req.body;
|
|
30
|
-
|
|
29
|
+
const { key, values, format = true } = req.body;
|
|
30
|
+
const options = { sort: format };
|
|
31
|
+
await manager.saveTranslation(key, values, options);
|
|
31
32
|
res.json({ success: true });
|
|
32
33
|
} catch (err) {
|
|
33
34
|
res.status(500).json({ error: err.message });
|
|
@@ -129,14 +130,53 @@ function startServer(targetDir, port = 3000, config = {}) {
|
|
|
129
130
|
|
|
130
131
|
app.post('/api/bulk-save', async (req, res) => {
|
|
131
132
|
try {
|
|
132
|
-
const { data } = req.body;
|
|
133
|
-
|
|
133
|
+
const { data, format = true } = req.body;
|
|
134
|
+
const options = { sort: format };
|
|
135
|
+
await manager.saveBulkTranslations(data, options);
|
|
134
136
|
res.json({ success: true });
|
|
135
137
|
} catch (err) {
|
|
136
138
|
res.status(500).json({ error: err.message });
|
|
137
139
|
}
|
|
138
140
|
});
|
|
139
141
|
|
|
142
|
+
app.get('/api/export-missing', async (req, res) => {
|
|
143
|
+
try {
|
|
144
|
+
const exportResult = await manager.exportMissingFromFiles();
|
|
145
|
+
|
|
146
|
+
// Set headers for file download
|
|
147
|
+
res.setHeader('Content-Type', 'application/json');
|
|
148
|
+
res.setHeader('Content-Disposition', `attachment; filename="missing-keys-from-code-${new Date().toISOString().split('T')[0]}.json"`);
|
|
149
|
+
|
|
150
|
+
res.json(exportResult.exportData);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
res.status(500).json({ error: err.message });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
app.post('/api/import-translations', async (req, res) => {
|
|
157
|
+
try {
|
|
158
|
+
const { data, options = {} } = req.body;
|
|
159
|
+
|
|
160
|
+
if (!data || typeof data !== 'object') {
|
|
161
|
+
return res.status(400).json({ error: 'Invalid import data format' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const importStats = await manager.importTranslations(data, options);
|
|
165
|
+
res.json({ success: true, stats: importStats });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
res.status(500).json({ error: err.message });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
app.get('/api/export-missing/preview', async (req, res) => {
|
|
172
|
+
try {
|
|
173
|
+
const exportResult = await manager.exportMissingFromFiles();
|
|
174
|
+
res.json(exportResult);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
res.status(500).json({ error: err.message });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
140
180
|
app.get('/api/config', (req, res) => {
|
|
141
181
|
res.json(manager.config);
|
|
142
182
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg-color:#0b0e14;--sidebar-bg:#111827cc;--accent-color:#7c3aed;--accent-hover:#6d28d9;--text-color:#f3f4f6;--text-muted:#9ca3af;--border-color:#ffffff14;--glass-bg:#ffffff08;--glass-blur:blur(20px);--green:#10b981;--red:#ef4444;--transition:all .25s ease}*{box-sizing:border-box;margin:0;padding:0}body{background-color:var(--bg-color);color:var(--text-color);-webkit-font-smoothing:antialiased;font-family:Inter,system-ui,-apple-system,sans-serif;line-height:1.6}#root{height:100vh;display:flex}.app-container{flex-direction:column;width:100%;height:100vh;display:flex}.app-body{flex:1;grid-template-columns:300px 1fr;display:grid;overflow:hidden}.app-header{background:var(--sidebar-bg);-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border-bottom:1px solid var(--border-color);z-index:100;justify-content:space-between;align-items:center;min-height:70px;padding:16px 24px;display:flex}.header-left{align-items:center;display:flex}.brand-section{align-items:center;gap:16px;display:flex}.header-logo{filter:drop-shadow(0 0 8px #7c3aed66);border-radius:8px;width:36px;height:36px}.app-title{background:linear-gradient(135deg,#a78bfa 0%,#3b82f6 100%);-webkit-text-fill-color:transparent;-webkit-background-clip:text;background-clip:text;margin:0;font-size:1.4rem;font-weight:800}.header-right{align-items:center;display:flex}.header-actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.header-primary-btn{background:linear-gradient(135deg, var(--accent-color) 0%, var(--accent-hover) 100%);color:#fff;cursor:pointer;transition:var(--transition);border:none;border-radius:8px;align-items:center;gap:8px;padding:10px 16px;font-size:.9rem;font-weight:600;display:flex}.header-primary-btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px #7c3aed4d}.header-btn{border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;transition:var(--transition);white-space:nowrap;background:#ffffff0d;border-radius:6px;align-items:center;gap:6px;padding:8px 12px;font-size:.8rem;font-weight:500;display:flex}.header-btn:hover{border-color:var(--accent-color);color:var(--accent-color);background:#ffffff14;transform:translateY(-1px)}.sidebar{background:var(--sidebar-bg);-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border-right:1px solid var(--border-color);flex-direction:column;display:flex;overflow:hidden}.sidebar-search{border-bottom:1px solid var(--border-color);padding:20px 16px}.search-input{border:1px solid var(--border-color);color:#fff;width:100%;transition:var(--transition);background:#0003;border-radius:10px;padding:12px 16px;font-size:.95rem}.search-input:focus{border-color:var(--accent-color);outline:none;box-shadow:0 0 0 2px #7c3aed1a}.search-input::placeholder{color:var(--text-muted)}.tree-view{flex:1;padding:16px;overflow-y:auto}.loading-state{color:var(--text-muted);justify-content:center;align-items:center;padding:40px 20px;font-style:italic;display:flex}.dropdown-container{position:relative}.dropdown-menu{background:var(--sidebar-bg);border:1px solid var(--border-color);z-index:1000;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border-radius:8px;min-width:200px;margin-top:4px;padding:8px 0;position:absolute;top:100%;left:0;right:0;box-shadow:0 8px 32px #0000004d}.dropdown-item{width:100%;color:var(--text-color);cursor:pointer;transition:var(--transition);text-align:left;background:0 0;border:none;align-items:center;gap:12px;padding:10px 16px;font-size:.9rem;display:flex}.dropdown-item:hover{color:var(--accent-color);background:#7c3aed1a}.item-icon{text-align:center;width:16px;font-size:1rem}.dropdown-divider{background:var(--border-color);height:1px;margin:8px 16px}.dropdown-arrow{color:var(--text-muted);transition:var(--transition);font-size:.8rem}.btn-icon{font-size:1rem}.key-tree{list-style:none}.tree-node{margin-bottom:2px}.tree-children{margin:0;padding:0;list-style:none}.tree-branch-header{cursor:pointer;transition:var(--transition);color:var(--text-color);border-radius:6px;align-items:center;padding:8px 14px;font-size:.9rem;font-weight:500;display:flex}.tree-branch-header:hover{background:#ffffff0d}.tree-branch-header.has-selected-child{color:#a78bfa;background:#7c3aed14}.tree-expand-icon{color:var(--text-muted);transition:var(--transition);text-align:center;width:12px;margin-right:8px;font-size:.8rem}.tree-branch-text{flex:1;align-items:center;gap:8px;display:flex}.tree-branch-count{color:var(--text-muted);font-size:.75rem;font-weight:400}.branch-indicator{margin-left:4px;font-size:.8rem}.branch-indicator.missing{filter:drop-shadow(0 0 3px #ef44444d)}.branch-indicator.unused{opacity:.6}.tree-leaf{cursor:pointer;transition:var(--transition);color:var(--text-muted);border-radius:6px;justify-content:space-between;align-items:center;margin-bottom:2px;padding:8px 14px;font-size:.85rem;display:flex}.tree-leaf.unused{opacity:.5;font-style:italic}.tree-leaf.unused .tree-leaf-text:after{content:" (unused)";opacity:.7;margin-left:4px;font-size:.7rem}.tree-leaf-content{flex:1;align-items:center;display:flex}.tree-leaf-text{white-space:nowrap;text-overflow:ellipsis;flex:1;overflow:hidden}.tree-leaf:hover{color:#fff;background:#ffffff0d}.tree-leaf.active{color:#a78bfa;background:#7c3aed26;font-weight:600}.tree-leaf.incomplete .tree-leaf-text{color:var(--text-muted)}.missing-translation-dot{filter:drop-shadow(0 0 5px #ef444480);margin-left:8px;font-size:.8rem}.delete-icon{opacity:0;color:var(--red);cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:4px;padding:2px 4px;font-size:1.1rem}.tree-leaf:hover .delete-icon{opacity:1}.delete-icon:hover{background:#ef44441a}.editor{justify-content:center;align-items:flex-start;padding:40px;display:flex;overflow-y:auto}.editor-form{width:100%;max-width:800px;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);background:#1f293766;border-radius:20px;padding:40px;box-shadow:0 25px 50px -12px #00000080}.form-group{text-align:left;margin-bottom:24px}.form-group.missing label{color:var(--red)}.form-group.missing input,.form-group.missing textarea{border-color:#ef44444d}.missing-label{margin-left:4px;font-size:.75rem;font-style:italic;font-weight:400}.form-group input,.form-group textarea{border:1px solid var(--border-color);color:#fff;background:#0000004d;border-radius:10px;width:100%;padding:14px;font-family:inherit;font-size:1rem}.form-group textarea{resize:vertical;min-height:100px}.form-actions{gap:16px;margin-top:32px;display:flex}.primary-btn{background:var(--accent-color);color:#fff;cursor:pointer;transition:var(--transition);border:none;border-radius:10px;padding:14px 28px;font-weight:600}.primary-btn:hover{background:var(--accent-hover);transform:translateY(-1px)}.secondary-btn{border:1px solid var(--border-color);color:var(--text-muted);cursor:pointer;background:0 0;border-radius:10px;padding:14px 28px;font-weight:600}.empty-state{text-align:center;max-width:500px}.hero-icon{filter:drop-shadow(0 0 20px #8b5cf64d);margin-bottom:24px;font-size:4rem}.empty-state h2{margin-bottom:12px;font-size:2rem}.empty-state p{color:var(--text-muted);margin-bottom:32px}.modal-overlay{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);z-index:1000;background:#000000b3;justify-content:center;align-items:center;padding:20px;display:flex;position:fixed;inset:0}.modal-content{border:1px solid var(--border-color);background:#1f2937;border-radius:20px;flex-direction:column;width:100%;max-width:600px;max-height:80vh;display:flex;overflow:hidden;box-shadow:0 25px 50px -12px #00000080}.modal-header{border-bottom:1px solid var(--border-color);justify-content:space-between;align-items:center;padding:24px;display:flex}.close-btn{color:var(--text-muted);cursor:pointer;transition:var(--transition);opacity:.6;background:0 0;border:none;padding:0;font-size:24px;line-height:1}.close-btn:hover{color:var(--text-color);opacity:1;transform:scale(1.1)}.modal-body{flex:1;padding:24px;overflow-y:auto}.cleanup-item{cursor:pointer;background:#ffffff08;border-radius:10px;align-items:center;gap:12px;margin-bottom:8px;padding:12px;display:flex}.cleanup-item:hover{background:#ffffff0f}.cleanup-item input{accent-color:var(--accent-color)}.maybe-used-warning{color:#f59e0b;align-items:center;gap:4px;margin-left:auto;font-size:.8rem;font-style:italic;display:flex}.modal-footer{border-top:1px solid var(--border-color);justify-content:flex-end;gap:12px;padding:20px;display:flex}.editor-header-row{justify-content:space-between;align-items:center;margin-bottom:32px;display:flex}.magic-btn{color:#a78bfa;background:#7c3aed1a;border-color:#7c3aed4d;padding:10px 16px;font-size:.85rem}.magic-btn:hover{background:var(--accent-color);color:#fff}.label-row{justify-content:space-between;align-items:center;margin-bottom:8px;display:flex}.label-row label{margin-bottom:0}.icon-btn{cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:4px;display:flex}.icon-btn:hover{background:#ffffff1a}.magic-wand{opacity:.6;font-size:1.1rem}.form-group:hover .magic-wand{opacity:1}.alert{border-radius:10px;margin-bottom:20px;padding:12px 16px;font-size:.9rem}.alert-success{color:#34d399;background:#10b9811a;border:1px solid #10b9814d}.alert-error{color:#f87171;background:#ef44441a;border:1px solid #ef44444d}.wizard-step{margin-bottom:24px}.wizard-step label{color:var(--text-color);margin-bottom:12px;font-weight:600;display:block}.source-select{border:1px solid var(--border-color);color:#fff;background:#0000004d;border-radius:10px;width:100%;padding:12px;font-size:1rem}.report-summary{border:1px solid var(--border-color);background:#0003;border-radius:12px;padding:16px}.report-summary ul{color:var(--text-muted);margin-top:12px;padding-left:20px}.preview-container{border:1px solid var(--border-color);background:#0003;border-radius:12px;max-height:400px;padding:16px;overflow-y:auto}.lang-preview{margin-bottom:24px}.lang-preview h3{color:var(--accent-color);border-bottom:1px solid var(--border-color);margin-bottom:12px;padding-bottom:4px;font-size:.85rem}.preview-list{flex-direction:column;gap:8px;display:flex}.preview-item{gap:8px;font-size:.85rem;display:flex}.preview-key{color:var(--text-muted);font-weight:500}.preview-value{color:var(--text-color)}.settings-input{letter-spacing:2px}.help-text{color:var(--text-muted);margin-top:8px;font-size:.8rem}.help-text a{color:var(--accent-color);text-decoration:none}.help-text a:hover{text-decoration:underline}.success-text{color:var(--green);font-weight:500}.approve-btn{background:var(--green)}.approve-btn:hover{background:#059669}.tool-btn.warning{color:#f59e0b;background:#f59e0b1a;border-color:#f59e0b4d}.tool-btn.warning:hover{color:#fff;background:#f59e0b}.missing-keys-list{margin-top:16px;list-style:none}.missing-key-item{border:1px solid var(--border-color);background:#ffffff08;border-radius:12px;justify-content:space-between;align-items:center;margin-bottom:8px;padding:14px;display:flex}.key-path{color:#a78bfa;font-family:JetBrains Mono,Fira Code,monospace;font-size:.85rem}.warning-box{color:#f59e0b;background:#f59e0b14;border:1px solid #f59e0b33;border-radius:12px;margin-bottom:24px;padding:16px;font-size:.85rem}.sm{padding:8px 14px;font-size:.8rem}.tab-container{margin-top:24px}.tab-buttons{border-bottom:1px solid var(--border-color);gap:12px;margin-bottom:24px;padding-bottom:8px;display:flex}.tab-button{color:var(--text-muted);cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:8px;padding:8px 16px;font-size:.95rem;font-weight:600;position:relative}.tab-button:hover{color:var(--text-color);background:#ffffff0d}.tab-button.active{color:var(--accent-color);background:#7c3aed1a}.tab-button.active:after{content:"";background:var(--accent-color);border-radius:2px 2px 0 0;height:2px;position:absolute;bottom:-9px;left:0;right:0}.export-tab,.import-tab{padding:8px 0}.export-info{border-left:4px solid var(--accent-color);background:#7c3aed0d;border-radius:4px 12px 12px 4px;margin-bottom:24px;padding:12px 16px}.export-info p{color:var(--text-color);margin:0;font-size:.9rem}.export-preview h3,.import-options h3{color:var(--text-color);margin-bottom:16px;font-size:1.1rem;font-weight:700}.preview-stats{grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:20px;display:grid}.preview-stats p{border:1px solid var(--border-color);background:#ffffff08;border-radius:12px;margin:0;padding:12px 16px;font-size:.9rem}.preview-sample h4{color:var(--text-muted);margin-bottom:12px;font-size:.9rem}.import-options{border-top:1px solid var(--border-color);margin-top:24px;padding-top:24px}.import-options label{cursor:pointer;color:var(--text-color);transition:var(--transition);align-items:center;gap:10px;margin-bottom:12px;font-size:.9rem;display:flex}.import-options label:hover{color:var(--accent-color)}.import-options input[type=checkbox]{accent-color:var(--accent-color);cursor:pointer;width:18px;height:18px}.export-import-modal .modal-content{max-width:600px}
|