@tuhama/translation-manager 0.7.0 → 0.7.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuhama/translation-manager",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "AI-powered i18n management with context-aware scanning and support for OpenAI, Gemini, and Google Translate.",
5
5
  "author": "Tuhama <tuhama.gh.qlyshi@gmail.com>",
6
6
  "license": "MIT",
@@ -106,7 +106,7 @@ class Scanner {
106
106
  // 4. <Trans i18nKey="key">
107
107
  // 5. useTranslation(['namespace']) -> t('key')
108
108
  const patterns = [
109
- /(?:\bt\(|i18n\.t\(|i18nKey=)\s*['"\`]([^'"\`\s]+)['"\`]/g
109
+ /(?:\bt\(|i18n\.t\(|i18nKey=)\s*['"\`]([a-zA-Z0-9._-]+)['"\`]/g
110
110
  ];
111
111
 
112
112
  for (const file of files) {
@@ -119,6 +119,16 @@ class Scanner {
119
119
  regex.lastIndex = 0;
120
120
  while ((match = regex.exec(content)) !== null) {
121
121
  const key = match[1];
122
+
123
+ // VALIDATION: Skip dynamic keys
124
+ // 1. Skip if it contains template literal placeholders ${...}
125
+ // 2. Skip if it's just the placeholder prefix ${
126
+ // 3. Skip if it looks like a variable (no dots, no spaces, starts with lowercase and followed by camelCase etc)
127
+ // - actually, dots are good, but ${ is the killer.
128
+ if (key.includes('${') || key.includes('`') || key.startsWith('$')) {
129
+ continue;
130
+ }
131
+
122
132
  // basic validation to avoid random strings and ensure it's not a translation file path
123
133
  if (key && !existingKeysSet.has(key) && !key.includes('/') && !key.includes('\\')) {
124
134
  if (includeContext) {
@@ -69,6 +69,9 @@ class TranslatorManager {
69
69
  * Saves a translation key across all language files.
70
70
  */
71
71
  async saveTranslation(key, values, options = {}) {
72
+ if (!key) throw new Error('Key is required');
73
+ if (!values || typeof values !== 'object') throw new Error('Values must be an object');
74
+
72
75
  const translations = await this.storage.readAll();
73
76
  const languages = Object.keys(translations);
74
77
 
@@ -84,6 +87,8 @@ class TranslatorManager {
84
87
  * Deletes one or more translation keys across all language files.
85
88
  */
86
89
  async deleteTranslations(keys) {
90
+ if (!keys || (Array.isArray(keys) && keys.length === 0)) return;
91
+
87
92
  const keysToDelete = Array.isArray(keys) ? keys : [keys];
88
93
  const translations = await this.storage.readAll();
89
94
  const languages = Object.keys(translations);
@@ -87,8 +87,15 @@ ${textsToTranslate.map(t => `- "${t}"`).join('\n')}
87
87
  })
88
88
  });
89
89
 
90
+ if (!response.ok) {
91
+ const errorData = await response.json().catch(() => ({}));
92
+ throw new Error(errorData.error?.message || `OpenAI API returned status ${response.status}`);
93
+ }
94
+
90
95
  const data = await response.json();
91
- if (data.error) throw new Error(data.error.message);
96
+ if (!data.choices || data.choices.length === 0) {
97
+ throw new Error('OpenAI API returned no results');
98
+ }
92
99
  return data.choices[0].message.content;
93
100
  }
94
101
 
@@ -102,8 +109,15 @@ ${textsToTranslate.map(t => `- "${t}"`).join('\n')}
102
109
  })
103
110
  });
104
111
 
112
+ if (!response.ok) {
113
+ const errorData = await response.json().catch(() => ({}));
114
+ throw new Error(errorData.error?.message || `Gemini API returned status ${response.status}`);
115
+ }
116
+
105
117
  const data = await response.json();
106
- if (data.error) throw new Error(data.error.message);
118
+ if (!data.candidates || data.candidates.length === 0 || !data.candidates[0].content?.parts?.length) {
119
+ throw new Error('Gemini API returned no results');
120
+ }
107
121
  return data.candidates[0].content.parts[0].text;
108
122
  }
