@walkinissue/angy 0.2.17

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.
@@ -0,0 +1,645 @@
1
+ <script lang="ts">
2
+ import { tick } from "svelte";
3
+ import VibeTooltip from "./VibeTooltip.svelte";
4
+ import TranslationAlternativeItem from "./TranslationAlternativeItem.svelte";
5
+ import {
6
+ getEffectiveResolvedKey,
7
+ getTranslationStatus,
8
+ TRANSLATION_STATUS_TOOLTIP,
9
+ translationKey,
10
+ type DraftTranslation,
11
+ type TranslationContextResult
12
+ } from "./toggleQA.shared";
13
+
14
+ let {
15
+ capturedSelection,
16
+ contextPending,
17
+ contextError,
18
+ contextResult,
19
+ selectedResolvedKey,
20
+ focusedAltKey,
21
+ translatedValue = $bindable(),
22
+ stagedTranslations,
23
+ hasActiveSuggestion,
24
+ pending,
25
+ error,
26
+ success,
27
+ onClose,
28
+ onSubmit,
29
+ onStage,
30
+ onAltFocusChange,
31
+ onAltKeydown,
32
+ onSelectAlt,
33
+ isStaged,
34
+ isSelectedAlt,
35
+ setTranslationInputEl,
36
+ moveSelection
37
+ }: {
38
+ capturedSelection: string | undefined;
39
+ contextPending: boolean;
40
+ contextError: string | null;
41
+ contextResult: TranslationContextResult | null;
42
+ selectedResolvedKey: { msgid: string; msgctxt: string | null } | null;
43
+ focusedAltKey: string | null;
44
+ translatedValue: string;
45
+ stagedTranslations: Record<string, DraftTranslation>;
46
+ hasActiveSuggestion: boolean;
47
+ pending: boolean;
48
+ error: string | null;
49
+ success: string | null;
50
+ onClose: () => void;
51
+ onSubmit: (event: SubmitEvent) => void;
52
+ onStage: (event?: Event) => void;
53
+ onAltFocusChange: (key: string | null) => void;
54
+ onAltKeydown: (event: KeyboardEvent, msgid: string, msgctxt: string | null) => void;
55
+ onSelectAlt: (event: Event, msgid: string, msgctxt: string | null) => void;
56
+ isStaged: (msgid: string, msgctxt: string | null) => boolean;
57
+ isSelectedAlt: (msgid: string, msgctxt: string | null) => boolean;
58
+ setTranslationInputEl: (element: HTMLTextAreaElement | null) => void;
59
+ moveSelection: (direction: 1 | -1) => boolean;
60
+ } = $props();
61
+
62
+ const hasDirtyTranslations = $derived(
63
+ Object.values(stagedTranslations).some((item) => item.isDirty)
64
+ );
65
+ const commitTarget = $derived(
66
+ getEffectiveResolvedKey(selectedResolvedKey, contextResult)?.msgid
67
+ );
68
+ const selectedAlternativeKey = $derived.by(() => {
69
+ const selected = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
70
+ if (!selected || !contextResult?.alternatives.length) return null;
71
+
72
+ const matchesAlternative = contextResult.alternatives.some(
73
+ (alt) =>
74
+ alt.msgid === selected.msgid && (alt.msgctxt ?? null) === (selected.msgctxt ?? null)
75
+ );
76
+
77
+ return matchesAlternative ? translationKey(selected.msgid, selected.msgctxt) : null;
78
+ });
79
+ let translationInputElement: HTMLTextAreaElement | null = null;
80
+ let alternativesListEl = $state<HTMLUListElement | null>(null);
81
+
82
+ $effect(() => {
83
+ setTranslationInputEl(translationInputElement);
84
+ });
85
+
86
+ $effect(() => {
87
+ void (async () => {
88
+ if (!alternativesListEl || !selectedAlternativeKey) return;
89
+
90
+ await tick();
91
+
92
+ const item = [...alternativesListEl.querySelectorAll<HTMLElement>("[data-alt-key]")].find(
93
+ (element) => element.dataset.altKey === selectedAlternativeKey
94
+ );
95
+
96
+ if (!item) return;
97
+ item.scrollIntoView({
98
+ block: "nearest",
99
+ inline: "nearest",
100
+ behavior: "smooth"
101
+ });
102
+ })();
103
+ });
104
+ </script>
105
+
106
+ <form method="POST" class="translation-card" onsubmit={onSubmit}>
107
+ <div class="card-header">
108
+ <div class="card-title">Translation helper</div>
109
+ <button
110
+ type="button"
111
+ class="icon-close"
112
+ aria-label="Close translation form"
113
+ onclick={onClose}
114
+ >
115
+ &#215;
116
+ </button>
117
+ </div>
118
+
119
+ <input name="translationKey" type="hidden" value={capturedSelection} />
120
+
121
+ <div class="meta-block">
122
+ {#if contextPending}
123
+ <p class="meta-status">Looking up PO context...</p>
124
+ {:else if contextError}
125
+ <p class="meta-status error">{contextError}</p>
126
+ {:else if contextResult}
127
+ <div class="context-panel">
128
+ <div
129
+ class="current-target"
130
+ class:staged={isStaged(contextResult.entry.msgid, contextResult.entry.msgctxt)}
131
+ >
132
+ <div class="current-target-copy">
133
+ <span class="context-label">Matched key</span>
134
+ <code class="current-target-key">{contextResult.entry.msgid}</code>
135
+ {#if contextResult.entry.msgctxt}
136
+ <div class="current-target-context">
137
+ <span class="context-label">Context</span>
138
+ <code>{contextResult.entry.msgctxt}</code>
139
+ </div>
140
+ {/if}
141
+ </div>
142
+ <VibeTooltip
143
+ delay={100}
144
+ disabled={false}
145
+ text={TRANSLATION_STATUS_TOOLTIP}
146
+ position="top"
147
+ >
148
+ <span class="alt-status current-target-status">{getTranslationStatus(contextResult.entry)}</span>
149
+ </VibeTooltip>
150
+ </div>
151
+
152
+ <div class="context-row">
153
+ <span class="context-label">Match score</span>
154
+ <span>{contextResult.match.score.toFixed(3)}</span>
155
+ </div>
156
+
157
+ {#if contextResult.entry.flags.length}
158
+ <div class="context-row">
159
+ <span class="context-label">Flags</span>
160
+ <span>{contextResult.entry.flags.join(", ")}</span>
161
+ </div>
162
+ {/if}
163
+
164
+ {#if contextResult.entry.references.length}
165
+ <div class="context-row stacked">
166
+ <span class="context-label">References</span>
167
+ <ul>
168
+ {#each contextResult.entry.references as ref}
169
+ <li><code>{ref}</code></li>
170
+ {/each}
171
+ </ul>
172
+ </div>
173
+ {/if}
174
+
175
+ {#if contextResult.entry.extractedComments.length}
176
+ <div class="context-row stacked">
177
+ <span class="context-label">Comments</span>
178
+ <ul>
179
+ {#each contextResult.entry.extractedComments as comment}
180
+ <li>{comment}</li>
181
+ {/each}
182
+ </ul>
183
+ </div>
184
+ {/if}
185
+
186
+ {#if contextResult.alternatives.length}
187
+ <div class="context-row stacked">
188
+ <span class="context-label">Alternatives</span>
189
+
190
+ <ul class="alt-list" bind:this={alternativesListEl}>
191
+ {#each contextResult.alternatives as alt}
192
+ <TranslationAlternativeItem
193
+ {alt}
194
+ selected={isSelectedAlt(alt.msgid, alt.msgctxt)}
195
+ focused={focusedAltKey === `${alt.msgctxt ?? ""}::${alt.msgid}`}
196
+ staged={isStaged(alt.msgid, alt.msgctxt)}
197
+ onFocusChange={onAltFocusChange}
198
+ onKeydown={onAltKeydown}
199
+ onSelect={onSelectAlt}
200
+ />
201
+ {/each}
202
+ </ul>
203
+ </div>
204
+ {/if}
205
+ </div>
206
+ {/if}
207
+ </div>
208
+
209
+ <div class="commit-target-row">
210
+ <span class="context-label">Commit target</span>
211
+ <code>{commitTarget ?? contextResult?.entry.msgid}</code>
212
+ </div>
213
+
214
+ <label class="field">
215
+ <span class="field-label">Selected text</span>
216
+ <div class="selected-copy">{capturedSelection}</div>
217
+ <textarea
218
+ bind:this={translationInputElement}
219
+ name="translationValue"
220
+ class="translation-input"
221
+ class:suggested={hasActiveSuggestion}
222
+ rows="4"
223
+ bind:value={translatedValue}
224
+ placeholder={capturedSelection}
225
+ onkeydown={(event) => {
226
+ if (event.key === "Enter" && !event.shiftKey) {
227
+ event.preventDefault();
228
+ onStage(event);
229
+ return;
230
+ }
231
+
232
+ if (event.key === "Tab") {
233
+ event.preventDefault();
234
+ moveSelection(event.shiftKey ? -1 : 1);
235
+ }
236
+ }}
237
+ ></textarea>
238
+ </label>
239
+
240
+ <div class="meta-row">
241
+ <span class="selection-count">{capturedSelection?.length ?? 0} chars</span>
242
+ </div>
243
+
244
+ <div class="actions">
245
+ {#if success}
246
+ <p>{success}</p>
247
+ {:else if error}
248
+ <p>Failed: {error}</p>
249
+ {/if}
250
+
251
+ <button
252
+ type="button"
253
+ class="tool-btn"
254
+ disabled={!translatedValue.trim() || !commitTarget}
255
+ onclick={(event) => {
256
+ event.preventDefault();
257
+ event.stopPropagation();
258
+ onStage(event);
259
+ }}
260
+ >
261
+ Stage
262
+ </button>
263
+
264
+ <button
265
+ type="submit"
266
+ class="tool-btn primary"
267
+ disabled={!hasDirtyTranslations || pending}
268
+ aria-disabled={!hasDirtyTranslations || pending}
269
+ >
270
+ {pending ? "Committing..." : "Commit all"}
271
+ </button>
272
+ </div>
273
+ </form>
274
+
275
+ <style>
276
+ .meta-block {
277
+ margin-bottom: 0.85rem;
278
+ }
279
+
280
+ .meta-status {
281
+ font-size: 0.78rem;
282
+ color: rgba(255, 255, 255, 0.7);
283
+ }
284
+
285
+ .meta-status.error {
286
+ color: rgba(255, 120, 120, 0.95);
287
+ }
288
+
289
+ .context-panel {
290
+ display: flex;
291
+ flex-direction: column;
292
+ gap: 0.55rem;
293
+ padding: 0.75rem;
294
+ border-radius: 0.75rem;
295
+ background: rgba(255, 255, 255, 0.045);
296
+ border: 1px solid rgba(255, 255, 255, 0.08);
297
+ margin-bottom: 0.8rem;
298
+ }
299
+
300
+ .context-row {
301
+ display: flex;
302
+ gap: 0.6rem;
303
+ align-items: flex-start;
304
+ justify-content: space-between;
305
+ font-size: 0.78rem;
306
+ }
307
+
308
+ .context-row.stacked {
309
+ flex-direction: column;
310
+ justify-content: flex-start;
311
+ }
312
+
313
+ .context-label {
314
+ font-size: 0.72rem;
315
+ font-weight: 700;
316
+ text-transform: uppercase;
317
+ letter-spacing: 0.05em;
318
+ color: rgba(255, 255, 255, 0.58);
319
+ }
320
+
321
+ .context-panel code {
322
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
323
+ font-size: 0.75rem;
324
+ color: rgba(195, 225, 255, 0.95);
325
+ word-break: break-word;
326
+ }
327
+
328
+ .context-panel ul {
329
+ margin: 0;
330
+ padding-left: 1rem;
331
+ }
332
+
333
+ .translation-card {
334
+ width: clamp(20rem, 32vw, 34rem);
335
+ max-width: min(34rem, calc(100vw - 2rem));
336
+ max-height: min(85vh, 60rem);
337
+ overflow: auto;
338
+ padding: 0.9rem;
339
+ border-radius: 1rem;
340
+ border: 1px solid rgba(255, 255, 255, 0.12);
341
+ background: linear-gradient(180deg, rgba(25, 27, 34, 0.94) 0%, rgba(17, 19, 25, 0.96) 100%);
342
+ box-shadow:
343
+ 0 20px 60px rgba(0, 0, 0, 0.38),
344
+ 0 8px 20px rgba(0, 0, 0, 0.22),
345
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
346
+ color: rgba(248, 250, 252, 0.96);
347
+ }
348
+
349
+ .card-header {
350
+ display: flex;
351
+ align-items: center;
352
+ justify-content: space-between;
353
+ gap: 0.75rem;
354
+ margin-bottom: 0.8rem;
355
+ }
356
+
357
+ .card-title {
358
+ font-size: 0.9rem;
359
+ font-weight: 700;
360
+ letter-spacing: 0.01em;
361
+ color: rgba(255, 255, 255, 0.96);
362
+ }
363
+
364
+ .icon-close {
365
+ display: inline-flex;
366
+ align-items: center;
367
+ justify-content: center;
368
+ width: 2rem;
369
+ height: 2rem;
370
+ padding: 0;
371
+ border: 0;
372
+ border-radius: 999px;
373
+ background: rgba(255, 255, 255, 0.06);
374
+ color: rgba(255, 255, 255, 0.78);
375
+ font-size: 1.1rem;
376
+ line-height: 1;
377
+ cursor: pointer;
378
+ transition:
379
+ background 0.18s ease,
380
+ color 0.18s ease,
381
+ transform 0.18s ease;
382
+ }
383
+
384
+ .icon-close:hover {
385
+ background: rgba(255, 255, 255, 0.12);
386
+ color: rgba(255, 255, 255, 0.96);
387
+ transform: scale(1.03);
388
+ }
389
+
390
+ .field {
391
+ display: flex;
392
+ flex-direction: column;
393
+ gap: 0.45rem;
394
+ }
395
+
396
+ .selected-copy {
397
+ padding: 0.75rem 0.85rem;
398
+ border-radius: 0.8rem;
399
+ background: rgba(255, 255, 255, 0.045);
400
+ border: 1px solid rgba(255, 255, 255, 0.08);
401
+ font-size: 0.92rem;
402
+ line-height: 1.45;
403
+ color: rgba(255, 255, 255, 0.95);
404
+ white-space: pre-wrap;
405
+ word-break: break-word;
406
+ }
407
+
408
+ .field-label {
409
+ font-size: 0.72rem;
410
+ font-weight: 600;
411
+ text-transform: uppercase;
412
+ letter-spacing: 0.06em;
413
+ color: rgba(255, 255, 255, 0.6);
414
+ }
415
+
416
+ .translation-input {
417
+ width: 100%;
418
+ min-height: 7rem;
419
+ max-height: min(40vh, 18rem);
420
+ padding: 0.85rem 0.95rem;
421
+ border: 1px solid rgba(255, 255, 255, 0.08);
422
+ border-radius: 0.85rem;
423
+ background: rgba(255, 255, 255, 0.05);
424
+ color: rgba(255, 255, 255, 0.96);
425
+ font: inherit;
426
+ font-size: 0.92rem;
427
+ line-height: 1.45;
428
+ resize: vertical;
429
+ overflow: auto;
430
+ outline: none;
431
+ box-sizing: border-box;
432
+ transition:
433
+ border-color 0.18s ease,
434
+ background 0.18s ease,
435
+ box-shadow 0.18s ease;
436
+ }
437
+
438
+ .translation-input::placeholder {
439
+ color: rgba(255, 255, 255, 0.35);
440
+ }
441
+
442
+ .translation-input:hover {
443
+ background: rgba(255, 255, 255, 0.065);
444
+ }
445
+
446
+ .translation-input:focus {
447
+ border-color: rgba(110, 168, 255, 0.55);
448
+ background: rgba(255, 255, 255, 0.07);
449
+ box-shadow: 0 0 0 4px rgba(110, 168, 255, 0.12);
450
+ }
451
+
452
+ .translation-input.suggested {
453
+ background: rgba(255, 214, 102, 0.12);
454
+ border-color: rgba(255, 214, 102, 0.4);
455
+ box-shadow: 0 0 0 1px rgba(255, 214, 102, 0.18);
456
+ }
457
+
458
+ .translation-input.suggested:hover,
459
+ .translation-input.suggested:focus {
460
+ background: rgba(255, 214, 102, 0.16);
461
+ border-color: rgba(255, 214, 102, 0.48);
462
+ box-shadow: 0 0 0 4px rgba(255, 214, 102, 0.12);
463
+ }
464
+
465
+ .meta-row {
466
+ display: flex;
467
+ justify-content: flex-end;
468
+ margin-top: 0.45rem;
469
+ margin-bottom: 0.85rem;
470
+ }
471
+
472
+ .selection-count {
473
+ font-size: 0.72rem;
474
+ color: rgba(255, 255, 255, 0.48);
475
+ }
476
+
477
+ .actions {
478
+ display: flex;
479
+ justify-content: flex-end;
480
+ align-items: center;
481
+ gap: 0.6rem;
482
+ flex-wrap: wrap;
483
+ }
484
+
485
+ .alt-list {
486
+ list-style: none;
487
+ margin: 0;
488
+ padding: 0;
489
+ display: flex;
490
+ flex-direction: column;
491
+ gap: 0.6rem;
492
+ max-height: 31rem;
493
+ overflow-y: auto;
494
+ padding-right: 0.3rem;
495
+ scroll-behavior: smooth;
496
+ scrollbar-gutter: stable;
497
+ }
498
+
499
+ .current-target {
500
+ display: flex;
501
+ align-items: flex-start;
502
+ justify-content: space-between;
503
+ gap: 0.85rem;
504
+ padding: 0.8rem;
505
+ border-radius: 0.85rem;
506
+ background: rgba(255, 255, 255, 0.04);
507
+ border: 1px solid rgba(255, 255, 255, 0.1);
508
+ }
509
+
510
+ .current-target-copy {
511
+ min-width: 0;
512
+ display: flex;
513
+ flex-direction: column;
514
+ gap: 0.45rem;
515
+ flex: 1 1 auto;
516
+ }
517
+
518
+ .current-target-key {
519
+ font-size: 0.9rem;
520
+ line-height: 1.5;
521
+ color: rgba(230, 243, 255, 0.98);
522
+ }
523
+
524
+ .current-target-context {
525
+ display: flex;
526
+ flex-direction: column;
527
+ gap: 0.25rem;
528
+ }
529
+
530
+ .current-target.staged {
531
+ border-color: rgba(120, 255, 170, 0.45);
532
+ background: rgba(70, 255, 170, 0.08);
533
+ box-shadow: 0 0 0 4px rgba(70, 255, 170, 0.08);
534
+ }
535
+
536
+ .current-target-status {
537
+ flex: 0 0 auto;
538
+ }
539
+
540
+ .commit-target-row {
541
+ display: flex;
542
+ flex-direction: column;
543
+ gap: 0.3rem;
544
+ margin-bottom: 0.85rem;
545
+ padding: 0.75rem 0.8rem;
546
+ border-radius: 0.8rem;
547
+ background: rgba(255, 255, 255, 0.035);
548
+ border: 1px solid rgba(255, 255, 255, 0.07);
549
+ }
550
+
551
+ .alt-status {
552
+ display: inline-flex;
553
+ align-items: center;
554
+ justify-content: center;
555
+ min-width: 1.6rem;
556
+ height: 1.6rem;
557
+ font-size: 0.95rem;
558
+ border-radius: 999px;
559
+ background: rgba(255, 255, 255, 0.05);
560
+ }
561
+
562
+ .tool-btn {
563
+ display: inline-flex;
564
+ align-items: center;
565
+ justify-content: center;
566
+ min-height: 2.5rem;
567
+ padding: 0.7rem 1rem;
568
+ border: 1px solid rgba(255, 255, 255, 0.12);
569
+ border-radius: 0.8rem;
570
+ background: rgba(255, 255, 255, 0.05);
571
+ color: rgba(248, 250, 252, 0.96);
572
+ font: inherit;
573
+ cursor: pointer;
574
+ transition:
575
+ background 0.16s ease,
576
+ border-color 0.16s ease,
577
+ transform 0.16s ease,
578
+ box-shadow 0.16s ease;
579
+ }
580
+
581
+ .tool-btn.primary {
582
+ border-color: rgba(102, 255, 178, 0.3);
583
+ background: rgba(70, 255, 170, 0.14);
584
+ color: rgba(236, 255, 243, 0.98);
585
+ }
586
+
587
+ .tool-btn:hover:not(:disabled) {
588
+ background: rgba(255, 255, 255, 0.09);
589
+ border-color: rgba(255, 255, 255, 0.18);
590
+ }
591
+
592
+ .tool-btn.primary:hover:not(:disabled) {
593
+ background: rgba(70, 255, 170, 0.2);
594
+ border-color: rgba(102, 255, 178, 0.45);
595
+ }
596
+
597
+ .tool-btn:focus-visible {
598
+ outline: none;
599
+ border-color: rgba(110, 168, 255, 0.55);
600
+ box-shadow: 0 0 0 4px rgba(110, 168, 255, 0.12);
601
+ }
602
+
603
+ .tool-btn:disabled {
604
+ opacity: 0.45;
605
+ cursor: not-allowed;
606
+ }
607
+
608
+ @media (max-width: 640px) {
609
+ .translation-card {
610
+ width: min(100%, 32rem);
611
+ max-width: 100%;
612
+ max-height: min(78vh, calc(100vh - 6rem));
613
+ padding: 0.8rem;
614
+ }
615
+
616
+ .current-target {
617
+ flex-direction: column;
618
+ }
619
+
620
+ .context-row {
621
+ flex-direction: column;
622
+ align-items: stretch;
623
+ gap: 0.35rem;
624
+ }
625
+
626
+ .alt-list {
627
+ max-height: min(32vh, 18rem);
628
+ }
629
+ }
630
+
631
+ @media (max-width: 420px) {
632
+ .translation-card {
633
+ width: min(100vw - 1rem, 32rem);
634
+ border-radius: 0.9rem;
635
+ }
636
+
637
+ .actions {
638
+ justify-content: stretch;
639
+ }
640
+
641
+ .actions :global(button) {
642
+ flex: 1 1 auto;
643
+ }
644
+ }
645
+ </style>