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.
134 lines
4.4 KiB
TypeScript
134 lines
4.4 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Dropdown, EmptyState, Skeleton } from "@shared/components";
|
|
import { BellIcon } from "@portal/components/icons";
|
|
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
|
import {
|
|
fetchNotifications,
|
|
markAllNotificationsRead,
|
|
type Notification,
|
|
type NotificationCategory,
|
|
} from "@portal/api/notifications";
|
|
import "@portal/components/NotificationsDropdown.css";
|
|
|
|
const CATEGORY_COLOUR: Record<NotificationCategory, string> = {
|
|
pipeline: "var(--color-blue)",
|
|
deploy: "var(--color-green)",
|
|
billing: "var(--color-amber)",
|
|
audit: "var(--color-purple)",
|
|
agent: "var(--color-cat-insurance)",
|
|
doc: "var(--color-cat-healthcare)",
|
|
};
|
|
|
|
export function NotificationsDropdown() {
|
|
const { t } = useTranslation();
|
|
const state = useAsync<Notification[]>(() => fetchNotifications(), []);
|
|
const { data: items } = state;
|
|
const { isLoading } = useSectionFlags(state);
|
|
|
|
// Optimistically clear on "mark all read"; revert if the request fails.
|
|
const [cleared, setCleared] = useState(false);
|
|
const visible = cleared ? [] : (items ?? []);
|
|
|
|
async function onMarkAllRead() {
|
|
setCleared(true);
|
|
try {
|
|
await markAllNotificationsRead();
|
|
} catch {
|
|
setCleared(false);
|
|
}
|
|
}
|
|
|
|
const isEmpty = !isLoading && visible.length === 0;
|
|
const hasUnread = visible.length > 0;
|
|
|
|
return (
|
|
<Dropdown.Root align="end">
|
|
<Dropdown.Trigger>
|
|
<button
|
|
type="button"
|
|
className="portal-header__icon-btn portal-header__icon-btn--badge"
|
|
aria-label={
|
|
hasUnread
|
|
? t("notifications.ariaLabel.unread", {
|
|
count: visible.length,
|
|
})
|
|
: t("notifications.ariaLabel.none")
|
|
}
|
|
>
|
|
<BellIcon size={16} />
|
|
{hasUnread && (
|
|
<span className="portal-header__bell-dot" aria-hidden />
|
|
)}
|
|
</button>
|
|
</Dropdown.Trigger>
|
|
<Dropdown.Menu width="22.5rem" className="portal-notif__menu">
|
|
<div className="portal-notif__header">
|
|
<span className="portal-notif__title">
|
|
{t("notifications.title")}
|
|
</span>
|
|
{hasUnread ? (
|
|
<span className="portal-notif__count">
|
|
{t("notifications.count.new", { count: visible.length })}
|
|
</span>
|
|
) : isLoading ? (
|
|
<span className="portal-notif__count portal-notif__count--quiet">
|
|
{t("notifications.count.loading")}
|
|
</span>
|
|
) : (
|
|
<span className="portal-notif__count portal-notif__count--quiet">
|
|
{t("notifications.count.allRead")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{isLoading && (
|
|
<div className="portal-notif__loading">
|
|
<Skeleton height="0.875rem" />
|
|
<Skeleton height="0.6875rem" width="60%" />
|
|
<Skeleton height="0.875rem" />
|
|
<Skeleton height="0.6875rem" width="60%" />
|
|
</div>
|
|
)}
|
|
{isEmpty && (
|
|
<EmptyState
|
|
size="compact"
|
|
title={t("notifications.empty.title")}
|
|
description={t("notifications.empty.description")}
|
|
/>
|
|
)}
|
|
{!isLoading && !isEmpty && (
|
|
<ul className="portal-notif__list">
|
|
{visible.map((item) => (
|
|
<li key={item.id} className="portal-notif__item">
|
|
<span
|
|
className="portal-notif__dot"
|
|
style={{ background: CATEGORY_COLOUR[item.category] }}
|
|
aria-hidden
|
|
/>
|
|
<div className="portal-notif__body">
|
|
<div className="portal-notif__item-title">{item.title}</div>
|
|
<div className="portal-notif__desc">{item.description}</div>
|
|
<div className="portal-notif__time">{item.time}</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<div className="portal-notif__footer">
|
|
<button
|
|
type="button"
|
|
className="portal-notif__action"
|
|
onClick={onMarkAllRead}
|
|
disabled={!hasUnread}
|
|
>
|
|
{t("notifications.markAllRead")}
|
|
</button>
|
|
<button type="button" className="portal-notif__action">
|
|
{t("notifications.viewAll")}
|
|
</button>
|
|
</div>
|
|
</Dropdown.Menu>
|
|
</Dropdown.Root>
|
|
);
|
|
}
|