109
123
  }
package/src/server.js CHANGED
@@ -15,131 +15,113 @@ function startServer(targetDir, port = 3000, config = {}) {
15
15
  app.use(express.json());
16
16
 
17
17
  // API endpoints
18
- app.get('/api/translations', async (req, res) => {
18
+ app.get('/api/translations', async (req, res, next) => {
19
19
  try {
20
20
  const data = await manager.scan();
21
21
  res.json(data);
22
22
  } catch (err) {
23
- res.status(500).json({ error: err.message });
23
+ next(err);
24
24
  }
25
25
  });
26
26
 
27
- app.post('/api/translations', async (req, res) => {
27
+ app.post('/api/translations', async (req, res, next) => {
28
28
  try {
29
29
  const { key, values, format = true } = req.body;
30
30
  const options = { sort: format };
31
31
  await manager.saveTranslation(key, values, options);
32
32
  res.json({ success: true });
33
33
  } catch (err) {
34
- res.status(500).json({ error: err.message });
34
+ next(err);
35
35
  }
36
36
  });
37
37
 
38
- app.delete('/api/translations', async (req, res) => {
38
+ app.delete('/api/translations', async (req, res, next) => {
39
39
  try {
40
40
  const { key } = req.body;
41
41
  await manager.deleteTranslations(key);
42
42
  res.json({ success: true });
43
43
  } catch (err) {
44
- res.status(500).json({ error: err.message });
44
+ next(err);
45
45
  }
46
46
  });
47
47
 
48
- app.post('/api/delete-keys', async (req, res) => {
48
+ app.post('/api/delete-keys', async (req, res, next) => {
49
49
  try {
50
50
  const { keys } = req.body;
51
51
  await manager.deleteTranslations(keys);
52
52
  res.json({ success: true });
53
53
  } catch (err) {
54
- res.status(500).json({ error: err.message });
54
+ next(err);
55
55
  }
56
56
  });
57
57
 
58
- app.post('/api/normalize', async (req, res) => {
58
+ app.post('/api/normalize', async (req, res, next) => {
59
59
  try {
60
60
  await manager.normalize();
61
61
  res.json({ success: true });
62
62
  } catch (err) {
63
- res.status(500).json({ error: err.message });
63
+ next(err);
64
64
  }
65
65
  });
66
66
 
67
- app.post('/api/translate', async (req, res) => {
68
- try {
69
- const { text, targetLang, sourceLang } = req.body;
70
-
71
- // Check if Google Translate is configured
72
- if (!manager.config.googleTranslate || !manager.config.googleTranslate.projectId) {
73
- return res.status(400).json({
74
- error: 'Google Translate is not configured. Please add your Google Cloud Project ID and key file in Settings.',
75
- configurationRequired: true
76
- });
77
- }
67
+ const checkConfig = (res) => {
68
+ const hasAI = manager.config.aiTranslate && manager.config.aiTranslate.apiKey;
69
+ const hasGoogle = manager.config.googleTranslate && manager.config.googleTranslate.projectId;
70
+
71
+ if (!hasAI && !hasGoogle) {
72
+ res.status(400).json({
73
+ error: 'No translation service configured. Please add OpenAI, Gemini, or Google Translate settings.',
74
+ configurationRequired: true
75
+ });
76
+ return false;
77
+ }
78
+ return true;
79
+ };
78
80
 
79
- const translatedText = await manager.translateSingle(text, targetLang, sourceLang);
81
+ app.post('/api/translate', async (req, res, next) => {
82
+ try {
83
+ if (!checkConfig(res)) return;
84
+ const { text, targetLang, sourceLang, key } = req.body;
85
+ const translatedText = await manager.translateSingle(text, targetLang, sourceLang, key);
80
86
  res.json({ translatedText });
81
87
  } catch (err) {
82
- // Check if it's a configuration error
83
- if (err.message.includes('Google Cloud Project ID is required') ||
84
- err.message.includes('Google Translate configuration is missing')) {
85
- return res.status(400).json({
86
- error: err.message,
87
- configurationRequired: true
88
- });
89
- }
90
- res.status(500).json({ error: err.message });
88
+ next(err);
91
89
  }
92
90
  });
93
91
 
94
- app.get('/api/bulk-translate/scan', async (req, res) => {
92
+ app.get('/api/bulk-translate/scan', async (req, res, next) => {
95
93
  try {
96
94
  const { sourceLang } = req.query;
97
95
  const report = await manager.getBulkTranslateReport(sourceLang || 'en');
98
96
  res.json(report);
99
97
  } catch (err) {
100
- res.status(500).json({ error: err.message });
98
+ next(err);
101
99
  }
102
100
  });
103
101
 
104
- app.post('/api/bulk-translate/execute', async (req, res) => {
102
+ app.post('/api/bulk-translate/execute', async (req, res, next) => {
105
103
  try {
104
+ if (!checkConfig(res)) return;
106
105
  const { sourceLang } = req.body;
107
-
108
- // Check if Google Translate is configured
109
- if (!manager.config.googleTranslate || !manager.config.googleTranslate.projectId) {
110
- return res.status(400).json({
111
- error: 'Google Translate is not configured. Please add your Google Cloud Project ID and key file in Settings.',
112
- configurationRequired: true
113
- });
114
- }
115
-
116
106
  const preview = await manager.bulkTranslate(sourceLang || 'en');
117
107
  res.json(preview);
118
108
  } catch (err) {
119
- // Check if it's a configuration error
120
- if (err.message.includes('Google Cloud Project ID is required') ||
121
- err.message.includes('Google Translate configuration is missing')) {
122
- return res.status(400).json({
123
- error: err.message,
124
- configurationRequired: true
125
- });
126
- }
127
- res.status(500).json({ error: err.message });
109
+ next(err);
128
110
  }
129
111
  });
130
112
 
131
- app.post('/api/bulk-save', async (req, res) => {
113
+ app.post('/api/bulk-save', async (req, res, next) => {
132
114
  try {
133
115
  const { data, format = true } = req.body;
134
116
  const options = { sort: format };
135
117
  await manager.saveBulkTranslations(data, options);
136
118
  res.json({ success: true });
137
119
  } catch (err) {
138
- res.status(500).json({ error: err.message });
120
+ next(err);
139
121
  }
140
122
  });
141
123
 
142
- app.get('/api/export-missing', async (req, res) => {
124
+ app.get('/api/export-missing', async (req, res, next) => {
143
125
  try {
144
126
  const exportResult = await manager.exportMissingFromFiles();
145
127
 
@@ -149,11 +131,11 @@ function startServer(targetDir, port = 3000, config = {}) {
149
131
 
150
132
  res.json(exportResult.exportData);
151
133
  } catch (err) {
152
- res.status(500).json({ error: err.message });
134
+ next(err);
153
135
  }
154
136
  });
155
137
 
156
- app.post('/api/import-translations', async (req, res) => {
138
+ app.post('/api/import-translations', async (req, res, next) => {
157
139
  try {
158
140
  const { data, options = {} } = req.body;
159
141
 
@@ -164,16 +146,16 @@ function startServer(targetDir, port = 3000, config = {}) {
164
146
  const importStats = await manager.importTranslations(data, options);
165
147
  res.json({ success: true, stats: importStats });
166
148
  } catch (err) {
167
- res.status(500).json({ error: err.message });
149
+ next(err);
168
150
  }
169
151
  });
170
152
 
171
- app.get('/api/export-missing/preview', async (req, res) => {
153
+ app.get('/api/export-missing/preview', async (req, res, next) => {
172
154
  try {
173
155
  const exportResult = await manager.exportMissingFromFiles();
174
156
  res.json(exportResult);
175
157
  } catch (err) {
176
- res.status(500).json({ error: err.message });
158
+ next(err);
177
159
  }
178
160
  });
179
161
 
@@ -181,13 +163,13 @@ function startServer(targetDir, port = 3000, config = {}) {
181
163
  res.json(manager.config);
182
164
  });
183
165
 
184
- app.post('/api/settings', async (req, res) => {
166
+ app.post('/api/settings', async (req, res, next) => {
185
167
  try {
186
168
  const { settings } = req.body;
187
169
  await manager.saveConfig(settings);
188
170
  res.json({ success: true, config: manager.config });
189
171
  } catch (err) {
190
- res.status(500).json({ error: err.message });
172
+ next(err);
191
173
  }
192
174
  });
193
175
 
@@ -196,6 +178,21 @@ function startServer(targetDir, port = 3000, config = {}) {
196
178
  app.use(express.static(buildPath));
197
179
  app.use(history('index.html', { root: buildPath }));
198
180
 
181
+ // Global error handler
182
+ app.use((err, req, res, next) => {
183
+ console.error('\x1b[31mError:\x1b[0m', err.message);
184
+
185
+ // Check for specific error types
186
+ const isConfigError = err.message.includes('not configured') ||
187
+ err.message.includes('API Key is required') ||
188
+ err.message.includes('Project ID is required');
189
+
190
+ res.status(isConfigError ? 400 : 500).json({
191
+ error: err.message,
192
+ configurationRequired: isConfigError
193
+ });
194
+ });
195
+
199
196
  app.listen(port, () => {
200
197
  console.log(`\x1b[32m✔\x1b[0m Translation Manager UI is running at http://localhost:${port}`);
201
198
  });
@@ -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:320px 1fr;display:grid;position:relative;overflow:hidden}@media (width<=1024px){.app-body{grid-template-columns:280px 1fr}}@media (width<=768px){.app-body{flex-direction:column;display:flex;overflow-y:auto}}.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}.logo-container{align-items:center;display:flex;position:relative}.logo-glow{background:var(--accent-color);filter:blur(15px);opacity:.2;z-index:-1;border-radius:50%;width:40px;height:40px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.title-stack{flex-direction:column;display:flex}.title-row{align-items:center;gap:10px;display:flex}.version-badge{color:var(--accent-color);text-transform:uppercase;letter-spacing:.5px;background:#7c3aed1a;border:1px solid #7c3aed33;border-radius:4px;padding:2px 6px;font-size:.65rem;font-weight:700}.header-stats{align-items:center;gap:12px;margin-top:2px;display:flex}.stat-item{align-items:center;gap:4px;display:flex}.stat-value{color:#fff;font-size:.75rem;font-weight:800}.stat-label{color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;font-size:.7rem}.stat-divider{background:var(--border-color);width:1px;height:10px}@media (width<=768px){.header-stats{display:none}}.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)}.mobile-menu-toggle{cursor:pointer;z-index:101;background:0 0;border:none;flex-direction:column;gap:4px;padding:8px;display:none}.mobile-menu-toggle span{background:var(--text-color);width:24px;height:2px;transition:var(--transition);border-radius:2px;display:block}@media (width<=768px){.mobile-menu-toggle{display:flex}.brand-section{gap:8px}.app-title{font-size:1.1rem}.header-logo{width:28px;height:28px}}.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;transition:transform .3s cubic-bezier(.4,0,.2,1);display:flex;overflow:hidden}@media (width<=768px){.sidebar{z-index:1000;background:#111827;width:300px;position:fixed;top:0;bottom:0;left:0;transform:translate(-100%);box-shadow:20px 0 50px #00000080}.sidebar.open{transform:translate(0)}.sidebar-overlay{-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:999;background:#0009;position:fixed;inset:0}}.sidebar-header-mobile{border-bottom:1px solid var(--border-color);justify-content:space-between;align-items:center;padding:20px;display:none}@media (width<=768px){.sidebar-header-mobile{display:flex}}.sidebar-title{text-transform:uppercase;letter-spacing:1px;color:var(--accent-color);font-size:.85rem;font-weight:700}.sidebar-close{color:var(--text-muted);cursor:pointer;background:0 0;border:none;font-size:1.5rem}.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-container:hover .dropdown-menu{opacity:1;pointer-events:auto;display:block;transform:translateY(0)}.dropdown-trigger{align-items:center;gap:8px;display:flex}.dropdown-menu{border:1px solid var(--border-color);z-index:1000;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);opacity:0;pointer-events:none;background:#111827;border-radius:12px;min-width:220px;margin-top:8px;padding:8px;transition:all .2s cubic-bezier(.4,0,.2,1);display:none;position:absolute;top:100%;right:0;transform:translateY(10px);box-shadow:0 20px 40px #0009}.dropdown-item{width:100%;color:var(--text-color);cursor:pointer;transition:var(--transition);text-align:left;background:0 0;border:none;border-radius:8px;align-items:center;gap:12px;padding:10px 12px;font-size:.9rem;display:flex}.dropdown-item:hover{color:#fff;background:#7c3aed26}.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{background:radial-gradient(circle at 100% 0,#7c3aed0d,#0000 400px),radial-gradient(circle at 0 100%,#3b82f60d,#0000 400px);justify-content:center;align-items:flex-start;padding:24px;display:flex;overflow-y:auto}@media (width<=768px){.editor{background:0 0;padding:16px}}.editor-form{width:100%;max-width:1100px;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);background:#1f293766;border-radius:24px;flex-direction:column;height:fit-content;max-height:calc(100vh - 120px);display:flex;box-shadow:0 25px 50px -12px #00000080}@media (width<=768px){.editor-form{border-radius:16px;max-height:none}}.editor-main-form{flex-direction:column;flex:1;display:flex;overflow:hidden}.form-scroll-area{padding:32px 40px;overflow-y:auto}@media (width<=768px){.form-scroll-area{padding:24px 20px}}.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-sticky{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border-top:1px solid var(--border-color);background:#111827cc;border-bottom-right-radius:24px;border-bottom-left-radius:24px;margin-top:auto;padding:20px 40px}@media (width<=768px){.form-actions-sticky{z-index:10;padding:16px 20px;position:sticky;bottom:0}}.form-actions-content{justify-content:flex-end;gap:16px;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}.modal-header h2,.modal-header h3{margin:0;font-size:1.25rem;font-weight:600}.settings-divider{border-bottom:1px solid var(--border-color);color:var(--primary-color);text-transform:uppercase;letter-spacing:.05em;margin:24px 0 16px;padding-bottom:8px;font-size:.9rem;font-weight:600}.close-btn{color:var(--text-muted);cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:50%;justify-content:center;align-items:center;width:36px;height:36px;padding:0;font-size:1.8rem;line-height:1;display:flex}.close-btn:hover{color:#fff;background:#ffffff0d}.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{border-bottom:1px solid var(--border-color);justify-content:space-between;align-items:center;padding:24px 40px;display:flex}@media (width<=768px){.editor-header-row{flex-direction:column;align-items:flex-start;gap:16px;padding:20px}}.editor-title-container{flex-direction:column;gap:4px;display:flex}.editor-subtitle{text-transform:uppercase;letter-spacing:.1em;color:var(--accent-color);font-size:.75rem;font-weight:700}.key-breadcrumbs{flex-wrap:wrap;align-items:center;gap:4px;display:flex}.breadcrumb-part{color:#fff}.breadcrumb-separator{color:var(--text-muted);opacity:.5}.languages-grid{grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:24px;display:grid}@media (width<=1200px){.languages-grid{grid-template-columns:1fr}}.full-width-field{grid-column:1/-1}.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}.warning-badge{color:#fbbf24;cursor:pointer;transition:var(--transition);background:#f59e0b1a;border:1px solid #f59e0b33;border-radius:8px;align-items:center;gap:8px;padding:8px 14px;font-size:.8rem;font-weight:600;display:flex}.warning-badge:hover{background:#f59e0b33;transform:translateY(-1px)}.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}