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.
- package/dist/{assemble-IOHQYYHI.js → assemble-ZHDLGVTL.js} +3 -4
- package/dist/chunk-I74LIORX.js +11211 -0
- package/dist/{chunk-QSVWLTGQ.js → chunk-OSMPWXSQ.js} +1 -1
- package/dist/{chunk-AKLW2MUS.js → chunk-PHDMD6EM.js} +29 -7
- package/dist/cli.js +6 -7
- package/dist/{rescan-VB2PILB2.js → rescan-OJTVWDAP.js} +2 -3
- package/dist/server-HNVJP43X.js +2742 -0
- package/dist/{status-EWQEACVF.js → status-ZG7F3FRT.js} +1 -2
- package/dist/translate-2PCYIWIG.js +14531 -0
- package/package.json +3 -2
- package/src/admin/index.html +13 -0
- package/src/admin/server/index.ts +88 -0
- package/src/admin/server/routes/jobs.ts +113 -0
- package/src/admin/server/routes/models.ts +87 -0
- package/src/admin/server/routes/status.ts +57 -0
- package/src/admin/server/services/job-manager.ts +184 -0
- package/src/admin/server/services/status.ts +183 -0
- package/src/admin/ui/App.tsx +326 -0
- package/src/admin/ui/components/FileList.tsx +438 -0
- package/src/admin/ui/components/JobDialog.tsx +360 -0
- package/src/admin/ui/components/JobPanel.tsx +134 -0
- package/src/admin/ui/components/LangGrid.tsx +54 -0
- package/src/admin/ui/components/Preview.tsx +369 -0
- package/src/admin/ui/components/ProgressBar.tsx +21 -0
- package/src/admin/ui/lib/api.ts +154 -0
- package/src/admin/ui/lib/flags.ts +30 -0
- package/src/admin/ui/main.tsx +19 -0
- package/src/admin/ui/styles.css +1096 -0
- package/src/admin/vite.config.ts +7 -0
- package/dist/build-4EQEL4NI.js +0 -12
- package/dist/build2-3W5WMFHZ.js +0 -4901
- package/dist/chunk-3YNFMSJH.js +0 -30
- package/dist/chunk-55MBYBVK.js +0 -368
- package/dist/chunk-FYDB7MZX.js +0 -38944
- package/dist/chunk-O35QHRY6.js +0 -6
- package/dist/chunk-PTIH4GGE.js +0 -44
- package/dist/chunk-SUIDX6IZ.js +0 -122
- package/dist/chunk-VKKNQBDN.js +0 -6487
- package/dist/dist-6C32URTL.js +0 -19
- package/dist/dist-HOWMMQFV.js +0 -6677
- package/dist/false-JGP4AGWN.js +0 -7
- package/dist/main-QVE5TVA3.js +0 -2505
- package/dist/node-4GLCLDJ6.js +0 -875
- package/dist/node-NUDVMOF2.js +0 -129
- package/dist/postcss-3SK7VUC2.js +0 -5886
- package/dist/postcss-import-JD46KA2Z.js +0 -458
- package/dist/prompt-BYQIwEjg-TG7DLENB.js +0 -915
- package/dist/server-ER56DGPR.js +0 -548
- 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|