mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
## Overview Internationalizes the **developer portal**, which previously had **zero i18n** — every string was hardcoded across ~118 components. Rather than stand up a parallel system, this shares the **editor's** existing i18n setup (same TOML locale format, same Crowdin pipeline), then converts every portal surface to `react-i18next` and adds a CI guard so coverage can't regress. ## What's included ### 🔗 Shared i18n core (`@shared/i18n`) - Extracts the editor's `TomlBackend` (HTTP loader for `public/locales/{lng}/translation.toml`) and language metadata/helpers (the 42-language list, RTL set, `LanguageSource` priority, code normalizers) into `frontend/shared/i18n/`. - The **editor** now imports and re-exports these from `@shared/i18n` — its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted. - The **portal** builds its own i18next instance from the shared core, with **en-US as the source of truth** and the same `/locales/{lng}/translation.toml` layout. ### 🌍 Full portal coverage - Every view and component converted to `t()` — all feature areas (home, pipelines, sources, infrastructure, usage, documents, agent-builder, editor-admin, policies, users, docs, catalogue, components view) plus app shell, nav, modals, and the home/domain widgets. - **1108 keys across ~30 namespaces** in `portal/public/locales/en-US/translation.toml`, grouped by feature; shared strings under `[common]`. Plurals use i18next count forms; dynamic labels (nav, settings sections, status badges) use template keys against populated tables. - Data-driven strings (values from `@portal/api/*` mocks, enum/id values, code samples) are intentionally left untranslated — they're data, not UI chrome. ### ✅ CI coverage guard - `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in portal source is missing from the en-US locale. Wired into `frontend:check` and `frontend:check:all`, so missed keys break CI. This mirrors the editor's `missingTranslations` test for the portal, which has no vitest harness of its own. ## Testing - `task frontend:check:all` passes locally (typecheck all variants, lint, format, **portal i18n guard**, builds, tests, storybook). - Every static `t()` key verified to resolve in the locale (1108 keys / 186 source files); all dynamic key prefixes map to populated tables. - Runtime sweep of all 12 portal routes shows **no unresolved keys** on screen; nav labels, plurals, and array-backed copy all render real text. ## Follow-ups (not in this PR) - **Crowdin** — register `frontend/portal/public/locales/` as a source so portal strings flow through the same translation pipeline as the editor (an ops step on the Crowdin side; there's no Crowdin config in the repo). - Only `en-US` is populated; other languages will arrive via the pipeline.
289 lines
8.6 KiB
TypeScript
289 lines
8.6 KiB
TypeScript
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { UsagePoint } from "@portal/api/home";
|
|
import "@portal/components/UsageAreaChart.css";
|
|
|
|
interface UsageAreaChartProps {
|
|
data: UsagePoint[];
|
|
/** Headline metric shown above the chart (e.g. total 30d, current value). */
|
|
totalLabel?: string;
|
|
totalValue?: string;
|
|
deltaPct?: number;
|
|
}
|
|
|
|
const PADDING = { top: 16, right: 20, bottom: 28, left: 32 } as const;
|
|
const VIEW = { width: 800, height: 240 } as const;
|
|
|
|
function formatTick(date: string): string {
|
|
return new Date(date).toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
function formatNumber(value: number): string {
|
|
if (value >= 1000) {
|
|
return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
|
}
|
|
return value.toLocaleString();
|
|
}
|
|
|
|
export function UsageAreaChart({
|
|
data,
|
|
totalLabel: totalLabelProp,
|
|
totalValue,
|
|
deltaPct,
|
|
}: UsageAreaChartProps) {
|
|
const { t } = useTranslation();
|
|
const totalLabel = totalLabelProp ?? t("usageChart.defaultLabel");
|
|
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
|
|
const { points, areaPath, linePath, yMax, yTicks, xTickIndices } =
|
|
useMemo(() => {
|
|
if (data.length === 0) {
|
|
return {
|
|
points: [] as Array<{ x: number; y: number; raw: UsagePoint }>,
|
|
areaPath: "",
|
|
linePath: "",
|
|
yMax: 0,
|
|
yTicks: [] as number[],
|
|
xTickIndices: [] as number[],
|
|
};
|
|
}
|
|
const max = Math.max(...data.map((d) => d.value));
|
|
// Round yMax up to a "nice" number for ticks. Floor at 500 so an
|
|
// all-zero series doesn't divide by zero (which would NaN every point).
|
|
const niceMax = Math.max(Math.ceil(max / 500) * 500, 500);
|
|
const yTicksCalc = [0, niceMax * 0.5, niceMax];
|
|
|
|
const innerW = VIEW.width - PADDING.left - PADDING.right;
|
|
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
|
|
const xStep = innerW / Math.max(data.length - 1, 1);
|
|
|
|
const pts = data.map((raw, i) => ({
|
|
x: PADDING.left + i * xStep,
|
|
y: PADDING.top + innerH - (raw.value / niceMax) * innerH,
|
|
raw,
|
|
}));
|
|
|
|
const linePathStr = pts
|
|
.map(
|
|
(p, i) =>
|
|
`${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`,
|
|
)
|
|
.join(" ");
|
|
const baseY = PADDING.top + innerH;
|
|
const areaPathStr = `${linePathStr} L ${pts[pts.length - 1].x.toFixed(2)} ${baseY} L ${pts[0].x.toFixed(2)} ${baseY} Z`;
|
|
|
|
// X ticks: 5 evenly spread.
|
|
const xIdx: number[] = [];
|
|
const tickCount = 5;
|
|
for (let t = 0; t < tickCount; t++) {
|
|
xIdx.push(Math.round((t * (data.length - 1)) / (tickCount - 1)));
|
|
}
|
|
|
|
return {
|
|
points: pts,
|
|
areaPath: areaPathStr,
|
|
linePath: linePathStr,
|
|
yMax: niceMax,
|
|
yTicks: yTicksCalc,
|
|
xTickIndices: xIdx,
|
|
};
|
|
}, [data]);
|
|
|
|
function onMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
|
if (!svgRef.current || points.length === 0) return;
|
|
const rect = svgRef.current.getBoundingClientRect();
|
|
const xRatio = (e.clientX - rect.left) / rect.width;
|
|
const svgX = xRatio * VIEW.width;
|
|
// Find nearest point
|
|
let nearestIdx = 0;
|
|
let nearestDist = Infinity;
|
|
for (let i = 0; i < points.length; i++) {
|
|
const d = Math.abs(points[i].x - svgX);
|
|
if (d < nearestDist) {
|
|
nearestDist = d;
|
|
nearestIdx = i;
|
|
}
|
|
}
|
|
setHoverIndex(nearestIdx);
|
|
}
|
|
|
|
const hovered = hoverIndex !== null ? points[hoverIndex] : null;
|
|
|
|
function onKeyDown(e: KeyboardEvent<SVGSVGElement>) {
|
|
if (points.length === 0) return;
|
|
if (e.key === "ArrowRight") {
|
|
e.preventDefault();
|
|
setHoverIndex((idx) =>
|
|
idx === null ? 0 : Math.min(points.length - 1, idx + 1),
|
|
);
|
|
} else if (e.key === "ArrowLeft") {
|
|
e.preventDefault();
|
|
setHoverIndex((idx) =>
|
|
idx === null ? points.length - 1 : Math.max(0, idx - 1),
|
|
);
|
|
} else if (e.key === "Home") {
|
|
e.preventDefault();
|
|
setHoverIndex(0);
|
|
} else if (e.key === "End") {
|
|
e.preventDefault();
|
|
setHoverIndex(points.length - 1);
|
|
} else if (e.key === "Escape") {
|
|
setHoverIndex(null);
|
|
}
|
|
}
|
|
|
|
const displayTotal =
|
|
totalValue ?? data.reduce((sum, p) => sum + p.value, 0).toLocaleString();
|
|
|
|
return (
|
|
<div className="portal-chart">
|
|
<header className="portal-chart__head">
|
|
<div>
|
|
<div className="portal-chart__label">{totalLabel}</div>
|
|
<div className="portal-chart__value">{displayTotal}</div>
|
|
</div>
|
|
{deltaPct !== undefined && (
|
|
<div
|
|
className={
|
|
"portal-chart__delta " + (deltaPct >= 0 ? "is-up" : "is-down")
|
|
}
|
|
>
|
|
<span aria-hidden>{deltaPct >= 0 ? "↑" : "↓"}</span>
|
|
{t("usageChart.delta", {
|
|
pct: Math.abs(Math.round(deltaPct * 100)),
|
|
})}
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}
|
|
preserveAspectRatio="none"
|
|
role="img"
|
|
aria-label={totalLabel}
|
|
className="portal-chart__svg"
|
|
tabIndex={points.length > 0 ? 0 : -1}
|
|
onMouseMove={onMouseMove}
|
|
onMouseLeave={() => setHoverIndex(null)}
|
|
onKeyDown={onKeyDown}
|
|
onBlur={() => setHoverIndex(null)}
|
|
>
|
|
<defs>
|
|
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
|
|
<stop
|
|
offset="0%"
|
|
stopColor="var(--color-blue)"
|
|
stopOpacity="0.32"
|
|
/>
|
|
<stop offset="100%" stopColor="var(--color-blue)" stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* Y gridlines + labels */}
|
|
{yTicks.map((tick) => {
|
|
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
|
|
const y = PADDING.top + innerH - (tick / yMax) * innerH;
|
|
return (
|
|
<g key={tick}>
|
|
<line
|
|
x1={PADDING.left}
|
|
x2={VIEW.width - PADDING.right}
|
|
y1={y}
|
|
y2={y}
|
|
className="portal-chart__gridline"
|
|
/>
|
|
<text
|
|
x={PADDING.left - 6}
|
|
y={y + 4}
|
|
className="portal-chart__axis-label"
|
|
textAnchor="end"
|
|
>
|
|
{formatNumber(tick)}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* Area + line */}
|
|
<path d={areaPath} fill="url(#chart-fill)" />
|
|
<path d={linePath} className="portal-chart__line" />
|
|
|
|
{/* X ticks */}
|
|
{xTickIndices.map((idx) => {
|
|
const p = points[idx];
|
|
if (!p) return null;
|
|
return (
|
|
<text
|
|
key={idx}
|
|
x={p.x}
|
|
y={VIEW.height - PADDING.bottom + 18}
|
|
className="portal-chart__axis-label"
|
|
textAnchor="middle"
|
|
>
|
|
{formatTick(p.raw.date)}
|
|
</text>
|
|
);
|
|
})}
|
|
|
|
{/* Hover scrub + dot + tooltip */}
|
|
{hovered && (
|
|
<>
|
|
<line
|
|
x1={hovered.x}
|
|
x2={hovered.x}
|
|
y1={PADDING.top}
|
|
y2={VIEW.height - PADDING.bottom}
|
|
className="portal-chart__scrub"
|
|
/>
|
|
<circle
|
|
cx={hovered.x}
|
|
cy={hovered.y}
|
|
r={4}
|
|
className="portal-chart__dot"
|
|
/>
|
|
</>
|
|
)}
|
|
</svg>
|
|
|
|
{hovered && (
|
|
<div
|
|
className="portal-chart__tooltip"
|
|
style={{
|
|
left: `${(hovered.x / VIEW.width) * 100}%`,
|
|
}}
|
|
>
|
|
<div className="portal-chart__tooltip-date">
|
|
{new Date(hovered.raw.date).toLocaleDateString(undefined, {
|
|
weekday: "short",
|
|
month: "short",
|
|
day: "numeric",
|
|
})}
|
|
</div>
|
|
<div className="portal-chart__tooltip-value">
|
|
{t("usageChart.docsValue", {
|
|
value: hovered.raw.value.toLocaleString(),
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="sr-only" aria-live="polite" aria-atomic="true">
|
|
{hovered
|
|
? t("usageChart.srAnnounce", {
|
|
date: new Date(hovered.raw.date).toLocaleDateString(undefined, {
|
|
weekday: "short",
|
|
month: "short",
|
|
day: "numeric",
|
|
}),
|
|
value: hovered.raw.value.toLocaleString(),
|
|
})
|
|
: ""}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|