docs-i18n 0.1.0 → 0.2.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.
Files changed (49) hide show
  1. package/dist/{assemble-IOHQYYHI.js → assemble-ZHDLGVTL.js} +3 -4
  2. package/dist/chunk-I74LIORX.js +11211 -0
  3. package/dist/{chunk-QSVWLTGQ.js → chunk-OSMPWXSQ.js} +1 -1
  4. package/dist/{chunk-AKLW2MUS.js → chunk-PHDMD6EM.js} +29 -7
  5. package/dist/cli.js +6 -7
  6. package/dist/{rescan-VB2PILB2.js → rescan-OJTVWDAP.js} +2 -3
  7. package/dist/server-HNVJP43X.js +2742 -0
  8. package/dist/{status-EWQEACVF.js → status-ZG7F3FRT.js} +1 -2
  9. package/dist/translate-2PCYIWIG.js +14531 -0
  10. package/package.json +3 -2
  11. package/src/admin/index.html +13 -0
  12. package/src/admin/server/index.ts +88 -0
  13. package/src/admin/server/routes/jobs.ts +113 -0
  14. package/src/admin/server/routes/models.ts +87 -0
  15. package/src/admin/server/routes/status.ts +57 -0
  16. package/src/admin/server/services/job-manager.ts +184 -0
  17. package/src/admin/server/services/status.ts +183 -0
  18. package/src/admin/ui/App.tsx +326 -0
  19. package/src/admin/ui/components/FileList.tsx +438 -0
  20. package/src/admin/ui/components/JobDialog.tsx +360 -0
  21. package/src/admin/ui/components/JobPanel.tsx +134 -0
  22. package/src/admin/ui/components/LangGrid.tsx +54 -0
  23. package/src/admin/ui/components/Preview.tsx +369 -0
  24. package/src/admin/ui/components/ProgressBar.tsx +21 -0
  25. package/src/admin/ui/lib/api.ts +154 -0
  26. package/src/admin/ui/lib/flags.ts +30 -0
  27. package/src/admin/ui/main.tsx +19 -0
  28. package/src/admin/ui/styles.css +1096 -0
  29. package/src/admin/vite.config.ts +7 -0
  30. package/dist/build-4EQEL4NI.js +0 -12
  31. package/dist/build2-3W5WMFHZ.js +0 -4901
  32. package/dist/chunk-3YNFMSJH.js +0 -30
  33. package/dist/chunk-55MBYBVK.js +0 -368
  34. package/dist/chunk-FYDB7MZX.js +0 -38944
  35. package/dist/chunk-O35QHRY6.js +0 -6
  36. package/dist/chunk-PTIH4GGE.js +0 -44
  37. package/dist/chunk-SUIDX6IZ.js +0 -122
  38. package/dist/chunk-VKKNQBDN.js +0 -6487
  39. package/dist/dist-6C32URTL.js +0 -19
  40. package/dist/dist-HOWMMQFV.js +0 -6677
  41. package/dist/false-JGP4AGWN.js +0 -7
  42. package/dist/main-QVE5TVA3.js +0 -2505
  43. package/dist/node-4GLCLDJ6.js +0 -875
  44. package/dist/node-NUDVMOF2.js +0 -129
  45. package/dist/postcss-3SK7VUC2.js +0 -5886
  46. package/dist/postcss-import-JD46KA2Z.js +0 -458
  47. package/dist/prompt-BYQIwEjg-TG7DLENB.js +0 -915
  48. package/dist/server-ER56DGPR.js +0 -548
  49. package/dist/translate-F3AQFN6X.js +0 -707
