diff --git a/src/app/(dashboard)/dashboard/translator/components/ResultNarrated.tsx b/src/app/(dashboard)/dashboard/translator/components/ResultNarrated.tsx new file mode 100644 index 0000000000..ad9a4ec229 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/ResultNarrated.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Badge, Button, Card } from "@/shared/components"; +import type { AdvancedSlug, TranslateNarratedResult } from "../types"; +import { FORMAT_META } from "../exampleTemplates"; + +interface ResultNarratedProps { + result: TranslateNarratedResult; + onSeeTranslatedJson: () => void; + onSeePipeline: () => void; +} + +// Resolve a display label for a FormatId +function formatLabel(id: string | null): string { + if (!id) return "—"; + const meta = (FORMAT_META as Record)[id]; + return meta?.label ?? id; +} + +// Ensure stack traces are never surfaced — safety net on top of hook sanitization +function safeErrorMessage(raw: string | null): string { + if (!raw) return "Unknown error"; + return raw + .replace(/\sat\s\/[^\s]*/g, "") + .replace(/sk-[A-Za-z0-9_-]{16,}/g, "[REDACTED]") + .replace(/Bearer\s+[A-Za-z0-9_.-]+/g, "Bearer [REDACTED]"); +} + +export default function ResultNarrated({ + result, + onSeeTranslatedJson, + onSeePipeline, +}: ResultNarratedProps) { + const t = useTranslations("translator"); + + const tr = useCallback( + (key: string, fallback: string, params?: Record): string => { + try { + const translated = t(key as Parameters[0], params as Parameters[1]); + if (translated === key || translated === `translator.${key}`) { + // i18n key not found — use fallback with param substitution + if (params) { + return Object.entries(params).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), + fallback + ); + } + return fallback; + } + return translated; + } catch { + if (params && fallback) { + return Object.entries(params).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), + fallback + ); + } + return fallback; + } + }, + [t] + ); + + const isSpinning = result.status === "translating" || result.status === "sending"; + + return ( + + {/* Header */} +
+ +

+ {tr("simpleResultPanelTitle", "Translation + Response")} +

+
+ + {/* Status area — aria-live for screen-reader announcements (D20) */} +
+ {/* idle */} + {result.status === "idle" && ( +
+ +

+ {tr("simpleStartWithExamplePlaceholder", "Select a ready-made example")} +

+
+ )} + + {/* translating or sending */} + {isSpinning && ( +
+ + + {result.status === "translating" + ? tr("narratedTranslating", "Translating to {target}...", { + target: formatLabel(result.target), + }) + : tr("narratedSending", "Sending to {target}...", { + target: formatLabel(result.target), + })} + +
+ )} + + {/* ok */} + {result.status === "ok" && ( +
+ {/* Detection badge */} + {result.detected && ( + + {tr("narratedDetected", "✓ Detected: {format}", { + format: formatLabel(result.detected), + })} + + )} + + {/* Narrated success line */} +

+ {tr("narratedSuccess", "→ translated to {target} · response in {latency}ms", { + target: formatLabel(result.target), + latency: result.latencyMs ?? 0, + })} +

+ + {/* Response preview */} + {result.responsePreview && ( +
+
+                  {result.responsePreview.slice(0, 500)}
+                
+
+ )} + + {/* Secondary action buttons */} +
+ {result.translatedJson && ( + + )} + +
+
+ )} + + {/* error */} + {result.status === "error" && ( +
+ + + Error + +

+ {tr("narratedError", "Failed: {reason}", { + reason: safeErrorMessage(result.errorMessage), + })} +

+
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/SimpleControls.tsx b/src/app/(dashboard)/dashboard/translator/components/SimpleControls.tsx new file mode 100644 index 0000000000..a22516d235 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/SimpleControls.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Select, SegmentedControl } from "@/shared/components"; +import { InfoTooltip } from "@/shared/components"; +import { FORMAT_OPTIONS, FORMAT_META, getExampleTemplates } from "../exampleTemplates"; +import { useProviderOptions } from "../hooks/useProviderOptions"; +import type { FormatId, TranslateMode } from "../types"; + +interface SimpleControlsProps { + source: FormatId; + target: FormatId; + provider: string; + inputText: string; + mode: TranslateMode; + onSourceChange: (source: FormatId) => void; + onTargetChange: (target: FormatId) => void; + onProviderChange: (provider: string) => void; + onInputChange: (text: string) => void; + onModeChange: (mode: TranslateMode) => void; + onSubmit: () => void; + onOpenAdvanced: () => void; + isLoading?: boolean; +} + +export default function SimpleControls({ + source, + target, + provider, + inputText, + mode, + onSourceChange, + onTargetChange, + onProviderChange, + onInputChange, + onModeChange, + onSubmit, + onOpenAdvanced, + isLoading = false, +}: SimpleControlsProps) { + const t = useTranslations("translator"); + const { providerOptions } = useProviderOptions(provider); + + const tr = useCallback( + (key: string, fallback: string): string => { + try { + const translated = t(key as Parameters[0]); + return translated === key || translated === `translator.${key}` ? fallback : translated; + } catch { + return fallback; + } + }, + [t] + ); + + const examples = getExampleTemplates(t as (key: string) => string); + + // Map provider string to a FormatId when a provider is selected + const providerToFormatId = useCallback((prov: string): FormatId => { + const normalized = prov.toLowerCase(); + if (normalized.includes("gemini")) return "gemini"; + if (normalized.includes("claude") || normalized.includes("anthropic")) return "claude"; + if (normalized.includes("cursor")) return "cursor"; + if (normalized.includes("kiro")) return "kiro"; + if (normalized.includes("antigravity")) return "antigravity"; + // Check FORMAT_META directly + const metaKeys = Object.keys(FORMAT_META) as FormatId[]; + const exactMatch = metaKeys.find((k) => k === normalized); + if (exactMatch) return exactMatch; + return "openai"; + }, []); + + const handleProviderChange = useCallback( + (e: React.ChangeEvent) => { + const prov = e.target.value; + onProviderChange(prov); + onTargetChange(providerToFormatId(prov)); + }, + [onProviderChange, onTargetChange, providerToFormatId] + ); + + const handleExampleChange = useCallback( + (e: React.ChangeEvent) => { + const selectedId = e.target.value; + if (selectedId === "__custom__") { + onOpenAdvanced(); + return; + } + const template = examples.find((ex) => ex.id === selectedId); + if (!template) return; + // Load template body for the current source format + const body = + template.formats[source] ?? + template.formats["openai"] ?? + Object.values(template.formats)[0]; + if (body) { + onInputChange(JSON.stringify(body, null, 2)); + } + }, + [examples, source, onInputChange, onOpenAdvanced] + ); + + const modeOptions = [ + { value: "preview", label: tr("simpleModePreview", "Preview translation only") }, + { value: "send", label: tr("simpleModeSend", "Send and see response") }, + ]; + + const exampleSelectOptions = [ + ...examples.map((ex) => ({ value: ex.id, label: ex.name })), + { value: "__custom__", label: tr("simpleStartWithCustomOption", "Paste your request (advanced)") }, + ]; + + const sourceOptions = FORMAT_OPTIONS; + + return ( +
+ {/* Row 1: source format + provider (destination) */} +
+
+
+ + {tr("simpleAppUsesLabel", "My app uses")} + + +
+ 0 ? providerOptions : [{ value: provider, label: provider }]} + value={provider} + onChange={handleProviderChange} + /> +
+
+ + {/* Row 2: example picker */} +
+ + {tr("simpleStartWithLabel", "Start with")} + +