@@ -0,0 +1,360 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useMemo, useState } from 'react';
3
+ import { api, type Model } from '../lib/api';
4
+ import { FLAGS } from '../lib/flags';
5
+
6
+ interface Props {
7
+ langs: string[];
8
+ versions: string[];
9
+ defaultLang?: string;
10
+ defaultVersion?: string;
11
+ files?: string[];
12
+ onClose: () => void;
13
+ onSuccess?: (msg: string) => void;
14
+ }
15
+
16
+ function formatPrice(price: number) {
17
+ if (price === 0) return 'Free';
18
+ if (price < 0.01) return `$${price.toFixed(4)}`;
19
+ return `$${price.toFixed(2)}`;
20
+ }
21
+
22
+ function ctxLabel(n: number) {
23
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
24
+ return `${(n / 1000).toFixed(0)}k`;
25
+ }
26
+
27
+ type SortKey = 'price-asc' | 'price-desc' | 'context-desc' | 'name';
28
+
29
+ const CTX_STEPS = [0, 8, 16, 32, 64, 128, 200, 512, 1024, 2048];
30
+
31
+ export function JobDialog({
32
+ langs,
33
+ versions,
34
+ defaultLang,
35
+ defaultVersion,
36
+ files,
37
+ onClose,
38
+ onSuccess,
39
+ }: Props) {
40
+ const [lang, setLang] = useState(defaultLang || langs[0]);
41
+ const [version, setVersion] = useState(defaultVersion || versions[0]);
42
+ const [max, setMax] = useState(files?.length || 999);
43
+ const [concurrency, setConcurrency] = useState(3);
44
+ const [error, setError] = useState<string | null>(null);
45
+
46
+ // Model selection
47
+ const [selectedModel, setSelectedModel] = useState('');
48
+ const [search, setSearch] = useState('');
49
+
50
+ // Filters
51
+ const [sort, setSort] = useState<SortKey>('context-desc');
52
+ const [ctxSlider, setCtxSlider] = useState(0);
53
+ const [freeOnly, setFreeOnly] = useState(true);
54
+ const [jsonOnly, setJsonOnly] = useState(false);
55
+
56
+ const minCtx = CTX_STEPS[ctxSlider] * 1000;
57
+
58
+ const { data: models, isLoading: modelsLoading } = useQuery({
59
+ queryKey: ['models'],
60
+ queryFn: api.models,
61
+ staleTime: 5 * 60 * 1000,
62
+ });
63
+
64
+ const filtered = useMemo(() => {
65
+ if (!models) return [];
66
+ let result = models.filter((m) => {
67
+ if (search) {
68
+ const q = search.toLowerCase();
69
+ if (
70
+ !m.id.toLowerCase().includes(q) &&
71
+ !m.name.toLowerCase().includes(q)
72
+ )
73
+ return false;
74
+ }
75
+ if (freeOnly && !m.isFree) return false;
76
+ if (minCtx > 0 && m.contextLength < minCtx) return false;
77
+ if (jsonOnly && !m.supportsJson) return false;
78
+ return true;
79
+ });
80
+
81
+ result = [...result];
82
+ switch (sort) {
83
+ case 'price-asc':
84
+ result.sort((a, b) => a.promptPrice - b.promptPrice);
85
+ break;
86
+ case 'price-desc':
87
+ result.sort((a, b) => b.promptPrice - a.promptPrice);
88
+ break;
89
+ case 'context-desc':
90
+ result.sort((a, b) => b.contextLength - a.contextLength);
91
+ break;
92
+ case 'name':
93
+ result.sort((a, b) => a.name.localeCompare(b.name));
94
+ break;
95
+ }
96
+ return result;
97
+ }, [models, search, freeOnly, minCtx, jsonOnly, sort]);
98
+
99
+ const qc = useQueryClient();
100
+ const create = useMutation({
101
+ mutationFn: () => {
102
+ return api.createJob({
103
+ lang,
104
+ version,
105
+ max,
106
+ concurrency,
107
+ model: selectedModel || undefined,
108
+ md5: true,
109
+ files: files?.length ? files : undefined,
110
+ });
111
+ },
112
+ onSuccess: () => {
113
+ qc.invalidateQueries({ queryKey: ['jobs'] });
114
+ if (onSuccess) {
115
+ onSuccess(
116
+ `✅ Job started: ${lang} / ${version}${files?.length ? ` (${files.length} files)` : ''}`,
117
+ );
118
+ } else {
119
+ onClose();
120
+ }
121
+ },
122
+ onError: (err) => setError(err.message),
123
+ });
124
+
125
+ const translatable = langs.filter((l) => l !== 'en');
126
+ const selectedModelInfo = selectedModel
127
+ ? models?.find((m) => m.id === selectedModel)
128
+ : null;
129
+
130
+ return (
131
+ <div
132
+ className="dialog-overlay"
133
+ onClick={(e) => {
134
+ if (e.target === e.currentTarget) onClose();
135
+ }}
136
+ >
137
+ <div className="dialog dialog-wide">
138
+ <h3>Start Translation Job</h3>
139
+
140
+ {error && <div className="dialog-error">{error}</div>}
141
+
142
+ {files?.length && (
143
+ <div className="dialog-mode-hint">
144
+ Translating {files.length} selected file
145
+ {files.length > 1 ? 's' : ''}
146
+ </div>
147
+ )}
148
+
149
+ {/* Job config */}
150
+ <div className="dialog-grid">
151
+ <div>
152
+ <label>Language</label>
153
+ <select value={lang} onChange={(e) => setLang(e.target.value)}>
154
+ {translatable.map((l) => (
155
+ <option key={l} value={l}>
156
+ {FLAGS[l]} {l}
157
+ </option>
158
+ ))}
159
+ </select>
160
+ </div>
161
+ <div>
162
+ <label>Version</label>
163
+ <select
164
+ value={version}
165
+ onChange={(e) => setVersion(e.target.value)}
166
+ >
167
+ {versions.map((v) => (
168
+ <option key={v} value={v}>
169
+ {v}
170
+ </option>
171
+ ))}
172
+ </select>
173
+ </div>
174
+ <div>
175
+ <label>Max API calls</label>
176
+ <input
177
+ type="number"
178
+ value={max}
179
+ onChange={(e) => setMax(Number(e.target.value))}
180
+ />
181
+ </div>
182
+ <div>
183
+ <label>Concurrency</label>
184
+ <input
185
+ type="number"
186
+ value={concurrency}
187
+ onChange={(e) => setConcurrency(Number(e.target.value))}
188
+ />
189
+ </div>
190
+ </div>
191
+
192
+ {/* Model mode toggle */}
193
+ <div className="dialog-section-hdr">
194
+ <span>Model</span>
195
+ </div>
196
+
197
+ {/* Filters */}
198
+ <div className="model-filters">
199
+ <input
200
+ type="text"
201
+ placeholder="Search models..."
202
+ value={search}
203
+ onChange={(e) => setSearch(e.target.value)}
204
+ className="model-search-input"
205
+ />
206
+ <select
207
+ value={sort}
208
+ onChange={(e) => setSort(e.target.value as SortKey)}
209
+ >
210
+ <option value="context-desc">Context ↓</option>
211
+ <option value="price-asc">Price ↑</option>
212
+ <option value="price-desc">Price ↓</option>
213
+ <option value="name">Name A-Z</option>
214
+ </select>
215
+ <label className="model-filter-check">
216
+ <input
217
+ type="checkbox"
218
+ checked={freeOnly}
219
+ onChange={(e) => setFreeOnly(e.target.checked)}
220
+ />
221
+ Free
222
+ </label>
223
+ <label
224
+ className="model-filter-check"
225
+ title="Structured JSON output — required for best translation quality"
226
+ >
227
+ <input
228
+ type="checkbox"
229
+ checked={jsonOnly}
230
+ onChange={(e) => setJsonOnly(e.target.checked)}
231
+ />
232
+ JSON mode
233
+ </label>
234
+ </div>
235
+
236
+ {/* Context slider */}
237
+ <div className="model-slider-row">
238
+ <span className="model-slider-label">
239
+ Context ≥ {minCtx === 0 ? 'any' : ctxLabel(minCtx)}
240
+ </span>
241
+ <input
242
+ type="range"
243
+ min={0}
244
+ max={CTX_STEPS.length - 1}
245
+ value={ctxSlider}
246
+ onChange={(e) => setCtxSlider(Number(e.target.value))}
247
+ className="model-slider"
248
+ />
249
+ </div>
250
+
251
+ {/* Model list */}
252
+ <div className="model-list">
253
+ <div
254
+ className={`model-item${selectedModel === '' ? ' active' : ''}`}
255
+ onClick={() => setSelectedModel('')}
256
+ >
257
+ <span className="model-name">Default (from .env)</span>
258
+ </div>
259
+ {modelsLoading && (
260
+ <div className="model-item disabled">Loading models...</div>
261
+ )}
262
+ {filtered.map((m) => (
263
+ <ModelRow
264
+ key={m.id}
265
+ m={m}
266
+ mode="single"
267
+ active={selectedModel === m.id}
268
+ onClick={() => setSelectedModel(m.id)}
269
+ />
270
+ ))}
271
+ {!modelsLoading && filtered.length === 0 && (
272
+ <div className="model-item disabled">No models match filters</div>
273
+ )}
274
+ </div>
275
+ <div className="model-count">
276
+ {filtered.length} model{filtered.length !== 1 ? 's' : ''}
277
+ {models ? ` / ${models.length} total` : ''}
278
+ </div>
279
+
280
+ {/* Selected model info */}
281
+ {selectedModelInfo && (
282
+ <div className="model-info">
283
+ <strong>{selectedModelInfo.name}</strong>
284
+ {selectedModelInfo.isFree && (
285
+ <span className="badge-free">FREE</span>
286
+ )}
287
+ {selectedModelInfo.supportsJson && (
288
+ <span className="badge-json">JSON</span>
289
+ )}
290
+ <br />
291
+ In: {formatPrice(selectedModelInfo.promptPrice)}/M · Out:{' '}
292
+ {formatPrice(selectedModelInfo.completionPrice)}/M · Ctx:{' '}
293
+ {ctxLabel(selectedModelInfo.contextLength)}
294
+ {selectedModelInfo.maxOutput > 0 &&
295
+ ` · Max out: ${ctxLabel(selectedModelInfo.maxOutput)}`}
296
+ </div>
297
+ )}
298
+
299
+ {files && files.length > 0 && (
300
+ <div className="file-list-preview">
301
+ <strong>{files.length} files selected:</strong>
302
+ {files.map((f) => (
303
+ <div key={f}>{f}</div>
304
+ ))}
305
+ </div>
306
+ )}
307
+
308
+ <div className="actions">
309
+ <button type="button" className="btn btn-outline" onClick={onClose}>
310
+ Cancel
311
+ </button>
312
+ <button
313
+ type="button"
314
+ className="btn"
315
+ disabled={create.isPending}
316
+ onClick={() => create.mutate()}
317
+ >
318
+ {create.isPending ? 'Starting...' : 'Start Job →'}
319
+ </button>
320
+ </div>
321
+ </div>
322
+ </div>
323
+ );
324
+ }
325
+
326
+ function ModelRow({
327
+ m,
328
+ mode,
329
+ active,
330
+ onClick,
331
+ }: {
332
+ m: Model;
333
+ mode: 'single' | 'rotate';
334
+ active: boolean;
335
+ onClick: () => void;
336
+ }) {
337
+ return (
338
+ <div className={`model-item${active ? ' active' : ''}`} onClick={onClick}>
339
+ {mode === 'rotate' && (
340
+ <input
341
+ type="checkbox"
342
+ checked={active}
343
+ readOnly
344
+ className="model-check"
345
+ />
346
+ )}
347
+ <span className="model-name">
348
+ {m.isFree && '🆓 '}
349
+ {m.name}
350
+ </span>
351
+ <span className="model-meta">
352
+ {m.supportsJson ? '✓ ' : ''}
353
+ {ctxLabel(m.contextLength)} ·{' '}
354
+ {m.isFree
355
+ ? 'Free'
356
+ : `${formatPrice(m.promptPrice)}/${formatPrice(m.completionPrice)}`}
357
+ </span>
358
+ </div>
359
+ );
360
+ }
@@ -0,0 +1,134 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useEffect, useRef } from 'react';
3
+ import { api, type Job } from '../lib/api';
4
+
5
+ function escapeHtml(s: string) {
6
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
7
+ }
8
+
9
+ function JobItem({ job }: { job: Job }) {
10
+ const qc = useQueryClient();
11
+ const del = useMutation({
12
+ mutationFn: () => api.deleteJob(job.id),
13
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['jobs'] }),
14
+ });
15
+
16
+ const pct =
17
+ job.toTranslate > 0
18
+ ? (job.translatedFiles / job.toTranslate) * 100
19
+ : job.status === 'completed'
20
+ ? 100
21
+ : 0;
22
+
23
+ let progress = '';
24
+ if (job.toTranslate > 0) {
25
+ progress = `${job.translatedFiles}/${job.toTranslate} translated`;
26
+ if (job.totalFiles) progress += ` (${job.totalFiles} total)`;
27
+ } else if (job.totalFiles > 0) {
28
+ progress = `scanning ${job.totalFiles} files...`;
29
+ }
30
+ if (job.errorFiles > 0) progress += ` · ${job.errorFiles} errors`;
31
+
32
+ const running = job.status === 'running';
33
+
34
+ return (
35
+ <div className="job-item">
36
+ <div className="hdr">
37
+ <span className={`status-badge ${job.status}`}>{job.status}</span>
38
+ <strong>
39
+ {job.lang}/{job.version}
40
+ </strong>
41
+ {job.currentFile && (
42
+ <span style={{ color: 'var(--fg2)' }}>{job.currentFile}</span>
43
+ )}
44
+ <span className="spacer" />
45
+ <button
46
+ type="button"
47
+ className="btn btn-sm btn-outline"
48
+ onClick={() => del.mutate()}
49
+ >
50
+ {running ? '⏹' : '🗑'}
51
+ </button>
52
+ </div>
53
+ <div className="progress-bar">
54
+ <div className="progress-fill" style={{ width: `${pct}%` }} />
55
+ </div>
56
+ <div className="job-meta">
57
+ {progress} · {new Date(job.startedAt).toLocaleTimeString()}
58
+ {job.finishedAt &&
59
+ ` · done ${new Date(job.finishedAt).toLocaleTimeString()}`}
60
+ {job.exitCode != null &&
61
+ job.exitCode !== 0 &&
62
+ ` · exit ${job.exitCode}`}
63
+ </div>
64
+ {job.logLines && job.logLines.length > 0 && (
65
+ <div className="log-viewer">
66
+ <button
67
+ type="button"
68
+ className="log-copy"
69
+ title="Copy logs"
70
+ onClick={() => {
71
+ navigator.clipboard.writeText(job.logLines!.join('\n'));
72
+ }}
73
+ >
74
+ 📋
75
+ </button>
76
+ {job.logLines.map((line, i) => (
77
+ <div
78
+ // biome-ignore lint/suspicious/noArrayIndexKey: log lines
79
+ key={i}
80
+ className={
81
+ line.includes('stderr') || line.includes('rror')
82
+ ? 'err'
83
+ : undefined
84
+ }
85
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: escaped
86
+ dangerouslySetInnerHTML={{ __html: escapeHtml(line) }}
87
+ />
88
+ ))}
89
+ </div>
90
+ )}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ export function JobPanel() {
96
+ const qc = useQueryClient();
97
+ const prevRunning = useRef(new Set<string>());
98
+
99
+ const { data: jobs = [] } = useQuery({
100
+ queryKey: ['jobs'],
101
+ queryFn: api.jobs,
102
+ refetchInterval: 3000,
103
+ });
104
+
105
+ // Detect completed jobs and refresh data
106
+ useEffect(() => {
107
+ const currentRunning = new Set(
108
+ jobs.filter((j) => j.status === 'running').map((j) => j.id),
109
+ );
110
+ const justFinished = [...prevRunning.current].filter(
111
+ (id) => !currentRunning.has(id),
112
+ );
113
+ if (justFinished.length > 0) {
114
+ // Invalidate all file/status queries
115
+ qc.invalidateQueries({ queryKey: ['files'] });
116
+ qc.invalidateQueries({ queryKey: ['fileBlocks'] });
117
+ qc.invalidateQueries({ queryKey: ['status'] });
118
+ }
119
+ prevRunning.current = currentRunning;
120
+ }, [jobs, qc]);
121
+
122
+ return (
123
+ <div className="jobs-panel">
124
+ <h3>Jobs</h3>
125
+ {jobs.length === 0 ? (
126
+ <span style={{ color: 'var(--fg2)', fontSize: '0.85rem' }}>
127
+ No jobs
128
+ </span>
129
+ ) : (
130
+ jobs.map((j) => <JobItem key={j.id} job={j} />)
131
+ )}
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,54 @@
1
+ import type { StatusOverview } from '../lib/api';
2
+ import { FLAGS, pctColor } from '../lib/flags';
3
+ import { ProgressBar } from './ProgressBar';
4
+
5
+ interface Props {
6
+ data: StatusOverview;
7
+ version: string;
8
+ selectedLang: string | null;
9
+ onSelect: (lang: string) => void;
10
+ }
11
+
12
+ export function LangGrid({ data, version, selectedLang, onSelect }: Props) {
13
+ const vData = data.data[version];
14
+ if (!vData) return null;
15
+
16
+ return (
17
+ <div className="lang-grid">
18
+ {data.langs.map((lang) => {
19
+ if (lang === 'en') {
20
+ return (
21
+ <div
22
+ key="en"
23
+ className={`lang-card is-en${selectedLang === 'en' ? ' selected' : ''}`}
24
+ onClick={() => onSelect('en')}
25
+ >
26
+ <div className="name">{FLAGS.en} en (source)</div>
27
+ <div className="stats">{vData.enFileCount} files</div>
28
+ <ProgressBar value={100} color="var(--green)" />
29
+ </div>
30
+ );
31
+ }
32
+
33
+ const ls = vData.langs[lang];
34
+ if (!ls) return null;
35
+ const pct =
36
+ ls.totalNodes > 0 ? (ls.translatedNodes / ls.totalNodes) * 100 : 0;
37
+
38
+ return (
39
+ <div
40
+ key={lang}
41
+ className={`lang-card${selectedLang === lang ? ' selected' : ''}`}
42
+ onClick={() => onSelect(lang)}
43
+ >
44
+ <div className="name">
45
+ {FLAGS[lang]} {lang}
46
+ </div>
47
+ <div className="stats">{pct.toFixed(1)}%</div>
48
+ <ProgressBar value={pct} color={pctColor(pct)} />
49
+ </div>
50
+ );
51
+ })}
52
+ </div>
53
+ );
54
+ }